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: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
|
||||||
- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.
|
- 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: 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: 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.
|
- 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.
|
- 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");
|
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,
|
||||||
|
|||||||
@ -360,6 +360,8 @@ export const registerTelegramNativeCommands = ({
|
|||||||
topicConfig,
|
topicConfig,
|
||||||
commandAuthorized,
|
commandAuthorized,
|
||||||
} = auth;
|
} = auth;
|
||||||
|
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||||
|
const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
|
||||||
|
|
||||||
const commandDefinition = findCommandByNativeName(command.name, "telegram");
|
const commandDefinition = findCommandByNativeName(command.name, "telegram");
|
||||||
const rawText = ctx.match?.trim() ?? "";
|
const rawText = ctx.match?.trim() ?? "";
|
||||||
@ -406,7 +408,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
fn: () =>
|
fn: () =>
|
||||||
bot.api.sendMessage(chatId, title, {
|
bot.api.sendMessage(chatId, title, {
|
||||||
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}),
|
...(threadIdForSend != null ? { message_thread_id: threadIdForSend } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@ -421,7 +423,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) })
|
||||||
@ -466,7 +469,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
CommandSource: "native" as const,
|
CommandSource: "native" as const,
|
||||||
SessionKey: `telegram:slash:${senderId || chatId}`,
|
SessionKey: `telegram:slash:${senderId || chatId}`,
|
||||||
CommandTargetSessionKey: sessionKey,
|
CommandTargetSessionKey: sessionKey,
|
||||||
MessageThreadId: resolvedThreadId,
|
MessageThreadId: threadIdForSend,
|
||||||
IsForum: isForum,
|
IsForum: isForum,
|
||||||
// Originating context for sub-agent announce routing
|
// Originating context for sub-agent announce routing
|
||||||
OriginatingChannel: "telegram" as const,
|
OriginatingChannel: "telegram" as const,
|
||||||
@ -493,7 +496,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
bot,
|
bot,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
messageThreadId: resolvedThreadId,
|
messageThreadId: threadIdForSend,
|
||||||
tableMode,
|
tableMode,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
linkPreview: telegramCfg.linkPreview,
|
linkPreview: telegramCfg.linkPreview,
|
||||||
@ -541,7 +544,9 @@ export const registerTelegramNativeCommands = ({
|
|||||||
requireAuth: match.command.requireAuth !== false,
|
requireAuth: match.command.requireAuth !== false,
|
||||||
});
|
});
|
||||||
if (!auth) return;
|
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({
|
const result = await executePluginCommand({
|
||||||
command: match.command,
|
command: match.command,
|
||||||
@ -567,7 +572,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
bot,
|
bot,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
messageThreadId: resolvedThreadId,
|
messageThreadId: threadIdForSend,
|
||||||
tableMode,
|
tableMode,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
linkPreview: telegramCfg.linkPreview,
|
linkPreview: telegramCfg.linkPreview,
|
||||||
|
|||||||
@ -238,12 +238,17 @@ describe("createTelegramBot", () => {
|
|||||||
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
|
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
|
||||||
expect(
|
expect(
|
||||||
getTelegramSequentialKey({
|
getTelegramSequentialKey({
|
||||||
message: { chat: { id: 123 }, message_thread_id: 9 },
|
message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
|
||||||
}),
|
}),
|
||||||
).toBe("telegram:123:topic:9");
|
).toBe("telegram:123:topic:9");
|
||||||
expect(
|
expect(
|
||||||
getTelegramSequentialKey({
|
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");
|
).toBe("telegram:123:topic:1");
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -340,12 +340,17 @@ describe("createTelegramBot", () => {
|
|||||||
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
|
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
|
||||||
expect(
|
expect(
|
||||||
getTelegramSequentialKey({
|
getTelegramSequentialKey({
|
||||||
message: { chat: { id: 123 }, message_thread_id: 9 },
|
message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
|
||||||
}),
|
}),
|
||||||
).toBe("telegram:123:topic:9");
|
).toBe("telegram:123:topic:9");
|
||||||
expect(
|
expect(
|
||||||
getTelegramSequentialKey({
|
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");
|
).toBe("telegram:123:topic:1");
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -94,11 +94,12 @@ export function getTelegramSequentialKey(ctx: {
|
|||||||
if (typeof chatId === "number") return `telegram:${chatId}:control`;
|
if (typeof chatId === "number") return `telegram:${chatId}:control`;
|
||||||
return "telegram: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 isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum;
|
||||||
const threadId = resolveTelegramForumThreadId({
|
const threadId = isGroup
|
||||||
isForum,
|
? resolveTelegramForumThreadId({ isForum, messageThreadId })
|
||||||
messageThreadId: msg?.message_thread_id,
|
: messageThreadId;
|
||||||
});
|
|
||||||
if (typeof chatId === "number") {
|
if (typeof chatId === "number") {
|
||||||
return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`;
|
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 },
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user