Merge branch 'main' into docs/vps-standardization
This commit is contained in:
commit
4307c8a1fc
@ -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.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) })
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user