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('#|]*>|?[a-z][^>]*>#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");