Merge branch 'main' into docs/vps-standardization

This commit is contained in:
Wayne 2026-01-28 12:21:51 +08:00 committed by GitHub
commit 4307c8a1fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 171 additions and 19 deletions

View File

@ -95,6 +95,7 @@ Status: beta.
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.

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

@ -360,6 +360,8 @@ export const registerTelegramNativeCommands = ({
topicConfig,
commandAuthorized,
} = auth;
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? "";
@ -406,7 +408,7 @@ export const registerTelegramNativeCommands = ({
fn: () =>
bot.api.sendMessage(chatId, title, {
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}),
...(threadIdForSend != null ? { message_thread_id: threadIdForSend } : {}),
}),
});
return;
@ -421,7 +423,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) })
@ -466,7 +469,7 @@ export const registerTelegramNativeCommands = ({
CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`,
CommandTargetSessionKey: sessionKey,
MessageThreadId: resolvedThreadId,
MessageThreadId: threadIdForSend,
IsForum: isForum,
// Originating context for sub-agent announce routing
OriginatingChannel: "telegram" as const,
@ -493,7 +496,7 @@ export const registerTelegramNativeCommands = ({
bot,
replyToMode,
textLimit,
messageThreadId: resolvedThreadId,
messageThreadId: threadIdForSend,
tableMode,
chunkMode,
linkPreview: telegramCfg.linkPreview,
@ -541,7 +544,9 @@ export const registerTelegramNativeCommands = ({
requireAuth: match.command.requireAuth !== false,
});
if (!auth) return;
const { resolvedThreadId, senderId, commandAuthorized } = auth;
const { resolvedThreadId, senderId, commandAuthorized, isGroup } = auth;
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
const result = await executePluginCommand({
command: match.command,
@ -567,7 +572,7 @@ export const registerTelegramNativeCommands = ({
bot,
replyToMode,
textLimit,
messageThreadId: resolvedThreadId,
messageThreadId: threadIdForSend,
tableMode,
chunkMode,
linkPreview: telegramCfg.linkPreview,

View File

@ -238,12 +238,17 @@ describe("createTelegramBot", () => {
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123 }, message_thread_id: 9 },
message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
}),
).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, is_forum: true } },
message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, type: "supergroup", is_forum: true } },
}),
).toBe("telegram:123:topic:1");
expect(

View File

@ -340,12 +340,17 @@ describe("createTelegramBot", () => {
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123 }, message_thread_id: 9 },
message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
}),
).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, is_forum: true } },
message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, type: "supergroup", is_forum: true } },
}),
).toBe("telegram:123:topic:1");
expect(

View File

@ -94,11 +94,12 @@ export function getTelegramSequentialKey(ctx: {
if (typeof chatId === "number") return `telegram:${chatId}:control`;
return "telegram:control";
}
const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
const messageThreadId = msg?.message_thread_id;
const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum;
const threadId = resolveTelegramForumThreadId({
isForum,
messageThreadId: msg?.message_thread_id,
});
const threadId = isGroup
? resolveTelegramForumThreadId({ isForum, messageThreadId })
: messageThreadId;
if (typeof chatId === "number") {
return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`;
}
@ -427,7 +428,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;
}
/**