From c81e2118c587a3efe266f829f20437feb1001081 Mon Sep 17 00:00:00 2001 From: Adrian Galilea Date: Mon, 11 May 2026 01:28:04 +0200 Subject: [PATCH 1/2] fix(send): forward message_thread_id without is_topic_message guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram introduced "Threaded Mode" for private chats via @BotFather (Bot Settings → Threaded Mode). Messages in those private-chat threads carry `message_thread_id` but NOT `is_topic_message` — the latter remains exclusive to forum-supergroup topics. The existing `this.isTopicMessage?.()` clause in every send method therefore skips auto-threading in private-chat threaded mode. Replies land in the general thread of the chat instead of the user's topic, breaking the use case Telegram explicitly designed Threaded Mode for (parallel conversations with bots, especially AI chatbots). The guard is also unnecessary in the original forum-supergroup case: `threadId` is only set when the incoming message belongs to a thread, and `is_topic_message` is set on every such message in forum supergroups. Dropping the guard: - preserves existing forum-supergroup behaviour (threadId is set → forwarded) - fixes private-chat threaded mode (threadId is set → now forwarded) - preserves caller override (`message_thread_id` in params still wins) Affects 17 send-style methods plus sendMessageDraft's draft+message param builders. SendMixinMetadata's `isTopicMessage` field is removed (no longer referenced). --- src/contexts/mixins/send.ts | 47 +++++++++++++++---------------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/src/contexts/mixins/send.ts b/src/contexts/mixins/send.ts index e3fac2a..c485edd 100644 --- a/src/contexts/mixins/send.ts +++ b/src/contexts/mixins/send.ts @@ -16,7 +16,6 @@ interface SendMixinMetadata { get businessConnectionId(): string | undefined; get senderId(): number | undefined; get threadId(): number | undefined; - isTopicMessage: () => boolean; } /** This object represents a mixin which can invoke `chatId`/`senderId`-dependent methods */ @@ -28,7 +27,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendMessage({ @@ -50,7 +49,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendPhoto({ @@ -76,7 +75,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendLivePhoto({ @@ -102,7 +101,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendDocument({ @@ -124,7 +123,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendAudio({ @@ -146,7 +145,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendVideo({ @@ -171,7 +170,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendAnimation({ @@ -196,7 +195,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendVideoNote({ @@ -218,7 +217,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendVoice({ @@ -244,7 +243,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendLocation({ @@ -279,7 +278,7 @@ class SendMixin { async sendVenue(params: Optional) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendVenue({ @@ -299,7 +298,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendContact({ @@ -317,7 +316,7 @@ class SendMixin { async sendPoll(params: Optional) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendPoll({ @@ -361,7 +360,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendSticker({ @@ -413,7 +412,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; return this.bot.api.sendChatAction({ @@ -430,7 +429,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendDice({ @@ -480,7 +479,7 @@ class SendMixin { ) { if (this.businessConnectionId && !params.business_connection_id) params.business_connection_id = this.businessConnectionId; - if (this.threadId && this.isTopicMessage?.() && !params.message_thread_id) + if (this.threadId && !params.message_thread_id) params.message_thread_id = this.threadId; const response = await this.bot.api.sendMediaGroup({ @@ -601,11 +600,7 @@ class SendMixin { const baseDraftParams: Record = { ...options.draftParams, }; - if ( - this.threadId && - this.isTopicMessage?.() && - !baseDraftParams.message_thread_id - ) { + if (this.threadId && !baseDraftParams.message_thread_id) { baseDraftParams.message_thread_id = this.threadId; } @@ -619,11 +614,7 @@ class SendMixin { ) { baseMessageParams.business_connection_id = this.businessConnectionId; } - if ( - this.threadId && - this.isTopicMessage?.() && - !baseMessageParams.message_thread_id - ) { + if (this.threadId && !baseMessageParams.message_thread_id) { baseMessageParams.message_thread_id = this.threadId; } From ba710b9aa1fe2bc56ce48e3c3ddcfb9e9c60a10f Mon Sep 17 00:00:00 2001 From: Adrian Galilea Date: Mon, 11 May 2026 01:40:59 +0200 Subject: [PATCH 2/2] feat(callback-query): expose threadId getter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CallbackQueryContext only exposed `chatId` (derived from `this.message.chat.id`). The thread was not surfaced at the same level, so the SendMixin (which reads `this.threadId`) saw `undefined` when auto-forwarding inside a callback handler — meaning replies and new sends from a button tap landed in the chat's general thread instead of the thread the button was tapped in. Add `get threadId()` returning `this.message?.threadId`. Symmetric with `get chatId()`. The SendMixin now auto-forwards `message_thread_id` on `ctx.send` / `ctx.reply` / `ctx.sendDocument` / ... inside callback handlers, keeping the reply in the same thread as the tapped button. --- src/contexts/callback-query.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/contexts/callback-query.ts b/src/contexts/callback-query.ts index 3b83006..33ab78d 100644 --- a/src/contexts/callback-query.ts +++ b/src/contexts/callback-query.ts @@ -65,6 +65,17 @@ class CallbackQueryContext extends Context { return this.message?.chat?.id; } + /** + * Identifier of the thread the originating message belongs to, or + * `undefined` if it's not in a thread. Exposed so the `SendMixin` + * auto-forwards `message_thread_id` on follow-up `send`/`reply` + * calls from within a callback handler — keeping replies in the + * same thread as the tapped button. + */ + get threadId() { + return this.message?.threadId; + } + /** Checks if the query has `queryPayload` property */ hasQueryPayload(): this is Require { return this.payload.data !== undefined;