mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 15:30:35 +00:00
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:
@@ -70,3 +70,102 @@ describe("buildTelegramMessageContext dm thread sessions", () => {
|
|||||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -173,7 +173,8 @@ export const buildTelegramMessageContext = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const baseSessionKey = route.sessionKey;
|
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 =
|
const threadKeys =
|
||||||
dmThreadId != null
|
dmThreadId != null
|
||||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
||||||
@@ -601,7 +602,8 @@ export const buildTelegramMessageContext = async ({
|
|||||||
Sticker: allMedia[0]?.stickerMetadata,
|
Sticker: allMedia[0]?.stickerMetadata,
|
||||||
...(locationData ? toLocationContext(locationData) : undefined),
|
...(locationData ? toLocationContext(locationData) : undefined),
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
MessageThreadId: resolvedThreadId,
|
// For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId
|
||||||
|
MessageThreadId: isGroup ? resolvedThreadId : messageThreadId,
|
||||||
IsForum: isForum,
|
IsForum: isForum,
|
||||||
// Originating channel for reply routing.
|
// Originating channel for reply routing.
|
||||||
OriginatingChannel: "telegram" as const,
|
OriginatingChannel: "telegram" as const,
|
||||||
|
|||||||
@@ -421,7 +421,8 @@ export const registerTelegramNativeCommands = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const baseSessionKey = route.sessionKey;
|
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 =
|
const threadKeys =
|
||||||
dmThreadId != null
|
dmThreadId != null
|
||||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
||||||
|
|||||||
@@ -427,7 +427,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
||||||
});
|
});
|
||||||
const baseSessionKey = route.sessionKey;
|
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 =
|
const threadKeys =
|
||||||
dmThreadId != null
|
dmThreadId != null
|
||||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
||||||
|
|||||||
@@ -3,8 +3,30 @@ import {
|
|||||||
buildTelegramThreadParams,
|
buildTelegramThreadParams,
|
||||||
buildTypingThreadParams,
|
buildTypingThreadParams,
|
||||||
normalizeForwardedContext,
|
normalizeForwardedContext,
|
||||||
|
resolveTelegramForumThreadId,
|
||||||
} from "./helpers.js";
|
} 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", () => {
|
describe("buildTelegramThreadParams", () => {
|
||||||
it("omits General topic thread id for message sends", () => {
|
it("omits General topic thread id for message sends", () => {
|
||||||
expect(buildTelegramThreadParams(1)).toBeUndefined();
|
expect(buildTelegramThreadParams(1)).toBeUndefined();
|
||||||
|
|||||||
@@ -13,14 +13,25 @@ import type {
|
|||||||
|
|
||||||
const TELEGRAM_GENERAL_TOPIC_ID = 1;
|
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: {
|
export function resolveTelegramForumThreadId(params: {
|
||||||
isForum?: boolean;
|
isForum?: boolean;
|
||||||
messageThreadId?: number | null;
|
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 TELEGRAM_GENERAL_TOPIC_ID;
|
||||||
}
|
}
|
||||||
return params.messageThreadId ?? undefined;
|
return params.messageThreadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user