Files

542 lines
19 KiB
PHP

<?php
namespace Leantime\Domain\Notifications\Services;
use Exception;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\OutboundUrlGuard;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Tickets\Services\Tickets;
class Messengers
{
private Client $httpClient;
private SettingRepository $settingsRepo;
private LanguageCore $language;
private array $supportedMessengers = ['slack', 'discord', 'mattermost', 'zulip', 'telegram'];
private string $projectName = '';
/**
* __construct - get database connection
*
*
* @api
*/
public function __construct(
Client $httpClient,
SettingRepository $settingsRepo,
LanguageCore $language
) {
$this->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[] = '📋 <b>'.e($this->projectName).'</b>';
$lines[] = '';
if (! empty($taskTitle)) {
$lines[] = '📌 <b>'.e($this->language->__('label.title')).':</b> '.e($taskTitle);
}
if (! empty($statusName) && $statusName !== 'N/A') {
$lines[] = '🏷 <b>'.e($this->language->__('label.todo_status')).':</b> '.e($statusName);
}
if (! empty($priorityName)) {
$lines[] = '⚡ <b>'.e($this->language->__('label.priority')).':</b> '.e($priorityName);
}
if (! empty($assignedTo) && $assignedTo !== 'Unassigned') {
$lines[] = '👤 <b>'.e($this->language->__('label.assigned_to')).':</b> '.e($assignedTo);
}
if (! empty($formattedDueDate)) {
$lines[] = '📅 <b>'.e($this->language->__('label.due_date')).':</b> '.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[] = '👉 <a href="'.e($hrefUrl).'">'.e($this->language->__('label.open_in_leantime')).'</a>';
}
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;
}
}