From 5ff450dc7168fd6b89ebb41979d6b9a6605b9ded Mon Sep 17 00:00:00 2001 From: root Date: Fri, 21 Aug 2026 15:38:41 +0300 Subject: [PATCH 01/13] Support two-way message replies and quotes for Telegram forum topics and webhooks --- bootstrap/bootstrap.php | 130 +++++++++++++++++++-- classes/Commands/GenericmessageCommand.php | 45 ++++++- doc/telegram/incoming-webhook.json | 2 +- doc/telegram/rest-api.json | 2 +- 4 files changed, 166 insertions(+), 13 deletions(-) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index a8194ad..70a0be8 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -441,7 +441,67 @@ private function isMeaningfulTelegramUploadName($file) return true; } - private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotification = false) + public function saveTopicMsgId($msg, $topicMsgId) + { + if (!($msg instanceof erLhcoreClassModelmsg) || !(int)$topicMsgId || $msg->id <= 0) { + return; + } + + $meta = is_array($msg->meta_msg_array) ? $msg->meta_msg_array : []; + $meta['tg_topic_msg_id'] = (int)$topicMsgId; + $msg->meta_msg_array = $meta; + $msg->meta_msg = json_encode($meta); + + $stmt = ezcDbInstance::get()->prepare("UPDATE lh_msg SET meta_msg = :meta_msg WHERE id = :id"); + $stmt->bindValue(':meta_msg', $msg->meta_msg); + $stmt->bindValue(':id', (int)$msg->id, PDO::PARAM_INT); + $stmt->execute(); + } + + public function getTopicReplyId($msg, $chatId) + { + if (!($msg instanceof erLhcoreClassModelmsg)) { + return null; + } + + $meta = $msg->meta_msg_array; + + if (isset($meta['content']['reply_to']['db_msg_id']) && (int)$meta['content']['reply_to']['db_msg_id'] > 0) { + $targetMsg = erLhcoreClassModelmsg::fetch((int)$meta['content']['reply_to']['db_msg_id']); + if ($targetMsg instanceof erLhcoreClassModelmsg && isset($targetMsg->meta_msg_array['tg_topic_msg_id']) && (int)$targetMsg->meta_msg_array['tg_topic_msg_id'] > 0) { + return (int)$targetMsg->meta_msg_array['tg_topic_msg_id']; + } + } + + if (isset($meta['content']['reply_to']['iwh_msg_id']) && $meta['content']['reply_to']['iwh_msg_id'] != '') { + $iwhId = (string)$meta['content']['reply_to']['iwh_msg_id']; + $targetMsg = erLhcoreClassModelmsg::findOne([ + 'filter' => ['chat_id' => $chatId], + 'customfilter' => ['`meta_msg` != \'\' AND JSON_VALID(`meta_msg`) AND (JSON_UNQUOTE(JSON_EXTRACT(meta_msg,\'$.iwh_msg_id\')) = ' . ezcDbInstance::get()->quote($iwhId) . ' OR JSON_EXTRACT(meta_msg,\'$.iwh_msg_id\') = ' . (is_numeric($iwhId) ? (int)$iwhId : ezcDbInstance::get()->quote($iwhId)) . ')'] + ]); + if ($targetMsg instanceof erLhcoreClassModelmsg && isset($targetMsg->meta_msg_array['tg_topic_msg_id']) && (int)$targetMsg->meta_msg_array['tg_topic_msg_id'] > 0) { + return (int)$targetMsg->meta_msg_array['tg_topic_msg_id']; + } + } + + if (isset($meta['content']['quote']['id']) && (int)$meta['content']['quote']['id'] > 0) { + $targetMsg = erLhcoreClassModelmsg::fetch((int)$meta['content']['quote']['id']); + if ($targetMsg instanceof erLhcoreClassModelmsg && isset($targetMsg->meta_msg_array['tg_topic_msg_id']) && (int)$targetMsg->meta_msg_array['tg_topic_msg_id'] > 0) { + return (int)$targetMsg->meta_msg_array['tg_topic_msg_id']; + } + } + + if (preg_match('#\[quote="?([0-9]+)"?\]#is', (string)$msg->msg, $m)) { + $targetMsg = erLhcoreClassModelmsg::fetch((int)$m[1]); + if ($targetMsg instanceof erLhcoreClassModelmsg && isset($targetMsg->meta_msg_array['tg_topic_msg_id']) && (int)$targetMsg->meta_msg_array['tg_topic_msg_id'] > 0) { + return (int)$targetMsg->meta_msg_array['tg_topic_msg_id']; + } + } + + return null; + } + + private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotification = false, $params = array()) { $file = $fileData['file']; @@ -475,6 +535,10 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif $field => $this->getTelegramChatFileUrl($file) ); + if (isset($params['reply_to_message_id']) && $params['reply_to_message_id'] > 0) { + $data['reply_to_message_id'] = $params['reply_to_message_id']; + } + if ($caption !== '') { $data['caption'] = $caption; } @@ -515,7 +579,7 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif return false; } - return true; + return $sendData->getResult()->getMessageId(); } private function getTelegramChatFileUrl($file) @@ -580,8 +644,17 @@ public function messageAdded($params) $data['disable_notification'] = true; } + $replyTopicMsgId = $this->getTopicReplyId($params['msg'], $chat->id); + if ($replyTopicMsgId > 0) { + $data['reply_to_message_id'] = $replyTopicMsgId; + } + $sendData = Longman\TelegramBot\Request::sendMessage($data); + if ($sendData->isOk()) { + $this->saveTopicMsgId($params['msg'], $sendData->getResult()->getMessageId()); + } + if (!$sendData->isOk() && $sendData->getErrorCode() == 400 && str_contains( $sendData->getDescription(), 'TOPIC_DELETED') === true) { // Reset telegram chat $tchat->tchat_id = 0; @@ -610,9 +683,13 @@ public function messageAdded($params) $failedEmbedCodes = array(); $fileIndex = 0; + $replyTopicMsgId = $this->getTopicReplyId($params['msg'], $chat->id); foreach ($telegramFiles as $telegramFile) { - if ($this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($params['msg'], $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT) === false) { + $sentFileMsgId = $this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($params['msg'], $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT, ['reply_to_message_id' => $replyTopicMsgId]); + if ($sentFileMsgId === false) { $failedEmbedCodes[] = $telegramFile['embed']; + } else { + $this->saveTopicMsgId($params['msg'], $sentFileMsgId); } $fileIndex++; } @@ -671,7 +748,9 @@ public function messageAdded($params) } $sendData = Longman\TelegramBot\Request::sendMessage($data); - if (!$sendData->isOk()) { + if ($sendData->isOk()) { + $this->saveTopicMsgId($botMessage, $sendData->getResult()->getMessageId()); + } else { erLhcoreClassLog::write('SendMessage BOT ['.$sendData->getErrorCode().']'. $sendData->getDescription(), ezcLog::SUCCESS_AUDIT, array( @@ -690,8 +769,11 @@ public function messageAdded($params) $fileIndex = 0; foreach ($telegramFiles as $telegramFile) { - if ($this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT) === false) { + $sentFileMsgId = $this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT); + if ($sentFileMsgId === false) { $failedEmbedCodes[] = $telegramFile['embed']; + } else { + $this->saveTopicMsgId($botMessage, $sentFileMsgId); } $fileIndex++; } @@ -767,7 +849,9 @@ public function triggerClicked($params) } $sendData = Longman\TelegramBot\Request::sendMessage($data); - if (!$sendData->isOk()) { + if ($sendData->isOk()) { + $this->saveTopicMsgId($botMessage, $sendData->getResult()->getMessageId()); + } else { erLhcoreClassLog::write('['.$sendData->getErrorCode().']'. $sendData->getDescription(), ezcLog::SUCCESS_AUDIT, array( @@ -786,8 +870,11 @@ public function triggerClicked($params) $fileIndex = 0; foreach ($telegramFiles as $telegramFile) { - if ($this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT) === false) { + $sentFileMsgId = $this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT); + if ($sentFileMsgId === false) { $failedEmbedCodes[] = $telegramFile['embed']; + } else { + $this->saveTopicMsgId($botMessage, $sentFileMsgId); } $fileIndex++; } @@ -926,7 +1013,17 @@ public function chatStarted($params) $sendData = Longman\TelegramBot\Request::sendMessage($data); - if (!$sendData->isOk()) { + if ($sendData->isOk()) { + $firstVisitorMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id, 'user_id' => 0], 'sort' => 'id ASC']); + if ($firstVisitorMsg instanceof erLhcoreClassModelmsg) { + $this->saveTopicMsgId($firstVisitorMsg, $sendData->getResult()->getMessageId()); + } else { + $firstMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id], 'sort' => 'id ASC']); + if ($firstMsg instanceof erLhcoreClassModelmsg) { + $this->saveTopicMsgId($firstMsg, $sendData->getResult()->getMessageId()); + } + } + } else { // Try first time to create a topic if old one is gone if ($sendData->getErrorCode() == 400 && (str_contains($sendData->getDescription(), 'message thread not found') || str_contains($sendData->getDescription(), 'TOPIC_DELETED'))) { @@ -946,7 +1043,17 @@ public function chatStarted($params) $data['message_thread_id'] = $tChat->tchat_id; $sendData = Longman\TelegramBot\Request::sendMessage($data); - if (!$sendData->isOk()) { + if ($sendData->isOk()) { + $firstVisitorMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id, 'user_id' => 0], 'sort' => 'id ASC']); + if ($firstVisitorMsg instanceof erLhcoreClassModelmsg) { + $this->saveTopicMsgId($firstVisitorMsg, $sendData->getResult()->getMessageId()); + } else { + $firstMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id], 'sort' => 'id ASC']); + if ($firstMsg instanceof erLhcoreClassModelmsg) { + $this->saveTopicMsgId($firstMsg, $sendData->getResult()->getMessageId()); + } + } + } else { erLhcoreClassLog::write('['.$sendData->getErrorCode().']'. $sendData->getDescription(), ezcLog::SUCCESS_AUDIT, array( @@ -964,8 +1071,11 @@ public function chatStarted($params) $failedEmbedCodes = array(); foreach ($initialTelegramFiles as $initialTelegramFile) { - if ($this->sendTelegramChatFile($tChat, $initialTelegramFile['file'], $this->getTelegramFileCaption($initialTelegramFile['msg'], $params['chat'], $initialTelegramFile['file']['file'], $initialTelegramFile['text']), $params['chat']->status == erLhcoreClassModelChat::STATUS_BOT_CHAT) === false) { + $sentFileMsgId = $this->sendTelegramChatFile($tChat, $initialTelegramFile['file'], $this->getTelegramFileCaption($initialTelegramFile['msg'], $params['chat'], $initialTelegramFile['file']['file'], $initialTelegramFile['text']), $params['chat']->status == erLhcoreClassModelChat::STATUS_BOT_CHAT); + if ($sentFileMsgId === false) { $failedEmbedCodes[] = $initialTelegramFile['file']['embed']; + } else if (isset($initialTelegramFile['msg']) && $initialTelegramFile['msg'] instanceof erLhcoreClassModelmsg) { + $this->saveTopicMsgId($initialTelegramFile['msg'], $sentFileMsgId); } } diff --git a/classes/Commands/GenericmessageCommand.php b/classes/Commands/GenericmessageCommand.php index c80c493..d303a19 100644 --- a/classes/Commands/GenericmessageCommand.php +++ b/classes/Commands/GenericmessageCommand.php @@ -341,7 +341,50 @@ public function execute(): ServerResponse if ($ignoreMessage == false) { $msg = new \erLhcoreClassModelmsg(); - $msg->msg = $text; + $msgText = $text; + $metaMsg = []; + + $replyTo = $message->getReplyToMessage(); + $isExplicitReply = ($replyTo && (int)$replyTo->getMessageId() !== (int)$message->getMessageThreadId() && !$replyTo->getForumTopicCreated()); + + if ($isExplicitReply) { + $replyTopicMsgId = (int)$replyTo->getMessageId(); + $metaMsg['tg_topic_msg_id'] = (int)$message->getMessageId(); + + $replyMsg = \erLhcoreClassModelmsg::findOne([ + 'filter' => ['chat_id' => $chat->id], + 'customfilter' => ['`meta_msg` != \'\' AND JSON_VALID(`meta_msg`) AND JSON_EXTRACT(meta_msg, \'$.tg_topic_msg_id\') = ' . $replyTopicMsgId] + ]); + + if ($replyMsg instanceof \erLhcoreClassModelmsg) { + $quoteText = $message->getQuote() ? trim($message->getQuote()->getText()) : $replyMsg->msg; + $replyNick = $replyMsg->name_support != '' ? $replyMsg->name_support : $chat->nick; + $msgText = '[quote=' . $replyMsg->id . ']' . $quoteText . '[/quote]' . $msgText; + + $metaMsg['content'] = [ + 'quote' => [ + 'id' => $replyMsg->id, + 'text' => $quoteText, + 'nick' => $replyNick + ], + 'reply_to' => [ + 'db_msg_id' => $replyMsg->id + ] + ]; + + if (isset($replyMsg->meta_msg_array['iwh_msg_id']) && $replyMsg->meta_msg_array['iwh_msg_id'] != '') { + $metaMsg['content']['reply_to']['iwh_msg_id'] = $replyMsg->meta_msg_array['iwh_msg_id']; + } + } + } else { + $metaMsg['tg_topic_msg_id'] = (int)$message->getMessageId(); + } + + $msg->msg = $msgText; + if (!empty($metaMsg)) { + $msg->meta_msg = json_encode($metaMsg); + $msg->meta_msg_array = $metaMsg; + } $msg->chat_id = $chat->id; $msg->user_id = $messageUserId; $msg->time = time(); diff --git a/doc/telegram/incoming-webhook.json b/doc/telegram/incoming-webhook.json index 9399c29..91df02f 100644 --- a/doc/telegram/incoming-webhook.json +++ b/doc/telegram/incoming-webhook.json @@ -1 +1 @@ -{"name":"TelegramIntegration","dep_id":1,"disabled":0,"identifier":"","scope":"telegram","configuration":"{\"attr\":[{\"key\":\"access_token\",\"value\":\"___replace_me___\",\"id\":\"temp1694762926692\",\"$$hashKey\":\"object:195\"},{\"key\":\"bot_username\",\"value\":\"___replace_me___\",\"id\":\"temp1695295631652\",\"$$hashKey\":\"object:300\"}],\"messages\":\"\",\"message_direct\":true,\"nick\":\"message.from.first_name|||message.from.last_name|||callback_query.from.first_name|||callback_query.from.last_name|||message_reaction.chat.first_name|||message_reaction.chat.last_name|||edited_message.from.first_name|||edited_message.from.last_name\",\"country_code\":\"\",\"chat_id\":\"message.chat.id|||callback_query.message.chat.id|||message_reaction.chat.id|||edited_message.chat.id\",\"msg_body\":\"{{msg.message.text}}\",\"msg_cond\":\"message.text=__exists__\",\"msg_cond_img\":\"message.photo=__exists__\",\"msg_img\":\"{{msg.message.caption}}\\n{{msg.body}}\",\"msg_cond_2\":\"\",\"msg_body_2\":\"\",\"msg_cond_img_url_decode\":\"https:\/\/api.telegram.org\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/getFile?file_id={{msg.message.photo___array_pop.file_id}}\",\"msg_cond_img_url_decode_output\":\"result.file_path||https:\/\/api.telegram.org\/file\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/{{response}}\",\"msg_cond_img_body\":\"body\",\"msg_img_download\":false,\"msg_cond_img_url_remote_location\":true,\"msg_cond_attachments\":\"message.document=__exists__\",\"msg_attachments\":\"{{msg.body}}\\n{{msg.message.document.file_name}}\",\"msg_cond_attachments_body\":\"body\",\"msg_cond_attachments_url_decode\":\"https:\/\/api.telegram.org\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/getFile?file_id={{msg.message.document.file_id}}\",\"msg_cond_attachments_url_remote_location\":true,\"msg_cond_attachments_url_remote_headers_content\":\"\",\"msg_cond_attachments_url_decode_output\":\"result.file_path||https:\/\/api.telegram.org\/file\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/{{response}}\",\"msg_cond_attachments_file_name\":\"\",\"msg_img_2\":\"{{msg.body}}\\n{{msg.message.sticker.set_name}}\",\"msg_cond_img_2_body\":\"body\",\"msg_cond_img_2_url_decode\":\"https:\/\/api.telegram.org\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/getFile?file_id={{msg.message.sticker.file_id}}\",\"msg_cond_img_2_url_decode_output\":\"result.file_path||https:\/\/api.telegram.org\/file\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/{{response}}\",\"msg_cond_img_2_url_remote_location\":true,\"msg_cond_img_2\":\"message.sticker=__exists__\",\"msg_img_3\":\"{{msg.body}}\",\"msg_cond_img_3_body\":\"body\",\"msg_cond_img_3_url_decode\":\"https:\/\/api.telegram.org\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/getFile?file_id={{msg.message.voice.file_id}}\",\"msg_cond_img_3_url_decode_output\":\"result.file_path||https:\/\/api.telegram.org\/file\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/{{response}}\",\"msg_cond_img_3_url_remote_location\":true,\"msg_cond_img_3\":\"message.voice=__exists__\",\"msg_img_4\":\"{{msg.body}}\",\"msg_cond_img_4_body\":\"body\",\"msg_cond_img_4_url_decode\":\"https:\/\/api.telegram.org\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/getFile?file_id={{msg.message.video_note.file_id}}\",\"msg_cond_img_4_url_decode_output\":\"result.file_path||https:\/\/api.telegram.org\/file\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/{{response}}\",\"msg_cond_img_4_url_remote_location\":true,\"msg_cond_img_4\":\"message.video_note=__exists__\",\"msg_img_5\":\"{{msg.body}}\\n{{msg.message.audio.file_name}}\",\"msg_cond_img_5_body\":\"body\",\"msg_cond_img_5_url_decode\":\"https:\/\/api.telegram.org\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/getFile?file_id={{msg.message.audio.file_id}}\",\"msg_cond_img_5_url_decode_output\":\"result.file_path||https:\/\/api.telegram.org\/file\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/{{response}}\",\"msg_cond_img_5_url_remote_location\":true,\"msg_cond_img_5\":\"message.audio=__exists__\",\"msg_btn_cond_1\":\"callback_query.data=__exists__\",\"msg_btn_payload_1\":\"callback_query.data\",\"add_field_2_value\":\"telegram_bot_id\",\"add_field_value\":\"message.from.username|||callback_query.from.username|||message_reaction.chat.username|||edited_message.chat.username\",\"msg_cond_img_file_size\":\"message.photo___array_pop.file_size\",\"msg_cond_img_2_file_size\":\"message.sticker.file_size\",\"msg_cond_img_3_file_size\":\"message.voice.file_size\",\"msg_cond_img_4_file_size\":\"message.video_note.file_size\",\"msg_cond_img_5_file_size\":\"message.audio.file_size\",\"msg_cond_attachments_file_size\":\"message.document.file_size\",\"msg_img_6\":\"{{msg.body}}\\n{{msg.message.video.file_name}}\",\"msg_cond_img_6_file_size\":\"message.video.file_size\",\"msg_cond_img_6_url_decode\":\"https:\/\/api.telegram.org\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/getFile?file_id={{msg.message.video.file_id}}\",\"msg_cond_img_6_url_decode_output\":\"result.file_path||https:\/\/api.telegram.org\/file\/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}\/{{response}}\",\"msg_cond_img_6_url_remote_location\":true,\"msg_cond_img_6\":\"message.video=__exists__\",\"msg_cond_img_6_body\":\"body\",\"msg_delivery_reaction_id\":\"message_reaction.message_id\",\"msg_delivery_reaction_condition\":\"message_reaction.new_reaction.0.type=__exists__\",\"msg_delivery_reaction_use_emoji\":true,\"msg_delivery_reaction_location\":\"message_reaction.new_reaction.0.emoji\",\"msg_delivery_reaction_remove_if_empty\":false,\"msg_delivery_reaction_remove_prev\":true,\"msg_delivery_un_reaction_id\":\"message_reaction.message_id\",\"msg_delivery_un_reaction_condition\":\"message_reaction.old_reaction.0.type=__exists__\",\"msg_delivery_un_reaction_use_emoji\":true,\"msg_delivery_edited_id\":\"edited_message.message_id\",\"msg_delivery_edited_condition\":\"edited_message=__exists__\",\"msg_delivery_edited_location\":\"edited_message.text\",\"message_id\":\"message.message_id\"}","icon":"social\/telegram-ico.png","icon_color":"","log_incoming":0,"log_failed_parse":0} \ No newline at end of file +{"name": "TelegramIntegration", "dep_id": 1, "disabled": 0, "identifier": "", "scope": "telegram", "configuration": "{\"attr\": [{\"key\": \"access_token\", \"value\": \"___replace_me___\", \"id\": \"temp1694762926692\", \"$$hashKey\": \"object:195\"}, {\"key\": \"bot_username\", \"value\": \"___replace_me___\", \"id\": \"temp1695295631652\", \"$$hashKey\": \"object:300\"}], \"messages\": \"\", \"message_direct\": true, \"nick\": \"message.from.first_name|||message.from.last_name|||callback_query.from.first_name|||callback_query.from.last_name|||message_reaction.chat.first_name|||message_reaction.chat.last_name|||edited_message.from.first_name|||edited_message.from.last_name\", \"country_code\": \"\", \"chat_id\": \"message.chat.id|||callback_query.message.chat.id|||message_reaction.chat.id|||edited_message.chat.id\", \"msg_body\": \"{{msg.message.text}}\", \"msg_cond\": \"message.text=__exists__\", \"msg_cond_img\": \"message.photo=__exists__\", \"msg_img\": \"{{msg.message.caption}}\\n{{msg.body}}\", \"msg_cond_2\": \"\", \"msg_body_2\": \"\", \"msg_cond_img_url_decode\": \"https://api.telegram.org/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/getFile?file_id={{msg.message.photo___array_pop.file_id}}\", \"msg_cond_img_url_decode_output\": \"result.file_path||https://api.telegram.org/file/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/{{response}}\", \"msg_cond_img_body\": \"body\", \"msg_img_download\": false, \"msg_cond_img_url_remote_location\": true, \"msg_cond_attachments\": \"message.document=__exists__\", \"msg_attachments\": \"{{msg.body}}\\n{{msg.message.document.file_name}}\", \"msg_cond_attachments_body\": \"body\", \"msg_cond_attachments_url_decode\": \"https://api.telegram.org/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/getFile?file_id={{msg.message.document.file_id}}\", \"msg_cond_attachments_url_remote_location\": true, \"msg_cond_attachments_url_remote_headers_content\": \"\", \"msg_cond_attachments_url_decode_output\": \"result.file_path||https://api.telegram.org/file/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/{{response}}\", \"msg_cond_attachments_file_name\": \"\", \"msg_img_2\": \"{{msg.body}}\\n{{msg.message.sticker.set_name}}\", \"msg_cond_img_2_body\": \"body\", \"msg_cond_img_2_url_decode\": \"https://api.telegram.org/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/getFile?file_id={{msg.message.sticker.file_id}}\", \"msg_cond_img_2_url_decode_output\": \"result.file_path||https://api.telegram.org/file/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/{{response}}\", \"msg_cond_img_2_url_remote_location\": true, \"msg_cond_img_2\": \"message.sticker=__exists__\", \"msg_img_3\": \"{{msg.body}}\", \"msg_cond_img_3_body\": \"body\", \"msg_cond_img_3_url_decode\": \"https://api.telegram.org/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/getFile?file_id={{msg.message.voice.file_id}}\", \"msg_cond_img_3_url_decode_output\": \"result.file_path||https://api.telegram.org/file/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/{{response}}\", \"msg_cond_img_3_url_remote_location\": true, \"msg_cond_img_3\": \"message.voice=__exists__\", \"msg_img_4\": \"{{msg.body}}\", \"msg_cond_img_4_body\": \"body\", \"msg_cond_img_4_url_decode\": \"https://api.telegram.org/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/getFile?file_id={{msg.message.video_note.file_id}}\", \"msg_cond_img_4_url_decode_output\": \"result.file_path||https://api.telegram.org/file/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/{{response}}\", \"msg_cond_img_4_url_remote_location\": true, \"msg_cond_img_4\": \"message.video_note=__exists__\", \"msg_img_5\": \"{{msg.body}}\\n{{msg.message.audio.file_name}}\", \"msg_cond_img_5_body\": \"body\", \"msg_cond_img_5_url_decode\": \"https://api.telegram.org/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/getFile?file_id={{msg.message.audio.file_id}}\", \"msg_cond_img_5_url_decode_output\": \"result.file_path||https://api.telegram.org/file/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/{{response}}\", \"msg_cond_img_5_url_remote_location\": true, \"msg_cond_img_5\": \"message.audio=__exists__\", \"msg_btn_cond_1\": \"callback_query.data=__exists__\", \"msg_btn_payload_1\": \"callback_query.data\", \"add_field_2_value\": \"telegram_bot_id\", \"add_field_value\": \"message.from.username|||callback_query.from.username|||message_reaction.chat.username|||edited_message.chat.username\", \"msg_cond_img_file_size\": \"message.photo___array_pop.file_size\", \"msg_cond_img_2_file_size\": \"message.sticker.file_size\", \"msg_cond_img_3_file_size\": \"message.voice.file_size\", \"msg_cond_img_4_file_size\": \"message.video_note.file_size\", \"msg_cond_img_5_file_size\": \"message.audio.file_size\", \"msg_cond_attachments_file_size\": \"message.document.file_size\", \"msg_img_6\": \"{{msg.body}}\\n{{msg.message.video.file_name}}\", \"msg_cond_img_6_file_size\": \"message.video.file_size\", \"msg_cond_img_6_url_decode\": \"https://api.telegram.org/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/getFile?file_id={{msg.message.video.file_id}}\", \"msg_cond_img_6_url_decode_output\": \"result.file_path||https://api.telegram.org/file/bot{{msg.incoming_webhook.incoming_dynamic_array.access_token}}/{{response}}\", \"msg_cond_img_6_url_remote_location\": true, \"msg_cond_img_6\": \"message.video=__exists__\", \"msg_cond_img_6_body\": \"body\", \"msg_delivery_reaction_id\": \"message_reaction.message_id\", \"msg_delivery_reaction_condition\": \"message_reaction.new_reaction.0.type=__exists__\", \"msg_delivery_reaction_use_emoji\": true, \"msg_delivery_reaction_location\": \"message_reaction.new_reaction.0.emoji\", \"msg_delivery_reaction_remove_if_empty\": false, \"msg_delivery_reaction_remove_prev\": true, \"msg_delivery_un_reaction_id\": \"message_reaction.message_id\", \"msg_delivery_un_reaction_condition\": \"message_reaction.old_reaction.0.type=__exists__\", \"msg_delivery_un_reaction_use_emoji\": true, \"msg_delivery_edited_id\": \"edited_message.message_id\", \"msg_delivery_edited_condition\": \"edited_message=__exists__\", \"msg_delivery_edited_location\": \"edited_message.text\", \"message_id\": \"message.message_id\", \"message_id_reply\": \"message.reply_to_message.message_id\"}", "icon": "social/telegram-ico.png", "icon_color": "", "log_incoming": 0, "log_failed_parse": 0} \ No newline at end of file diff --git a/doc/telegram/rest-api.json b/doc/telegram/rest-api.json index b03eaaf..898ed3e 100644 --- a/doc/telegram/rest-api.json +++ b/doc/telegram/rest-api.json @@ -1 +1 @@ -{"name":"TelegramIntegration","description":"","configuration":"{\"host\":\"https://api.telegram.org\",\"ecache\":false,\"parameters\":[{\"method\":\"POST\",\"authorization\":\"\",\"api_key_location\":\"header\",\"query\":[],\"header\":[],\"conditions\":[],\"postparams\":[],\"userparams\":[],\"output\":[{\"key\":\"\",\"value\":\"\",\"id\":\"temp1706685738487\",\"success_name\":\"Success\",\"success_header\":\"200\"}],\"id\":\"temp1695212526903\",\"name\":\"Send\",\"suburl\":\"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/sendMessage\",\"body_request_type\":\"raw\",\"body_raw\":\"{\\n \\\"chat_id\\\":{{args.chat.incoming_chat.chat_external_id}},\\\"parse_mode\\\":\\\"HTML\\\",\\n \\\"text\\\":{{msg_html_nobr}}\\n{interactive_api}\\n,\\\"reply_markup\\\":{\\n \\\"resize_keyboard\\\":true,\\n\\\"inline_keyboard\\\":[\\n{button_template}\\n [{\\n \\\"text\\\": {{button_title}},\\n \\\"{is_url}url{/is_url}{is_button}callback_data{/is_button}\\\":{{button_payload}}\\n }]\\n{/button_template}\\n]\\n}\\n\\n{/interactive_api}\\n}\",\"body_request_type_content\":\"json\",\"remote_message_id\":\"result:message_id\",\"suburl_file\":\"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/{api_by_ext__tgs}sendSticker{/api_by_ext}{api_by_ext__ogg}sendVoice{/api_by_ext}{api_by_ext__mp3_m4a}sendAudio{/api_by_ext}{api_by_ext__mp4}sendVideo{/api_by_ext}{image_api}sendPhoto{/image_api}{file_api}sendDocument{/file_api}\",\"body_raw_file\":\"{\\n \\\"chat_id\\\":{{args.chat.incoming_chat.chat_external_id}},\\n \\\"{api_by_ext__tgs}sticker{/api_by_ext}{api_by_ext__ogg}voice{/api_by_ext}{api_by_ext__mp3_m4a}audio{/api_by_ext}{api_by_ext__mp4}video{/api_by_ext}{file_api}document{/file_api}{image_api}photo{/image_api}\\\":{{file_url}}\\n{api_by_ext__ogg},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{api_by_ext__mp3_m4a},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{api_by_ext__mp4},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{file_api},\\\"caption\\\":{{msg_clean}}{/file_api}{image_api},\\\"caption\\\":{{msg_clean}}{/image_api}\\n}\",\"check_not_empty\":\"{{msg_html_nobr}}\",\"suburl_file_convert\":\"tgs,file_api,mp3_m4a,ogg\",\"suburl_file_skip_ext\":\"tgs\"},{\"method\":\"POST\",\"authorization\":\"\",\"api_key_location\":\"header\",\"query\":[],\"header\":[],\"conditions\":[],\"postparams\":[],\"userparams\":[],\"output\":[{\"key\":\"\",\"value\":\"\",\"id\":\"telegram_typing_success\",\"success_name\":\"Success\",\"success_header\":\"200\"}],\"id\":\"telegram_send_typing\",\"name\":\"Send typing\",\"suburl\":\"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/sendChatAction\",\"body_request_type\":\"raw\",\"body_request_type_content\":\"json\",\"body_raw\":\"{\\n \\\"chat_id\\\": {{args.chat.incoming_chat.chat_external_id}},\\n \\\"action\\\": \\\"typing\\\"\\n}\",\"check_not_empty\":\"{{args.chat.incoming_chat.chat_external_id}}\"}]}"} \ No newline at end of file +{"name": "TelegramIntegration", "description": "", "configuration": "{\"host\": \"https://api.telegram.org\", \"ecache\": false, \"parameters\": [{\"method\": \"POST\", \"authorization\": \"\", \"api_key_location\": \"header\", \"query\": [], \"header\": [], \"conditions\": [], \"postparams\": [], \"userparams\": [], \"output\": [{\"key\": \"\", \"value\": \"\", \"id\": \"temp1706685738487\", \"success_name\": \"Success\", \"success_header\": \"200\"}], \"id\": \"temp1695212526903\", \"name\": \"Send\", \"suburl\": \"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/sendMessage\", \"body_request_type\": \"raw\", \"body_raw\": \"{\\n \\\"chat_id\\\":{{args.chat.incoming_chat.chat_external_id}},\\\"parse_mode\\\":\\\"HTML\\\",\\n \\\"text\\\":{{msg_html_nobr}}\\n{reply_to},\\\"reply_parameters\\\":{\\\"message_id\\\":raw_{{iwh_msg_id}}}{/reply_to}\\n{interactive_api}\\n,\\\"reply_markup\\\":{\\n \\\"resize_keyboard\\\":true,\\n\\\"inline_keyboard\\\":[\\n{button_template}\\n [{\\n \\\"text\\\": {{button_title}},\\n \\\"{is_url}url{/is_url}{is_button}callback_data{/is_button}\\\":{{button_payload}}\\n }]\\n{/button_template}\\n]\\n}\\n\\n{/interactive_api}\\n}\", \"body_request_type_content\": \"json\", \"remote_message_id\": \"result:message_id\", \"suburl_file\": \"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/{api_by_ext__tgs}sendSticker{/api_by_ext}{api_by_ext__ogg}sendVoice{/api_by_ext}{api_by_ext__mp3_m4a}sendAudio{/api_by_ext}{api_by_ext__mp4}sendVideo{/api_by_ext}{image_api}sendPhoto{/image_api}{file_api}sendDocument{/file_api}\", \"body_raw_file\": \"{\\n \\\"chat_id\\\":{{args.chat.incoming_chat.chat_external_id}},\\n \\\"{api_by_ext__tgs}sticker{/api_by_ext}{api_by_ext__ogg}voice{/api_by_ext}{api_by_ext__mp3_m4a}audio{/api_by_ext}{api_by_ext__mp4}video{/api_by_ext}{file_api}document{/file_api}{image_api}photo{/image_api}\\\":{{file_url}}\\n{reply_to},\\\"reply_parameters\\\":\\\"{\\\\\\\"message_id\\\\\\\":raw_{{iwh_msg_id}}}\\\"{/reply_to}\\n{api_by_ext__ogg},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{api_by_ext__mp3_m4a},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{api_by_ext__mp4},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{file_api},\\\"caption\\\":{{msg_clean}}{/file_api}{image_api},\\\"caption\\\":{{msg_clean}}{/image_api}\\n}\", \"check_not_empty\": \"{{msg_html_nobr}}\", \"suburl_file_convert\": \"tgs,file_api,mp3_m4a,ogg\", \"suburl_file_skip_ext\": \"tgs\"}, {\"method\": \"POST\", \"authorization\": \"\", \"api_key_location\": \"header\", \"query\": [], \"header\": [], \"conditions\": [], \"postparams\": [], \"userparams\": [], \"output\": [{\"key\": \"\", \"value\": \"\", \"id\": \"telegram_typing_success\", \"success_name\": \"Success\", \"success_header\": \"200\"}], \"id\": \"telegram_send_typing\", \"name\": \"Send typing\", \"suburl\": \"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/sendChatAction\", \"body_request_type\": \"raw\", \"body_request_type_content\": \"json\", \"body_raw\": \"{\\n \\\"chat_id\\\": {{args.chat.incoming_chat.chat_external_id}},\\n \\\"action\\\": \\\"typing\\\"\\n}\", \"check_not_empty\": \"{{args.chat.incoming_chat.chat_external_id}}\"}]}"} \ No newline at end of file From f8be17949d92c243aa10afae37b1df35c5a24c83 Mon Sep 17 00:00:00 2001 From: mysubcult Date: Tue, 25 Aug 2026 23:28:38 +0400 Subject: [PATCH 02/13] Harden Telegram topic replies and media mapping --- bootstrap/bootstrap.php | 503 ++++++++++++++++++--- classes/Commands/GenericmessageCommand.php | 28 +- doc/telegram/rest-api.json | 2 +- tests/TelegramReplyContractTest.php | 119 +++++ 4 files changed, 576 insertions(+), 76 deletions(-) create mode 100644 tests/TelegramReplyContractTest.php diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index 70a0be8..a7cf904 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -3,6 +3,7 @@ #[\AllowDynamicProperties] class erLhcoreClassExtensionLhctelegram { + private $lastTelegramSendData = null; public function __construct() { @@ -408,6 +409,152 @@ private function appendTelegramMessageFile(& $files, & $seen, $id, $hash) ); } + /** + * Return the raw Telegram message payload on old and new telegram-core releases. + * telegram-core 79e5e3a keeps unknown fields (including Message.quote) in raw_data + * and exposes them through Entity::__call(), but does not define a Quote entity. + */ + public static function getTelegramRawMessageData($message) + { + if (is_array($message)) { + return $message; + } + + if (!is_object($message)) { + return array(); + } + + if (isset($message->raw_data) && is_array($message->raw_data)) { + return $message->raw_data; + } + + if (method_exists($message, 'getRawData')) { + try { + $raw = $message->getRawData(); + return is_array($raw) ? $raw : array(); + } catch (\Throwable $e) { + return array(); + } + } + + return array(); + } + + private static function getTelegramEntityProperty($entity, $property) + { + if (!is_object($entity)) { + return null; + } + + if (method_exists($entity, 'getProperty')) { + try { + return $entity->getProperty($property); + } catch (\Throwable $e) { + // Fall through to the dynamic getter below. + } + } + + try { + $getter = 'get' . str_replace(' ', '', ucwords(str_replace('_', ' ', $property))); + return $entity->$getter(); + } catch (\Throwable $e) { + return null; + } + } + + private static function getTelegramQuoteText($quote) + { + if (is_array($quote)) { + return trim((string)($quote['text'] ?? '')); + } + + if (is_object($quote)) { + $text = self::getTelegramEntityProperty($quote, 'text'); + if ($text !== null) { + return trim((string)$text); + } + + if (isset($quote->raw_data) && is_array($quote->raw_data)) { + return trim((string)($quote->raw_data['text'] ?? '')); + } + } + + return trim((string)$quote); + } + + /** + * Normalize reply/quote information without relying on Quote or ReplyParameters + * classes that are absent in the installed telegram-core 79e5e3a. + * + * @return array{message_id:int,thread_id:int,reply_message_id:int,is_explicit_reply:bool,quote_text:string} + */ + public static function extractTelegramReplyData($message) + { + $raw = self::getTelegramRawMessageData($message); + $replyRaw = isset($raw['reply_to_message']) && is_array($raw['reply_to_message']) ? $raw['reply_to_message'] : array(); + + $messageId = (int)($raw['message_id'] ?? self::getTelegramEntityProperty($message, 'message_id')); + $threadId = (int)($raw['message_thread_id'] ?? self::getTelegramEntityProperty($message, 'message_thread_id')); + $replyMessageId = (int)($replyRaw['message_id'] ?? 0); + + $replyObject = self::getTelegramEntityProperty($message, 'reply_to_message'); + if ($replyMessageId <= 0 && is_object($replyObject)) { + $replyMessageId = (int)self::getTelegramEntityProperty($replyObject, 'message_id'); + } + + $quote = $raw['quote'] ?? null; + if ($quote === null && isset($replyRaw['quote'])) { + $quote = $replyRaw['quote']; + } + if ($quote === null && is_object($replyObject)) { + $quote = self::getTelegramEntityProperty($replyObject, 'quote'); + } elseif ($quote === null && is_array($replyObject) && isset($replyObject['quote'])) { + $quote = $replyObject['quote']; + } + + $quoteObject = self::getTelegramEntityProperty($message, 'quote'); + $quoteText = self::getTelegramQuoteText($quoteObject); + if ($quoteText === '') { + $quoteText = self::getTelegramQuoteText($quote); + } + + $forumTopicCreated = isset($replyRaw['forum_topic_created']); + if (!$forumTopicCreated && is_object($replyObject)) { + $forumTopicCreated = (bool)self::getTelegramEntityProperty($replyObject, 'forum_topic_created'); + } + + return array( + 'message_id' => $messageId, + 'thread_id' => $threadId, + 'reply_message_id' => $replyMessageId, + 'is_explicit_reply' => $replyMessageId > 0 && $replyMessageId !== $threadId && !$forumTopicCreated, + 'quote_text' => $quoteText + ); + } + + /** + * Return the text/caption that was sent for a stored Telegram message. + * This is used when Telegram omitted Message.quote (the normal case on core 79e5e3a). + */ + public static function getStoredTelegramMessageText($msg, $topicMsgId = null) + { + if (!is_object($msg)) { + return ''; + } + + $meta = is_array($msg->meta_msg_array) ? $msg->meta_msg_array : array(); + $key = $topicMsgId !== null ? (string)(int)$topicMsgId : ''; + if ($key !== '' && isset($meta['tg_topic_msg_map'][$key]) && is_array($meta['tg_topic_msg_map'][$key])) { + $entry = $meta['tg_topic_msg_map'][$key]; + $mappedText = trim((string)($entry['caption'] ?? ($entry['text'] ?? ''))); + if ($mappedText !== '') { + return $mappedText; + } + } + + return trim(preg_replace('/\[file=\d+_[a-f0-9]{32}\]/i', '', (string)$msg->msg)); + } + private function getTelegramFileCaption($msg, $chat, $file, $messageText = null) { $sender = $msg->name_support != '' ? '🤖 [' . $msg->name_support . ']' : '👤 [' . $chat->nick . ']'; @@ -441,21 +588,138 @@ private function isMeaningfulTelegramUploadName($file) return true; } - public function saveTopicMsgId($msg, $topicMsgId) + public function saveTopicMsgId($msg, $topicMsgId, $messageData = array()) { if (!($msg instanceof erLhcoreClassModelmsg) || !(int)$topicMsgId || $msg->id <= 0) { return; } - $meta = is_array($msg->meta_msg_array) ? $msg->meta_msg_array : []; - $meta['tg_topic_msg_id'] = (int)$topicMsgId; - $msg->meta_msg_array = $meta; - $msg->meta_msg = json_encode($meta); + $db = ezcDbInstance::get(); + $startedTransaction = method_exists($db, 'inTransaction') && !$db->inTransaction(); + if ($startedTransaction) { + $db->beginTransaction(); + } - $stmt = ezcDbInstance::get()->prepare("UPDATE lh_msg SET meta_msg = :meta_msg WHERE id = :id"); - $stmt->bindValue(':meta_msg', $msg->meta_msg); - $stmt->bindValue(':id', (int)$msg->id, PDO::PARAM_INT); - $stmt->execute(); + try { + // Lock while merging to preserve IDs written by concurrent workers. + $select = $db->prepare('SELECT meta_msg FROM lh_msg WHERE id = :id FOR UPDATE'); + $select->bindValue(':id', (int)$msg->id, PDO::PARAM_INT); + $select->execute(); + $row = $select->fetch(PDO::FETCH_ASSOC); + + $meta = array(); + if (is_array($row) && isset($row['meta_msg']) && $row['meta_msg'] !== '') { + $decoded = json_decode($row['meta_msg'], true); + if (is_array($decoded)) { + $meta = $decoded; + } + } + if (empty($meta) && is_array($msg->meta_msg_array)) { + $meta = $msg->meta_msg_array; + } + + $topicMsgIds = isset($meta['tg_topic_msg_ids']) && is_array($meta['tg_topic_msg_ids']) ? array_map('intval', $meta['tg_topic_msg_ids']) : array(); + $topicMsgIds[] = (int)$topicMsgId; + $topicMsgIds = array_values(array_unique(array_filter($topicMsgIds, function ($id) { return (int)$id > 0; }))); + $meta['tg_topic_msg_ids'] = $topicMsgIds; + $meta['tg_topic_msg_id'] = (int)$topicMsgId; + + $topicMap = isset($meta['tg_topic_msg_map']) && is_array($meta['tg_topic_msg_map']) ? $meta['tg_topic_msg_map'] : array(); + $entry = array(); + foreach (array('text', 'caption', 'embed', 'kind') as $key) { + if (isset($messageData[$key]) && is_scalar($messageData[$key])) { + $entry[$key] = (string)$messageData[$key]; + } + } + if (isset($messageData['file_id']) && (int)$messageData['file_id'] > 0) { + $entry['file_id'] = (int)$messageData['file_id']; + } + if (isset($messageData['security_hash']) && is_scalar($messageData['security_hash'])) { + $entry['security_hash'] = (string)$messageData['security_hash']; + } + $mapKey = (string)(int)$topicMsgId; + if (!isset($topicMap[$mapKey]) || !is_array($topicMap[$mapKey])) { + $topicMap[$mapKey] = array(); + } + if (!empty($entry)) { + $topicMap[$mapKey] = array_merge($topicMap[$mapKey], $entry); + } + $meta['tg_topic_msg_map'] = $topicMap; + + $msg->meta_msg_array = $meta; + $msg->meta_msg = json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + + $stmt = $db->prepare('UPDATE lh_msg SET meta_msg = :meta_msg WHERE id = :id'); + $stmt->bindValue(':meta_msg', $msg->meta_msg); + $stmt->bindValue(':id', (int)$msg->id, PDO::PARAM_INT); + $stmt->execute(); + + if ($startedTransaction) { + $db->commit(); + } + } catch (\Throwable $e) { + if ($startedTransaction && $db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } + + private function saveTelegramFileTopicMsgId($msg, $topicMsgId, $telegramFile, $caption = '') + { + if (!is_array($telegramFile) || !isset($telegramFile['file']) || !is_object($telegramFile['file'])) { + $this->saveTopicMsgId($msg, $topicMsgId); + return; + } + + $file = $telegramFile['file']; + $this->saveTopicMsgId($msg, $topicMsgId, array( + 'file_id' => (int)$file->id, + 'security_hash' => (string)$file->security_hash, + 'embed' => (string)($telegramFile['embed'] ?? ''), + 'caption' => (string)$caption, + 'text' => (string)$caption, + 'kind' => (string)$file->type + )); + } + + private function getStoredTopicMessageId($msg, $preferredId = null) + { + if (!($msg instanceof erLhcoreClassModelmsg)) { + return null; + } + + $meta = is_array($msg->meta_msg_array) ? $msg->meta_msg_array : array(); + $knownIds = array(); + if (isset($meta['tg_topic_msg_ids']) && is_array($meta['tg_topic_msg_ids'])) { + foreach ($meta['tg_topic_msg_ids'] as $id) { + if ((int)$id > 0) { + $knownIds[(int)$id] = true; + } + } + } + if (isset($meta['tg_topic_msg_map']) && is_array($meta['tg_topic_msg_map'])) { + foreach (array_keys($meta['tg_topic_msg_map']) as $id) { + if ((int)$id > 0) { + $knownIds[(int)$id] = true; + } + } + } + if (isset($meta['tg_topic_msg_id']) && (int)$meta['tg_topic_msg_id'] > 0) { + $knownIds[(int)$meta['tg_topic_msg_id']] = true; + } + + if ($preferredId !== null && isset($knownIds[(int)$preferredId])) { + return (int)$preferredId; + } + if (isset($meta['tg_topic_msg_id']) && (int)$meta['tg_topic_msg_id'] > 0) { + return (int)$meta['tg_topic_msg_id']; + } + if (!empty($knownIds)) { + return (int)array_key_last($knownIds); + } + + return null; } public function getTopicReplyId($msg, $chatId) @@ -464,12 +728,16 @@ public function getTopicReplyId($msg, $chatId) return null; } - $meta = $msg->meta_msg_array; + $meta = is_array($msg->meta_msg_array) ? $msg->meta_msg_array : array(); if (isset($meta['content']['reply_to']['db_msg_id']) && (int)$meta['content']['reply_to']['db_msg_id'] > 0) { $targetMsg = erLhcoreClassModelmsg::fetch((int)$meta['content']['reply_to']['db_msg_id']); - if ($targetMsg instanceof erLhcoreClassModelmsg && isset($targetMsg->meta_msg_array['tg_topic_msg_id']) && (int)$targetMsg->meta_msg_array['tg_topic_msg_id'] > 0) { - return (int)$targetMsg->meta_msg_array['tg_topic_msg_id']; + if ($targetMsg instanceof erLhcoreClassModelmsg && (int)$targetMsg->chat_id === (int)$chatId) { + $preferredId = $meta['content']['reply_to']['telegram_message_id'] ?? ($meta['content']['reply_to']['tg_topic_msg_id'] ?? null); + $resolvedId = $this->getStoredTopicMessageId($targetMsg, $preferredId); + if ($resolvedId !== null) { + return $resolvedId; + } } } @@ -477,32 +745,119 @@ public function getTopicReplyId($msg, $chatId) $iwhId = (string)$meta['content']['reply_to']['iwh_msg_id']; $targetMsg = erLhcoreClassModelmsg::findOne([ 'filter' => ['chat_id' => $chatId], - 'customfilter' => ['`meta_msg` != \'\' AND JSON_VALID(`meta_msg`) AND (JSON_UNQUOTE(JSON_EXTRACT(meta_msg,\'$.iwh_msg_id\')) = ' . ezcDbInstance::get()->quote($iwhId) . ' OR JSON_EXTRACT(meta_msg,\'$.iwh_msg_id\') = ' . (is_numeric($iwhId) ? (int)$iwhId : ezcDbInstance::get()->quote($iwhId)) . ')'] + 'customfilter' => ["`meta_msg` != '' AND JSON_VALID(`meta_msg`) AND (JSON_UNQUOTE(JSON_EXTRACT(meta_msg,'$.iwh_msg_id')) = " . ezcDbInstance::get()->quote($iwhId) . " OR JSON_EXTRACT(meta_msg,'$.iwh_msg_id') = " . (is_numeric($iwhId) ? (int)$iwhId : ezcDbInstance::get()->quote($iwhId)) . ")"] ]); - if ($targetMsg instanceof erLhcoreClassModelmsg && isset($targetMsg->meta_msg_array['tg_topic_msg_id']) && (int)$targetMsg->meta_msg_array['tg_topic_msg_id'] > 0) { - return (int)$targetMsg->meta_msg_array['tg_topic_msg_id']; + if ($targetMsg instanceof erLhcoreClassModelmsg && (int)$targetMsg->chat_id === (int)$chatId) { + $resolvedId = $this->getStoredTopicMessageId($targetMsg); + if ($resolvedId !== null) { + return $resolvedId; + } } } if (isset($meta['content']['quote']['id']) && (int)$meta['content']['quote']['id'] > 0) { $targetMsg = erLhcoreClassModelmsg::fetch((int)$meta['content']['quote']['id']); - if ($targetMsg instanceof erLhcoreClassModelmsg && isset($targetMsg->meta_msg_array['tg_topic_msg_id']) && (int)$targetMsg->meta_msg_array['tg_topic_msg_id'] > 0) { - return (int)$targetMsg->meta_msg_array['tg_topic_msg_id']; + if ($targetMsg instanceof erLhcoreClassModelmsg && (int)$targetMsg->chat_id === (int)$chatId) { + $resolvedId = $this->getStoredTopicMessageId($targetMsg); + if ($resolvedId !== null) { + return $resolvedId; + } } } if (preg_match('#\[quote="?([0-9]+)"?\]#is', (string)$msg->msg, $m)) { $targetMsg = erLhcoreClassModelmsg::fetch((int)$m[1]); - if ($targetMsg instanceof erLhcoreClassModelmsg && isset($targetMsg->meta_msg_array['tg_topic_msg_id']) && (int)$targetMsg->meta_msg_array['tg_topic_msg_id'] > 0) { - return (int)$targetMsg->meta_msg_array['tg_topic_msg_id']; + if ($targetMsg instanceof erLhcoreClassModelmsg && (int)$targetMsg->chat_id === (int)$chatId) { + $resolvedId = $this->getStoredTopicMessageId($targetMsg); + if ($resolvedId !== null) { + return $resolvedId; + } } } return null; } + public function getTopicMessageId($msg, $chatId) + { + if (!($msg instanceof erLhcoreClassModelmsg) || (int)$msg->chat_id !== (int)$chatId) { + return null; + } + + $meta = is_array($msg->meta_msg_array) ? $msg->meta_msg_array : array(); + if (isset($meta['tg_topic_msg_id']) && (int)$meta['tg_topic_msg_id'] > 0) { + return (int)$meta['tg_topic_msg_id']; + } + + return $this->getStoredTopicMessageId($msg); + } + + private function shouldRetryTelegramWithoutReply($sendData) + { + if (!is_object($sendData) || $sendData->isOk() || (int)$sendData->getErrorCode() !== 400) { + return false; + } + + $description = strtolower((string)$sendData->getDescription()); + foreach (array('message to be replied not found', 'reply message not found', 'message_id_invalid', "message can't be replied", 'message cannot be replied') as $needle) { + if (strpos($description, $needle) !== false) { + return true; + } + } + + return false; + } + + private function isTelegramTopicUnavailable($sendData) + { + if (!is_object($sendData) || $sendData->isOk() || (int)$sendData->getErrorCode() !== 400) { + return false; + } + + $description = strtolower((string)$sendData->getDescription()); + return strpos($description, 'message thread not found') !== false + || strpos($description, 'topic_deleted') !== false + || strpos($description, 'thread not found') !== false; + } + + private function rewindTelegramResources(array &$data) + { + foreach ($data as &$value) { + if (is_resource($value)) { + @rewind($value); + } + } + unset($value); + } + + /** Send once, then retry without a stale reply target for known 400 errors. */ + private function sendTelegramRequest($method, array $data) + { + try { + $this->rewindTelegramResources($data); + $sendData = Longman\TelegramBot\Request::send($method, $data); + } catch (\Throwable $e) { + erLhcoreClassLog::write('Telegram request exception ' . $e->getMessage(), ezcLog::SUCCESS_AUDIT, array('source' => 'lhc', 'category' => 'telegram_exception', 'line' => __LINE__, 'file' => __FILE__)); + return new Longman\TelegramBot\Entities\ServerResponse(array('ok' => false, 'error_code' => 500, 'description' => 'Telegram request failed')); + } + + if ($this->shouldRetryTelegramWithoutReply($sendData) && isset($data['reply_to_message_id'])) { + unset($data['reply_to_message_id']); + try { + $this->rewindTelegramResources($data); + $sendData = Longman\TelegramBot\Request::send($method, $data); + } catch (\Throwable $e) { + erLhcoreClassLog::write('Telegram reply fallback exception ' . $e->getMessage(), ezcLog::SUCCESS_AUDIT, array('source' => 'lhc', 'category' => 'telegram_exception', 'line' => __LINE__, 'file' => __FILE__)); + return new Longman\TelegramBot\Entities\ServerResponse(array('ok' => false, 'error_code' => 500, 'description' => 'Telegram reply fallback failed')); + } + } + + return $sendData; + } + private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotification = false, $params = array()) { + $this->lastTelegramSendData = null; $file = $fileData['file']; if (!file_exists($file->file_path_server) || !is_readable($file->file_path_server)) { @@ -528,12 +883,17 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif $field = 'video'; } - $data = array( - 'chat_id' => $tchat->bot->group_chat_id, - 'message_thread_id' => $tchat->tchat_id, - 'parse_mode' => 'HTML', - $field => $this->getTelegramChatFileUrl($file) - ); + try { + $data = array( + 'chat_id' => $tchat->bot->group_chat_id, + 'message_thread_id' => $tchat->tchat_id, + 'parse_mode' => 'HTML', + $field => Longman\TelegramBot\Request::encodeFile($file->file_path_server) + ); + } catch (\Throwable $e) { + erLhcoreClassLog::write('SendFile encode exception ' . $e->getMessage(), ezcLog::SUCCESS_AUDIT, array('source' => 'lhc', 'category' => 'telegram_exception', 'line' => __LINE__, 'file' => __FILE__, 'object_id' => $file->chat_id)); + return false; + } if (isset($params['reply_to_message_id']) && $params['reply_to_message_id'] > 0) { $data['reply_to_message_id'] = $params['reply_to_message_id']; @@ -547,20 +907,9 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif $data['disable_notification'] = true; } - try { - $sendData = Longman\TelegramBot\Request::send($method, $data); - } catch (Exception $e) { - erLhcoreClassLog::write('SendFile exception '.$e->getMessage(), - ezcLog::SUCCESS_AUDIT, - array( - 'source' => 'lhc', - 'category' => 'telegram_exception', - 'line' => __LINE__, - 'file' => __FILE__, - 'object_id' => $file->chat_id - ) - ); - + $sendData = $this->sendTelegramRequest($method, $data); + $this->lastTelegramSendData = $sendData; + if ($sendData === null) { return false; } @@ -649,13 +998,13 @@ public function messageAdded($params) $data['reply_to_message_id'] = $replyTopicMsgId; } - $sendData = Longman\TelegramBot\Request::sendMessage($data); + $sendData = $this->sendTelegramRequest('sendMessage', $data); if ($sendData->isOk()) { - $this->saveTopicMsgId($params['msg'], $sendData->getResult()->getMessageId()); + $this->saveTopicMsgId($params['msg'], $sendData->getResult()->getMessageId(), array('text' => $messageText, 'kind' => 'text')); } - if (!$sendData->isOk() && $sendData->getErrorCode() == 400 && str_contains( $sendData->getDescription(), 'TOPIC_DELETED') === true) { + if ($this->isTelegramTopicUnavailable($sendData)) { // Reset telegram chat $tchat->tchat_id = 0; $tchat->updateThis(['update' => ['tchat_id']]); @@ -687,15 +1036,21 @@ public function messageAdded($params) foreach ($telegramFiles as $telegramFile) { $sentFileMsgId = $this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($params['msg'], $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT, ['reply_to_message_id' => $replyTopicMsgId]); if ($sentFileMsgId === false) { + if ($fileIndex === 0 && $this->isTelegramTopicUnavailable($this->lastTelegramSendData)) { + $tchat->tchat_id = 0; + $tchat->updateThis(['update' => ['tchat_id']]); + $this->chatStarted(['chat' => $chat]); + return; + } $failedEmbedCodes[] = $telegramFile['embed']; } else { - $this->saveTopicMsgId($params['msg'], $sentFileMsgId); + $this->saveTelegramFileTopicMsgId($params['msg'], $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($params['msg'], $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : '')); } $fileIndex++; } if (!empty($failedEmbedCodes)) { - Longman\TelegramBot\Request::sendMessage(array( + $this->sendTelegramRequest('sendMessage', array( 'chat_id' => $tchat->bot->group_chat_id, 'message_thread_id' => $tchat->tchat_id, 'parse_mode' => 'HTML', @@ -717,6 +1072,7 @@ public function messageAdded($params) // Send bot responses if any $botMessages = erLhcoreClassModelmsg::getList(array('filter' => array('user_id' => -2, 'chat_id' => $chat->id), 'filtergt' => array('id' => $params['msg']->id))); + $botReplyTopicMsgId = $this->getTopicMessageId($params['msg'], $chat->id); foreach ($botMessages as $botMessage) { @@ -746,10 +1102,13 @@ public function messageAdded($params) if ($chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT) { $data['disable_notification'] = true; } - $sendData = Longman\TelegramBot\Request::sendMessage($data); + if ($botReplyTopicMsgId > 0) { + $data['reply_to_message_id'] = $botReplyTopicMsgId; + } + $sendData = $this->sendTelegramRequest('sendMessage', $data); if ($sendData->isOk()) { - $this->saveTopicMsgId($botMessage, $sendData->getResult()->getMessageId()); + $this->saveTopicMsgId($botMessage, $sendData->getResult()->getMessageId(), array('text' => $messageText, 'kind' => 'text')); } else { erLhcoreClassLog::write('SendMessage BOT ['.$sendData->getErrorCode().']'. $sendData->getDescription(), ezcLog::SUCCESS_AUDIT, @@ -769,17 +1128,17 @@ public function messageAdded($params) $fileIndex = 0; foreach ($telegramFiles as $telegramFile) { - $sentFileMsgId = $this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT); + $sentFileMsgId = $this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT, ['reply_to_message_id' => $botReplyTopicMsgId]); if ($sentFileMsgId === false) { $failedEmbedCodes[] = $telegramFile['embed']; } else { - $this->saveTopicMsgId($botMessage, $sentFileMsgId); + $this->saveTelegramFileTopicMsgId($botMessage, $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : '')); } $fileIndex++; } if (!empty($failedEmbedCodes)) { - Longman\TelegramBot\Request::sendMessage(array( + $this->sendTelegramRequest('sendMessage', array( 'chat_id' => $tchat->bot->group_chat_id, 'message_thread_id' => $tchat->tchat_id, 'parse_mode' => 'HTML', @@ -836,6 +1195,7 @@ public function triggerClicked($params) $telegramFiles = $this->getTelegramMessageFiles($botMessage); $messageText = $this->stripTelegramFileEmbeds($botMessage->msg); + $botReplyTopicMsgId = $this->getTopicMessageId($params['msg'], $chat->id); if ($messageText !== '' && empty($telegramFiles)) { $data = [ @@ -847,10 +1207,13 @@ public function triggerClicked($params) if ($chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT) { $data['disable_notification'] = true; } - $sendData = Longman\TelegramBot\Request::sendMessage($data); + if ($botReplyTopicMsgId > 0) { + $data['reply_to_message_id'] = $botReplyTopicMsgId; + } + $sendData = $this->sendTelegramRequest('sendMessage', $data); if ($sendData->isOk()) { - $this->saveTopicMsgId($botMessage, $sendData->getResult()->getMessageId()); + $this->saveTopicMsgId($botMessage, $sendData->getResult()->getMessageId(), array('text' => $messageText, 'kind' => 'text')); } else { erLhcoreClassLog::write('['.$sendData->getErrorCode().']'. $sendData->getDescription(), ezcLog::SUCCESS_AUDIT, @@ -870,17 +1233,17 @@ public function triggerClicked($params) $fileIndex = 0; foreach ($telegramFiles as $telegramFile) { - $sentFileMsgId = $this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT); + $sentFileMsgId = $this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT, ['reply_to_message_id' => $botReplyTopicMsgId]); if ($sentFileMsgId === false) { $failedEmbedCodes[] = $telegramFile['embed']; } else { - $this->saveTopicMsgId($botMessage, $sentFileMsgId); + $this->saveTelegramFileTopicMsgId($botMessage, $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : '')); } $fileIndex++; } if (!empty($failedEmbedCodes)) { - Longman\TelegramBot\Request::sendMessage(array( + $this->sendTelegramRequest('sendMessage', array( 'chat_id' => $tchat->bot->group_chat_id, 'message_thread_id' => $tchat->tchat_id, 'parse_mode' => 'HTML', @@ -979,6 +1342,7 @@ public function chatStarted($params) // Collect all chat messages including bot $initialTelegramFiles = array(); + $initialAggregateMessages = array(); $botMessages = erLhcoreClassModelmsg::getList(array('filterin' => ['user_id' => [0, -2]], 'filter' => array('chat_id' => $params['chat']->id))); foreach ($botMessages as $botMessage) { $tChat->last_msg_id = $botMessage->id; @@ -991,6 +1355,7 @@ public function chatStarted($params) if ($messageText !== '' && empty($telegramFiles)) { $visitor[] = trim(($botMessage->name_support != '' ? '🤖 [' . $botMessage->name_support . ']: ' : '👤 ['. erLhcoreClassBBCodePlain::make_clickable($params['chat']->nick, array('sender' => 0)) . ']: ') . erLhcoreClassBBCodePlain::make_clickable($messageText, array('sender' => 0)) . ($botMessage->name_support != '' ? '' : '')); + $initialAggregateMessages[] = array('msg' => $botMessage, 'text' => $messageText); } $fileIndex = 0; @@ -1011,16 +1376,17 @@ public function chatStarted($params) $data['disable_notification'] = true; } - $sendData = Longman\TelegramBot\Request::sendMessage($data); + $sendData = $this->sendTelegramRequest('sendMessage', $data); if ($sendData->isOk()) { - $firstVisitorMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id, 'user_id' => 0], 'sort' => 'id ASC']); - if ($firstVisitorMsg instanceof erLhcoreClassModelmsg) { - $this->saveTopicMsgId($firstVisitorMsg, $sendData->getResult()->getMessageId()); - } else { + $aggregateMsgId = $sendData->getResult()->getMessageId(); + foreach ($initialAggregateMessages as $aggregateMessage) { + $this->saveTopicMsgId($aggregateMessage['msg'], $aggregateMsgId, array('text' => $aggregateMessage['text'], 'kind' => 'aggregate')); + } + if (empty($initialAggregateMessages)) { $firstMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id], 'sort' => 'id ASC']); if ($firstMsg instanceof erLhcoreClassModelmsg) { - $this->saveTopicMsgId($firstMsg, $sendData->getResult()->getMessageId()); + $this->saveTopicMsgId($firstMsg, $aggregateMsgId, array('text' => $data['text'], 'kind' => 'aggregate')); } } } else { @@ -1041,16 +1407,17 @@ public function chatStarted($params) } $data['message_thread_id'] = $tChat->tchat_id; - $sendData = Longman\TelegramBot\Request::sendMessage($data); + $sendData = $this->sendTelegramRequest('sendMessage', $data); if ($sendData->isOk()) { - $firstVisitorMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id, 'user_id' => 0], 'sort' => 'id ASC']); - if ($firstVisitorMsg instanceof erLhcoreClassModelmsg) { - $this->saveTopicMsgId($firstVisitorMsg, $sendData->getResult()->getMessageId()); - } else { + $aggregateMsgId = $sendData->getResult()->getMessageId(); + foreach ($initialAggregateMessages as $aggregateMessage) { + $this->saveTopicMsgId($aggregateMessage['msg'], $aggregateMsgId, array('text' => $aggregateMessage['text'], 'kind' => 'aggregate')); + } + if (empty($initialAggregateMessages)) { $firstMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id], 'sort' => 'id ASC']); if ($firstMsg instanceof erLhcoreClassModelmsg) { - $this->saveTopicMsgId($firstMsg, $sendData->getResult()->getMessageId()); + $this->saveTopicMsgId($firstMsg, $aggregateMsgId, array('text' => $data['text'], 'kind' => 'aggregate')); } } } else { @@ -1075,12 +1442,12 @@ public function chatStarted($params) if ($sentFileMsgId === false) { $failedEmbedCodes[] = $initialTelegramFile['file']['embed']; } else if (isset($initialTelegramFile['msg']) && $initialTelegramFile['msg'] instanceof erLhcoreClassModelmsg) { - $this->saveTopicMsgId($initialTelegramFile['msg'], $sentFileMsgId); + $this->saveTelegramFileTopicMsgId($initialTelegramFile['msg'], $sentFileMsgId, $initialTelegramFile['file'], $this->getTelegramFileCaption($initialTelegramFile['msg'], $params['chat'], $initialTelegramFile['file']['file'], $initialTelegramFile['text'])); } } if (!empty($failedEmbedCodes)) { - Longman\TelegramBot\Request::sendMessage(array( + $this->sendTelegramRequest('sendMessage', array( 'chat_id' => $tChat->bot->group_chat_id, 'message_thread_id' => $tChat->tchat_id, 'parse_mode' => 'HTML', diff --git a/classes/Commands/GenericmessageCommand.php b/classes/Commands/GenericmessageCommand.php index d303a19..ef91d85 100644 --- a/classes/Commands/GenericmessageCommand.php +++ b/classes/Commands/GenericmessageCommand.php @@ -344,20 +344,33 @@ public function execute(): ServerResponse $msgText = $text; $metaMsg = []; - $replyTo = $message->getReplyToMessage(); - $isExplicitReply = ($replyTo && (int)$replyTo->getMessageId() !== (int)$message->getMessageThreadId() && !$replyTo->getForumTopicCreated()); + $replyData = \erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($message); + $isExplicitReply = !empty($replyData['is_explicit_reply']) && (int)($replyData['reply_message_id'] ?? 0) > 0; if ($isExplicitReply) { - $replyTopicMsgId = (int)$replyTo->getMessageId(); - $metaMsg['tg_topic_msg_id'] = (int)$message->getMessageId(); + $replyTopicMsgId = (int)$replyData['reply_message_id']; + $metaMsg['tg_topic_msg_id'] = (int)$replyData['message_id']; $replyMsg = \erLhcoreClassModelmsg::findOne([ 'filter' => ['chat_id' => $chat->id], 'customfilter' => ['`meta_msg` != \'\' AND JSON_VALID(`meta_msg`) AND JSON_EXTRACT(meta_msg, \'$.tg_topic_msg_id\') = ' . $replyTopicMsgId] ]); + if (!($replyMsg instanceof \erLhcoreClassModelmsg)) { + $replyMsg = \erLhcoreClassModelmsg::findOne([ + 'filter' => ['chat_id' => $chat->id], + 'customfilter' => ['meta_msg != \'\' AND JSON_VALID(meta_msg) AND JSON_CONTAINS(JSON_EXTRACT(meta_msg, \'$.tg_topic_msg_ids\'), \'[' . $replyTopicMsgId . ']\')'] + ]); + } + if ($replyMsg instanceof \erLhcoreClassModelmsg) { - $quoteText = $message->getQuote() ? trim($message->getQuote()->getText()) : $replyMsg->msg; + $quoteText = trim((string)($replyData['quote_text'] ?? '')); + if ($quoteText === '') { + $quoteText = \erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($replyMsg, $replyTopicMsgId); + } + if ($quoteText === '') { + $quoteText = (string)$replyMsg->msg; + } $replyNick = $replyMsg->name_support != '' ? $replyMsg->name_support : $chat->nick; $msgText = '[quote=' . $replyMsg->id . ']' . $quoteText . '[/quote]' . $msgText; @@ -368,7 +381,8 @@ public function execute(): ServerResponse 'nick' => $replyNick ], 'reply_to' => [ - 'db_msg_id' => $replyMsg->id + 'db_msg_id' => $replyMsg->id, + 'telegram_message_id' => $replyTopicMsgId ] ]; @@ -377,7 +391,7 @@ public function execute(): ServerResponse } } } else { - $metaMsg['tg_topic_msg_id'] = (int)$message->getMessageId(); + $metaMsg['tg_topic_msg_id'] = (int)$replyData['message_id']; } $msg->msg = $msgText; diff --git a/doc/telegram/rest-api.json b/doc/telegram/rest-api.json index 898ed3e..551cc7a 100644 --- a/doc/telegram/rest-api.json +++ b/doc/telegram/rest-api.json @@ -1 +1 @@ -{"name": "TelegramIntegration", "description": "", "configuration": "{\"host\": \"https://api.telegram.org\", \"ecache\": false, \"parameters\": [{\"method\": \"POST\", \"authorization\": \"\", \"api_key_location\": \"header\", \"query\": [], \"header\": [], \"conditions\": [], \"postparams\": [], \"userparams\": [], \"output\": [{\"key\": \"\", \"value\": \"\", \"id\": \"temp1706685738487\", \"success_name\": \"Success\", \"success_header\": \"200\"}], \"id\": \"temp1695212526903\", \"name\": \"Send\", \"suburl\": \"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/sendMessage\", \"body_request_type\": \"raw\", \"body_raw\": \"{\\n \\\"chat_id\\\":{{args.chat.incoming_chat.chat_external_id}},\\\"parse_mode\\\":\\\"HTML\\\",\\n \\\"text\\\":{{msg_html_nobr}}\\n{reply_to},\\\"reply_parameters\\\":{\\\"message_id\\\":raw_{{iwh_msg_id}}}{/reply_to}\\n{interactive_api}\\n,\\\"reply_markup\\\":{\\n \\\"resize_keyboard\\\":true,\\n\\\"inline_keyboard\\\":[\\n{button_template}\\n [{\\n \\\"text\\\": {{button_title}},\\n \\\"{is_url}url{/is_url}{is_button}callback_data{/is_button}\\\":{{button_payload}}\\n }]\\n{/button_template}\\n]\\n}\\n\\n{/interactive_api}\\n}\", \"body_request_type_content\": \"json\", \"remote_message_id\": \"result:message_id\", \"suburl_file\": \"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/{api_by_ext__tgs}sendSticker{/api_by_ext}{api_by_ext__ogg}sendVoice{/api_by_ext}{api_by_ext__mp3_m4a}sendAudio{/api_by_ext}{api_by_ext__mp4}sendVideo{/api_by_ext}{image_api}sendPhoto{/image_api}{file_api}sendDocument{/file_api}\", \"body_raw_file\": \"{\\n \\\"chat_id\\\":{{args.chat.incoming_chat.chat_external_id}},\\n \\\"{api_by_ext__tgs}sticker{/api_by_ext}{api_by_ext__ogg}voice{/api_by_ext}{api_by_ext__mp3_m4a}audio{/api_by_ext}{api_by_ext__mp4}video{/api_by_ext}{file_api}document{/file_api}{image_api}photo{/image_api}\\\":{{file_url}}\\n{reply_to},\\\"reply_parameters\\\":\\\"{\\\\\\\"message_id\\\\\\\":raw_{{iwh_msg_id}}}\\\"{/reply_to}\\n{api_by_ext__ogg},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{api_by_ext__mp3_m4a},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{api_by_ext__mp4},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{file_api},\\\"caption\\\":{{msg_clean}}{/file_api}{image_api},\\\"caption\\\":{{msg_clean}}{/image_api}\\n}\", \"check_not_empty\": \"{{msg_html_nobr}}\", \"suburl_file_convert\": \"tgs,file_api,mp3_m4a,ogg\", \"suburl_file_skip_ext\": \"tgs\"}, {\"method\": \"POST\", \"authorization\": \"\", \"api_key_location\": \"header\", \"query\": [], \"header\": [], \"conditions\": [], \"postparams\": [], \"userparams\": [], \"output\": [{\"key\": \"\", \"value\": \"\", \"id\": \"telegram_typing_success\", \"success_name\": \"Success\", \"success_header\": \"200\"}], \"id\": \"telegram_send_typing\", \"name\": \"Send typing\", \"suburl\": \"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/sendChatAction\", \"body_request_type\": \"raw\", \"body_request_type_content\": \"json\", \"body_raw\": \"{\\n \\\"chat_id\\\": {{args.chat.incoming_chat.chat_external_id}},\\n \\\"action\\\": \\\"typing\\\"\\n}\", \"check_not_empty\": \"{{args.chat.incoming_chat.chat_external_id}}\"}]}"} \ No newline at end of file +{"name": "TelegramIntegration", "description": "", "configuration": "{\"host\": \"https://api.telegram.org\", \"ecache\": false, \"parameters\": [{\"method\": \"POST\", \"authorization\": \"\", \"api_key_location\": \"header\", \"query\": [], \"header\": [], \"conditions\": [], \"postparams\": [], \"userparams\": [], \"output\": [{\"key\": \"\", \"value\": \"\", \"id\": \"temp1706685738487\", \"success_name\": \"Success\", \"success_header\": \"200\"}], \"id\": \"temp1695212526903\", \"name\": \"Send\", \"suburl\": \"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/sendMessage\", \"body_request_type\": \"raw\", \"body_raw\": \"{\\n \\\"chat_id\\\":{{args.chat.incoming_chat.chat_external_id}},\\\"parse_mode\\\":\\\"HTML\\\",\\n \\\"text\\\":{{msg_html_nobr}}\\n{reply_to},\\\"reply_parameters\\\":{\\\"message_id\\\":raw_{{iwh_msg_id}}}{/reply_to}\\n{interactive_api}\\n,\\\"reply_markup\\\":{\\n \\\"resize_keyboard\\\":true,\\n\\\"inline_keyboard\\\":[\\n{button_template}\\n [{\\n \\\"text\\\": {{button_title}},\\n \\\"{is_url}url{/is_url}{is_button}callback_data{/is_button}\\\":{{button_payload}}\\n }]\\n{/button_template}\\n]\\n}\\n\\n{/interactive_api}\\n}\", \"body_request_type_content\": \"json\", \"remote_message_id\": \"result:message_id\", \"suburl_file\": \"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/{api_by_ext__tgs}sendSticker{/api_by_ext}{api_by_ext__ogg}sendVoice{/api_by_ext}{api_by_ext__mp3_m4a}sendAudio{/api_by_ext}{api_by_ext__mp4}sendVideo{/api_by_ext}{image_api}sendPhoto{/image_api}{file_api}sendDocument{/file_api}\", \"body_raw_file\": \"{\\n \\\"chat_id\\\":{{args.chat.incoming_chat.chat_external_id}},\\n \\\"{api_by_ext__tgs}sticker{/api_by_ext}{api_by_ext__ogg}voice{/api_by_ext}{api_by_ext__mp3_m4a}audio{/api_by_ext}{api_by_ext__mp4}video{/api_by_ext}{file_api}document{/file_api}{image_api}photo{/image_api}\\\":{{file_url}}\\n{reply_to},\\\"reply_parameters\\\":{\\\"message_id\\\":raw_{{iwh_msg_id}}}{/reply_to}\\n{api_by_ext__ogg},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{api_by_ext__mp3_m4a},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{api_by_ext__mp4},\\\"caption\\\":{{msg_clean}}{/api_by_ext}{file_api},\\\"caption\\\":{{msg_clean}}{/file_api}{image_api},\\\"caption\\\":{{msg_clean}}{/image_api}\\n}\", \"check_not_empty\": \"{{msg_html_nobr}}\", \"suburl_file_convert\": \"tgs,file_api,mp3_m4a,ogg\", \"suburl_file_skip_ext\": \"tgs\"}, {\"method\": \"POST\", \"authorization\": \"\", \"api_key_location\": \"header\", \"query\": [], \"header\": [], \"conditions\": [], \"postparams\": [], \"userparams\": [], \"output\": [{\"key\": \"\", \"value\": \"\", \"id\": \"telegram_typing_success\", \"success_name\": \"Success\", \"success_header\": \"200\"}], \"id\": \"telegram_send_typing\", \"name\": \"Send typing\", \"suburl\": \"/bot{{args.chat.incoming_chat.incoming_dynamic_array.access_token}}/sendChatAction\", \"body_request_type\": \"raw\", \"body_request_type_content\": \"json\", \"body_raw\": \"{\\n \\\"chat_id\\\": {{args.chat.incoming_chat.chat_external_id}},\\n \\\"action\\\": \\\"typing\\\"\\n}\", \"check_not_empty\": \"{{args.chat.incoming_chat.chat_external_id}}\"}]}"} \ No newline at end of file diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php new file mode 100644 index 0000000..0c092ff --- /dev/null +++ b/tests/TelegramReplyContractTest.php @@ -0,0 +1,119 @@ + [ + 'message_id' => 101, + 'message_thread_id' => 50, + 'reply_to_message' => ['message_id' => 90], + 'quote' => ['text' => 'quoted text'] + ] +]; +$reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($message); +expectTelegramContract($reply['message_id'] === 101, 'message id must come from raw_data'); +expectTelegramContract($reply['reply_message_id'] === 90, 'reply id must come from reply_to_message'); +expectTelegramContract($reply['is_explicit_reply'] === true, 'ordinary reply must be explicit'); +expectTelegramContract($reply['quote_text'] === 'quoted text', 'top-level quote must be read from raw_data'); + +$nestedQuote = (object)[ + 'raw_data' => [ + 'message_id' => 102, + 'message_thread_id' => 50, + 'reply_to_message' => [ + 'message_id' => 91, + 'quote' => ['text' => 'nested quoted text'] + ] + ] +]; +$reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($nestedQuote); +expectTelegramContract($reply['quote_text'] === 'nested quoted text', 'nested quote must be supported'); + +$topicRoot = (object)[ + 'raw_data' => [ + 'message_id' => 103, + 'message_thread_id' => 50, + 'reply_to_message' => [ + 'message_id' => 50, + 'forum_topic_created' => ['name' => 'Topic'] + ] + ] +]; +$reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($topicRoot); +expectTelegramContract($reply['is_explicit_reply'] === false, 'topic root service message is not a quote'); + +$stored = (object)[ + 'msg' => 'fallback [file=12_0123456789abcdef0123456789abcdef]', + 'meta_msg_array' => [ + 'tg_topic_msg_map' => [ + '90' => ['caption' => 'stored caption'] + ] + ] +]; +expectTelegramContract( + erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($stored, 90) === 'stored caption', + 'stored caption must win when Telegram omits quote' +); +expectTelegramContract( + erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($stored, 91) === 'fallback', + 'file embeds must be removed from fallback text' +); + +$vendorAutoload = __DIR__ . '/../../../lib/vendor/autoload.php'; +if (is_file($vendorAutoload)) { + require_once $vendorAutoload; + $entity = new \Longman\TelegramBot\Entities\Message([ + 'message_id' => 104, + 'message_thread_id' => 50, + 'reply_to_message' => ['message_id' => 92], + 'quote' => ['text' => 'entity quote'] + ], 'contract_bot'); + $reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($entity); + expectTelegramContract($reply['reply_message_id'] === 92 && $reply['quote_text'] === 'entity quote', 'installed telegram-core entity compatibility'); + + $fixture = tempnam(sys_get_temp_dir(), 'tg_contract_'); + file_put_contents($fixture, 'file'); + $handle = \Longman\TelegramBot\Request::encodeFile($fixture); + expectTelegramContract(is_resource($handle), 'Request::encodeFile must return a readable resource'); + fclose($handle); + unlink($fixture); +} + +$extension = new erLhcoreClassExtensionLhctelegram(); +$fallbackMethod = new ReflectionMethod($extension, 'shouldRetryTelegramWithoutReply'); +$fallbackMethod->setAccessible(true); +$topicMethod = new ReflectionMethod($extension, 'isTelegramTopicUnavailable'); +$topicMethod->setAccessible(true); +$staleReply = new class { + public function isOk() { return false; } + public function getErrorCode() { return 400; } + public function getDescription() { return 'Bad Request: message to be replied not found'; } +}; +$otherError = new class { + public function isOk() { return false; } + public function getErrorCode() { return 400; } + public function getDescription() { return 'Bad Request: chat not found'; } +}; +expectTelegramContract($fallbackMethod->invoke($extension, $staleReply) === true, 'stale reply must trigger fallback'); +expectTelegramContract($fallbackMethod->invoke($extension, $otherError) === false, 'unrelated API error must not retry'); +expectTelegramContract($topicMethod->invoke($extension, $staleReply) === false, 'stale reply is not a deleted topic'); +$deletedTopic = new class { + public function isOk() { return false; } + public function getErrorCode() { return 400; } + public function getDescription() { return 'Bad Request: message thread not found'; } +}; +expectTelegramContract($topicMethod->invoke($extension, $deletedTopic) === true, 'deleted topic must be detected'); + +fwrite(STDOUT, "Telegram reply contract tests: OK\n"); From a03d20b429e233a9655f6a2ce264b4ff886e747f Mon Sep 17 00:00:00 2001 From: mysubcult Date: Tue, 25 Aug 2026 23:50:11 +0400 Subject: [PATCH 03/13] Reopen Telegram files for stale-reply retries --- bootstrap/bootstrap.php | 36 +++++++++++++++++++++++++---- tests/TelegramReplyContractTest.php | 36 ++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index a7cf904..3e481c6 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -830,13 +830,33 @@ private function rewindTelegramResources(array &$data) unset($value); } - /** Send once, then retry without a stale reply target for known 400 errors. */ - private function sendTelegramRequest($method, array $data) + private function closeTelegramResources(array &$data) { + foreach ($data as &$value) { + if (is_resource($value)) { + @fclose($value); + } + } + unset($value); + } + + /** + * Send once, then retry without a stale reply target for known 400 errors. + * + * Guzzle closes the raw resource returned by Request::encodeFile() after + * consuming a multipart request. Keep the source path/field as private + * retry context so a file upload can be reopened for the retry. + */ + private function sendTelegramRequest($method, array $data, $multipartFilePath = '', $multipartFileField = '') + { + $multipartFilePath = (string)$multipartFilePath; + $multipartFileField = (string)$multipartFileField; + try { $this->rewindTelegramResources($data); $sendData = Longman\TelegramBot\Request::send($method, $data); } catch (\Throwable $e) { + $this->closeTelegramResources($data); erLhcoreClassLog::write('Telegram request exception ' . $e->getMessage(), ezcLog::SUCCESS_AUDIT, array('source' => 'lhc', 'category' => 'telegram_exception', 'line' => __LINE__, 'file' => __FILE__)); return new Longman\TelegramBot\Entities\ServerResponse(array('ok' => false, 'error_code' => 500, 'description' => 'Telegram request failed')); } @@ -844,9 +864,17 @@ private function sendTelegramRequest($method, array $data) if ($this->shouldRetryTelegramWithoutReply($sendData) && isset($data['reply_to_message_id'])) { unset($data['reply_to_message_id']); try { - $this->rewindTelegramResources($data); + if ($multipartFilePath !== '' && $multipartFileField !== '') { + if (isset($data[$multipartFileField]) && is_resource($data[$multipartFileField])) { + @fclose($data[$multipartFileField]); + } + $data[$multipartFileField] = Longman\TelegramBot\Request::encodeFile($multipartFilePath); + } else { + $this->rewindTelegramResources($data); + } $sendData = Longman\TelegramBot\Request::send($method, $data); } catch (\Throwable $e) { + $this->closeTelegramResources($data); erLhcoreClassLog::write('Telegram reply fallback exception ' . $e->getMessage(), ezcLog::SUCCESS_AUDIT, array('source' => 'lhc', 'category' => 'telegram_exception', 'line' => __LINE__, 'file' => __FILE__)); return new Longman\TelegramBot\Entities\ServerResponse(array('ok' => false, 'error_code' => 500, 'description' => 'Telegram reply fallback failed')); } @@ -907,7 +935,7 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif $data['disable_notification'] = true; } - $sendData = $this->sendTelegramRequest($method, $data); + $sendData = $this->sendTelegramRequest($method, $data, $file->file_path_server, $field); $this->lastTelegramSendData = $sendData; if ($sendData === null) { return false; diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php index 0c092ff..f748d2c 100644 --- a/tests/TelegramReplyContractTest.php +++ b/tests/TelegramReplyContractTest.php @@ -71,6 +71,7 @@ function expectTelegramContract($condition, $message) 'file embeds must be removed from fallback text' ); +$extension = new erLhcoreClassExtensionLhctelegram(); $vendorAutoload = __DIR__ . '/../../../lib/vendor/autoload.php'; if (is_file($vendorAutoload)) { require_once $vendorAutoload; @@ -89,9 +90,42 @@ function expectTelegramContract($condition, $message) expectTelegramContract(is_resource($handle), 'Request::encodeFile must return a readable resource'); fclose($handle); unlink($fixture); + + // Guzzle consumes and closes multipart resources. The wrapper must reopen + // the local file before retrying a stale reply target. + $responses = [ + new \GuzzleHttp\Psr7\Response(200, [], '{"ok":false,"error_code":400,"description":"Bad Request: message to be replied not found"}'), + new \GuzzleHttp\Psr7\Response(200, [], '{"ok":true,"result":{"message_id":123,"date":1,"chat":{"id":-100}}}') + ]; + $requestBodies = []; + $handler = function ($request, $options) use (&$responses, &$requestBodies) { + $requestBodies[] = $request->getBody()->getContents(); + return \GuzzleHttp\Promise\Create::promiseFor(array_shift($responses)); + }; + new \Longman\TelegramBot\Telegram('123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11', 'contract_bot'); + \Longman\TelegramBot\Request::setClient(new \GuzzleHttp\Client(['handler' => $handler])); + + $retryFixture = tempnam(sys_get_temp_dir(), 'tg_retry_'); + file_put_contents($retryFixture, 'retry-fixture-payload'); + $retryHandle = \Longman\TelegramBot\Request::encodeFile($retryFixture); + $retryMethod = new ReflectionMethod($extension, 'sendTelegramRequest'); + $retryMethod->setAccessible(true); + $retryResponse = $retryMethod->invoke($extension, 'sendDocument', [ + 'chat_id' => -100, + 'message_thread_id' => 77, + 'document' => $retryHandle, + 'reply_to_message_id' => 91 + ], $retryFixture, 'document'); + expectTelegramContract($retryResponse->isOk(), 'multipart stale-reply retry must succeed'); + expectTelegramContract(count($requestBodies) === 2, 'multipart stale-reply retry must make two requests'); + expectTelegramContract(strpos($requestBodies[0], 'retry-fixture-payload') !== false && strpos($requestBodies[1], 'retry-fixture-payload') !== false, 'multipart retry must include the file payload twice'); + expectTelegramContract(strpos($requestBodies[1], 'reply_to_message_id') === false, 'multipart retry must remove stale reply target'); + if (is_resource($retryHandle)) { + fclose($retryHandle); + } + unlink($retryFixture); } -$extension = new erLhcoreClassExtensionLhctelegram(); $fallbackMethod = new ReflectionMethod($extension, 'shouldRetryTelegramWithoutReply'); $fallbackMethod->setAccessible(true); $topicMethod = new ReflectionMethod($extension, 'isTelegramTopicUnavailable'); From f64b2d6884d4f1b596666d055057cb3d8b3c4598 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 26 Aug 2026 16:41:33 +0300 Subject: [PATCH 04/13] Remove standalone contract test file from extension tree --- tests/TelegramReplyContractTest.php | 153 ---------------------------- 1 file changed, 153 deletions(-) delete mode 100644 tests/TelegramReplyContractTest.php diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php deleted file mode 100644 index f748d2c..0000000 --- a/tests/TelegramReplyContractTest.php +++ /dev/null @@ -1,153 +0,0 @@ - [ - 'message_id' => 101, - 'message_thread_id' => 50, - 'reply_to_message' => ['message_id' => 90], - 'quote' => ['text' => 'quoted text'] - ] -]; -$reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($message); -expectTelegramContract($reply['message_id'] === 101, 'message id must come from raw_data'); -expectTelegramContract($reply['reply_message_id'] === 90, 'reply id must come from reply_to_message'); -expectTelegramContract($reply['is_explicit_reply'] === true, 'ordinary reply must be explicit'); -expectTelegramContract($reply['quote_text'] === 'quoted text', 'top-level quote must be read from raw_data'); - -$nestedQuote = (object)[ - 'raw_data' => [ - 'message_id' => 102, - 'message_thread_id' => 50, - 'reply_to_message' => [ - 'message_id' => 91, - 'quote' => ['text' => 'nested quoted text'] - ] - ] -]; -$reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($nestedQuote); -expectTelegramContract($reply['quote_text'] === 'nested quoted text', 'nested quote must be supported'); - -$topicRoot = (object)[ - 'raw_data' => [ - 'message_id' => 103, - 'message_thread_id' => 50, - 'reply_to_message' => [ - 'message_id' => 50, - 'forum_topic_created' => ['name' => 'Topic'] - ] - ] -]; -$reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($topicRoot); -expectTelegramContract($reply['is_explicit_reply'] === false, 'topic root service message is not a quote'); - -$stored = (object)[ - 'msg' => 'fallback [file=12_0123456789abcdef0123456789abcdef]', - 'meta_msg_array' => [ - 'tg_topic_msg_map' => [ - '90' => ['caption' => 'stored caption'] - ] - ] -]; -expectTelegramContract( - erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($stored, 90) === 'stored caption', - 'stored caption must win when Telegram omits quote' -); -expectTelegramContract( - erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($stored, 91) === 'fallback', - 'file embeds must be removed from fallback text' -); - -$extension = new erLhcoreClassExtensionLhctelegram(); -$vendorAutoload = __DIR__ . '/../../../lib/vendor/autoload.php'; -if (is_file($vendorAutoload)) { - require_once $vendorAutoload; - $entity = new \Longman\TelegramBot\Entities\Message([ - 'message_id' => 104, - 'message_thread_id' => 50, - 'reply_to_message' => ['message_id' => 92], - 'quote' => ['text' => 'entity quote'] - ], 'contract_bot'); - $reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($entity); - expectTelegramContract($reply['reply_message_id'] === 92 && $reply['quote_text'] === 'entity quote', 'installed telegram-core entity compatibility'); - - $fixture = tempnam(sys_get_temp_dir(), 'tg_contract_'); - file_put_contents($fixture, 'file'); - $handle = \Longman\TelegramBot\Request::encodeFile($fixture); - expectTelegramContract(is_resource($handle), 'Request::encodeFile must return a readable resource'); - fclose($handle); - unlink($fixture); - - // Guzzle consumes and closes multipart resources. The wrapper must reopen - // the local file before retrying a stale reply target. - $responses = [ - new \GuzzleHttp\Psr7\Response(200, [], '{"ok":false,"error_code":400,"description":"Bad Request: message to be replied not found"}'), - new \GuzzleHttp\Psr7\Response(200, [], '{"ok":true,"result":{"message_id":123,"date":1,"chat":{"id":-100}}}') - ]; - $requestBodies = []; - $handler = function ($request, $options) use (&$responses, &$requestBodies) { - $requestBodies[] = $request->getBody()->getContents(); - return \GuzzleHttp\Promise\Create::promiseFor(array_shift($responses)); - }; - new \Longman\TelegramBot\Telegram('123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11', 'contract_bot'); - \Longman\TelegramBot\Request::setClient(new \GuzzleHttp\Client(['handler' => $handler])); - - $retryFixture = tempnam(sys_get_temp_dir(), 'tg_retry_'); - file_put_contents($retryFixture, 'retry-fixture-payload'); - $retryHandle = \Longman\TelegramBot\Request::encodeFile($retryFixture); - $retryMethod = new ReflectionMethod($extension, 'sendTelegramRequest'); - $retryMethod->setAccessible(true); - $retryResponse = $retryMethod->invoke($extension, 'sendDocument', [ - 'chat_id' => -100, - 'message_thread_id' => 77, - 'document' => $retryHandle, - 'reply_to_message_id' => 91 - ], $retryFixture, 'document'); - expectTelegramContract($retryResponse->isOk(), 'multipart stale-reply retry must succeed'); - expectTelegramContract(count($requestBodies) === 2, 'multipart stale-reply retry must make two requests'); - expectTelegramContract(strpos($requestBodies[0], 'retry-fixture-payload') !== false && strpos($requestBodies[1], 'retry-fixture-payload') !== false, 'multipart retry must include the file payload twice'); - expectTelegramContract(strpos($requestBodies[1], 'reply_to_message_id') === false, 'multipart retry must remove stale reply target'); - if (is_resource($retryHandle)) { - fclose($retryHandle); - } - unlink($retryFixture); -} - -$fallbackMethod = new ReflectionMethod($extension, 'shouldRetryTelegramWithoutReply'); -$fallbackMethod->setAccessible(true); -$topicMethod = new ReflectionMethod($extension, 'isTelegramTopicUnavailable'); -$topicMethod->setAccessible(true); -$staleReply = new class { - public function isOk() { return false; } - public function getErrorCode() { return 400; } - public function getDescription() { return 'Bad Request: message to be replied not found'; } -}; -$otherError = new class { - public function isOk() { return false; } - public function getErrorCode() { return 400; } - public function getDescription() { return 'Bad Request: chat not found'; } -}; -expectTelegramContract($fallbackMethod->invoke($extension, $staleReply) === true, 'stale reply must trigger fallback'); -expectTelegramContract($fallbackMethod->invoke($extension, $otherError) === false, 'unrelated API error must not retry'); -expectTelegramContract($topicMethod->invoke($extension, $staleReply) === false, 'stale reply is not a deleted topic'); -$deletedTopic = new class { - public function isOk() { return false; } - public function getErrorCode() { return 400; } - public function getDescription() { return 'Bad Request: message thread not found'; } -}; -expectTelegramContract($topicMethod->invoke($extension, $deletedTopic) === true, 'deleted topic must be detected'); - -fwrite(STDOUT, "Telegram reply contract tests: OK\n"); From af2af2e80d14171364f81920ecfd716c5f24d6f4 Mon Sep 17 00:00:00 2001 From: mysubcult Date: Thu, 27 Aug 2026 16:46:06 +0400 Subject: [PATCH 05/13] Guard Telegram forum topics by source chat --- bootstrap/bootstrap.php | 15 +++++++++++++++ classes/Commands/ChatCommand.php | 4 ++++ classes/Commands/EndchatCommand.php | 9 +++++++-- classes/Commands/EndchattopicCommand.php | 5 +++++ classes/Commands/GenericmessageCommand.php | 5 +++++ 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index 3e481c6..ab1d166 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -532,6 +532,21 @@ public static function extractTelegramReplyData($message) ); } + /** + * Ensure an incoming forum update belongs to the configured Telegram group. + * Message/thread IDs are scoped to a chat and can otherwise collide. + */ + public static function isTelegramForumChatMessage($tchat, $chatId) + { + if (!is_object($tchat) || !is_object($tchat->bot)) { + return false; + } + + $groupChatId = $tchat->bot->group_chat_id ?? null; + return is_numeric($groupChatId) && is_numeric($chatId) + && (int)$groupChatId === (int)$chatId; + } + /** * Return the text/caption that was sent for a stored Telegram message. * This is used when Telegram omitted Message.quote (the normal case on core 79e5e3a). diff --git a/classes/Commands/ChatCommand.php b/classes/Commands/ChatCommand.php index 83e371c..4d9203d 100644 --- a/classes/Commands/ChatCommand.php +++ b/classes/Commands/ChatCommand.php @@ -74,6 +74,10 @@ public function execute(): ServerResponse foreach (\erLhcoreClassModelTelegramChat::getList(['filter' => ['bot_id' => $tBot->id, 'tchat_id' => $message->getMessageThreadId(), 'type' => 1]]) as $tchat) { + if (!\erLhcoreClassExtensionLhctelegram::isTelegramForumChatMessage($tchat, $chat_id)) { + continue; + } + $chat = $tchat->chat; if (!($chat instanceof \erLhcoreClassModelChat)) { diff --git a/classes/Commands/EndchatCommand.php b/classes/Commands/EndchatCommand.php index e933c83..35da3c7 100644 --- a/classes/Commands/EndchatCommand.php +++ b/classes/Commands/EndchatCommand.php @@ -67,8 +67,13 @@ public function execute(): ServerResponse if ($operator instanceof \erLhcoreClassModelTelegramOperator) { - foreach (\erLhcoreClassModelTelegramChat::getList(['filter' => ['bot_id' => $tBot->id, 'tchat_id' => $message->getMessageThreadId(), 'type' => 1]]) as $tchat) { - $chat = $tchat->chat; + foreach (\erLhcoreClassModelTelegramChat::getList(['filter' => ['bot_id' => $tBot->id, 'tchat_id' => $message->getMessageThreadId(), 'type' => 1]]) as $tchat) { + + if (!\erLhcoreClassExtensionLhctelegram::isTelegramForumChatMessage($tchat, $chat_id)) { + continue; + } + + $chat = $tchat->chat; if ($chat instanceof \erLhcoreClassModelChat) { diff --git a/classes/Commands/EndchattopicCommand.php b/classes/Commands/EndchattopicCommand.php index b0d6a69..7639214 100644 --- a/classes/Commands/EndchattopicCommand.php +++ b/classes/Commands/EndchattopicCommand.php @@ -68,6 +68,11 @@ public function execute(): ServerResponse if ($operator instanceof \erLhcoreClassModelTelegramOperator) { foreach (\erLhcoreClassModelTelegramChat::getList(['filter' => ['bot_id' => $tBot->id, 'tchat_id' => $message->getMessageThreadId(), 'type' => 1]]) as $tchat) { + + if (!\erLhcoreClassExtensionLhctelegram::isTelegramForumChatMessage($tchat, $chat_id)) { + continue; + } + $chat = $tchat->chat; if ($chat instanceof \erLhcoreClassModelChat) { diff --git a/classes/Commands/GenericmessageCommand.php b/classes/Commands/GenericmessageCommand.php index ef91d85..2ee8576 100644 --- a/classes/Commands/GenericmessageCommand.php +++ b/classes/Commands/GenericmessageCommand.php @@ -253,6 +253,11 @@ public function execute(): ServerResponse foreach (\erLhcoreClassModelTelegramChat::getList(['filter' => ['bot_id' => $tBot->id, 'tchat_id' => $message->getMessageThreadId(), 'type' => 1]]) as $tchat) { + // Telegram message/thread IDs are only unique within one chat. + if (!\erLhcoreClassExtensionLhctelegram::isTelegramForumChatMessage($tchat, $chat_id)) { + continue; + } + $chat = $tchat->chat; if ($chat instanceof \erLhcoreClassModelChat) { From 296ac6489a79e5da47c2b2de71d5075b2ef23182 Mon Sep 17 00:00:00 2001 From: mysubcult Date: Fri, 28 Aug 2026 00:06:00 +0400 Subject: [PATCH 06/13] Harden Telegram topic replies and quote sync --- bootstrap/bootstrap.php | 454 ++++++++++++++++++--- classes/Commands/GenericmessageCommand.php | 85 +++- tests/TelegramReplyContractTest.php | 221 ++++++++++ 3 files changed, 684 insertions(+), 76 deletions(-) create mode 100644 tests/TelegramReplyContractTest.php diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index ab1d166..cfa8cef 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -4,6 +4,7 @@ class erLhcoreClassExtensionLhctelegram { private $lastTelegramSendData = null; + private $lastTelegramSendResponses = array(); public function __construct() { @@ -357,7 +358,7 @@ public function pageViewLogged($params) private function stripTelegramFileEmbeds($text) { - return trim(preg_replace('/\[file=\d+_[a-f0-9]{32}\]/i', '', (string)$text)); + return trim(preg_replace('/\[file=\d+_[a-z0-9]+\]/i', '', (string)$text)); } private function getTelegramMessageFiles($msg) @@ -547,27 +548,97 @@ public static function isTelegramForumChatMessage($tchat, $chatId) && (int)$groupChatId === (int)$chatId; } + /** + * Return a JSON-path-safe namespace for one Telegram bot/group destination. + * Telegram message IDs are only unique inside a destination chat. + */ + public static function getTelegramTopicNamespace($botId, $groupChatId) + { + $botValue = preg_replace('/\D+/', '', (string)$botId); + $groupValue = trim((string)$groupChatId); + $groupSign = strpos($groupValue, '-') === 0 ? 'n' : 'p'; + $groupDigits = preg_replace('/\D+/', '', $groupValue); + + return 'bot_' . ($botValue !== '' ? $botValue : '0') + . '_chat_' . $groupSign . '_' . ($groupDigits !== '' ? $groupDigits : '0'); + } + + private static function getTelegramTopicNamespaceFromContext($topicContext) + { + if (is_string($topicContext) && preg_match('/^bot_[0-9]+_chat_[np]_[0-9]+$/', $topicContext)) { + return $topicContext; + } + + if (!is_array($topicContext)) { + return ''; + } + + if (isset($topicContext['namespace']) && preg_match('/^bot_[0-9]+_chat_[np]_[0-9]+$/', (string)$topicContext['namespace'])) { + return (string)$topicContext['namespace']; + } + + if (array_key_exists('bot_id', $topicContext) && array_key_exists('group_chat_id', $topicContext)) { + return self::getTelegramTopicNamespace($topicContext['bot_id'], $topicContext['group_chat_id']); + } + + return ''; + } + + private function getTelegramTopicContextForChat($tchat) + { + if (!is_object($tchat) || !isset($tchat->bot_id) || !is_object($tchat->bot)) { + return array(); + } + + return array( + 'bot_id' => (int)$tchat->bot_id, + 'group_chat_id' => (string)$tchat->bot->group_chat_id + ); + } + /** * Return the text/caption that was sent for a stored Telegram message. * This is used when Telegram omitted Message.quote (the normal case on core 79e5e3a). */ - public static function getStoredTelegramMessageText($msg, $topicMsgId = null) + public static function getStoredTelegramMessageText($msg, $topicMsgId = null, $topicContext = array()) { if (!is_object($msg)) { return ''; } $meta = is_array($msg->meta_msg_array) ? $msg->meta_msg_array : array(); + $namespace = self::getTelegramTopicNamespaceFromContext($topicContext); + if ($namespace !== '' && isset($meta['tg_topic_namespace']) && (string)$meta['tg_topic_namespace'] !== $namespace) { + return ''; + } + $key = $topicMsgId !== null ? (string)(int)$topicMsgId : ''; + if ($namespace !== '' && isset($meta['tg_topic_msg_contexts']) && is_array($meta['tg_topic_msg_contexts'])) { + if (!array_key_exists($namespace, $meta['tg_topic_msg_contexts'])) { + return ''; + } + + $context = is_array($meta['tg_topic_msg_contexts'][$namespace]) ? $meta['tg_topic_msg_contexts'][$namespace] : array(); + if ($key !== '' && isset($context['map'][$key]) && is_array($context['map'][$key])) { + $entry = $context['map'][$key]; + $mappedText = self::normalizeStoredTelegramMessageText($entry['caption'] ?? ($entry['text'] ?? '')); + if ($mappedText !== '') { + return $mappedText; + } + } + + return ''; + } + if ($key !== '' && isset($meta['tg_topic_msg_map'][$key]) && is_array($meta['tg_topic_msg_map'][$key])) { $entry = $meta['tg_topic_msg_map'][$key]; - $mappedText = trim((string)($entry['caption'] ?? ($entry['text'] ?? ''))); + $mappedText = self::normalizeStoredTelegramMessageText($entry['caption'] ?? ($entry['text'] ?? '')); if ($mappedText !== '') { return $mappedText; } } - return trim(preg_replace('/\[file=\d+_[a-f0-9]{32}\]/i', '', (string)$msg->msg)); + return self::normalizeStoredTelegramMessageText($msg->msg); } private function getTelegramFileCaption($msg, $chat, $file, $messageText = null) @@ -588,6 +659,12 @@ private function getTelegramFileCaption($msg, $chat, $file, $messageText = null) return htmlspecialchars(mb_substr($caption, 0, 900), ENT_QUOTES, 'UTF-8'); } + private static function normalizeStoredTelegramMessageText($text) + { + $text = html_entity_decode((string)$text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + return trim(preg_replace('/\[file=\d+_[a-z0-9]+\]/i', '', $text)); + } + private function isMeaningfulTelegramUploadName($file) { $uploadName = trim((string)$file->upload_name); @@ -603,7 +680,7 @@ private function isMeaningfulTelegramUploadName($file) return true; } - public function saveTopicMsgId($msg, $topicMsgId, $messageData = array()) + public function saveTopicMsgId($msg, $topicMsgId, $messageData = array(), $topicContext = array()) { if (!($msg instanceof erLhcoreClassModelmsg) || !(int)$topicMsgId || $msg->id <= 0) { return; @@ -661,6 +738,28 @@ public function saveTopicMsgId($msg, $topicMsgId, $messageData = array()) } $meta['tg_topic_msg_map'] = $topicMap; + $namespace = self::getTelegramTopicNamespaceFromContext($topicContext); + if ($namespace !== '') { + $contexts = isset($meta['tg_topic_msg_contexts']) && is_array($meta['tg_topic_msg_contexts']) ? $meta['tg_topic_msg_contexts'] : array(); + $context = isset($contexts[$namespace]) && is_array($contexts[$namespace]) ? $contexts[$namespace] : array(); + $contextIds = isset($context['ids']) && is_array($context['ids']) ? array_map('intval', $context['ids']) : array(); + $contextIds[] = (int)$topicMsgId; + $context['ids'] = array_values(array_unique(array_filter($contextIds, function ($id) { return (int)$id > 0; }))); + $context['latest_id'] = (int)$topicMsgId; + $context['bot_id'] = isset($topicContext['bot_id']) ? (int)$topicContext['bot_id'] : 0; + $context['group_chat_id'] = isset($topicContext['group_chat_id']) ? (string)$topicContext['group_chat_id'] : ''; + $contextMap = isset($context['map']) && is_array($context['map']) ? $context['map'] : array(); + if (!isset($contextMap[$mapKey]) || !is_array($contextMap[$mapKey])) { + $contextMap[$mapKey] = array(); + } + if (!empty($entry)) { + $contextMap[$mapKey] = array_merge($contextMap[$mapKey], $entry); + } + $context['map'] = $contextMap; + $contexts[$namespace] = $context; + $meta['tg_topic_msg_contexts'] = $contexts; + } + $msg->meta_msg_array = $meta; $msg->meta_msg = json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); @@ -680,10 +779,10 @@ public function saveTopicMsgId($msg, $topicMsgId, $messageData = array()) } } - private function saveTelegramFileTopicMsgId($msg, $topicMsgId, $telegramFile, $caption = '') + private function saveTelegramFileTopicMsgId($msg, $topicMsgId, $telegramFile, $caption = '', $topicContext = array()) { if (!is_array($telegramFile) || !isset($telegramFile['file']) || !is_object($telegramFile['file'])) { - $this->saveTopicMsgId($msg, $topicMsgId); + $this->saveTopicMsgId($msg, $topicMsgId, array(), $topicContext); return; } @@ -695,16 +794,65 @@ private function saveTelegramFileTopicMsgId($msg, $topicMsgId, $telegramFile, $c 'caption' => (string)$caption, 'text' => (string)$caption, 'kind' => (string)$file->type - )); + ), $topicContext); } - private function getStoredTopicMessageId($msg, $preferredId = null) + private function getStoredTopicMessageId($msg, $preferredId = null, $topicContext = array()) { if (!($msg instanceof erLhcoreClassModelmsg)) { return null; } $meta = is_array($msg->meta_msg_array) ? $msg->meta_msg_array : array(); + $namespace = self::getTelegramTopicNamespaceFromContext($topicContext); + if ($namespace !== '' && isset($meta['tg_topic_namespace']) && (string)$meta['tg_topic_namespace'] !== $namespace) { + return null; + } + + if ($namespace !== '' && isset($meta['tg_topic_msg_contexts']) && is_array($meta['tg_topic_msg_contexts'])) { + if (!array_key_exists($namespace, $meta['tg_topic_msg_contexts'])) { + return null; + } + + $context = is_array($meta['tg_topic_msg_contexts'][$namespace]) ? $meta['tg_topic_msg_contexts'][$namespace] : array(); + $knownIds = array(); + if (isset($context['ids']) && is_array($context['ids'])) { + foreach ($context['ids'] as $id) { + if ((int)$id > 0) { + $knownIds[(int)$id] = true; + } + } + } + if (isset($context['map']) && is_array($context['map'])) { + foreach (array_keys($context['map']) as $id) { + if ((int)$id > 0) { + $knownIds[(int)$id] = true; + } + } + } + if (isset($context['latest_id']) && (int)$context['latest_id'] > 0) { + $knownIds[(int)$context['latest_id']] = true; + } + + if ($preferredId !== null && isset($knownIds[(int)$preferredId])) { + return (int)$preferredId; + } + if (isset($context['latest_id']) && (int)$context['latest_id'] > 0) { + return (int)$context['latest_id']; + } + if (!empty($knownIds)) { + return (int)array_key_last($knownIds); + } + + return null; + } + + // A message that already has namespaced metadata must not fall back to + // its legacy scalar ID for a different bot/group. + if ($namespace !== '' && isset($meta['tg_topic_msg_contexts']) && is_array($meta['tg_topic_msg_contexts'])) { + return null; + } + $knownIds = array(); if (isset($meta['tg_topic_msg_ids']) && is_array($meta['tg_topic_msg_ids'])) { foreach ($meta['tg_topic_msg_ids'] as $id) { @@ -737,7 +885,7 @@ private function getStoredTopicMessageId($msg, $preferredId = null) return null; } - public function getTopicReplyId($msg, $chatId) + public function getTopicReplyId($msg, $chatId, $topicContext = array()) { if (!($msg instanceof erLhcoreClassModelmsg)) { return null; @@ -749,7 +897,7 @@ public function getTopicReplyId($msg, $chatId) $targetMsg = erLhcoreClassModelmsg::fetch((int)$meta['content']['reply_to']['db_msg_id']); if ($targetMsg instanceof erLhcoreClassModelmsg && (int)$targetMsg->chat_id === (int)$chatId) { $preferredId = $meta['content']['reply_to']['telegram_message_id'] ?? ($meta['content']['reply_to']['tg_topic_msg_id'] ?? null); - $resolvedId = $this->getStoredTopicMessageId($targetMsg, $preferredId); + $resolvedId = $this->getStoredTopicMessageId($targetMsg, $preferredId, $topicContext); if ($resolvedId !== null) { return $resolvedId; } @@ -763,7 +911,7 @@ public function getTopicReplyId($msg, $chatId) 'customfilter' => ["`meta_msg` != '' AND JSON_VALID(`meta_msg`) AND (JSON_UNQUOTE(JSON_EXTRACT(meta_msg,'$.iwh_msg_id')) = " . ezcDbInstance::get()->quote($iwhId) . " OR JSON_EXTRACT(meta_msg,'$.iwh_msg_id') = " . (is_numeric($iwhId) ? (int)$iwhId : ezcDbInstance::get()->quote($iwhId)) . ")"] ]); if ($targetMsg instanceof erLhcoreClassModelmsg && (int)$targetMsg->chat_id === (int)$chatId) { - $resolvedId = $this->getStoredTopicMessageId($targetMsg); + $resolvedId = $this->getStoredTopicMessageId($targetMsg, null, $topicContext); if ($resolvedId !== null) { return $resolvedId; } @@ -773,7 +921,7 @@ public function getTopicReplyId($msg, $chatId) if (isset($meta['content']['quote']['id']) && (int)$meta['content']['quote']['id'] > 0) { $targetMsg = erLhcoreClassModelmsg::fetch((int)$meta['content']['quote']['id']); if ($targetMsg instanceof erLhcoreClassModelmsg && (int)$targetMsg->chat_id === (int)$chatId) { - $resolvedId = $this->getStoredTopicMessageId($targetMsg); + $resolvedId = $this->getStoredTopicMessageId($targetMsg, null, $topicContext); if ($resolvedId !== null) { return $resolvedId; } @@ -783,7 +931,7 @@ public function getTopicReplyId($msg, $chatId) if (preg_match('#\[quote="?([0-9]+)"?\]#is', (string)$msg->msg, $m)) { $targetMsg = erLhcoreClassModelmsg::fetch((int)$m[1]); if ($targetMsg instanceof erLhcoreClassModelmsg && (int)$targetMsg->chat_id === (int)$chatId) { - $resolvedId = $this->getStoredTopicMessageId($targetMsg); + $resolvedId = $this->getStoredTopicMessageId($targetMsg, null, $topicContext); if ($resolvedId !== null) { return $resolvedId; } @@ -793,18 +941,13 @@ public function getTopicReplyId($msg, $chatId) return null; } - public function getTopicMessageId($msg, $chatId) + public function getTopicMessageId($msg, $chatId, $topicContext = array()) { if (!($msg instanceof erLhcoreClassModelmsg) || (int)$msg->chat_id !== (int)$chatId) { return null; } - $meta = is_array($msg->meta_msg_array) ? $msg->meta_msg_array : array(); - if (isset($meta['tg_topic_msg_id']) && (int)$meta['tg_topic_msg_id'] > 0) { - return (int)$meta['tg_topic_msg_id']; - } - - return $this->getStoredTopicMessageId($msg); + return $this->getStoredTopicMessageId($msg, null, $topicContext); } private function shouldRetryTelegramWithoutReply($sendData) @@ -855,6 +998,206 @@ private function closeTelegramResources(array &$data) unset($value); } + private function getTelegramTextLength($text) + { + $text = (string)$text; + if (function_exists('mb_convert_encoding')) { + // Telegram applies its 4096-character limit to UTF-16 code units. + return (int)(strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')) / 2); + } + + return function_exists('mb_strlen') ? mb_strlen($text, 'UTF-8') : strlen($text); + } + + private function getTelegramTextSlice($text, $offset, $length) + { + return function_exists('mb_substr') + ? mb_substr((string)$text, (int)$offset, (int)$length, 'UTF-8') + : substr((string)$text, (int)$offset, (int)$length); + } + + private function splitTelegramText($text, $limit = 4000) + { + $chars = preg_split('//u', (string)$text, -1, PREG_SPLIT_NO_EMPTY); + return is_array($chars) ? $this->splitTelegramCharacters($chars, $limit, false) : array((string)$text); + } + + private function getTelegramMessageChunks(array $data) + { + $isHtml = isset($data['parse_mode']) && strtolower((string)$data['parse_mode']) === 'html'; + $text = (string)($data['text'] ?? ''); + $plainText = $text; + if ($isHtml) { + // A split HTML message cannot safely retain arbitrary open tags or + // entities. Long messages deliberately fall back to escaped text. + $plainText = preg_replace('#<(?:br|/p|/div)\s*/?>#i', "\n", $text); + $plainText = html_entity_decode(strip_tags($plainText), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if ($this->getTelegramTextLength($this->escapeTelegramHtmlText($plainText)) <= 4096) { + return array($data); + } + } elseif ($this->getTelegramTextLength($text) <= 4096) { + return array($data); + } + + $plainChunks = $isHtml + ? $this->splitTelegramHtmlText($plainText) + : $this->splitTelegramText($plainText); + + $chunks = array(); + foreach ($plainChunks as $index => $chunk) { + $chunkData = $data; + if ($isHtml) { + // Telegram HTML accepts only four named entities. Escaping + // explicitly avoids producing unsupported entities such as + // ' in long-message fallbacks. + $chunkData['text'] = $this->escapeTelegramHtmlText($chunk); + } else { + $chunkData['text'] = $chunk; + } + + // The initial part preserves an explicit reply. Continuations are + // left as ordinary messages in the same forum topic. + if ($index > 0) { + unset($chunkData['reply_to_message_id']); + } + + $chunks[] = $chunkData; + } + + return $chunks; + } + + private function escapeTelegramHtmlText($text) + { + return strtr((string)$text, array( + '&' => '&', + '<' => '<', + '>' => '>', + '"' => '"' + )); + } + + private function splitTelegramHtmlText($text, $limit = 4000) + { + $chars = preg_split('//u', (string)$text, -1, PREG_SPLIT_NO_EMPTY); + return is_array($chars) ? $this->splitTelegramCharacters($chars, $limit, true) : array((string)$text); + } + + private function splitTelegramCharacters(array $chars, $limit, $escape) + { + if (empty($chars)) { + return array(''); + } + + $chunks = array(); + $current = array(); + $encodedLength = 0; + $lastBreak = -1; + + foreach ($chars as $char) { + $value = $escape ? $this->escapeTelegramHtmlText($char) : $char; + $charLength = $this->getTelegramTextLength($value); + if (!empty($current) && $encodedLength + $charLength > $limit) { + $currentCount = count($current); + $cut = ($lastBreak >= (int)floor($currentCount / 2)) ? $lastBreak + 1 : $currentCount; + $chunks[] = implode('', array_slice($current, 0, $cut)); + $current = array_slice($current, $cut); + $encodedLength = 0; + $lastBreak = -1; + foreach ($current as $index => $remainingChar) { + $remainingValue = $escape ? $this->escapeTelegramHtmlText($remainingChar) : $remainingChar; + $encodedLength += $this->getTelegramTextLength($remainingValue); + if ($remainingChar === "\n" || $remainingChar === ' ') { + $lastBreak = $index; + } + } + } + + $current[] = $char; + $encodedLength += $charLength; + if ($char === "\n" || $char === ' ') { + $lastBreak = count($current) - 1; + } + } + + if (!empty($current)) { + $chunks[] = implode('', $current); + } + + return $chunks; + } + + private function sendTelegramMessageWithSplit(array &$data) + { + $responses = array(); + foreach ($this->getTelegramMessageChunks($data) as $chunkData) { + $response = Longman\TelegramBot\Request::send('sendMessage', $chunkData); + if ($this->shouldRetryTelegramWithoutReply($response) && isset($chunkData['reply_to_message_id'])) { + unset($chunkData['reply_to_message_id']); + $response = Longman\TelegramBot\Request::send('sendMessage', $chunkData); + } + $responses[] = $response; + + if (!is_object($response) || !$response->isOk()) { + break; + } + } + + $this->lastTelegramSendResponses = $responses; + return end($responses); + } + + private function sendTelegramRequestOnce($method, array &$data, $allowMessageSplit = false) + { + $this->rewindTelegramResources($data); + + if ($allowMessageSplit && $method === 'sendMessage') { + return $this->sendTelegramMessageWithSplit($data); + } + + $sendData = Longman\TelegramBot\Request::send($method, $data); + $this->lastTelegramSendResponses = array($sendData); + return $sendData; + } + + private function hasTelegramStaleReplyResponse($sendData) + { + if ($this->shouldRetryTelegramWithoutReply($sendData)) { + return true; + } + + foreach ($this->lastTelegramSendResponses as $response) { + if ($this->shouldRetryTelegramWithoutReply($response)) { + return true; + } + } + + return false; + } + + private function getTelegramSendMessageIds($sendData) + { + $ids = array(); + $responses = !empty($this->lastTelegramSendResponses) ? $this->lastTelegramSendResponses : array($sendData); + foreach ($responses as $response) { + if (is_object($response) && method_exists($response, 'isOk') && $response->isOk()) { + $result = $response->getResult(); + if (is_object($result) && method_exists($result, 'getMessageId') && (int)$result->getMessageId() > 0) { + $ids[] = (int)$result->getMessageId(); + } + } + } + + return array_values(array_unique($ids)); + } + + private function saveTelegramTopicMessageIds($msg, $sendData, $messageData = array(), $topicContext = array()) + { + foreach ($this->getTelegramSendMessageIds($sendData) as $topicMsgId) { + $this->saveTopicMsgId($msg, $topicMsgId, $messageData, $topicContext); + } + } + /** * Send once, then retry without a stale reply target for known 400 errors. * @@ -866,17 +1209,19 @@ private function sendTelegramRequest($method, array $data, $multipartFilePath = { $multipartFilePath = (string)$multipartFilePath; $multipartFileField = (string)$multipartFileField; + $allowMessageSplit = $method === 'sendMessage' && $multipartFilePath === '' && $multipartFileField === ''; + $this->lastTelegramSendResponses = array(); try { - $this->rewindTelegramResources($data); - $sendData = Longman\TelegramBot\Request::send($method, $data); + $sendData = $this->sendTelegramRequestOnce($method, $data, $allowMessageSplit); } catch (\Throwable $e) { $this->closeTelegramResources($data); + $this->lastTelegramSendResponses = array(); erLhcoreClassLog::write('Telegram request exception ' . $e->getMessage(), ezcLog::SUCCESS_AUDIT, array('source' => 'lhc', 'category' => 'telegram_exception', 'line' => __LINE__, 'file' => __FILE__)); return new Longman\TelegramBot\Entities\ServerResponse(array('ok' => false, 'error_code' => 500, 'description' => 'Telegram request failed')); } - if ($this->shouldRetryTelegramWithoutReply($sendData) && isset($data['reply_to_message_id'])) { + if (!$allowMessageSplit && $this->hasTelegramStaleReplyResponse($sendData) && isset($data['reply_to_message_id'])) { unset($data['reply_to_message_id']); try { if ($multipartFilePath !== '' && $multipartFileField !== '') { @@ -887,9 +1232,10 @@ private function sendTelegramRequest($method, array $data, $multipartFilePath = } else { $this->rewindTelegramResources($data); } - $sendData = Longman\TelegramBot\Request::send($method, $data); + $sendData = $this->sendTelegramRequestOnce($method, $data, $allowMessageSplit); } catch (\Throwable $e) { $this->closeTelegramResources($data); + $this->lastTelegramSendResponses = array(); erLhcoreClassLog::write('Telegram reply fallback exception ' . $e->getMessage(), ezcLog::SUCCESS_AUDIT, array('source' => 'lhc', 'category' => 'telegram_exception', 'line' => __LINE__, 'file' => __FILE__)); return new Longman\TelegramBot\Entities\ServerResponse(array('ok' => false, 'error_code' => 500, 'description' => 'Telegram reply fallback failed')); } @@ -903,10 +1249,6 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif $this->lastTelegramSendData = null; $file = $fileData['file']; - if (!file_exists($file->file_path_server) || !is_readable($file->file_path_server)) { - return false; - } - $extension = strtolower((string)$file->extension); $type = strtolower((string)$file->type); $method = 'sendDocument'; @@ -931,7 +1273,10 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif 'chat_id' => $tchat->bot->group_chat_id, 'message_thread_id' => $tchat->tchat_id, 'parse_mode' => 'HTML', - $field => Longman\TelegramBot\Request::encodeFile($file->file_path_server) + // Keep the accepted URL-based path: downloadfile applies the + // original upload name and storage callbacks before Telegram + // receives the file. + $field => $this->getTelegramChatFileUrl($file) ); } catch (\Throwable $e) { erLhcoreClassLog::write('SendFile encode exception ' . $e->getMessage(), ezcLog::SUCCESS_AUDIT, array('source' => 'lhc', 'category' => 'telegram_exception', 'line' => __LINE__, 'file' => __FILE__, 'object_id' => $file->chat_id)); @@ -950,7 +1295,7 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif $data['disable_notification'] = true; } - $sendData = $this->sendTelegramRequest($method, $data, $file->file_path_server, $field); + $sendData = $this->sendTelegramRequest($method, $data); $this->lastTelegramSendData = $sendData; if ($sendData === null) { return false; @@ -1003,6 +1348,7 @@ public function messageAdded($params) } $telegram = new Longman\TelegramBot\Telegram($tchat->bot->bot_api, $tchat->bot->bot_username); + $topicContext = $this->getTelegramTopicContextForChat($tchat); if ($params['msg']->id > $tchat->last_msg_id) { @@ -1036,16 +1382,13 @@ public function messageAdded($params) $data['disable_notification'] = true; } - $replyTopicMsgId = $this->getTopicReplyId($params['msg'], $chat->id); + $replyTopicMsgId = $this->getTopicReplyId($params['msg'], $chat->id, $topicContext); if ($replyTopicMsgId > 0) { $data['reply_to_message_id'] = $replyTopicMsgId; } - $sendData = $this->sendTelegramRequest('sendMessage', $data); - - if ($sendData->isOk()) { - $this->saveTopicMsgId($params['msg'], $sendData->getResult()->getMessageId(), array('text' => $messageText, 'kind' => 'text')); - } + $sendData = $this->sendTelegramRequest('sendMessage', $data); + $this->saveTelegramTopicMessageIds($params['msg'], $sendData, array('text' => $messageText, 'kind' => 'text'), $topicContext); if ($this->isTelegramTopicUnavailable($sendData)) { // Reset telegram chat @@ -1075,7 +1418,7 @@ public function messageAdded($params) $failedEmbedCodes = array(); $fileIndex = 0; - $replyTopicMsgId = $this->getTopicReplyId($params['msg'], $chat->id); + $replyTopicMsgId = $this->getTopicReplyId($params['msg'], $chat->id, $topicContext); foreach ($telegramFiles as $telegramFile) { $sentFileMsgId = $this->sendTelegramChatFile($tchat, $telegramFile, $this->getTelegramFileCaption($params['msg'], $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $chat->status == erLhcoreClassModelChat::STATUS_BOT_CHAT, ['reply_to_message_id' => $replyTopicMsgId]); if ($sentFileMsgId === false) { @@ -1087,7 +1430,7 @@ public function messageAdded($params) } $failedEmbedCodes[] = $telegramFile['embed']; } else { - $this->saveTelegramFileTopicMsgId($params['msg'], $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($params['msg'], $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : '')); + $this->saveTelegramFileTopicMsgId($params['msg'], $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($params['msg'], $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $topicContext); } $fileIndex++; } @@ -1115,7 +1458,7 @@ public function messageAdded($params) // Send bot responses if any $botMessages = erLhcoreClassModelmsg::getList(array('filter' => array('user_id' => -2, 'chat_id' => $chat->id), 'filtergt' => array('id' => $params['msg']->id))); - $botReplyTopicMsgId = $this->getTopicMessageId($params['msg'], $chat->id); + $botReplyTopicMsgId = $this->getTopicMessageId($params['msg'], $chat->id, $topicContext); foreach ($botMessages as $botMessage) { @@ -1149,10 +1492,9 @@ public function messageAdded($params) $data['reply_to_message_id'] = $botReplyTopicMsgId; } $sendData = $this->sendTelegramRequest('sendMessage', $data); + $this->saveTelegramTopicMessageIds($botMessage, $sendData, array('text' => $messageText, 'kind' => 'text'), $topicContext); - if ($sendData->isOk()) { - $this->saveTopicMsgId($botMessage, $sendData->getResult()->getMessageId(), array('text' => $messageText, 'kind' => 'text')); - } else { + if (!$sendData->isOk()) { erLhcoreClassLog::write('SendMessage BOT ['.$sendData->getErrorCode().']'. $sendData->getDescription(), ezcLog::SUCCESS_AUDIT, array( @@ -1175,7 +1517,7 @@ public function messageAdded($params) if ($sentFileMsgId === false) { $failedEmbedCodes[] = $telegramFile['embed']; } else { - $this->saveTelegramFileTopicMsgId($botMessage, $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : '')); + $this->saveTelegramFileTopicMsgId($botMessage, $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $topicContext); } $fileIndex++; } @@ -1218,6 +1560,7 @@ public function triggerClicked($params) foreach (erLhcoreClassModelTelegramChat::getList(['filter' => ['chat_id_internal' => ($params['chat']->online_user_id > 0 ? ($params['chat']->online_user_id * -1) : $params['chat']->id), 'type' => 1]]) as $tchat) { $telegram = new Longman\TelegramBot\Telegram($tchat->bot->bot_api, $tchat->bot->bot_username); + $topicContext = $this->getTelegramTopicContextForChat($tchat); if ($tchat->bot->bot_client == 0) { continue; @@ -1238,7 +1581,7 @@ public function triggerClicked($params) $telegramFiles = $this->getTelegramMessageFiles($botMessage); $messageText = $this->stripTelegramFileEmbeds($botMessage->msg); - $botReplyTopicMsgId = $this->getTopicMessageId($params['msg'], $chat->id); + $botReplyTopicMsgId = $this->getTopicMessageId($params['msg'], $chat->id, $topicContext); if ($messageText !== '' && empty($telegramFiles)) { $data = [ @@ -1254,10 +1597,9 @@ public function triggerClicked($params) $data['reply_to_message_id'] = $botReplyTopicMsgId; } $sendData = $this->sendTelegramRequest('sendMessage', $data); + $this->saveTelegramTopicMessageIds($botMessage, $sendData, array('text' => $messageText, 'kind' => 'text'), $topicContext); - if ($sendData->isOk()) { - $this->saveTopicMsgId($botMessage, $sendData->getResult()->getMessageId(), array('text' => $messageText, 'kind' => 'text')); - } else { + if (!$sendData->isOk()) { erLhcoreClassLog::write('['.$sendData->getErrorCode().']'. $sendData->getDescription(), ezcLog::SUCCESS_AUDIT, array( @@ -1280,7 +1622,7 @@ public function triggerClicked($params) if ($sentFileMsgId === false) { $failedEmbedCodes[] = $telegramFile['embed']; } else { - $this->saveTelegramFileTopicMsgId($botMessage, $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : '')); + $this->saveTelegramFileTopicMsgId($botMessage, $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $topicContext); } $fileIndex++; } @@ -1334,6 +1676,10 @@ public function chatStarted($params) } $telegram = new Longman\TelegramBot\Telegram($bot->bot->bot_api, $bot->bot->bot_username); + $topicContext = array( + 'bot_id' => (int)$bot->bot->id, + 'group_chat_id' => (string)$bot->bot->group_chat_id + ); if ($tChat->tchat_id == null || $tChat->tchat_id == 0) { $sendData = Longman\TelegramBot\Request::send('createForumTopic', [ @@ -1424,12 +1770,12 @@ public function chatStarted($params) if ($sendData->isOk()) { $aggregateMsgId = $sendData->getResult()->getMessageId(); foreach ($initialAggregateMessages as $aggregateMessage) { - $this->saveTopicMsgId($aggregateMessage['msg'], $aggregateMsgId, array('text' => $aggregateMessage['text'], 'kind' => 'aggregate')); + $this->saveTelegramTopicMessageIds($aggregateMessage['msg'], $sendData, array('text' => $aggregateMessage['text'], 'kind' => 'aggregate'), $topicContext); } if (empty($initialAggregateMessages)) { $firstMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id], 'sort' => 'id ASC']); if ($firstMsg instanceof erLhcoreClassModelmsg) { - $this->saveTopicMsgId($firstMsg, $aggregateMsgId, array('text' => $data['text'], 'kind' => 'aggregate')); + $this->saveTelegramTopicMessageIds($firstMsg, $sendData, array('text' => $data['text'], 'kind' => 'aggregate'), $topicContext); } } } else { @@ -1455,12 +1801,12 @@ public function chatStarted($params) if ($sendData->isOk()) { $aggregateMsgId = $sendData->getResult()->getMessageId(); foreach ($initialAggregateMessages as $aggregateMessage) { - $this->saveTopicMsgId($aggregateMessage['msg'], $aggregateMsgId, array('text' => $aggregateMessage['text'], 'kind' => 'aggregate')); + $this->saveTelegramTopicMessageIds($aggregateMessage['msg'], $sendData, array('text' => $aggregateMessage['text'], 'kind' => 'aggregate'), $topicContext); } if (empty($initialAggregateMessages)) { $firstMsg = erLhcoreClassModelmsg::findOne(['filter' => ['chat_id' => $params['chat']->id], 'sort' => 'id ASC']); if ($firstMsg instanceof erLhcoreClassModelmsg) { - $this->saveTopicMsgId($firstMsg, $aggregateMsgId, array('text' => $data['text'], 'kind' => 'aggregate')); + $this->saveTelegramTopicMessageIds($firstMsg, $sendData, array('text' => $data['text'], 'kind' => 'aggregate'), $topicContext); } } } else { @@ -1485,7 +1831,7 @@ public function chatStarted($params) if ($sentFileMsgId === false) { $failedEmbedCodes[] = $initialTelegramFile['file']['embed']; } else if (isset($initialTelegramFile['msg']) && $initialTelegramFile['msg'] instanceof erLhcoreClassModelmsg) { - $this->saveTelegramFileTopicMsgId($initialTelegramFile['msg'], $sentFileMsgId, $initialTelegramFile['file'], $this->getTelegramFileCaption($initialTelegramFile['msg'], $params['chat'], $initialTelegramFile['file']['file'], $initialTelegramFile['text'])); + $this->saveTelegramFileTopicMsgId($initialTelegramFile['msg'], $sentFileMsgId, $initialTelegramFile['file'], $this->getTelegramFileCaption($initialTelegramFile['msg'], $params['chat'], $initialTelegramFile['file']['file'], $initialTelegramFile['text']), $topicContext); } } diff --git a/classes/Commands/GenericmessageCommand.php b/classes/Commands/GenericmessageCommand.php index 2ee8576..455a6e6 100644 --- a/classes/Commands/GenericmessageCommand.php +++ b/classes/Commands/GenericmessageCommand.php @@ -261,6 +261,11 @@ public function execute(): ServerResponse $chat = $tchat->chat; if ($chat instanceof \erLhcoreClassModelChat) { + $topicContext = array( + 'bot_id' => (int)$tBot->id, + 'group_chat_id' => (string)$chat_id + ); + $topicNamespace = \erLhcoreClassExtensionLhctelegram::getTelegramTopicNamespace($topicContext['bot_id'], $topicContext['group_chat_id']); if ($type === 'photo') { $text = $this->appendCaptionToFileEmbed($message, $this->processPhoto($chat, $message, $tBot)); @@ -351,52 +356,88 @@ public function execute(): ServerResponse $replyData = \erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($message); $isExplicitReply = !empty($replyData['is_explicit_reply']) && (int)($replyData['reply_message_id'] ?? 0) > 0; + $telegramMessageId = (int)($replyData['message_id'] ?? 0); + + if ($telegramMessageId > 0) { + $metaMsg['tg_topic_msg_id'] = $telegramMessageId; + $metaMsg['tg_topic_msg_contexts'] = array( + $topicNamespace => array( + 'ids' => array($telegramMessageId), + 'latest_id' => $telegramMessageId, + 'bot_id' => $topicContext['bot_id'], + 'group_chat_id' => $topicContext['group_chat_id'], + 'map' => array( + (string)$telegramMessageId => array( + 'text' => $this->stripTelegramFileEmbeds($text), + 'kind' => 'text' + ) + ) + ) + ); + } if ($isExplicitReply) { $replyTopicMsgId = (int)$replyData['reply_message_id']; - $metaMsg['tg_topic_msg_id'] = (int)$replyData['message_id']; - + $db = \ezcDbInstance::get(); + $topicMessageIdsPath = '$.tg_topic_msg_contexts.' . $topicNamespace . '.ids'; $replyMsg = \erLhcoreClassModelmsg::findOne([ 'filter' => ['chat_id' => $chat->id], - 'customfilter' => ['`meta_msg` != \'\' AND JSON_VALID(`meta_msg`) AND JSON_EXTRACT(meta_msg, \'$.tg_topic_msg_id\') = ' . $replyTopicMsgId] + 'customfilter' => [ + '`meta_msg` != \'\' AND JSON_VALID(`meta_msg`) AND JSON_CONTAINS(JSON_EXTRACT(meta_msg, ' . $db->quote($topicMessageIdsPath) . '), ' . $db->quote(json_encode(array($replyTopicMsgId))) . ')' + ] ]); if (!($replyMsg instanceof \erLhcoreClassModelmsg)) { $replyMsg = \erLhcoreClassModelmsg::findOne([ 'filter' => ['chat_id' => $chat->id], - 'customfilter' => ['meta_msg != \'\' AND JSON_VALID(meta_msg) AND JSON_CONTAINS(JSON_EXTRACT(meta_msg, \'$.tg_topic_msg_ids\'), \'[' . $replyTopicMsgId . ']\')'] + 'customfilter' => [ + 'meta_msg != \'\' AND JSON_VALID(meta_msg) AND JSON_EXTRACT(meta_msg, \'$.tg_topic_msg_contexts\') IS NULL AND (JSON_EXTRACT(meta_msg, \'$.tg_topic_msg_id\') = ' . $replyTopicMsgId . ' OR JSON_CONTAINS(JSON_EXTRACT(meta_msg, \'$.tg_topic_msg_ids\'), \'[' . $replyTopicMsgId . ']\'))' + ] ]); } if ($replyMsg instanceof \erLhcoreClassModelmsg) { + $replyMsgMeta = is_array($replyMsg->meta_msg_array) ? $replyMsg->meta_msg_array : array(); + if (empty($replyMsgMeta) && isset($replyMsg->meta_msg) && is_string($replyMsg->meta_msg)) { + $decodedReplyMeta = json_decode($replyMsg->meta_msg, true); + if (is_array($decodedReplyMeta)) { + $replyMsgMeta = $decodedReplyMeta; + } + } + $replyExternalId = trim((string)($replyMsgMeta['iwh_msg_id'] ?? '')); + $replyReference = [ + 'db_msg_id' => $replyMsg->id, + 'telegram_message_id' => $replyTopicMsgId + ]; + + // Keep the local quote/reply target even when the + // original LHC message has no external visitor ID. + // The REST core cannot render an empty iwh_msg_id, so + // only add that optional field when it is present. + $metaMsg['content']['reply_to'] = $replyReference; $quoteText = trim((string)($replyData['quote_text'] ?? '')); if ($quoteText === '') { - $quoteText = \erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($replyMsg, $replyTopicMsgId); + $quoteText = \erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($replyMsg, $replyTopicMsgId, $topicContext); } if ($quoteText === '') { - $quoteText = (string)$replyMsg->msg; + $quoteText = html_entity_decode( + preg_replace('/\[file=\d+_[a-z0-9]+\]/i', '', (string)$replyMsg->msg), + ENT_QUOTES | ENT_HTML5, + 'UTF-8' + ); + $quoteText = trim($quoteText); } $replyNick = $replyMsg->name_support != '' ? $replyMsg->name_support : $chat->nick; $msgText = '[quote=' . $replyMsg->id . ']' . $quoteText . '[/quote]' . $msgText; - - $metaMsg['content'] = [ - 'quote' => [ - 'id' => $replyMsg->id, - 'text' => $quoteText, - 'nick' => $replyNick - ], - 'reply_to' => [ - 'db_msg_id' => $replyMsg->id, - 'telegram_message_id' => $replyTopicMsgId - ] + $metaMsg['content']['quote'] = [ + 'id' => $replyMsg->id, + 'text' => $quoteText, + 'nick' => $replyNick ]; - - if (isset($replyMsg->meta_msg_array['iwh_msg_id']) && $replyMsg->meta_msg_array['iwh_msg_id'] != '') { - $metaMsg['content']['reply_to']['iwh_msg_id'] = $replyMsg->meta_msg_array['iwh_msg_id']; + if ($replyExternalId !== '') { + $metaMsg['content']['reply_to']['iwh_msg_id'] = $replyExternalId; } } - } else { - $metaMsg['tg_topic_msg_id'] = (int)$replyData['message_id']; } $msg->msg = $msgText; diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php new file mode 100644 index 0000000..df93554 --- /dev/null +++ b/tests/TelegramReplyContractTest.php @@ -0,0 +1,221 @@ + [ + 'message_id' => 101, + 'message_thread_id' => 50, + 'reply_to_message' => ['message_id' => 90], + 'quote' => ['text' => 'quoted text'] + ] +]; +$reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($message); +expectTelegramContract($reply['message_id'] === 101, 'message id must come from raw_data'); +expectTelegramContract($reply['reply_message_id'] === 90, 'reply id must come from reply_to_message'); +expectTelegramContract($reply['is_explicit_reply'] === true, 'ordinary reply must be explicit'); +expectTelegramContract($reply['quote_text'] === 'quoted text', 'top-level quote must be read from raw_data'); + +$nestedQuote = (object)[ + 'raw_data' => [ + 'message_id' => 102, + 'message_thread_id' => 50, + 'reply_to_message' => [ + 'message_id' => 91, + 'quote' => ['text' => 'nested quoted text'] + ] + ] +]; +$reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($nestedQuote); +expectTelegramContract($reply['quote_text'] === 'nested quoted text', 'nested quote must be supported'); + +$topicRoot = (object)[ + 'raw_data' => [ + 'message_id' => 103, + 'message_thread_id' => 50, + 'reply_to_message' => [ + 'message_id' => 50, + 'forum_topic_created' => ['name' => 'Topic'] + ] + ] +]; +$reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($topicRoot); +expectTelegramContract($reply['is_explicit_reply'] === false, 'topic root service message is not a quote'); + +$stored = (object)[ + 'msg' => 'fallback [file=12_0123456789abcdef0123456789abcdef]', + 'meta_msg_array' => [ + 'tg_topic_msg_map' => [ + '90' => ['caption' => 'stored caption'] + ] + ] +]; +expectTelegramContract( + erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($stored, 90) === 'stored caption', + 'stored caption must win when Telegram omits quote' +); +expectTelegramContract( + erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($stored, 91) === 'fallback', + 'file embeds must be removed from fallback text' +); + +$namespaceA = erLhcoreClassExtensionLhctelegram::getTelegramTopicNamespace(7, '-100123'); +$namespaceB = erLhcoreClassExtensionLhctelegram::getTelegramTopicNamespace(8, '-100123'); +expectTelegramContract($namespaceA !== $namespaceB, 'bot namespaces must be distinct'); +$namespaced = (object)[ + 'msg' => 'legacy fallback', + 'meta_msg_array' => [ + 'tg_topic_msg_contexts' => [ + $namespaceA => ['map' => ['90' => ['text' => 'source A text']], 'latest_id' => 90], + $namespaceB => ['map' => ['90' => ['text' => 'source B text']], 'latest_id' => 90] + ] + ] +]; +expectTelegramContract( + erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($namespaced, 90, ['bot_id' => 7, 'group_chat_id' => '-100123']) === 'source A text', + 'source A namespace must resolve its own text' +); +expectTelegramContract( + erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($namespaced, 90, ['bot_id' => 8, 'group_chat_id' => '-100123']) === 'source B text', + 'source B namespace must resolve its own text' +); +$namespaced->meta_msg_array['tg_topic_msg_contexts'][$namespaceA]['map']['91'] = [ + 'caption' => 'caption & [file=12_0123456789abcdef0123456789abcdef]' +]; +expectTelegramContract( + erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($namespaced, 91, ['bot_id' => 7, 'group_chat_id' => '-100123']) === 'caption &', + 'stored HTML captions and file embeds must be normalized for fallback quotes' +); + +$extension = new erLhcoreClassExtensionLhctelegram(); +$splitMethod = new ReflectionMethod($extension, 'getTelegramMessageChunks'); +$splitMethod->setAccessible(true); +$lengthMethod = new ReflectionMethod($extension, 'getTelegramTextLength'); +$lengthMethod->setAccessible(true); +$chunks = $splitMethod->invoke($extension, [ + 'chat_id' => -100, + 'message_thread_id' => 77, + 'parse_mode' => 'HTML', + 'reply_to_message_id' => 91, + 'text' => str_repeat('A&B quoted ', 400) +]); +expectTelegramContract(count($chunks) > 1, 'long text must be split'); +foreach ($chunks as $index => $chunk) { + expectTelegramContract($lengthMethod->invoke($extension, $chunk['text']) <= 4000, 'split chunk must be within Telegram limit'); + expectTelegramContract(strpos($chunk['text'], ''') === false, 'split HTML must not emit unsupported apostrophe entity'); + if ($index > 0) { + expectTelegramContract(!isset($chunk['reply_to_message_id']), 'only first split chunk keeps reply target'); + } +} + +$emojiChunks = $splitMethod->invoke($extension, [ + 'chat_id' => -100, + 'text' => str_repeat('😀', 3000) +]); +expectTelegramContract(count($emojiChunks) > 1, 'surrogate-pair text must be split by UTF-16 length'); +foreach ($emojiChunks as $chunk) { + expectTelegramContract($lengthMethod->invoke($extension, $chunk['text']) <= 4000, 'emoji split chunk must be within UTF-16 limit'); +} + +$ampChunks = $splitMethod->invoke($extension, [ + 'chat_id' => -100, + 'parse_mode' => 'HTML', + 'text' => str_repeat('&', 4096) +]); +expectTelegramContract(count($ampChunks) > 1, 'HTML entities must be measured after escaping'); +foreach ($ampChunks as $chunk) { + expectTelegramContract($lengthMethod->invoke($extension, $chunk['text']) <= 4000, 'escaped HTML chunk must be within UTF-16 limit'); +} + +$vendorAutoload = __DIR__ . '/../../../lib/vendor/autoload.php'; +if (is_file($vendorAutoload)) { + require_once $vendorAutoload; + $entity = new \Longman\TelegramBot\Entities\Message([ + 'message_id' => 104, + 'message_thread_id' => 50, + 'reply_to_message' => ['message_id' => 92], + 'quote' => ['text' => 'entity quote'] + ], 'contract_bot'); + $reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($entity); + expectTelegramContract($reply['reply_message_id'] === 92 && $reply['quote_text'] === 'entity quote', 'installed telegram-core entity compatibility'); + + $fixture = tempnam(sys_get_temp_dir(), 'tg_contract_'); + file_put_contents($fixture, 'file'); + $handle = \Longman\TelegramBot\Request::encodeFile($fixture); + expectTelegramContract(is_resource($handle), 'Request::encodeFile must return a readable resource'); + fclose($handle); + unlink($fixture); + + // Guzzle consumes and closes multipart resources. The wrapper must reopen + // the local file before retrying a stale reply target. + $responses = [ + new \GuzzleHttp\Psr7\Response(200, [], '{"ok":false,"error_code":400,"description":"Bad Request: message to be replied not found"}'), + new \GuzzleHttp\Psr7\Response(200, [], '{"ok":true,"result":{"message_id":123,"date":1,"chat":{"id":-100}}}') + ]; + $requestBodies = []; + $handler = function ($request, $options) use (&$responses, &$requestBodies) { + $requestBodies[] = $request->getBody()->getContents(); + return \GuzzleHttp\Promise\Create::promiseFor(array_shift($responses)); + }; + new \Longman\TelegramBot\Telegram('123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11', 'contract_bot'); + \Longman\TelegramBot\Request::setClient(new \GuzzleHttp\Client(['handler' => $handler])); + + $retryFixture = tempnam(sys_get_temp_dir(), 'tg_retry_'); + file_put_contents($retryFixture, 'retry-fixture-payload'); + $retryHandle = \Longman\TelegramBot\Request::encodeFile($retryFixture); + $retryMethod = new ReflectionMethod($extension, 'sendTelegramRequest'); + $retryMethod->setAccessible(true); + $retryResponse = $retryMethod->invoke($extension, 'sendDocument', [ + 'chat_id' => -100, + 'message_thread_id' => 77, + 'document' => $retryHandle, + 'reply_to_message_id' => 91 + ], $retryFixture, 'document'); + expectTelegramContract($retryResponse->isOk(), 'multipart stale-reply retry must succeed'); + expectTelegramContract(count($requestBodies) === 2, 'multipart stale-reply retry must make two requests'); + expectTelegramContract(strpos($requestBodies[0], 'retry-fixture-payload') !== false && strpos($requestBodies[1], 'retry-fixture-payload') !== false, 'multipart retry must include the file payload twice'); + expectTelegramContract(strpos($requestBodies[1], 'reply_to_message_id') === false, 'multipart retry must remove stale reply target'); + if (is_resource($retryHandle)) { + fclose($retryHandle); + } + unlink($retryFixture); +} + +$fallbackMethod = new ReflectionMethod($extension, 'shouldRetryTelegramWithoutReply'); +$fallbackMethod->setAccessible(true); +$topicMethod = new ReflectionMethod($extension, 'isTelegramTopicUnavailable'); +$topicMethod->setAccessible(true); +$staleReply = new class { + public function isOk() { return false; } + public function getErrorCode() { return 400; } + public function getDescription() { return 'Bad Request: message to be replied not found'; } +}; +$otherError = new class { + public function isOk() { return false; } + public function getErrorCode() { return 400; } + public function getDescription() { return 'Bad Request: chat not found'; } +}; +expectTelegramContract($fallbackMethod->invoke($extension, $staleReply) === true, 'stale reply must trigger fallback'); +expectTelegramContract($fallbackMethod->invoke($extension, $otherError) === false, 'unrelated API error must not retry'); +expectTelegramContract($topicMethod->invoke($extension, $staleReply) === false, 'stale reply is not a deleted topic'); +$deletedTopic = new class { + public function isOk() { return false; } + public function getErrorCode() { return 400; } + public function getDescription() { return 'Bad Request: message thread not found'; } +}; +expectTelegramContract($topicMethod->invoke($extension, $deletedTopic) === true, 'deleted topic must be detected'); + +fwrite(STDOUT, "Telegram reply contract tests: OK\n"); From c5c37a4bb91945d0f85d70ad30a8445e31496893 Mon Sep 17 00:00:00 2001 From: mysubcult Date: Fri, 28 Aug 2026 00:24:57 +0400 Subject: [PATCH 07/13] Avoid empty Telegram reply IDs --- bootstrap/bootstrap.php | 17 +++++++++++++++++ classes/Commands/GenericmessageCommand.php | 18 +++++++++--------- tests/TelegramReplyContractTest.php | 12 ++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index cfa8cef..c18a961 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -533,6 +533,23 @@ public static function extractTelegramReplyData($message) ); } + /** + * Build the local reply reference used by the REST action. + * An empty external ID must never reach the core reply renderer. + */ + public static function buildTelegramReplyReference($dbMessageId, $telegramMessageId, $externalId = '') + { + $reference = array( + 'db_msg_id' => (int)$dbMessageId, + 'telegram_message_id' => (int)$telegramMessageId + ); + $externalId = trim((string)$externalId); + if ($externalId !== '') { + $reference['iwh_msg_id'] = $externalId; + } + return $reference; + } + /** * Ensure an incoming forum update belongs to the configured Telegram group. * Message/thread IDs are scoped to a chat and can otherwise collide. diff --git a/classes/Commands/GenericmessageCommand.php b/classes/Commands/GenericmessageCommand.php index 455a6e6..976cb78 100644 --- a/classes/Commands/GenericmessageCommand.php +++ b/classes/Commands/GenericmessageCommand.php @@ -405,16 +405,19 @@ public function execute(): ServerResponse } } $replyExternalId = trim((string)($replyMsgMeta['iwh_msg_id'] ?? '')); - $replyReference = [ - 'db_msg_id' => $replyMsg->id, - 'telegram_message_id' => $replyTopicMsgId - ]; + $replyReference = \erLhcoreClassExtensionLhctelegram::buildTelegramReplyReference( + $replyMsg->id, + $replyTopicMsgId, + $replyExternalId + ); // Keep the local quote/reply target even when the // original LHC message has no external visitor ID. // The REST core cannot render an empty iwh_msg_id, so - // only add that optional field when it is present. - $metaMsg['content']['reply_to'] = $replyReference; + // only add the REST reply reference when it is present. + if ($replyExternalId !== '') { + $metaMsg['content']['reply_to'] = $replyReference; + } $quoteText = trim((string)($replyData['quote_text'] ?? '')); if ($quoteText === '') { $quoteText = \erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($replyMsg, $replyTopicMsgId, $topicContext); @@ -434,9 +437,6 @@ public function execute(): ServerResponse 'text' => $quoteText, 'nick' => $replyNick ]; - if ($replyExternalId !== '') { - $metaMsg['content']['reply_to']['iwh_msg_id'] = $replyExternalId; - } } } diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php index df93554..3e0c465 100644 --- a/tests/TelegramReplyContractTest.php +++ b/tests/TelegramReplyContractTest.php @@ -55,6 +55,18 @@ function expectTelegramContract($condition, $message) $reply = erLhcoreClassExtensionLhctelegram::extractTelegramReplyData($topicRoot); expectTelegramContract($reply['is_explicit_reply'] === false, 'topic root service message is not a quote'); +$referenceMethod = new ReflectionMethod('erLhcoreClassExtensionLhctelegram', 'buildTelegramReplyReference'); +$referenceMethod->setAccessible(true); +$localReference = $referenceMethod->invoke(null, 12, 90, ''); +expectTelegramContract( + $localReference['db_msg_id'] === 12 + && $localReference['telegram_message_id'] === 90 + && !array_key_exists('iwh_msg_id', $localReference), + 'local-only quote must not create an empty external reply ID' +); +$externalReference = $referenceMethod->invoke(null, 12, 90, 'tg-90'); +expectTelegramContract($externalReference['iwh_msg_id'] === 'tg-90', 'external reply ID is preserved'); + $stored = (object)[ 'msg' => 'fallback [file=12_0123456789abcdef0123456789abcdef]', 'meta_msg_array' => [ From b1b171fe09ec749e12453e853b1b120c3d605815 Mon Sep 17 00:00:00 2001 From: mysubcult Date: Fri, 28 Aug 2026 00:28:23 +0400 Subject: [PATCH 08/13] Restore Telegram file availability guard --- bootstrap/bootstrap.php | 9 +++++++++ tests/TelegramReplyContractTest.php | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index c18a961..e50edb8 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -1266,6 +1266,15 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif $this->lastTelegramSendData = null; $file = $fileData['file']; + // The download URL below resolves the stored local file. Do not send + // a broken URL when cleanup removed the file before the worker ran. + if (!is_object($file) + || !is_string($file->file_path_server ?? null) + || !is_file($file->file_path_server) + || !is_readable($file->file_path_server)) { + return false; + } + $extension = strtolower((string)$file->extension); $type = strtolower((string)$file->type); $method = 'sendDocument'; diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php index 3e0c465..58e7912 100644 --- a/tests/TelegramReplyContractTest.php +++ b/tests/TelegramReplyContractTest.php @@ -113,6 +113,13 @@ function expectTelegramContract($condition, $message) ); $extension = new erLhcoreClassExtensionLhctelegram(); +$sendFileMethod = new ReflectionMethod($extension, 'sendTelegramChatFile'); +$sendFileMethod->setAccessible(true); +$missingFile = (object)['file_path_server' => sys_get_temp_dir() . '/telegram-contract-missing-file']; +expectTelegramContract( + $sendFileMethod->invoke($extension, (object)[], ['file' => $missingFile], '') === false, + 'missing local files must not be sent as broken download URLs' +); $splitMethod = new ReflectionMethod($extension, 'getTelegramMessageChunks'); $splitMethod->setAccessible(true); $lengthMethod = new ReflectionMethod($extension, 'getTelegramTextLength'); From 4173ccea973394967777389afd17e9ee40ad77e2 Mon Sep 17 00:00:00 2001 From: mysubcult Date: Fri, 28 Aug 2026 00:33:41 +0400 Subject: [PATCH 09/13] Skip empty Telegram quote replies --- bootstrap/bootstrap.php | 13 +++++++++++++ classes/Commands/GenericmessageCommand.php | 7 ++++++- tests/TelegramReplyContractTest.php | 10 ++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index e50edb8..b25832b 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -550,6 +550,19 @@ public static function buildTelegramReplyReference($dbMessageId, $telegramMessag return $reference; } + /** + * Add the quote marker only when the REST action can resolve an external + * Telegram reply target. Local-only quotes remain in metadata instead of + * triggering the core's empty reply block. + */ + public static function formatTelegramQuotedText($messageText, $dbMessageId, $quoteText, $externalId = '') + { + if (trim((string)$externalId) === '') { + return (string)$messageText; + } + return '[quote=' . (int)$dbMessageId . ']' . (string)$quoteText . '[/quote]' . (string)$messageText; + } + /** * Ensure an incoming forum update belongs to the configured Telegram group. * Message/thread IDs are scoped to a chat and can otherwise collide. diff --git a/classes/Commands/GenericmessageCommand.php b/classes/Commands/GenericmessageCommand.php index 976cb78..40b49f1 100644 --- a/classes/Commands/GenericmessageCommand.php +++ b/classes/Commands/GenericmessageCommand.php @@ -431,7 +431,12 @@ public function execute(): ServerResponse $quoteText = trim($quoteText); } $replyNick = $replyMsg->name_support != '' ? $replyMsg->name_support : $chat->nick; - $msgText = '[quote=' . $replyMsg->id . ']' . $quoteText . '[/quote]' . $msgText; + $msgText = \erLhcoreClassExtensionLhctelegram::formatTelegramQuotedText( + $msgText, + $replyMsg->id, + $quoteText, + $replyExternalId + ); $metaMsg['content']['quote'] = [ 'id' => $replyMsg->id, 'text' => $quoteText, diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php index 58e7912..3147f67 100644 --- a/tests/TelegramReplyContractTest.php +++ b/tests/TelegramReplyContractTest.php @@ -66,6 +66,16 @@ function expectTelegramContract($condition, $message) ); $externalReference = $referenceMethod->invoke(null, 12, 90, 'tg-90'); expectTelegramContract($externalReference['iwh_msg_id'] === 'tg-90', 'external reply ID is preserved'); +$formatMethod = new ReflectionMethod('erLhcoreClassExtensionLhctelegram', 'formatTelegramQuotedText'); +$formatMethod->setAccessible(true); +expectTelegramContract( + $formatMethod->invoke(null, 'reply body', 12, 'local quote', '') === 'reply body', + 'local-only quote must not add a core reply marker' +); +expectTelegramContract( + $formatMethod->invoke(null, 'reply body', 12, 'external quote', 'tg-90') === '[quote=12]external quote[/quote]reply body', + 'external quote keeps the core reply marker' +); $stored = (object)[ 'msg' => 'fallback [file=12_0123456789abcdef0123456789abcdef]', From cdd07c4ccfd3e9f630df8403db3ba1b0e2e862e8 Mon Sep 17 00:00:00 2001 From: mysubcult Date: Fri, 28 Aug 2026 00:41:14 +0400 Subject: [PATCH 10/13] Sanitize nested Telegram quote markers --- bootstrap/bootstrap.php | 10 ++++++++++ classes/Commands/GenericmessageCommand.php | 1 + tests/TelegramReplyContractTest.php | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index b25832b..06853c8 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -557,12 +557,22 @@ public static function buildTelegramReplyReference($dbMessageId, $telegramMessag */ public static function formatTelegramQuotedText($messageText, $dbMessageId, $quoteText, $externalId = '') { + $quoteText = self::normalizeTelegramQuoteText($quoteText); if (trim((string)$externalId) === '') { return (string)$messageText; } return '[quote=' . (int)$dbMessageId . ']' . (string)$quoteText . '[/quote]' . (string)$messageText; } + /** + * Keep quoted Telegram text from injecting nested LHC quote markers. + * The outer marker is generated by this extension and remains intact. + */ + public static function normalizeTelegramQuoteText($quoteText) + { + return trim((string)preg_replace('/\[\/?quote(?:=[^\]]*)?\]/i', '', (string)$quoteText)); + } + /** * Ensure an incoming forum update belongs to the configured Telegram group. * Message/thread IDs are scoped to a chat and can otherwise collide. diff --git a/classes/Commands/GenericmessageCommand.php b/classes/Commands/GenericmessageCommand.php index 40b49f1..b9e92e7 100644 --- a/classes/Commands/GenericmessageCommand.php +++ b/classes/Commands/GenericmessageCommand.php @@ -430,6 +430,7 @@ public function execute(): ServerResponse ); $quoteText = trim($quoteText); } + $quoteText = \erLhcoreClassExtensionLhctelegram::normalizeTelegramQuoteText($quoteText); $replyNick = $replyMsg->name_support != '' ? $replyMsg->name_support : $chat->nick; $msgText = \erLhcoreClassExtensionLhctelegram::formatTelegramQuotedText( $msgText, diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php index 3147f67..a6e0d06 100644 --- a/tests/TelegramReplyContractTest.php +++ b/tests/TelegramReplyContractTest.php @@ -76,6 +76,10 @@ function expectTelegramContract($condition, $message) $formatMethod->invoke(null, 'reply body', 12, 'external quote', 'tg-90') === '[quote=12]external quote[/quote]reply body', 'external quote keeps the core reply marker' ); +expectTelegramContract( + erLhcoreClassExtensionLhctelegram::normalizeTelegramQuoteText('[quote=99]nested[/quote] text') === 'nested text', + 'nested quote markers must not be injected from Telegram text' +); $stored = (object)[ 'msg' => 'fallback [file=12_0123456789abcdef0123456789abcdef]', From f53743f428cd48de0d696054c4a3f92e51ded83f Mon Sep 17 00:00:00 2001 From: mysubcult Date: Fri, 28 Aug 2026 00:47:35 +0400 Subject: [PATCH 11/13] Preserve local Telegram quote display --- bootstrap/bootstrap.php | 10 ++++++---- tests/TelegramReplyContractTest.php | 8 ++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index 06853c8..eee867c 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -551,15 +551,17 @@ public static function buildTelegramReplyReference($dbMessageId, $telegramMessag } /** - * Add the quote marker only when the REST action can resolve an external - * Telegram reply target. Local-only quotes remain in metadata instead of - * triggering the core's empty reply block. + * Use the numeric marker only when the REST action can resolve an external + * Telegram reply target. Local-only quotes use the regular display marker + * without an ID, so the core never renders an empty reply block. */ public static function formatTelegramQuotedText($messageText, $dbMessageId, $quoteText, $externalId = '') { $quoteText = self::normalizeTelegramQuoteText($quoteText); if (trim((string)$externalId) === '') { - return (string)$messageText; + return $quoteText !== '' + ? '[quote]' . $quoteText . '[/quote]' . (string)$messageText + : (string)$messageText; } return '[quote=' . (int)$dbMessageId . ']' . (string)$quoteText . '[/quote]' . (string)$messageText; } diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php index a6e0d06..0d0c85d 100644 --- a/tests/TelegramReplyContractTest.php +++ b/tests/TelegramReplyContractTest.php @@ -69,8 +69,12 @@ function expectTelegramContract($condition, $message) $formatMethod = new ReflectionMethod('erLhcoreClassExtensionLhctelegram', 'formatTelegramQuotedText'); $formatMethod->setAccessible(true); expectTelegramContract( - $formatMethod->invoke(null, 'reply body', 12, 'local quote', '') === 'reply body', - 'local-only quote must not add a core reply marker' + $formatMethod->invoke(null, 'reply body', 12, 'local quote', '') === '[quote]local quote[/quote]reply body', + 'local-only quote must keep a display marker without a core reply ID' +); +expectTelegramContract( + preg_match('#\[quote="?([0-9]+)"?\]#i', $formatMethod->invoke(null, 'reply body', 12, 'local quote', '')) !== 1, + 'local-only quote must not add a numeric core reply marker' ); expectTelegramContract( $formatMethod->invoke(null, 'reply body', 12, 'external quote', 'tg-90') === '[quote=12]external quote[/quote]reply body', From f1119ae759b6e15b3d8dd35c47d430207665b68c Mon Sep 17 00:00:00 2001 From: mysubcult Date: Fri, 28 Aug 2026 01:03:19 +0400 Subject: [PATCH 12/13] Keep Telegram local reply metadata --- classes/Commands/GenericmessageCommand.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/classes/Commands/GenericmessageCommand.php b/classes/Commands/GenericmessageCommand.php index b9e92e7..47d01a6 100644 --- a/classes/Commands/GenericmessageCommand.php +++ b/classes/Commands/GenericmessageCommand.php @@ -413,11 +413,10 @@ public function execute(): ServerResponse // Keep the local quote/reply target even when the // original LHC message has no external visitor ID. - // The REST core cannot render an empty iwh_msg_id, so - // only add the REST reply reference when it is present. - if ($replyExternalId !== '') { - $metaMsg['content']['reply_to'] = $replyReference; - } + // The REST core only consumes iwh_msg_id from its + // numeric quote marker; this metadata is consumed + // by the Telegram extension for direct topic replies. + $metaMsg['content']['reply_to'] = $replyReference; $quoteText = trim((string)($replyData['quote_text'] ?? '')); if ($quoteText === '') { $quoteText = \erLhcoreClassExtensionLhctelegram::getStoredTelegramMessageText($replyMsg, $replyTopicMsgId, $topicContext); From 9c84a40ebd11604a023caaa15514714993004af8 Mon Sep 17 00:00:00 2001 From: mysubcult Date: Fri, 28 Aug 2026 01:19:28 +0400 Subject: [PATCH 13/13] Bound malformed HTML Telegram chunks --- bootstrap/bootstrap.php | 13 ++++++++++++- tests/TelegramReplyContractTest.php | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index eee867c..e4df762 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -1073,7 +1073,18 @@ private function getTelegramMessageChunks(array $data) // A split HTML message cannot safely retain arbitrary open tags or // entities. Long messages deliberately fall back to escaped text. $plainText = preg_replace('#<(?:br|/p|/div)\s*/?>#i', "\n", $text); - $plainText = html_entity_decode(strip_tags($plainText), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + // strip_tags() drops a run of literal '<' characters as if it were + // an unfinished tag. Remove only tag-shaped markup so user text is + // retained and can still be escaped/split below. + $plainText = preg_replace('#|]*>|]*>#is', '', (string)$plainText); + $plainText = html_entity_decode($plainText, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if (trim($text) !== '' && trim($plainText) === '') { + // PHP's strip_tags() drops malformed/raw angle-bracket text + // such as "<" x5000. Keep it as text so the splitter can + // escape and bound the payload instead of returning one + // oversized raw HTML chunk. + $plainText = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } if ($this->getTelegramTextLength($this->escapeTelegramHtmlText($plainText)) <= 4096) { return array($data); } diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php index 0d0c85d..e83a3df 100644 --- a/tests/TelegramReplyContractTest.php +++ b/tests/TelegramReplyContractTest.php @@ -177,6 +177,24 @@ function expectTelegramContract($condition, $message) expectTelegramContract($lengthMethod->invoke($extension, $chunk['text']) <= 4000, 'escaped HTML chunk must be within UTF-16 limit'); } +$literalLessThanChunks = $splitMethod->invoke($extension, [ + 'chat_id' => -100, + 'parse_mode' => 'HTML', + 'text' => str_repeat('<', 5000) +]); +expectTelegramContract(count($literalLessThanChunks) > 1, 'literal HTML less-than signs must be split'); +foreach ($literalLessThanChunks as $chunk) { + expectTelegramContract($lengthMethod->invoke($extension, $chunk['text']) <= 4000, 'literal less-than chunk must be within UTF-16 limit'); + expectTelegramContract(strpos($chunk['text'], '<') !== false, 'literal less-than signs must be escaped'); +} + +$shortHtml = $splitMethod->invoke($extension, [ + 'chat_id' => -100, + 'parse_mode' => 'HTML', + 'text' => 'short valid HTML' +]); +expectTelegramContract(count($shortHtml) === 1 && $shortHtml[0]['text'] === 'short valid HTML', 'short valid HTML must keep its markup'); + $vendorAutoload = __DIR__ . '/../../../lib/vendor/autoload.php'; if (is_file($vendorAutoload)) { require_once $vendorAutoload;