@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight)
|
- Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight)
|
||||||
|
- Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow)
|
||||||
- Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow)
|
- Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow)
|
||||||
- Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow)
|
- Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow)
|
||||||
- System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow)
|
- System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow)
|
||||||
|
|||||||
@@ -211,6 +211,11 @@ describe("createTelegramBot", () => {
|
|||||||
message: { chat: { id: 123 }, message_thread_id: 9 },
|
message: { chat: { id: 123 }, message_thread_id: 9 },
|
||||||
}),
|
}),
|
||||||
).toBe("telegram:123:topic:9");
|
).toBe("telegram:123:topic:9");
|
||||||
|
expect(
|
||||||
|
getTelegramSequentialKey({
|
||||||
|
message: { chat: { id: 123, is_forum: true } },
|
||||||
|
}),
|
||||||
|
).toBe("telegram:123:topic:1");
|
||||||
expect(
|
expect(
|
||||||
getTelegramSequentialKey({
|
getTelegramSequentialKey({
|
||||||
update: { message: { chat: { id: 555 } } },
|
update: { message: { chat: { id: 555 } } },
|
||||||
@@ -1766,6 +1771,51 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes General topic replies using thread id 1", async () => {
|
||||||
|
onSpy.mockReset();
|
||||||
|
sendMessageSpy.mockReset();
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
replySpy.mockReset();
|
||||||
|
replySpy.mockResolvedValue({ text: "response" });
|
||||||
|
|
||||||
|
loadConfig.mockReturnValue({
|
||||||
|
telegram: {
|
||||||
|
groupPolicy: "open",
|
||||||
|
groups: { "*": { requireMention: false } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createTelegramBot({ token: "tok" });
|
||||||
|
const handler = getOnHandler("message") as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
chat: {
|
||||||
|
id: -1001234567890,
|
||||||
|
type: "supergroup",
|
||||||
|
title: "Forum Group",
|
||||||
|
is_forum: true,
|
||||||
|
},
|
||||||
|
from: { id: 12345, username: "testuser" },
|
||||||
|
text: "hello",
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 42,
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMessageSpy).toHaveBeenCalledWith(
|
||||||
|
"-1001234567890",
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({ message_thread_id: 1 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("applies topic skill filters and system prompts", async () => {
|
it("applies topic skill filters and system prompts", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||||
|
|||||||
@@ -196,7 +196,11 @@ export function getTelegramSequentialKey(ctx: {
|
|||||||
ctx.update?.edited_message ??
|
ctx.update?.edited_message ??
|
||||||
ctx.update?.callback_query?.message;
|
ctx.update?.callback_query?.message;
|
||||||
const chatId = msg?.chat?.id ?? ctx.chat?.id;
|
const chatId = msg?.chat?.id ?? ctx.chat?.id;
|
||||||
const threadId = msg?.message_thread_id;
|
const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum;
|
||||||
|
const threadId = resolveTelegramForumThreadId({
|
||||||
|
isForum,
|
||||||
|
messageThreadId: msg?.message_thread_id,
|
||||||
|
});
|
||||||
if (typeof chatId === "number") {
|
if (typeof chatId === "number") {
|
||||||
return threadId != null
|
return threadId != null
|
||||||
? `telegram:${chatId}:topic:${threadId}`
|
? `telegram:${chatId}:topic:${threadId}`
|
||||||
@@ -205,6 +209,16 @@ export function getTelegramSequentialKey(ctx: {
|
|||||||
return "telegram:unknown";
|
return "telegram:unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTelegramForumThreadId(params: {
|
||||||
|
isForum?: boolean;
|
||||||
|
messageThreadId?: number | null;
|
||||||
|
}) {
|
||||||
|
if (params.isForum && params.messageThreadId == null) {
|
||||||
|
return TELEGRAM_GENERAL_TOPIC_ID;
|
||||||
|
}
|
||||||
|
return params.messageThreadId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function createTelegramBot(opts: TelegramBotOptions) {
|
export function createTelegramBot(opts: TelegramBotOptions) {
|
||||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
@@ -423,12 +437,16 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const messageThreadId = (msg as { message_thread_id?: number })
|
const messageThreadId = (msg as { message_thread_id?: number })
|
||||||
.message_thread_id;
|
.message_thread_id;
|
||||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||||
|
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||||
|
isForum,
|
||||||
|
messageThreadId,
|
||||||
|
});
|
||||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
|
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
|
||||||
chatId,
|
chatId,
|
||||||
messageThreadId,
|
resolvedThreadId,
|
||||||
);
|
);
|
||||||
const peerId = isGroup
|
const peerId = isGroup
|
||||||
? buildTelegramGroupPeerId(chatId, messageThreadId)
|
? buildTelegramGroupPeerId(chatId, resolvedThreadId)
|
||||||
: String(chatId);
|
: String(chatId);
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -460,23 +478,17 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
}
|
}
|
||||||
if (isGroup && topicConfig?.enabled === false) {
|
if (isGroup && topicConfig?.enabled === false) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Blocked telegram topic ${chatId} (${messageThreadId ?? "unknown"}) (topic disabled)`,
|
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendTyping = async () => {
|
const sendTyping = async () => {
|
||||||
try {
|
try {
|
||||||
// In forums, the General topic has no message_thread_id in updates,
|
|
||||||
// but sendChatAction requires one to show typing.
|
|
||||||
const typingThreadId =
|
|
||||||
isForum && messageThreadId == null
|
|
||||||
? TELEGRAM_GENERAL_TOPIC_ID
|
|
||||||
: messageThreadId;
|
|
||||||
await bot.api.sendChatAction(
|
await bot.api.sendChatAction(
|
||||||
chatId,
|
chatId,
|
||||||
"typing",
|
"typing",
|
||||||
buildTelegramThreadParams(typingThreadId),
|
buildTelegramThreadParams(resolvedThreadId),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
@@ -588,7 +600,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
);
|
);
|
||||||
const activationOverride = resolveGroupActivation({
|
const activationOverride = resolveGroupActivation({
|
||||||
chatId,
|
chatId,
|
||||||
messageThreadId,
|
messageThreadId: resolvedThreadId,
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
@@ -684,19 +696,19 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
}]\n${replyTarget.body}\n[/Replying]`
|
}]\n${replyTarget.body}\n[/Replying]`
|
||||||
: "";
|
: "";
|
||||||
const groupLabel = isGroup
|
const groupLabel = isGroup
|
||||||
? buildGroupLabel(msg, chatId, messageThreadId)
|
? buildGroupLabel(msg, chatId, resolvedThreadId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const body = formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
provider: "Telegram",
|
provider: "Telegram",
|
||||||
from: isGroup
|
from: isGroup
|
||||||
? buildGroupFromLabel(msg, chatId, senderId, messageThreadId)
|
? buildGroupFromLabel(msg, chatId, senderId, resolvedThreadId)
|
||||||
: buildSenderLabel(msg, senderId || chatId),
|
: buildSenderLabel(msg, senderId || chatId),
|
||||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||||
body: `${bodyText}${replySuffix}`,
|
body: `${bodyText}${replySuffix}`,
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
const historyKey = isGroup
|
const historyKey = isGroup
|
||||||
? buildTelegramGroupPeerId(chatId, messageThreadId)
|
? buildTelegramGroupPeerId(chatId, resolvedThreadId)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (isGroup && historyKey && historyLimit > 0) {
|
if (isGroup && historyKey && historyLimit > 0) {
|
||||||
combinedBody = buildHistoryContextFromMap({
|
combinedBody = buildHistoryContextFromMap({
|
||||||
@@ -736,7 +748,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
RawBody: rawBody,
|
RawBody: rawBody,
|
||||||
CommandBody: commandBody,
|
CommandBody: commandBody,
|
||||||
From: isGroup
|
From: isGroup
|
||||||
? buildTelegramGroupFrom(chatId, messageThreadId)
|
? buildTelegramGroupFrom(chatId, resolvedThreadId)
|
||||||
: `telegram:${chatId}`,
|
: `telegram:${chatId}`,
|
||||||
To: `telegram:${chatId}`,
|
To: `telegram:${chatId}`,
|
||||||
SessionKey: route.sessionKey,
|
SessionKey: route.sessionKey,
|
||||||
@@ -766,7 +778,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
...(locationData ? toLocationContext(locationData) : undefined),
|
...(locationData ? toLocationContext(locationData) : undefined),
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
MessageThreadId: messageThreadId,
|
MessageThreadId: resolvedThreadId,
|
||||||
IsForum: isForum,
|
IsForum: isForum,
|
||||||
// Originating channel for reply routing.
|
// Originating channel for reply routing.
|
||||||
OriginatingChannel: "telegram" as const,
|
OriginatingChannel: "telegram" as const,
|
||||||
@@ -799,7 +811,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const mediaInfo =
|
const mediaInfo =
|
||||||
allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
|
allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
|
||||||
const topicInfo =
|
const topicInfo =
|
||||||
messageThreadId != null ? ` topic=${messageThreadId}` : "";
|
resolvedThreadId != null ? ` topic=${resolvedThreadId}` : "";
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`,
|
`telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`,
|
||||||
);
|
);
|
||||||
@@ -810,7 +822,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const canStreamDraft =
|
const canStreamDraft =
|
||||||
streamMode !== "off" &&
|
streamMode !== "off" &&
|
||||||
isPrivateChat &&
|
isPrivateChat &&
|
||||||
typeof messageThreadId === "number" &&
|
typeof resolvedThreadId === "number" &&
|
||||||
(await resolveBotTopicsEnabled(primaryCtx));
|
(await resolveBotTopicsEnabled(primaryCtx));
|
||||||
const draftStream = canStreamDraft
|
const draftStream = canStreamDraft
|
||||||
? createTelegramDraftStream({
|
? createTelegramDraftStream({
|
||||||
@@ -818,7 +830,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
chatId,
|
chatId,
|
||||||
draftId: msg.message_id || Date.now(),
|
draftId: msg.message_id || Date.now(),
|
||||||
maxChars: draftMaxChars,
|
maxChars: draftMaxChars,
|
||||||
messageThreadId,
|
messageThreadId: resolvedThreadId,
|
||||||
log: logVerbose,
|
log: logVerbose,
|
||||||
warn: logVerbose,
|
warn: logVerbose,
|
||||||
})
|
})
|
||||||
@@ -905,7 +917,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
bot,
|
bot,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
messageThreadId,
|
messageThreadId: resolvedThreadId,
|
||||||
});
|
});
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
},
|
},
|
||||||
@@ -999,12 +1011,16 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
.message_thread_id;
|
.message_thread_id;
|
||||||
const isForum =
|
const isForum =
|
||||||
(msg.chat as { is_forum?: boolean }).is_forum === true;
|
(msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||||
|
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||||
|
isForum,
|
||||||
|
messageThreadId,
|
||||||
|
});
|
||||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(
|
const storeAllowFrom = await readTelegramAllowFromStore().catch(
|
||||||
() => [],
|
() => [],
|
||||||
);
|
);
|
||||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
|
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
|
||||||
chatId,
|
chatId,
|
||||||
messageThreadId,
|
resolvedThreadId,
|
||||||
);
|
);
|
||||||
const groupAllowOverride = firstDefined(
|
const groupAllowOverride = firstDefined(
|
||||||
topicConfig?.allowFrom,
|
topicConfig?.allowFrom,
|
||||||
@@ -1116,7 +1132,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
peer: {
|
peer: {
|
||||||
kind: isGroup ? "group" : "dm",
|
kind: isGroup ? "group" : "dm",
|
||||||
id: isGroup
|
id: isGroup
|
||||||
? buildTelegramGroupPeerId(chatId, messageThreadId)
|
? buildTelegramGroupPeerId(chatId, resolvedThreadId)
|
||||||
: String(chatId),
|
: String(chatId),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1135,7 +1151,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const ctxPayload = {
|
const ctxPayload = {
|
||||||
Body: prompt,
|
Body: prompt,
|
||||||
From: isGroup
|
From: isGroup
|
||||||
? buildTelegramGroupFrom(chatId, messageThreadId)
|
? buildTelegramGroupFrom(chatId, resolvedThreadId)
|
||||||
: `telegram:${chatId}`,
|
: `telegram:${chatId}`,
|
||||||
To: `slash:${senderId || chatId}`,
|
To: `slash:${senderId || chatId}`,
|
||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
@@ -1152,7 +1168,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
CommandSource: "native" as const,
|
CommandSource: "native" as const,
|
||||||
SessionKey: `telegram:slash:${senderId || chatId}`,
|
SessionKey: `telegram:slash:${senderId || chatId}`,
|
||||||
CommandTargetSessionKey: route.sessionKey,
|
CommandTargetSessionKey: route.sessionKey,
|
||||||
MessageThreadId: messageThreadId,
|
MessageThreadId: resolvedThreadId,
|
||||||
IsForum: isForum,
|
IsForum: isForum,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1176,7 +1192,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
bot,
|
bot,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
messageThreadId,
|
messageThreadId: resolvedThreadId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
@@ -1256,10 +1272,15 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
msg.chat.type === "group" || msg.chat.type === "supergroup";
|
msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||||
const messageThreadId = (msg as { message_thread_id?: number })
|
const messageThreadId = (msg as { message_thread_id?: number })
|
||||||
.message_thread_id;
|
.message_thread_id;
|
||||||
|
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||||
|
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||||
|
isForum,
|
||||||
|
messageThreadId,
|
||||||
|
});
|
||||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
|
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
|
||||||
chatId,
|
chatId,
|
||||||
messageThreadId,
|
resolvedThreadId,
|
||||||
);
|
);
|
||||||
const groupAllowOverride = firstDefined(
|
const groupAllowOverride = firstDefined(
|
||||||
topicConfig?.allowFrom,
|
topicConfig?.allowFrom,
|
||||||
@@ -1278,7 +1299,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
}
|
}
|
||||||
if (topicConfig?.enabled === false) {
|
if (topicConfig?.enabled === false) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Blocked telegram topic ${chatId} (${messageThreadId ?? "unknown"}) (topic disabled)`,
|
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user