diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index a8194ad..e4df762 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -3,6 +3,8 @@ #[\AllowDynamicProperties] class erLhcoreClassExtensionLhctelegram { + private $lastTelegramSendData = null; + private $lastTelegramSendResponses = array(); public function __construct() { @@ -356,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) @@ -408,6 +410,279 @@ 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 + ); + } + + /** + * 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; + } + + /** + * 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 $quoteText !== '' + ? '[quote]' . $quoteText . '[/quote]' . (string)$messageText + : (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. + */ + 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 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, $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 = self::normalizeStoredTelegramMessageText($entry['caption'] ?? ($entry['text'] ?? '')); + if ($mappedText !== '') { + return $mappedText; + } + } + + return self::normalizeStoredTelegramMessageText($msg->msg); + } + private function getTelegramFileCaption($msg, $chat, $file, $messageText = null) { $sender = $msg->name_support != '' ? '🤖 [' . $msg->name_support . ']' : '👤 [' . $chat->nick . ']'; @@ -426,6 +701,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); @@ -441,11 +722,592 @@ private function isMeaningfulTelegramUploadName($file) return true; } - private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotification = false) + public function saveTopicMsgId($msg, $topicMsgId, $messageData = array(), $topicContext = array()) + { + if (!($msg instanceof erLhcoreClassModelmsg) || !(int)$topicMsgId || $msg->id <= 0) { + return; + } + + $db = ezcDbInstance::get(); + $startedTransaction = method_exists($db, 'inTransaction') && !$db->inTransaction(); + if ($startedTransaction) { + $db->beginTransaction(); + } + + 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; + + $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); + + $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 = '', $topicContext = array()) + { + if (!is_array($telegramFile) || !isset($telegramFile['file']) || !is_object($telegramFile['file'])) { + $this->saveTopicMsgId($msg, $topicMsgId, array(), $topicContext); + 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 + ), $topicContext); + } + + 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) { + 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, $topicContext = array()) { + if (!($msg instanceof erLhcoreClassModelmsg)) { + return null; + } + + $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 && (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, $topicContext); + if ($resolvedId !== null) { + return $resolvedId; + } + } + } + + 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 && (int)$targetMsg->chat_id === (int)$chatId) { + $resolvedId = $this->getStoredTopicMessageId($targetMsg, null, $topicContext); + 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 && (int)$targetMsg->chat_id === (int)$chatId) { + $resolvedId = $this->getStoredTopicMessageId($targetMsg, null, $topicContext); + 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 && (int)$targetMsg->chat_id === (int)$chatId) { + $resolvedId = $this->getStoredTopicMessageId($targetMsg, null, $topicContext); + if ($resolvedId !== null) { + return $resolvedId; + } + } + } + + return null; + } + + public function getTopicMessageId($msg, $chatId, $topicContext = array()) + { + if (!($msg instanceof erLhcoreClassModelmsg) || (int)$msg->chat_id !== (int)$chatId) { + return null; + } + + return $this->getStoredTopicMessageId($msg, null, $topicContext); + } + + 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); + } + + private function closeTelegramResources(array &$data) + { + foreach ($data as &$value) { + if (is_resource($value)) { + @fclose($value); + } + } + 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); + // 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); + } + } 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. + * + * 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; + $allowMessageSplit = $method === 'sendMessage' && $multipartFilePath === '' && $multipartFileField === ''; + $this->lastTelegramSendResponses = array(); + + try { + $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 (!$allowMessageSplit && $this->hasTelegramStaleReplyResponse($sendData) && isset($data['reply_to_message_id'])) { + unset($data['reply_to_message_id']); + try { + 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 = $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')); + } + } + + 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)) { + // 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; } @@ -468,12 +1330,24 @@ 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', + // 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)); + 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']; + } if ($caption !== '') { $data['caption'] = $caption; @@ -483,20 +1357,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; } @@ -515,7 +1378,7 @@ private function sendTelegramChatFile($tchat, $fileData, $caption, $disableNotif return false; } - return true; + return $sendData->getResult()->getMessageId(); } private function getTelegramChatFileUrl($file) @@ -547,6 +1410,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) { @@ -580,9 +1444,15 @@ public function messageAdded($params) $data['disable_notification'] = true; } - $sendData = Longman\TelegramBot\Request::sendMessage($data); + $replyTopicMsgId = $this->getTopicReplyId($params['msg'], $chat->id, $topicContext); + if ($replyTopicMsgId > 0) { + $data['reply_to_message_id'] = $replyTopicMsgId; + } + + $sendData = $this->sendTelegramRequest('sendMessage', $data); + $this->saveTelegramTopicMessageIds($params['msg'], $sendData, array('text' => $messageText, 'kind' => 'text'), $topicContext); - 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']]); @@ -610,15 +1480,25 @@ public function messageAdded($params) $failedEmbedCodes = array(); $fileIndex = 0; + $replyTopicMsgId = $this->getTopicReplyId($params['msg'], $chat->id, $topicContext); 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) { + 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->saveTelegramFileTopicMsgId($params['msg'], $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($params['msg'], $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $topicContext); } $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', @@ -640,6 +1520,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, $topicContext); foreach ($botMessages as $botMessage) { @@ -669,7 +1550,11 @@ 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); + $this->saveTelegramTopicMessageIds($botMessage, $sendData, array('text' => $messageText, 'kind' => 'text'), $topicContext); if (!$sendData->isOk()) { erLhcoreClassLog::write('SendMessage BOT ['.$sendData->getErrorCode().']'. $sendData->getDescription(), @@ -690,14 +1575,17 @@ 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, ['reply_to_message_id' => $botReplyTopicMsgId]); + if ($sentFileMsgId === false) { $failedEmbedCodes[] = $telegramFile['embed']; + } else { + $this->saveTelegramFileTopicMsgId($botMessage, $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $topicContext); } $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', @@ -734,6 +1622,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; @@ -754,6 +1643,7 @@ public function triggerClicked($params) $telegramFiles = $this->getTelegramMessageFiles($botMessage); $messageText = $this->stripTelegramFileEmbeds($botMessage->msg); + $botReplyTopicMsgId = $this->getTopicMessageId($params['msg'], $chat->id, $topicContext); if ($messageText !== '' && empty($telegramFiles)) { $data = [ @@ -765,7 +1655,11 @@ 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); + $this->saveTelegramTopicMessageIds($botMessage, $sendData, array('text' => $messageText, 'kind' => 'text'), $topicContext); if (!$sendData->isOk()) { erLhcoreClassLog::write('['.$sendData->getErrorCode().']'. $sendData->getDescription(), @@ -786,14 +1680,17 @@ 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, ['reply_to_message_id' => $botReplyTopicMsgId]); + if ($sentFileMsgId === false) { $failedEmbedCodes[] = $telegramFile['embed']; + } else { + $this->saveTelegramFileTopicMsgId($botMessage, $sentFileMsgId, $telegramFile, $this->getTelegramFileCaption($botMessage, $chat, $telegramFile['file'], $fileIndex === 0 ? $messageText : ''), $topicContext); } $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', @@ -841,6 +1738,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', [ @@ -892,6 +1793,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; @@ -904,6 +1806,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; @@ -924,9 +1827,20 @@ public function chatStarted($params) $data['disable_notification'] = true; } - $sendData = Longman\TelegramBot\Request::sendMessage($data); + $sendData = $this->sendTelegramRequest('sendMessage', $data); - if (!$sendData->isOk()) { + if ($sendData->isOk()) { + $aggregateMsgId = $sendData->getResult()->getMessageId(); + foreach ($initialAggregateMessages as $aggregateMessage) { + $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->saveTelegramTopicMessageIds($firstMsg, $sendData, array('text' => $data['text'], 'kind' => 'aggregate'), $topicContext); + } + } + } 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'))) { @@ -944,9 +1858,20 @@ 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()) { + if ($sendData->isOk()) { + $aggregateMsgId = $sendData->getResult()->getMessageId(); + foreach ($initialAggregateMessages as $aggregateMessage) { + $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->saveTelegramTopicMessageIds($firstMsg, $sendData, array('text' => $data['text'], 'kind' => 'aggregate'), $topicContext); + } + } + } else { erLhcoreClassLog::write('['.$sendData->getErrorCode().']'. $sendData->getDescription(), ezcLog::SUCCESS_AUDIT, array( @@ -964,13 +1889,16 @@ 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->saveTelegramFileTopicMsgId($initialTelegramFile['msg'], $sentFileMsgId, $initialTelegramFile['file'], $this->getTelegramFileCaption($initialTelegramFile['msg'], $params['chat'], $initialTelegramFile['file']['file'], $initialTelegramFile['text']), $topicContext); } } 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/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 c80c493..47d01a6 100644 --- a/classes/Commands/GenericmessageCommand.php +++ b/classes/Commands/GenericmessageCommand.php @@ -253,9 +253,19 @@ 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) { + $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)); @@ -341,7 +351,105 @@ public function execute(): ServerResponse if ($ignoreMessage == false) { $msg = new \erLhcoreClassModelmsg(); - $msg->msg = $text; + $msgText = $text; + $metaMsg = []; + + $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']; + $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_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_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 = \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 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); + } + if ($quoteText === '') { + $quoteText = html_entity_decode( + preg_replace('/\[file=\d+_[a-z0-9]+\]/i', '', (string)$replyMsg->msg), + ENT_QUOTES | ENT_HTML5, + 'UTF-8' + ); + $quoteText = trim($quoteText); + } + $quoteText = \erLhcoreClassExtensionLhctelegram::normalizeTelegramQuoteText($quoteText); + $replyNick = $replyMsg->name_support != '' ? $replyMsg->name_support : $chat->nick; + $msgText = \erLhcoreClassExtensionLhctelegram::formatTelegramQuotedText( + $msgText, + $replyMsg->id, + $quoteText, + $replyExternalId + ); + $metaMsg['content']['quote'] = [ + 'id' => $replyMsg->id, + 'text' => $quoteText, + 'nick' => $replyNick + ]; + } + } + + $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..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{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 diff --git a/tests/TelegramReplyContractTest.php b/tests/TelegramReplyContractTest.php new file mode 100644 index 0000000..e83a3df --- /dev/null +++ b/tests/TelegramReplyContractTest.php @@ -0,0 +1,276 @@ + [ + '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'); + +$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'); +$formatMethod = new ReflectionMethod('erLhcoreClassExtensionLhctelegram', 'formatTelegramQuotedText'); +$formatMethod->setAccessible(true); +expectTelegramContract( + $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', + '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]', + '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(); +$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'); +$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'); +} + +$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; + $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");