diff --git a/.changeset/telegram-mention-on-reply.md b/.changeset/telegram-mention-on-reply.md new file mode 100644 index 00000000..b4e5bd92 --- /dev/null +++ b/.changeset/telegram-mention-on-reply.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/telegram": minor +--- + +Add `mentionOnReply`: when enabled, a reply to one of the bot own messages reports `isMention`, so a bot in a group keeps the conversation going without the handle being repeated. Off by default — existing mention-only bots are unaffected — and readable from `TELEGRAM_MENTION_ON_REPLY`. diff --git a/.changeset/telegram-native-replies.md b/.changeset/telegram-native-replies.md new file mode 100644 index 00000000..f994f3b9 --- /dev/null +++ b/.changeset/telegram-native-replies.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/telegram": minor +--- + +Implement `reply` in the Telegram adapter so `Thread.reply()` threads the answer to its target instead of throwing `NotImplementedError`. The reference travels as Bot API `reply_parameters` and covers text, rich messages, documents, attachments and media groups; `allow_sending_without_reply` keeps delivery working when the target has been deleted. diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 149a5e21..6760b060 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -5856,3 +5856,157 @@ describe("mention regex caching", () => { expect(adapter.checkMention("hi @first_bot")).toBe(false); }); }); + +describe("reply", () => { + function createReplyAdapter() { + return createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + } + + it("threads a text message to its target", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ id: 1, is_bot: true, username: "mybot" }) + ) + .mockResolvedValueOnce(telegramOk(sampleMessage({ message_id: 11 }))); + + const adapter = createReplyAdapter(); + await adapter.initialize(createMockChat()); + + await adapter.reply("telegram:123", "123:7", { markdown: "hello" }); + + const body = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ); + expect(body.reply_parameters).toEqual({ + message_id: 7, + allow_sending_without_reply: true, + }); + }); + + it("leaves a plain postMessage unthreaded", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ id: 1, is_bot: true, username: "mybot" }) + ) + .mockResolvedValueOnce(telegramOk(sampleMessage({ message_id: 12 }))); + + const adapter = createReplyAdapter(); + await adapter.initialize(createMockChat()); + + await adapter.postMessage("telegram:123", { markdown: "hello" }); + + const body = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ); + expect(body.reply_parameters).toBeUndefined(); + }); + + it("refuses a target that belongs to another chat", async () => { + mockFetch.mockResolvedValueOnce( + telegramOk({ id: 1, is_bot: true, username: "mybot" }) + ); + + const adapter = createReplyAdapter(); + await adapter.initialize(createMockChat()); + + await expect( + adapter.reply("telegram:123", "999:7", { markdown: "hello" }) + ).rejects.toThrow("chat mismatch"); + }); +}); + +describe("mentionOnReply", () => { + const BOT_USER_ID = 8981792219; + + async function deliverReply(options: { + mentionOnReply?: boolean; + replyFromBot: boolean; + }) { + mockFetch.mockResolvedValue( + telegramOk({ + id: BOT_USER_ID, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ); + const chat = createMockChatInstance({ + logger: mockLogger, + state: createMockState(), + userName: "mybot", + }); + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + ...(options.mentionOnReply === undefined + ? {} + : { mentionOnReply: options.mentionOnReply }), + }); + await adapter.initialize(chat); + + await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + update_id: 1, + message: sampleMessage({ + chat: { id: -100123, type: "supergroup", title: "General" }, + text: "and the second one?", + reply_to_message: sampleMessage({ + message_id: 5, + chat: { id: -100123, type: "supergroup", title: "General" }, + from: options.replyFromBot + ? { + id: BOT_USER_ID, + is_bot: true, + first_name: "Bot", + username: "mybot", + } + : { + id: 777, + is_bot: false, + first_name: "Someone", + username: "someone", + }, + }), + }), + }), + }) + ); + + const processMessage = chat.processMessage as ReturnType; + const call = processMessage.mock.calls[0] as + | [unknown, string, { isMention?: boolean }] + | undefined; + return call?.[2]; + } + + it("counts a reply to the bot as a mention when enabled", async () => { + const parsed = await deliverReply({ + mentionOnReply: true, + replyFromBot: true, + }); + expect(parsed?.isMention).toBe(true); + }); + + it("ignores a reply to somebody else", async () => { + const parsed = await deliverReply({ + mentionOnReply: true, + replyFromBot: false, + }); + expect(parsed?.isMention).toBe(false); + }); + + it("stays off by default so existing bots keep mention-only behaviour", async () => { + const parsed = await deliverReply({ replyFromBot: true }); + expect(parsed?.isMention).toBe(false); + }); +}); diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 24bee212..3a5fd2d9 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -281,6 +281,7 @@ export class TelegramAdapter protected readonly staticBotToken?: string; protected readonly apiBaseUrl: string; protected readonly secretToken?: string; + protected readonly mentionOnReply: boolean; private botIdentityPromise: Promise | null = null; private webhookScope?: string; private warnedNoVerification = false; @@ -342,6 +343,8 @@ export class TelegramAdapter ); this.secretToken = config.secretToken ?? process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN; + this.mentionOnReply = + config.mentionOnReply ?? process.env.TELEGRAM_MENTION_ON_REPLY === "true"; const allowedUserIds = config.allowedUserIds ?? process.env.TELEGRAM_ALLOWED_USER_IDS?.split(","); @@ -1100,7 +1103,8 @@ export class TelegramAdapter async postMessage( threadId: string, - message: AdapterPostableMessage + message: AdapterPostableMessage, + replyToMessageId?: string ): Promise> { const parsedThread = this.resolveThreadId(threadId); @@ -1161,7 +1165,8 @@ export class TelegramAdapter text, plainText, replyMarkup, - parseMode + parseMode, + replyToMessageId ), ] : await this.sendDocumentMediaGroup( @@ -1170,7 +1175,8 @@ export class TelegramAdapter text, plainText, replyMarkup, - parseMode + parseMode, + replyToMessageId ); } else if (attachments.length > 0) { const [attachment] = attachments; @@ -1190,7 +1196,8 @@ export class TelegramAdapter text, plainText, replyMarkup, - parseMode + parseMode, + replyToMessageId ), ] : await this.sendAttachmentMediaGroup( @@ -1199,7 +1206,8 @@ export class TelegramAdapter text, plainText, replyMarkup, - parseMode + parseMode, + replyToMessageId ); } else { if (!text.trim()) { @@ -1213,7 +1221,8 @@ export class TelegramAdapter plainText, parseMode, replyMarkup, - threadId + threadId, + replyToMessageId ); rawMessages = [ @@ -1227,6 +1236,10 @@ export class TelegramAdapter markdown: rich.markdown, }, reply_markup: replyMarkup, + reply_parameters: this.buildReplyParameters( + replyToMessageId, + parsedThread.chatId + ), }), sendRegular, { @@ -1281,6 +1294,22 @@ export class TelegramAdapter return this.postMessage(channelId, message); } + /** + * Post a message as a native Telegram reply to `messageId`. + * + * Telegram threads the answer to its question with `reply_parameters`, which + * is what `Thread.reply()` expects an adapter to provide. Only the first + * message carries the reference when content spans several sends, matching + * the behaviour of the other adapters. + */ + async reply( + threadId: string, + messageId: string, + message: AdapterPostableMessage + ): Promise> { + return this.postMessage(threadId, message, messageId); + } + async editMessage( threadId: string, messageId: string, @@ -2309,7 +2338,8 @@ export class TelegramAdapter text: string, plainText: string, replyMarkup?: TelegramInlineKeyboardMarkup, - parseMode: TelegramParseMode = "plain" + parseMode: TelegramParseMode = "plain", + replyToMessageId?: string ): Promise { const buffer = await this.toTelegramBuffer(file.data); @@ -2324,7 +2354,8 @@ export class TelegramAdapter buffer, resolvedText, replyMarkup, - resolvedParseMode + resolvedParseMode, + replyToMessageId ) ), { @@ -2345,10 +2376,18 @@ export class TelegramAdapter buffer: Buffer, text: string, replyMarkup?: TelegramInlineKeyboardMarkup, - parseMode: TelegramParseMode = "plain" + parseMode: TelegramParseMode = "plain", + replyToMessageId?: string ): FormData { const formData = new FormData(); formData.append("chat_id", thread.chatId); + const replyParameters = this.buildReplyParameters( + replyToMessageId, + thread.chatId + ); + if (replyParameters) { + formData.append("reply_parameters", JSON.stringify(replyParameters)); + } if (typeof thread.messageThreadId === "number") { formData.append("message_thread_id", String(thread.messageThreadId)); } @@ -2381,7 +2420,8 @@ export class TelegramAdapter text: string, plainText: string, replyMarkup?: TelegramInlineKeyboardMarkup, - parseMode: TelegramParseMode = "plain" + parseMode: TelegramParseMode = "plain", + replyToMessageId?: string ): Promise { const upload = ATTACHMENT_UPLOADS[attachment.type]; const data = @@ -2433,6 +2473,13 @@ export class TelegramAdapter if (replyMarkup) { payload.reply_markup = replyMarkup; + const urlReplyParameters = this.buildReplyParameters( + replyToMessageId, + thread.chatId + ); + if (urlReplyParameters) { + payload.reply_parameters = urlReplyParameters; + } } return this.telegramFetch(upload.method, payload); @@ -2476,6 +2523,16 @@ export class TelegramAdapter if (replyMarkup) { formData.append("reply_markup", JSON.stringify(replyMarkup)); } + const bufferReplyParameters = this.buildReplyParameters( + replyToMessageId, + thread.chatId + ); + if (bufferReplyParameters) { + formData.append( + "reply_parameters", + JSON.stringify(bufferReplyParameters) + ); + } return this.telegramFetch(upload.method, formData); }, @@ -2494,7 +2551,8 @@ export class TelegramAdapter text: string, plainText: string, replyMarkup?: TelegramInlineKeyboardMarkup, - parseMode: TelegramParseMode = "plain" + parseMode: TelegramParseMode = "plain", + replyToMessageId?: string ): Promise { this.validateMediaGroupLength(files.length); @@ -2525,7 +2583,8 @@ export class TelegramAdapter thread, parts, resolvedText, - resolvedParseMode + resolvedParseMode, + replyToMessageId ) ), { @@ -2543,7 +2602,8 @@ export class TelegramAdapter text: string, plainText: string, replyMarkup?: TelegramInlineKeyboardMarkup, - parseMode: TelegramParseMode = "plain" + parseMode: TelegramParseMode = "plain", + replyToMessageId?: string ): Promise { this.validateMediaGroupLength(attachments.length); this.validateAttachmentMediaGroupTypes(attachments); @@ -2597,7 +2657,8 @@ export class TelegramAdapter thread, parts, resolvedText, - resolvedParseMode + resolvedParseMode, + replyToMessageId ) ), { @@ -2613,10 +2674,18 @@ export class TelegramAdapter thread: TelegramThreadId, parts: TelegramMediaGroupPart[], text: string, - parseMode: TelegramParseMode + parseMode: TelegramParseMode, + replyToMessageId?: string ): FormData { const formData = new FormData(); formData.append("chat_id", thread.chatId); + const replyParameters = this.buildReplyParameters( + replyToMessageId, + thread.chatId + ); + if (replyParameters) { + formData.append("reply_parameters", JSON.stringify(replyParameters)); + } if (typeof thread.messageThreadId === "number") { formData.append("message_thread_id", String(thread.messageThreadId)); } @@ -2830,6 +2899,29 @@ export class TelegramAdapter return `${chatId}:${messageId}`; } + /** + * Build Bot API `reply_parameters` for an optional reply target. + * + * `allow_sending_without_reply` keeps delivery working when the target was + * deleted in the meantime: the message arrives unthreaded instead of the + * send failing outright. + */ + protected buildReplyParameters( + replyToMessageId: string | undefined, + expectedChatId: string + ): { message_id: number; allow_sending_without_reply: boolean } | undefined { + if (!replyToMessageId) { + return undefined; + } + + const { messageId } = this.decodeCompositeMessageId( + replyToMessageId, + expectedChatId + ); + + return { message_id: messageId, allow_sending_without_reply: true }; + } + protected decodeCompositeMessageId( messageId: string, expectedChatId?: string @@ -2919,6 +3011,19 @@ export class TelegramAdapter } protected isBotMentioned(message: TelegramMessage, text: string): boolean { + // Replying to one of the bot's own messages addresses it as directly as an + // @mention does — it is how Telegram users continue a conversation without + // repeating the handle. Opt-in, and checked before the empty-text guard so + // a reply carrying only a photo or a document still counts. + if ( + this.mentionOnReply && + this._botUserId && + message.reply_to_message?.from && + String(message.reply_to_message.from.id) === this._botUserId + ) { + return true; + } + if (!text) { return false; } @@ -3087,7 +3192,8 @@ export class TelegramAdapter plainText: string, parseMode: TelegramParseMode, replyMarkup: TelegramInlineKeyboardMarkup | undefined, - threadId: string + threadId: string, + replyToMessageId?: string ): Promise { return this.withTelegramMarkdownFallback( parseMode, @@ -3098,6 +3204,10 @@ export class TelegramAdapter text: resolvedText, reply_markup: replyMarkup, parse_mode: toBotApiParseMode(resolvedParseMode), + reply_parameters: this.buildReplyParameters( + replyToMessageId, + thread.chatId + ), }), { initialText: text, diff --git a/packages/adapter-telegram/src/types.ts b/packages/adapter-telegram/src/types.ts index bbe75a0c..2d46cff5 100644 --- a/packages/adapter-telegram/src/types.ts +++ b/packages/adapter-telegram/src/types.ts @@ -20,6 +20,17 @@ export interface TelegramAdapterConfig { logger?: Logger; /** Optional long-polling configuration for getUpdates flow. */ longPolling?: TelegramLongPollingConfig; + /** + * Treat a reply to one of the bot's own messages as a mention. + * + * Telegram users continue a conversation by replying rather than repeating + * the handle, so a bot that only reacts to `@name` looks unresponsive in a + * group. Off by default: turning it on changes which messages report + * `isMention`, and a bot that deliberately answers mentions only should keep + * the stricter behaviour. Defaults to the `TELEGRAM_MENTION_ON_REPLY` + * environment variable when set to `"true"`. + */ + mentionOnReply?: boolean; /** * Adapter runtime mode: * - auto: choose webhook vs polling based on webhook registration/runtime (default) diff --git a/packages/chat/src/adapters/index.ts b/packages/chat/src/adapters/index.ts index 049db562..f326bbe9 100644 --- a/packages/chat/src/adapters/index.ts +++ b/packages/chat/src/adapters/index.ts @@ -1006,6 +1006,10 @@ export const ADAPTERS = { "Optional webhook secret token." ), env("TELEGRAM_BOT_USERNAME", "Bot username for mention detection."), + env( + "TELEGRAM_MENTION_ON_REPLY", + 'Set to "true" to treat a reply to the bot as a mention.' + ), urlEnv("TELEGRAM_API_BASE_URL", "Override the Telegram API base URL."), ], required: [secretEnv("TELEGRAM_BOT_TOKEN", "Telegram bot token.")],