+ add extension for Notifications

This commit is contained in:
maxpozdeev 2022-09-23 12:06:46 +03:00
parent 0512a5d1a6
commit 5370d36b30
10 changed files with 785 additions and 0 deletions

View file

@ -0,0 +1 @@
deny from all

View file

@ -0,0 +1,116 @@
<?php declare(strict_types=1);
/*
This file is a part of myTinyTodo.
(C) Copyright 2022 Max Pozdeev <maxpozdeev@gmail.com>
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);
}
}

View file

@ -0,0 +1,65 @@
<?php declare(strict_types=1);
/*
This file is a part of myTinyTodo.
(C) Copyright 2022 Max Pozdeev <maxpozdeev@gmail.com>
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'] ?? '';
}
}

View file

@ -0,0 +1,191 @@
<?php declare(strict_types=1);
/*
This file is a part of myTinyTodo.
(C) Copyright 2022 Max Pozdeev <maxpozdeev@gmail.com>
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 <a href=\"$link\">#". $task['id']. "</a> 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: <a href=\"$link\">". $list['name']. "</a>";
$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);
}
}

View file

@ -0,0 +1,86 @@
<?php declare(strict_types=1);
/*
This file is a part of myTinyTodo.
(C) Copyright 2022 Max Pozdeev <maxpozdeev@gmail.com>
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;
}
}

View file

@ -0,0 +1,59 @@
<?php declare(strict_types=1);
/*
This file is a part of myTinyTodo.
(C) Copyright 2022 Max Pozdeev <maxpozdeev@gmail.com>
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);

View file

@ -0,0 +1,6 @@
{
"bundleId": "notifications",
"name": "Notifications",
"version": "0.9",
"description": "Notify about new tasks and lists on e-mail or telegram"
}

View file

@ -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"
}

View file

@ -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": "Активирован"
}

View file

@ -0,0 +1,209 @@
<?php declare(strict_types=1);
/*
This file is a part of myTinyTodo.
(C) Copyright 2022 Max Pozdeev <maxpozdeev@gmail.com>
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 <API_PATH>/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 = "<demo>";
}
$token = htmlspecialchars($token);
$botname = $prefs['botname'] ?? '';
$botLink = '';
if ($botname != '') {
$botname = htmlspecialchars($botname);
$botLink = "<a href='https://t.me/$botname' target='_blank'>@$botname</a>";
$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 <br><br>$code &nbsp; <a href=\"#\" data-ext-settings-action=\"post:check\" data-ext=\"$ext\">{$e('notifications.check')}</a>";
}
else {
$newChat = $e('notifications.bot_not_configured');
}
//$e = function($s) { return __($s, true); };
//$c = function($key) { return htmlspecialchars(Config::get($key)); };
return
<<<EOD
<div class="tr">
<div class="th"> {$e('notifications.h_email')}
<div class="descr">{$e('notifications.d_email')}</div>
</div>
<div class="td"> <input name="emails" value="$emails" class="in350" autocomplete="off" /> </div>
</div>
<div class="tr">
<div class="th"> {$e('notifications.h_telegram')} </div>
</div>
<div class="tr">
<div class="th"> {$e('notifications.h_token')}
<div class="descr">{$e('notifications.d_token')}</div>
</div>
<div class="td"> <input name="token" value="$token" class="in350" autocomplete="off" /> </div>
</div>
<div class="tr">
<div class="th"> {$e('notifications.h_active_chats')}
<div class="descr">{$e('notifications.d_active_chats')}</div>
</div>
<div class="td"> $numberOfChats &nbsp; <a href="#" data-ext-settings-action="post:deactivate" data-ext="$ext">{$e('notifications.deactivate_all')}</a> </div>
</div>
<div class="tr">
<div class="th"> {$e('notifications.h_new_chat')}
<div class="descr">{$e('notifications.d_new_chat')}</div>
</div>
<div class="td"> $newChat </div>
</div>
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;
}
}