fix(telegram): ignore message_thread_id for non-forum group sessions

Regular Telegram groups (without Topics/Forums enabled) can send
message_thread_id when users reply to messages. This was incorrectly
being used to create separate session keys like '-123:topic:42',
causing each reply chain to get its own conversation context.

Now resolveTelegramForumThreadId only returns a thread ID when the
chat is actually a forum (is_forum=true). For regular groups, the
thread ID is ignored, ensuring all messages share the same session.

DMs continue to use messageThreadId for thread sessions as before.
This commit is contained in:
Dylan Neve 2026-01-27 11:17:31 +00:00 committed by Ayaan Zaidi
parent 8f452dbc08
commit 915497114e
6 changed files with 142 additions and 6 deletions

View File

@ -70,3 +70,102 @@ describe("buildTelegramMessageContext dm thread sessions", () => {
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
});
});
describe("buildTelegramMessageContext group sessions without forum", () => {
const baseConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
} as never;
const buildContext = async (message: Record<string, unknown>) =>
await buildTelegramMessageContext({
primaryCtx: {
message,
me: { id: 7, username: "bot" },
} as never,
allMedia: [],
storeAllowFrom: [],
options: { forceWasMentioned: true },
bot: {
api: {
sendChatAction: vi.fn(),
setMessageReaction: vi.fn(),
},
} as never,
cfg: baseConfig,
account: { accountId: "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "off",
logger: { info: vi.fn() },
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: undefined,
}),
});
it("ignores message_thread_id for regular groups (not forums)", async () => {
// When someone replies to a message in a non-forum group, Telegram sends
// message_thread_id but this should NOT create a separate session
const ctx = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: "@bot hello",
message_thread_id: 42, // This is a reply thread, NOT a forum topic
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
// Session key should NOT include :topic:42
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890");
// MessageThreadId should be undefined (not a forum)
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
});
it("keeps same session for regular group with and without message_thread_id", async () => {
const ctxWithThread = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: "@bot hello",
message_thread_id: 42,
from: { id: 42, first_name: "Alice" },
});
const ctxWithoutThread = await buildContext({
message_id: 2,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000001,
text: "@bot world",
from: { id: 42, first_name: "Alice" },
});
expect(ctxWithThread).not.toBeNull();
expect(ctxWithoutThread).not.toBeNull();
// Both messages should use the same session key
expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey);
});
it("uses topic session for forum groups with message_thread_id", async () => {
const ctx = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000000,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
// Session key SHOULD include :topic:99 for forums
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99");
expect(ctx?.ctxPayload?.MessageThreadId).toBe(99);
});
});

View File

@ -173,7 +173,8 @@ export const buildTelegramMessageContext = async ({
},
});
const baseSessionKey = route.sessionKey;
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
@ -601,7 +602,8 @@ export const buildTelegramMessageContext = async ({
Sticker: allMedia[0]?.stickerMetadata,
...(locationData ? toLocationContext(locationData) : undefined),
CommandAuthorized: commandAuthorized,
MessageThreadId: resolvedThreadId,
// For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId
MessageThreadId: isGroup ? resolvedThreadId : messageThreadId,
IsForum: isForum,
// Originating channel for reply routing.
OriginatingChannel: "telegram" as const,

View File

@ -421,7 +421,8 @@ export const registerTelegramNativeCommands = ({
},
});
const baseSessionKey = route.sessionKey;
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })

View File

@ -427,7 +427,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
peer: { kind: isGroup ? "group" : "dm", id: peerId },
});
const baseSessionKey = route.sessionKey;
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })

View File

@ -3,8 +3,30 @@ import {
buildTelegramThreadParams,
buildTypingThreadParams,
normalizeForwardedContext,
resolveTelegramForumThreadId,
} from "./helpers.js";
describe("resolveTelegramForumThreadId", () => {
it("returns undefined for non-forum groups even with messageThreadId", () => {
// Reply threads in regular groups should not create separate sessions
expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: 42 })).toBeUndefined();
});
it("returns undefined for non-forum groups without messageThreadId", () => {
expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined })).toBeUndefined();
expect(resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 })).toBeUndefined();
});
it("returns General topic (1) for forum groups without messageThreadId", () => {
expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: undefined })).toBe(1);
expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: null })).toBe(1);
});
it("returns the topic id for forum groups with messageThreadId", () => {
expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: 99 })).toBe(99);
});
});
describe("buildTelegramThreadParams", () => {
it("omits General topic thread id for message sends", () => {
expect(buildTelegramThreadParams(1)).toBeUndefined();

View File

@ -13,14 +13,25 @@ import type {
const TELEGRAM_GENERAL_TOPIC_ID = 1;
/**
* Resolve the thread ID for Telegram forum topics.
* For non-forum groups, returns undefined even if messageThreadId is present
* (reply threads in regular groups should not create separate sessions).
* For forum groups, returns the topic ID (or General topic ID=1 if unspecified).
*/
export function resolveTelegramForumThreadId(params: {
isForum?: boolean;
messageThreadId?: number | null;
}) {
if (params.isForum && params.messageThreadId == null) {
// Non-forum groups: ignore message_thread_id (reply threads are not real topics)
if (!params.isForum) {
return undefined;
}
// Forum groups: use the topic ID, defaulting to General topic
if (params.messageThreadId == null) {
return TELEGRAM_GENERAL_TOPIC_ID;
}
return params.messageThreadId ?? undefined;
return params.messageThreadId;
}
/**