diff --git a/src/ext/notifications/.htaccess b/src/ext/notifications/.htaccess new file mode 100644 index 0000000..8d2f256 --- /dev/null +++ b/src/ext/notifications/.htaccess @@ -0,0 +1 @@ +deny from all diff --git a/src/ext/notifications/class.controller.php b/src/ext/notifications/class.controller.php new file mode 100644 index 0000000..98a6f83 --- /dev/null +++ b/src/ext/notifications/class.controller.php @@ -0,0 +1,116 @@ + + Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. +*/ + +namespace Notify; + +use NotificationsExtension; +use Config; + +class Controller extends \ApiController +{ + function postDeactivateAll() + { + $prefs = Config::requestDomain(NotificationsExtension::domain); + if (isset($prefs['chats'])) { + $prefs['chats'] = []; + Config::saveDomain(NotificationsExtension::domain, $prefs); + } + $this->response->data = [ 'total' => 1, 'msg' => __("notifications.all_chats_deactivated") ]; + } + + function postCheck() + { + $prefs = Config::requestDomain(NotificationsExtension::domain); + if (!($prefs['validToken'] ?? false)) { + $this->response->data = [ 'total' => 0, 'msg' => __("notifications.bot_not_configured") ]; + return; + } + if (!isset($prefs['chats']) || !is_array($prefs['chats'])) { + $prefs['chats'] = []; + } + $code = $prefs['code'] ?? null; + $codeExpires = $prefs['codeExpires'] ?? 0; + $token = $prefs['token'] ?? ''; + + $this->response->data = [ 'total' => 0, 'msg' => __("notifications.no_new_chats") ]; + + // Read messages since last check + $maxId = $prefs['lastUpdateId'] ?? 0; + $api = new TelegramApi($token); + $updates = $api->getUpdates([ + 'offset' => $maxId + 1, + 'allowed_updates' => ['message'] + ]); + if (!is_array($updates) || count($updates) == 0) { + return; + } + + // Select last message in every chat + $messages = array(); + foreach ($updates as $update) { + $message = $update['message'] ?? []; + $chatId = (string)($message['chat']['id'] ?? 0); + $prefs['lastUpdateId'] = max($maxId, $update['update_id'] ?? 0); + $messages[$chatId] = $message; + } + + $total = 0; + foreach ($messages as $chatId => $message) { + $chatId = (int) $chatId; + $text = $message['text'] ?? ''; + $msgId = (int) ($message['message_id'] ?? 0); + if (in_array($chatId, $prefs['chats'])) { + $api->sendMessage([ + 'chat_id' => $chatId, + 'text' => __("notifications.already_active") + ]); + } + else if ($text === '/start') { + $api->sendMessage([ + 'chat_id' => $chatId, + 'text' => __("notifications.please_send") + ]); + } + else if ($code === null) { + $api->sendMessage([ + 'chat_id' => $chatId, + 'text' => __("notifications.code_not_set") + ]); + } + else if ($codeExpires < time()) { + $api->sendMessage([ + 'chat_id' => $chatId, + 'text' => __("notifications.code_expired") + ]); + } + else if ($text == $code) { + $prefs['chats'][] = $chatId; + $api->sendMessage([ + 'chat_id' => $chatId, + 'reply_to_message_id' => $msgId, + 'text' => __("notifications.activated") + ]); + $total++; + $this->response->data = [ + 'total' => $total, + 'msg' => __("notifications.activated") + ]; + } + else { + $api->sendMessage([ + 'chat_id' => $chatId, + 'reply_to_message_id' => $msgId, + 'text' => __("notifications.code_wrong") + ]); + } + } + + Config::saveDomain(NotificationsExtension::domain, $prefs); + } + +} diff --git a/src/ext/notifications/class.observer.php b/src/ext/notifications/class.observer.php new file mode 100644 index 0000000..96566df --- /dev/null +++ b/src/ext/notifications/class.observer.php @@ -0,0 +1,65 @@ + + Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. +*/ + +namespace Notify; + +use NotificationsExtension; +use MTTNotification; +use MTTNotificationCenter; +use DBConnection; + + +class NotificationObserver implements \MTTNotificationObserverInterface +{ + private $prefs = null; + private $delayedNotifications = []; + + public function notification(string $notification, $object) + { + if (!$this->prefs) { + $this->init(); + } + if (count($this->prefs['chats']) == 0 && count($this->prefs['emails']) == 0) { + return; // nobody to notify + } + + $db = DBConnection::instance(); + switch ($notification) { + case MTTNotification::didFinishRequest: + $this->processDelayed(); + break; + case MTTNotification::didCreateTask: + case MTTNotification::didCreateList: + // Get list name + $list = $db->sqa( "SELECT name FROM {$db->prefix}lists WHERE id=?", array($object['listId'] ?? 0) ); + $object['listName'] = htmlspecialchars($list['name'] ?? ''); + $this->delayedNotifications[] = [ + 'notification' => $notification, + 'object' => $object + ]; + MTTNotificationCenter::addObserverForNotification(MTTNotification::didFinishRequest, $this); + } + } + + private function processDelayed() + { + $db = DBConnection::instance(); + $useCli = !function_exists('fastcgi_finish_request'); + $sender = new Sender( $this->prefs, $useCli ); + foreach ($this->delayedNotifications as $item) { + $sender->notify($item); + } + } + + private function init() + { + $this->prefs = NotificationsExtension::preferences(); + $this->token = $this->prefs['token'] ?? ''; + } + +} diff --git a/src/ext/notifications/class.sender.php b/src/ext/notifications/class.sender.php new file mode 100644 index 0000000..770ef6f --- /dev/null +++ b/src/ext/notifications/class.sender.php @@ -0,0 +1,191 @@ + + Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. +*/ + +namespace Notify; + +use NotificationsExtension; +use MTTNotification; + + +class Sender +{ + private $prefs; + private $cli = false; + + function __construct(array $prefs, bool $useCli = false) + { + $this->prefs = $prefs; + if ($useCli && function_exists('pcntl_fork')) { + $this->cli = true; + } + } + + function notify(array $item) + { + $notification = $item['notification'] ?? ''; + $object = $item['object'] ?? null; + switch ($notification) { + case MTTNotification::didCreateTask: $this->notifyTaskCreated($object); break; + case MTTNotification::didCreateList: $this->notifyListCreated($object); break; + } + } + + /* + $task['title'], $task['tags'], $task['duedate'], $task['listName'] are already escaped + */ + private function notifyTaskCreated($task) + { + $link = get_mttinfo('url'). '?task='. $task['id']; + + //email + if (count($this->prefs['emails']) > 0) { + $aText = []; + $aText[] = "New task in ". htmlspecialchars_decode($task['listName']). ":"; + $aText[] = htmlspecialchars_decode($task['title']); + $aText[] = ""; + if ($task['duedate'] != '') { + $aText[] = "Due: ". htmlspecialchars_decode($task['duedate']); + } + if ($task['tags'] != '') { + $aText[] = "Tags: ". implode(", ", preg_split("/,\s*/", htmlspecialchars_decode($task['tags']), -1, PREG_SPLIT_NO_EMPTY)); + } + if ($aText[count($aText)-1] != '') { + $aText[] = ""; + } + $aText[] = "Link: $link"; + $text = implode("\r\n", $aText); + + $this->sendEmails( $text, "New task #". $task['id']); + } + + // telegram + if (count($this->prefs['chats']) > 0) { + $text = "New task #". $task['id']. " in ". $task['listName'] .": ". $task['title']; + if ($task['duedate'] != '') { + $text .= "\nDue: ". $task['duedate']; + } + if ($task['tags'] != '') { + $text .= "\nTags: ". implode(", ", preg_split("/,\s*/", $task['tags'], -1, PREG_SPLIT_NO_EMPTY)); + } + + $this->sendTelegrams($text); + } + } + + /* + $list['name'] is already escaped + */ + private function notifyListCreated($list) + { + $link = get_mttinfo('url'). '?list='. $list['id']; + + //email + if (count($this->prefs['emails']) > 0) { + $aText = []; + $aText[] = "New list:"; + $aText[] = htmlspecialchars_decode($list['name']); + $aText[] = ""; + $aText[] = "Link: $link"; + $text = implode("\r\n", $aText); + + $this->sendEmails( $text, "New list"); + } + + // telegram + if (count($this->prefs['chats']) > 0) { + $text = "New list: ". $list['name']. ""; + + $this->sendTelegrams($text); + } + } + + private function sendEmails(string $text, string $subject) + { + $host = parse_url(get_unsafe_mttinfo('url'), PHP_URL_HOST); + $host = preg_replace('/^(www\.)/', '', $host); + $fromAddr = "mytinytodo@$host"; + $from = "myTinyTodo <$fromAddr>"; + $mttTitle = str_replace( ["\r","\n"], '', get_unsafe_mttinfo('title') ); + $subject = "[$mttTitle] $subject"; + if (!mb_check_encoding($subject, 'ASCII')) { + $subject = mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n"); + } + $headers = [ + 'From: '. $from + ]; + if (mb_check_encoding($text, 'ASCII')) { + $headers[] = 'Content-Type: text/plain'; + } + else { + $headers[] = 'Content-Type: text/plain; charset=UTF-8'; + $headers[] = 'Content-Transfer-Encoding: 8bit'; + } + foreach ($this->prefs['emails'] as $email) { + mail($email, $subject, $text, implode("\r\n", $headers), "-f$fromAddr"); + } + } + + private function sendTelegrams(string $text) + { + if ($this->cli) { + $this->sendTelegramsInBackground($text); + } + else { + $this->sendTelegramsWithApi($text); + } + } + + // public! + function sendTelegramsWithApi(string $text) + { + if (!isset($this->prefs['token'])) { + return; + } + $api = new TelegramApi($this->prefs['token']); + $blockedChats = []; + foreach ($this->prefs['chats'] as $chatId) { + // try-catch? + $result = $api->sendMessage([ + 'chat_id' => $chatId, + 'parse_mode' => 'HTML', //or MarkdownV2 + 'text' => $text, + 'disable_web_page_preview' => true + ]); + if (!$result && $api->lastError) { + if ($api->lastError['error_code'] == 403) { + // User has blocked the bot + $blockedChats[] = $chatId; + error_log("Bot is blocked in chat $chatId, chat will be deactivated"); + } + else { + error_log("Telegram API Error ". $api->lastError['error_code']. ": ". $api->lastError['description']); + } + } + } + //We can remove blocked chats from settings + if (count($blockedChats) > 0) { + $this->prefs['chats'] = array_diff($this->prefs['chats'], $blockedChats); + \Config::saveDomain(NotificationsExtension::domain, $this->prefs); + } + } + + private function sendTelegramsInBackground(string $text) + { + $hash = password_hash($this->prefs['token'], PASSWORD_DEFAULT); + $dir = __DIR__; + $outfile = ''; # or '> /dev/null 2>&1'; + //$outfile = '> /dev/null 2>&1'; + // if (MTT_DEBUG) { + // $outfile = "> $dir/../../db/cli-notify.log 2>&1"; + // } + $fh = popen("php -f $dir/cli-notify.php $outfile", 'w'); + fwrite($fh, $hash."\n".$text); + fclose($fh); + } + +} diff --git a/src/ext/notifications/class.telegramapi.php b/src/ext/notifications/class.telegramapi.php new file mode 100644 index 0000000..afe6d2a --- /dev/null +++ b/src/ext/notifications/class.telegramapi.php @@ -0,0 +1,86 @@ + + Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. +*/ + +namespace Notify; + +class TelegramApi +{ + private $token = ''; + /** @var ?array $lastError */ + public $lastError = null; + + function __construct(string $token) + { + $this->token = $token; + } + + function getMe(): ?array + { + return $this->makeGetRequest('getMe'); + } + + function getUpdates(?array $params = null): ?array + { + return $this->makePostRequest('getUpdates', $params ?? []); + } + + function sendMessage(array $params): ?array + { + return $this->makePostRequest('sendMessage', $params); + } + + private function makeGetRequest(string $method): ?array + { + $this->lastError = null; + $body = @file_get_contents('https://api.telegram.org/bot'. $this->token .'/'. $method, false); + if ($body === false) { + throw new \Exception("Failed to make request to Telegram API"); + } + $decodedBody = $this->decodeBody($body); + return $decodedBody['result'] ?? []; + } + + private function makePostRequest(string $method, array $params): ?array + { + $json = json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + $options = array( + 'http' => array( + 'header' => "Content-type: application/json\r\n", + 'method' => 'POST', + 'content' => $json, + 'ignore_errors' => true + ) + ); + $context = stream_context_create($options); + $this->lastError = null; + $body = @file_get_contents('https://api.telegram.org/bot'. $this->token .'/'. $method, false, $context); + if ($body === false) { + throw new \Exception("Failed to make request to Telegram API"); + } + $decodedBody = $this->decodeBody($body); + return $decodedBody['result'] ?? []; + } + + private function decodeBody(string $body): array + { + $decodedBody = json_decode($body, true); + if (!is_array($decodedBody)) { + $decodedBody = []; + } + if (!isset($decodedBody['ok'])) { + throw new \Exception("Telegram API Error"); + } + if ($decodedBody['ok'] === false) { + $this->lastError = [ + 'error_code' => $decodedBody['error_code'] ?? 0, + 'description' => ($decodedBody['description'] ?? '') + ]; + } + return $decodedBody; + } +} diff --git a/src/ext/notifications/cli-notify.php b/src/ext/notifications/cli-notify.php new file mode 100644 index 0000000..9dfc642 --- /dev/null +++ b/src/ext/notifications/cli-notify.php @@ -0,0 +1,59 @@ + + Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. +*/ + +set_time_limit(30); + +if (php_sapi_name() != 'cli') { + error_log("Supports cli only"); + exit(-1); +} +if (!function_exists('pcntl_fork')) { + error_log("Required PHP module is not found: pcntl"); + exit(-2); +} +$dontStartSession = 1; +require(__DIR__.'/../../init.php'); + +$hash = fgets(STDIN); +if ($hash === false) { + error_log("No input"); + exit(-3); +} +$hash = trim($hash); +$text = stream_get_contents(STDIN); + +// Wi will fork a child to do a long work +$pid = pcntl_fork(); +if ($pid == -1) { + error_log("Failed to fork a child"); + exit(-1); +} +else if ($pid) { + // parent will not wait for child's exit + exit; +} + +// Child is here, detach it +if (posix_setsid() < 0) { + error_log("posix_setsid() failed"); + exit; +} + +$prefs = NotificationsExtension::preferences(); +if (!isset($prefs['token'])) { + error_log("No telegram token"); + exit(-4); +} +$token = $prefs['token'] ?? ''; +if (!password_verify($prefs['token'], $hash)) { + error_log("Not authorized"); + exit(-5); +} + +$sender = new Notify\Sender($prefs); +$sender->sendTelegramsWithApi($text); diff --git a/src/ext/notifications/extension.json b/src/ext/notifications/extension.json new file mode 100644 index 0000000..b76879c --- /dev/null +++ b/src/ext/notifications/extension.json @@ -0,0 +1,6 @@ +{ + "bundleId": "notifications", + "name": "Notifications", + "version": "0.9", + "description": "Notify about new tasks and lists on e-mail or telegram" +} diff --git a/src/ext/notifications/lang/en.json b/src/ext/notifications/lang/en.json new file mode 100644 index 0000000..efa6aa8 --- /dev/null +++ b/src/ext/notifications/lang/en.json @@ -0,0 +1,26 @@ +{ + "ext.notifications.name": "Notifications", + "notifications.check": "Check", + "notifications.bot_not_configured": "Bot is not configured", + "notifications.h_email": "E-Mail:", + "notifications.d_email": "Separate multiple addresses with comma.", + "notifications.h_telegram": "Telegram", + "notifications.h_token": "Bot token:", + "notifications.d_token": "Telegram Bot API token from @BotFather.", + "notifications.h_active_chats": "Active chats:", + "notifications.d_active_chats": "Number of chats where bot sends notifications.", + "notifications.deactivate_all": "Deactivate all", + "notifications.h_new_chat": "New chat:", + "notifications.d_new_chat": "Start new conversation with the bot, send this code to the chat and click \"Check\" here.", + "notifications.saved": "Saved", + "notifications.invalid_email": "Invalid email address", + "notifications.no_bot_info": "Can not get bot info, seems token is invalid", + "notifications.all_chats_deactivated": "All chats deactivated", + "notifications.no_new_chats": "No new chats", + "notifications.already_active": "Already active", + "notifications.please_send": "Please send a code to activate", + "notifications.code_not_set": "Code is not set", + "notifications.code_expired": "Code has expired", + "notifications.code_wrong": "Wrong code", + "notifications.activated": "Activated" +} diff --git a/src/ext/notifications/lang/ru.json b/src/ext/notifications/lang/ru.json new file mode 100644 index 0000000..04e20f0 --- /dev/null +++ b/src/ext/notifications/lang/ru.json @@ -0,0 +1,26 @@ +{ + "ext.notifications.name": "Уведомления", + "notifications.check": "Проверить", + "notifications.bot_not_configured": "Бот не настроен", + "notifications.h_email": "E-Mail:", + "notifications.d_email": "Разделите несколько адресов c помощью запятой.", + "notifications.h_telegram": "Телеграм", + "notifications.h_token": "Токен для бота:", + "notifications.d_token": "Токен для телеграм-бота, полученный от @BotFather.", + "notifications.h_active_chats": "Активные чаты:", + "notifications.d_active_chats": "Количество чатов, куда бот присылает уведомления.", + "notifications.deactivate_all": "Отключить все", + "notifications.h_new_chat": "Новый чат:", + "notifications.d_new_chat": "Начните чат с ботом, отправьте ему этот код и нажмите \"Проверить\"", + "notifications.saved": "Сохранено", + "notifications.invalid_email": "Некорректный адрес e-mail", + "notifications.no_bot_info": "Ошибка в подключении бота; возможно, токен некорректный", + "notifications.all_chats_deactivated": "All chats deactivated", + "notifications.no_new_chats": "Нет новых чатов", + "notifications.already_active": "Уже активирован", + "notifications.please_send": "Пожалуйста, отправьте код для активации", + "notifications.code_not_set": "Код не установлен", + "notifications.code_expired": "Истек срок действия кода", + "notifications.code_wrong": "Неправильный код", + "notifications.activated": "Активирован" +} diff --git a/src/ext/notifications/loader.php b/src/ext/notifications/loader.php new file mode 100644 index 0000000..eced61a --- /dev/null +++ b/src/ext/notifications/loader.php @@ -0,0 +1,209 @@ + + Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. +*/ + +if (!defined('MTTPATH')) { + die("Unexpected usage."); +} + +if (!function_exists('mb_internal_encoding')) { + throw new Exception("Required PHP module is not found: mbstring"); +} +if (strtoupper(mb_internal_encoding()) != 'UTF-8') { + throw new Exception("mb_internal_encoding is not UTF-8"); +} + +require_once('class.observer.php'); +require_once('class.controller.php'); +require_once('class.sender.php'); +require_once('class.telegramapi.php'); + + +// on PHP-FPM we can send telegrams on didFinishRequest without delay + +// folder is the bundleId of extension +// name of function for extension loader has format "mtt_ext_${bundleId}_loader" + +function mtt_ext_notifications_instance(): MTTExtension +{ + return new NotificationsExtension(); +} + +use Notify\NotificationObserver; +use Notify\Controller; +use Notify\TelegramApi; + +class NotificationsExtension extends MTTExtension implements MTTHttpApiExtender, MTTExtensionSettingsInterface +{ + //the same as dir name + const bundleId = 'notifications'; + + // settings domain + const domain = "ext.notifications.json"; + + function init() + { + // subscribe for notifications + MTTNotificationCenter::addObserverForNotifications( + [ MTTNotification::didCreateTask, MTTNotification::didCreateList ], + new NotificationObserver() + ); + } + + // produces smth like like /ext/notifications/deactivate + function extendHttpApi(): array + { + return array( + '/deactivate' => [ + 'POST' => [ Controller::class , 'postDeactivateAll' ], + ], + '/check' => [ + 'POST' => [ Controller::class , 'postCheck' ], + ] + ); + } + + function settingsPage(): string + { + $e = function($s) { return __($s, true); }; + $ext = htmlspecialchars(self::bundleId); + $prefs = self::preferences(); + $emails = htmlspecialchars( implode(', ', $prefs['emails']) ); + $numberOfChats = count($prefs['chats']); + + $token = $prefs['token'] ?? ''; + if (defined('MTT_DEMO') && $token != '') { + $token = ""; + } + $token = htmlspecialchars($token); + + $botname = $prefs['botname'] ?? ''; + $botLink = ''; + if ($botname != '') { + $botname = htmlspecialchars($botname); + $botLink = "@$botname"; + + $code = $prefs['code'] ?? null; + $codeExpires = $prefs['codeExpires'] ?? 0; + if ($code === null || $codeExpires < time()) { + $prefs['code'] = $code = randomString(6, '0123456789'); + $prefs['codeExpires'] = $codeExpires = time() + 60*15; // 15 min + Config::saveDomain(self::domain, $prefs); + } + $newChat = "$botLink $code {$e('notifications.check')}"; + } + else { + $newChat = $e('notifications.bot_not_configured'); + } + + //$e = function($s) { return __($s, true); }; + //$c = function($key) { return htmlspecialchars(Config::get($key)); }; + + return +<< + {$e('notifications.h_email')} + {$e('notifications.d_email')} + + + + + {$e('notifications.h_telegram')} + + + {$e('notifications.h_token')} + {$e('notifications.d_token')} + + + + + {$e('notifications.h_active_chats')} + {$e('notifications.d_active_chats')} + + $numberOfChats {$e('notifications.deactivate_all')} + + + {$e('notifications.h_new_chat')} + {$e('notifications.d_new_chat')} + + $newChat + +EOD; + } + + function saveSettings(array $params, ?string &$outMessage): bool + { + if (defined('MTT_DEMO')) { + $outMessage = "Demo"; + return true; + } + $token = $params['token'] ?? ''; + $emails = $params['emails'] ?? ''; + if (!is_string($token) || !is_string($emails)) { + throw new Exception("Invalid format"); + } + + $prefs = Config::requestDomain(self::domain); + if ($token !== ($prefs['token'] ?? '')) { + $prefs['botname'] = ''; + $prefs['chats'] = []; + $prefs['validToken'] = false; + } + $prefs['token'] = $token; + $prefs['code'] = null; + $prefs['emails'] = []; + + // validate emails + if ($emails != '') { + $a = explode(',', $emails); + foreach ($a as $email) { + $email = trim($email); + if (preg_match('/^[^\s\@\|]+@[^\s\@\|]+$/', $email)) { + $prefs['emails'][] = $email; + } + else { + $outMessage = __('notifications.invalid_email'); + return false; + } + } + } + + + // validate token + if ($token != '' && !$prefs['validToken']) { + $api = new TelegramApi($token); + try { + $result = $api->getMe(); + if ($result && isset($result['username'])) { + $prefs['botname'] = $result['username']; + } + $prefs['validToken'] = true; + } + catch (Exception $e) { + error_log($e->getMessage()); + $outMessage = __('notifications.no_bot_info');; + return false; + } + } + + Config::saveDomain(self::domain, $prefs); + $outMessage = __('notifications.saved'); + return true; + } + + static function preferences(): array + { + $prefs = Config::requestDomain(self::domain); + if (!isset($prefs['chats']) || !is_array($prefs['chats'])) { + $prefs['chats'] = []; + } + if (!isset($prefs['emails']) || !is_array($prefs['emails'])) { + $prefs['emails'] = []; + } + return $prefs; + } +}