httpClient = $httpClient; $this->settingsRepo = $settingsRepo; $this->language = $language; } /** * @api */ /** * @api */ public function sendNotificationToMessengers(NotificationModel $notification, $projectName, array|string $messengers = 'all'): void { $this->projectName = $projectName ?? 'a Leantime project'; $messengersToSend = []; if (is_string($messengers) && $messengers == 'all') { $messengersToSend = $this->supportedMessengers; } elseif (is_array($messengers)) { foreach ($messengers as $messenger) { if (in_array($messenger, $this->supportedMessengers)) { $messengersToSend[] = $messenger; } } } foreach ($messengersToSend as $messenger) { $this->{''.$messenger.'Webhook'}($notification); } } /** * slackWebhook * * * @api */ private function slackWebhook(NotificationModel $notification): bool { $slackWebhookURL = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.slackWebhookURL"); if ($slackWebhookURL !== '' && $slackWebhookURL !== false) { $message = $this->prepareMessage($notification); $data = [ 'text' => '', 'attachments' => $message, ]; $data_string = json_encode($data); try { if (! OutboundUrlGuard::isAllowedUrl($slackWebhookURL)) { Log::warning('Blocked Slack webhook to disallowed URL (SSRF guard)', ['host' => parse_url($slackWebhookURL, PHP_URL_HOST)]); return false; } $this->httpClient->post($slackWebhookURL, [ 'allow_redirects' => OutboundUrlGuard::redirectOptions(), 'body' => $data_string, 'headers' => ['Content-Type' => 'application/json'], ]); return true; } catch (\Throwable $e) { report($e); return false; } } return false; } /** * mattermostWebhook * * * @api */ private function mattermostWebhook(NotificationModel $notification): bool { $mattermostWebhookURL = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.mattermostWebhookURL"); if ($mattermostWebhookURL !== '' && $mattermostWebhookURL !== false) { $message = $this->prepareMessage($notification); $data = [ 'username' => 'Leantime', 'icon_url' => '', 'text' => '', 'attachments' => $message, ]; $data_string = json_encode($data); try { if (! OutboundUrlGuard::isAllowedUrl($mattermostWebhookURL)) { Log::warning('Blocked Mattermost webhook to disallowed URL (SSRF guard)', ['host' => parse_url($mattermostWebhookURL, PHP_URL_HOST)]); return false; } $this->httpClient->post($mattermostWebhookURL, [ 'allow_redirects' => OutboundUrlGuard::redirectOptions(), 'body' => $data_string, ]); return true; } catch (Exception $e) { report($e); return false; } } return false; } /** * zulipWebhook * * * @api */ private function zulipWebhook(NotificationModel $notification): bool { $zulipWebhookSerialized = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.zulipHook"); if ($zulipWebhookSerialized !== false && $zulipWebhookSerialized !== '') { $zulipWebhook = safe_unserialize($zulipWebhookSerialized, []); $botEmail = $zulipWebhook['zulipEmail']; $botKey = $zulipWebhook['zulipBotKey']; $botURL = $zulipWebhook['zulipURL'].'/api/v1/messages'; $prepareChatMessage = '**Project: '.$this->projectName."** \n\r".$notification->message; if ($notification->url !== false) { $prepareChatMessage .= ' '.$notification->url['url'].''; } $data = [ 'type' => 'stream', 'to' => $zulipWebhook['zulipStream'], 'topic' => $zulipWebhook['zulipTopic'], 'content' => $prepareChatMessage, ]; $curlUrl = $botURL.'?'.http_build_query($data); $data_string = json_encode($data); try { if (! OutboundUrlGuard::isAllowedUrl($curlUrl)) { Log::warning('Blocked Zulip webhook to disallowed URL (SSRF guard)', ['host' => parse_url($curlUrl, PHP_URL_HOST)]); return false; } $this->httpClient->post($curlUrl, [ 'allow_redirects' => OutboundUrlGuard::redirectOptions(), 'body' => $data_string, 'headers' => ['Content-Type' => 'application/json'], 'auth' => [ $botEmail, $botKey, ], ]); return true; } catch (\Throwable $e) { report($e); return false; } } return false; } /** * telegramWebhook */ private function telegramWebhook(NotificationModel $notification): bool { $telegramHookSerialized = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.telegramHook"); if ($telegramHookSerialized !== false && $telegramHookSerialized !== '') { $telegramHook = safe_unserialize($telegramHookSerialized, []); if (! is_array($telegramHook) || empty($telegramHook['telegramBotToken']) || empty($telegramHook['telegramChatId'])) { return false; } $text = $this->prepareTelegramMessage($notification); $data = [ 'chat_id' => $telegramHook['telegramChatId'], 'text' => $text, 'parse_mode' => 'HTML', 'disable_web_page_preview' => true, ]; if (! empty($telegramHook['telegramTopicId']) && is_numeric($telegramHook['telegramTopicId']) && (int) $telegramHook['telegramTopicId'] > 0) { $data['message_thread_id'] = (int) $telegramHook['telegramTopicId']; } try { $response = $this->httpClient->post( "https://api.telegram.org/bot{$telegramHook['telegramBotToken']}/sendMessage", [ 'allow_redirects' => OutboundUrlGuard::redirectOptions(), 'connect_timeout' => 5, 'timeout' => 10, 'json' => $data, ] ); $resBody = json_decode((string) $response->getBody(), true); return is_array($resBody) && ! empty($resBody['ok']); } catch (\Throwable $e) { Log::warning('Telegram sendMessage failed', ['exception' => get_class($e)]); return false; } } return false; } /** * prepareTelegramMessage */ private function prepareTelegramMessage(NotificationModel $notification): string { $headline = ''; $status = ''; $priority = ''; $userId = 0; $userFirstname = ''; $userLastname = ''; $dateToFinish = ''; if (isset($notification->entity)) { if (is_array($notification->entity)) { $headline = $notification->entity['headline'] ?? ''; $status = $notification->entity['status'] ?? ''; $priority = $notification->entity['priority'] ?? ''; $userId = (int) ($notification->entity['userId'] ?? 0); $userFirstname = $notification->entity['userFirstname'] ?? $notification->entity['user_firstname'] ?? ''; $userLastname = $notification->entity['userLastname'] ?? $notification->entity['user_lastname'] ?? ''; $dateToFinish = $notification->entity['dateToFinish'] ?? $notification->entity['timelineDateToFinish'] ?? ''; } elseif (is_object($notification->entity)) { $headline = $notification->entity->headline ?? ''; $status = $notification->entity->status ?? ''; $priority = $notification->entity->priority ?? ''; $userId = (int) ($notification->entity->userId ?? 0); $userFirstname = $notification->entity->userFirstname ?? $notification->entity->user_firstname ?? ''; $userLastname = $notification->entity->userLastname ?? $notification->entity->user_lastname ?? ''; $dateToFinish = $notification->entity->dateToFinish ?? $notification->entity->timelineDateToFinish ?? ''; } } $ticketService = null; try { $ticketService = app()->make(Tickets::class); } catch (\Throwable $e) { // Container resolution fallback } // 1. Task Title $taskTitle = ! empty($headline) ? $headline : $notification->message; // 2. Status $statusName = ''; if (! empty($status)) { if ($ticketService !== null) { try { $statusLabelsArray = $ticketService->getStatusLabels($notification->projectId); if (! empty($statusLabelsArray[$status]['name'])) { $statusName = $statusLabelsArray[$status]['name']; } else { $statusName = (string) $status; } } catch (\Throwable $e) { $statusName = (string) $status; } } else { $statusName = (string) $status; } } // 3. Priority $priorityName = ''; if (! empty($priority)) { if ($ticketService !== null) { try { $priorityLabels = $ticketService->getPriorityLabels(); if (! empty($priorityLabels[$priority])) { $priorityName = $priorityLabels[$priority]; } } catch (\Throwable $e) { // Fallback to static mapping } } if (empty($priorityName)) { $priorityMap = [ '1' => 'Critical', '2' => 'High', '3' => 'Medium', '4' => 'Low', '5' => 'Lowest', 'critical' => 'Critical', 'high' => 'High', 'medium' => 'Medium', 'low' => 'Low', 'lowest' => 'Lowest', 'urgent' => 'Urgent', ]; $priorityName = $priorityMap[strtolower((string) $priority)] ?? (string) $priority; } } // 4. Assigned To $assignedTo = trim("{$userFirstname} {$userLastname}"); if (empty($assignedTo) && $userId > 0) { try { $userService = app()->make(\Leantime\Domain\Users\Services\Users::class); $user = $userService->getUser($userId); if (! empty($user)) { $assignedTo = trim(($user['firstname'] ?? '').' '.($user['lastname'] ?? '')); } } catch (\Throwable $e) { // Keep default if user service unresolvable } } // 5. Due Date $formattedDueDate = ''; if (! empty($dateToFinish) && $dateToFinish !== '0000-00-00 00:00:00' && $dateToFinish !== '0000-00-00') { try { $formattedDueDate = dtHelper()->parseDbDateTime($dateToFinish)->formatDateForUser(); } catch (\Throwable $e) { $formattedDueDate = (string) $dateToFinish; } } // 6. Link $urlLink = is_array($notification->url) && ! empty($notification->url['url']) ? $notification->url['url'] : ''; // Build clean Telegram message $lines = []; $lines[] = 'πŸ“‹ '.e($this->projectName).''; $lines[] = ''; if (! empty($taskTitle)) { $lines[] = 'πŸ“Œ '.e($this->language->__('label.title')).': '.e($taskTitle); } if (! empty($statusName) && $statusName !== 'N/A') { $lines[] = '🏷 '.e($this->language->__('label.todo_status')).': '.e($statusName); } if (! empty($priorityName)) { $lines[] = '⚑ '.e($this->language->__('label.priority')).': '.e($priorityName); } if (! empty($assignedTo) && $assignedTo !== 'Unassigned') { $lines[] = 'πŸ‘€ '.e($this->language->__('label.assigned_to')).': '.e($assignedTo); } if (! empty($formattedDueDate)) { $lines[] = 'πŸ“… '.e($this->language->__('label.due_date')).': '.e($formattedDueDate); } if (! empty($urlLink)) { $hrefUrl = $urlLink; // Only rewrite localhostβ†’127.0.0.1 in local/dev environments. // Self-hosted installs that legitimately use localhost as their base URL // must not be mutated in production. if (app()->isLocal()) { $hrefUrl = preg_replace('/^(https?:\/\/)localhost(?=[\/:]|$)/i', '${1}127.0.0.1', $hrefUrl); } $hrefUrl = str_replace('#', '%23', $hrefUrl); $lines[] = ''; $lines[] = 'πŸ‘‰ '.e($this->language->__('label.open_in_leantime')).''; } return implode("\n", $lines); } /** * mattermostWebhook * * * @api */ public function discordWebhook(NotificationModel $notification): bool { $ticketService = app()->make(Tickets::class); for ($i = 1; $i <= 3; $i++) { $discordWebhookURL = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.discordWebhookURL{$i}"); if ($discordWebhookURL !== '' && $discordWebhookURL !== false) { $fields = [ [ 'name' => $this->language->__('label.project'), 'value' => $this->projectName, 'inline' => true, ], ]; $statusLabelsArray = $ticketService->getStatusLabels($notification->projectId); if (! empty($notification->entity->status) && ! empty($statusLabelsArray[$notification->entity->status])) { $fields[] = [ 'name' => $this->language->__('label.todo_status'), 'value' => $statusLabelsArray[$notification->entity->status]['name'], 'inline' => true, ]; } $url_link = ( empty($notification->url['url']) ? '' : $notification->url['url'] ); // For details on the JSON layout: https://birdie0.github.io/discord-webhooks-guide/index.html $data_string = json_encode([ 'avatar_url' => 'https://s3-us-west-2.amazonaws.com/leantime-website/wp-content/uploads/2019/03/22224016/logoIcon.png', 'tts' => false, 'embeds' => [ [ 'color' => hexdec('1b75bb'), 'title' => $notification->message, 'url' => $url_link, 'timestamp' => date('c', strtotime('now')), 'fields' => $fields, ], ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); try { if (! OutboundUrlGuard::isAllowedUrl($discordWebhookURL)) { Log::warning('Blocked Discord webhook to disallowed URL (SSRF guard)', ['host' => parse_url($discordWebhookURL, PHP_URL_HOST)]); return false; } $this->httpClient->post($discordWebhookURL, [ 'allow_redirects' => OutboundUrlGuard::redirectOptions(), 'body' => $data_string, 'headers' => ['Content-Type' => 'application/json'], ]); } catch (\Throwable $e) { report($e); return false; } } } return true; } /** * @return array[] * * @api */ public function prepareMessage(NotificationModel $notification): array { $ticketService = app()->make(Tickets::class); if (is_array($notification->entity)) { $headline = $notification->entity['headline'] ?? ''; $status = $notification->entity['status'] ?? ''; } else { $headline = $notification->entity->headline; $status = $notification->entity->status; } $fields = [ 'title' => $this->language->__('headlines.project_with_name').' '.$this->projectName, 'short' => false, ]; $statusLabelsArray = $ticketService->getStatusLabels($notification->projectId); if (! empty($statusLabelsArray[$status])) { $fields['value'] = $this->language->__('label.todo_status').': '.$statusLabelsArray[$status]['name']; } $message = [ [ 'color' => '#006d9f', 'fallback' => $notification->message, 'pretext' => $notification->message, 'title' => $headline, 'title_link' => $notification->url['url'], 'fields' => $fields, ], ]; return $message; } }