-
-
Notifications
You must be signed in to change notification settings - Fork 39.6k
Description
Summary
The v2026.2.15 release introduced a regression where DM chats with forum/topics enabled (chat.is_forum=true) no longer receive message_thread_id in outbound messages, breaking thread routing. Messages intended for specific topic threads are sent to the main chat instead.
Background
Telegram supports enabling forum/topics mode on bot DM chats (not just groups). This is the natural way to organize ongoing conversations with a bot by topic — email digests, code reviews, triage outputs, cron job results, etc. all go to their own named threads.
Root Cause
PR #17235 (fix(telegram): ignore messageThreadId for DM chats) fixed a valid bug (#14653) where non-forum DM chats received spurious message_thread_id values causing 400 Bad Request: message thread not found errors.
However, the fix over-corrected. Two functions are involved:
1. resolveTelegramThreadSpec in src/telegram/bot/helpers.ts
This function resolves the thread scope for inbound messages. On both v2026.2.14 and v2026.2.15, the non-group path is identical:
// Current (v2026.2.14 and v2026.2.15) — same behavior
if (!params.isGroup) {
if (params.messageThreadId == null) {
return { scope: "dm" };
}
return { id: params.messageThreadId, scope: "dm" };
}The problem: when a DM has isForum=true, this still returns scope: "dm" instead of scope: "forum". The isForum param is only checked in the isGroup branch.
2. buildTelegramThreadParams in src/telegram/bot/helpers.ts
This is where the regression was introduced. The v2026.2.15 change:
// v2026.2.14 — works for forum DMs (passes thread_id through)
if (!thread?.id) return undefined;
const normalized = Math.trunc(thread.id);
if (normalized === TELEGRAM_GENERAL_TOPIC_ID && thread.scope === "forum") {
return undefined;
}
return { message_thread_id: normalized };
// v2026.2.15 — breaks forum DMs (blanket-strips all DM thread_ids)
if (thread?.id == null) return undefined;
const normalized = Math.trunc(thread.id);
if (thread.scope === "dm") return undefined; // ← this line
if (normalized === TELEGRAM_GENERAL_TOPIC_ID) return undefined;
return { message_thread_id: normalized };Because resolveTelegramThreadSpec tags forum DM threads as scope: "dm", and buildTelegramThreadParams now strips all scope: "dm" thread IDs, forum DM threads are silently dropped.
Proposed Fix
Check isForum in the non-group path of resolveTelegramThreadSpec:
if (!params.isGroup) {
// DM with forum/topics enabled — treat like a forum, not a flat DM
if (params.isForum && params.messageThreadId != null) {
return { id: params.messageThreadId, scope: "forum" };
}
return { scope: "dm" };
}This way:
- Non-forum DMs →
scope: "dm", no thread ID (the Telegram plugin: replyTo parameter incorrectly mapped to message_thread_id instead of reply_to_message_id #14653 fix works correctly) - Forum DMs →
scope: "forum", thread ID preserved (topics work) - Forum groups → unchanged (already handled by
isGroupbranch)
No changes needed to buildTelegramThreadParams — it already handles scope: "forum" correctly (passes thread IDs through, strips General topic ID=1).
Proposed Tests
The following test cases should be added to src/telegram/bot/helpers.test.ts to prevent future regressions:
resolveTelegramThreadSpec — DM forum cases
describe("resolveTelegramThreadSpec — DM with forum/topics", () => {
it("returns scope 'forum' for DM with isForum=true and messageThreadId", () => {
expect(resolveTelegramThreadSpec({
isGroup: false,
isForum: true,
messageThreadId: 42,
})).toEqual({ id: 42, scope: "forum" });
});
it("returns scope 'dm' for DM with isForum=true but no messageThreadId", () => {
expect(resolveTelegramThreadSpec({
isGroup: false,
isForum: true,
messageThreadId: null,
})).toEqual({ scope: "dm" });
});
it("returns scope 'dm' for DM with isForum=false and messageThreadId (stale thread)", () => {
// Non-forum DM with stale thread ID should NOT get scope: "forum"
expect(resolveTelegramThreadSpec({
isGroup: false,
isForum: false,
messageThreadId: 42,
})).toEqual({ scope: "dm" });
});
it("returns scope 'dm' for DM with isForum=undefined (legacy behavior)", () => {
expect(resolveTelegramThreadSpec({
isGroup: false,
messageThreadId: 42,
})).toEqual({ scope: "dm" });
});
});buildTelegramThreadParams — forum thread passthrough
describe("buildTelegramThreadParams — forum scope threads", () => {
it("passes through non-General thread IDs for forum scope", () => {
expect(buildTelegramThreadParams({ id: 42, scope: "forum" }))
.toEqual({ message_thread_id: 42 });
});
it("strips General topic (id=1) for forum scope", () => {
expect(buildTelegramThreadParams({ id: 1, scope: "forum" }))
.toBeUndefined();
});
it("strips all thread IDs for dm scope (non-forum DMs)", () => {
expect(buildTelegramThreadParams({ id: 42, scope: "dm" }))
.toBeUndefined();
});
});Integration-level: end-to-end DM forum thread routing
describe("DM forum topic routing (e2e)", () => {
it("preserves message_thread_id when replying in a DM forum topic", () => {
// Simulate inbound message from a DM chat with is_forum=true
// and message_thread_id=42 (a named topic)
// Assert: outbound sendMessage includes message_thread_id=42
});
it("omits message_thread_id when replying in a non-forum DM", () => {
// Simulate inbound message from a normal DM chat (is_forum=false)
// with a stale message_thread_id=1
// Assert: outbound sendMessage does NOT include message_thread_id
});
});Impact
Anyone using forum/topics on a Telegram bot DM chat loses all thread routing on v2026.2.15. Messages land in the main chat instead of their target topics. This affects:
- Sub-agent announce deliveries
- Cron job thread-targeted sends
- Proactive
messagetool sends withthreadId - Reply routing for messages received in specific topics
Environment
- OpenClaw v2026.2.15
- Telegram bot DM with forum/topics enabled
- HA addon
Workaround
Roll back to v2026.2.14 (pre-regression) or switch to a private supergroup with topics (group path handles isForum correctly).