refactor: move Telegram channel implementation to extensions/ (#45635)

* refactor: move Telegram channel implementation to extensions/telegram/src/

Move all Telegram channel code (123 files + 10 bot/ files + 8 channel plugin
files) from src/telegram/ and src/channels/plugins/*/telegram.ts to
extensions/telegram/src/. Leave thin re-export shims at original locations so
cross-cutting src/ imports continue to resolve.

- Fix all relative import paths in moved files (../X/ -> ../../../src/X/)
- Fix vi.mock paths in 60 test files
- Fix inline typeof import() expressions
- Update tsconfig.plugin-sdk.dts.json rootDir to "." for cross-directory DTS
- Update write-plugin-sdk-entry-dts.ts for new rootDir structure
- Move channel plugin files with correct path remapping

* fix: support keyed telegram send deps

* fix: sync telegram extension copies with latest main

* fix: correct import paths and remove misplaced files in telegram extension

* fix: sync outbound-adapter with main (add sendTelegramPayloadMessages) and fix delivery.test import path
This commit is contained in:
scoootscooob
2026-03-14 02:50:17 -07:00
committed by GitHub
parent 8746362f5e
commit e5bca0832f
230 changed files with 19157 additions and 19204 deletions

View File

@@ -1,287 +1 @@
import {
readNumberParam,
readStringArrayParam,
readStringOrNumberParam,
readStringParam,
} from "../../../agents/tools/common.js";
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
import type { TelegramActionConfig } from "../../../config/types.telegram.js";
import { readBooleanParam } from "../../../plugin-sdk/boolean-param.js";
import { extractToolSend } from "../../../plugin-sdk/tool-send.js";
import { resolveTelegramPollVisibility } from "../../../poll-params.js";
import {
createTelegramActionGate,
listEnabledTelegramAccounts,
resolveTelegramPollActionGateState,
} from "../../../telegram/accounts.js";
import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
import { resolveReactionMessageId } from "./reaction-message-id.js";
import { createUnionActionGate, listTokenSourcedAccounts } from "./shared.js";
const providerId = "telegram";
function readTelegramSendParams(params: Record<string, unknown>) {
const to = readStringParam(params, "to", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
const caption = readStringParam(params, "caption", { allowEmpty: true });
const content = message || caption || "";
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
const asVoice = readBooleanParam(params, "asVoice");
const silent = readBooleanParam(params, "silent");
const quoteText = readStringParam(params, "quoteText");
return {
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
buttons,
asVoice,
silent,
quoteText: quoteText ?? undefined,
};
}
function readTelegramChatIdParam(params: Record<string, unknown>): string | number {
return (
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringParam(params, "to", { required: true })
);
}
function readTelegramMessageIdParam(params: Record<string, unknown>): number {
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
if (typeof messageId !== "number") {
throw new Error("messageId is required.");
}
return messageId;
}
export const telegramMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
if (accounts.length === 0) {
return [];
}
// Union of all accounts' action gates (any account enabling an action makes it available)
const gate = createUnionActionGate(accounts, (account) =>
createTelegramActionGate({
cfg,
accountId: account.accountId,
}),
);
const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) =>
gate(key, defaultValue);
const actions = new Set<ChannelMessageActionName>(["send"]);
const pollEnabledForAnyAccount = accounts.some((account) => {
const accountGate = createTelegramActionGate({
cfg,
accountId: account.accountId,
});
return resolveTelegramPollActionGateState(accountGate).enabled;
});
if (pollEnabledForAnyAccount) {
actions.add("poll");
}
if (isEnabled("reactions")) {
actions.add("react");
}
if (isEnabled("deleteMessage")) {
actions.add("delete");
}
if (isEnabled("editMessage")) {
actions.add("edit");
}
if (isEnabled("sticker", false)) {
actions.add("sticker");
actions.add("sticker-search");
}
if (isEnabled("createForumTopic")) {
actions.add("topic-create");
}
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
if (accounts.length === 0) {
return false;
}
return accounts.some((account) =>
isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }),
);
},
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");
},
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => {
if (action === "send") {
const sendParams = readTelegramSendParams(params);
return await handleTelegramAction(
{
action: "sendMessage",
...sendParams,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "react") {
const messageId = resolveReactionMessageId({ args: params, toolContext });
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = readBooleanParam(params, "remove");
return await handleTelegramAction(
{
action: "react",
chatId: readTelegramChatIdParam(params),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", { required: true });
const answers = readStringArrayParam(params, "pollOption", { required: true });
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
strict: true,
});
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
integer: true,
strict: true,
});
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
const allowMultiselect = readBooleanParam(params, "pollMulti");
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
const pollPublic = readBooleanParam(params, "pollPublic");
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
const silent = readBooleanParam(params, "silent");
return await handleTelegramAction(
{
action: "poll",
to,
question,
answers,
allowMultiselect,
durationHours: durationHours ?? undefined,
durationSeconds: durationSeconds ?? undefined,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
isAnonymous,
silent,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "delete") {
const chatId = readTelegramChatIdParam(params);
const messageId = readTelegramMessageIdParam(params);
return await handleTelegramAction(
{
action: "deleteMessage",
chatId,
messageId,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "edit") {
const chatId = readTelegramChatIdParam(params);
const messageId = readTelegramMessageIdParam(params);
const message = readStringParam(params, "message", { required: true, allowEmpty: false });
const buttons = params.buttons;
return await handleTelegramAction(
{
action: "editMessage",
chatId,
messageId,
content: message,
buttons,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "sticker") {
const to =
readStringParam(params, "to") ?? readStringParam(params, "target", { required: true });
// Accept stickerId (array from shared schema) and use first element as fileId
const stickerIds = readStringArrayParam(params, "stickerId");
const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true });
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
return await handleTelegramAction(
{
action: "sendSticker",
to,
fileId,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "sticker-search") {
const query = readStringParam(params, "query", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
return await handleTelegramAction(
{
action: "searchSticker",
query,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "topic-create") {
const chatId = readTelegramChatIdParam(params);
const name = readStringParam(params, "name", { required: true });
const iconColor = readNumberParam(params, "iconColor", { integer: true });
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
return await handleTelegramAction(
{
action: "createForumTopic",
chatId,
name,
iconColor: iconColor ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};
export * from "../../../../extensions/telegram/src/channel-actions.js";

View File

@@ -1,43 +0,0 @@
import { describe, expect, it } from "vitest";
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./telegram.js";
describe("normalizeTelegramMessagingTarget", () => {
it("normalizes t.me links to prefixed usernames", () => {
expect(normalizeTelegramMessagingTarget("https://t.me/MyChannel")).toBe("telegram:@mychannel");
});
it("keeps unprefixed topic targets valid", () => {
expect(normalizeTelegramMessagingTarget("@MyChannel:topic:9")).toBe(
"telegram:@mychannel:topic:9",
);
expect(normalizeTelegramMessagingTarget("-1001234567890:topic:456")).toBe(
"telegram:-1001234567890:topic:456",
);
});
it("keeps legacy prefixed topic targets valid", () => {
expect(normalizeTelegramMessagingTarget("telegram:group:-1001234567890:topic:456")).toBe(
"telegram:group:-1001234567890:topic:456",
);
expect(normalizeTelegramMessagingTarget("tg:group:-1001234567890:topic:456")).toBe(
"telegram:group:-1001234567890:topic:456",
);
});
});
describe("looksLikeTelegramTargetId", () => {
it("recognizes unprefixed topic targets", () => {
expect(looksLikeTelegramTargetId("@mychannel:topic:9")).toBe(true);
expect(looksLikeTelegramTargetId("-1001234567890:topic:456")).toBe(true);
});
it("recognizes legacy prefixed topic targets", () => {
expect(looksLikeTelegramTargetId("telegram:group:-1001234567890:topic:456")).toBe(true);
expect(looksLikeTelegramTargetId("tg:group:-1001234567890:topic:456")).toBe(true);
});
it("still recognizes normalized lookup targets", () => {
expect(looksLikeTelegramTargetId("https://t.me/MyChannel")).toBe(true);
expect(looksLikeTelegramTargetId("@mychannel")).toBe(true);
});
});

View File

@@ -1,44 +1 @@
import { normalizeTelegramLookupTarget, parseTelegramTarget } from "../../../telegram/targets.js";
const TELEGRAM_PREFIX_RE = /^(telegram|tg):/i;
function normalizeTelegramTargetBody(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
const prefixStripped = trimmed.replace(TELEGRAM_PREFIX_RE, "").trim();
if (!prefixStripped) {
return undefined;
}
const parsed = parseTelegramTarget(trimmed);
const normalizedChatId = normalizeTelegramLookupTarget(parsed.chatId);
if (!normalizedChatId) {
return undefined;
}
const keepLegacyGroupPrefix = /^group:/i.test(prefixStripped);
const hasTopicSuffix = /:topic:\d+$/i.test(prefixStripped);
const chatSegment = keepLegacyGroupPrefix ? `group:${normalizedChatId}` : normalizedChatId;
if (parsed.messageThreadId == null) {
return chatSegment;
}
const threadSuffix = hasTopicSuffix
? `:topic:${parsed.messageThreadId}`
: `:${parsed.messageThreadId}`;
return `${chatSegment}${threadSuffix}`;
}
export function normalizeTelegramMessagingTarget(raw: string): string | undefined {
const normalizedBody = normalizeTelegramTargetBody(raw);
if (!normalizedBody) {
return undefined;
}
return `telegram:${normalizedBody}`.toLowerCase();
}
export function looksLikeTelegramTargetId(raw: string): boolean {
return normalizeTelegramTargetBody(raw) !== undefined;
}
export * from "../../../../extensions/telegram/src/normalize.js";

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { normalizeTelegramAllowFromInput, parseTelegramAllowFromId } from "./telegram.js";
describe("normalizeTelegramAllowFromInput", () => {
it("strips telegram/tg prefixes and trims whitespace", () => {
expect(normalizeTelegramAllowFromInput(" telegram:123 ")).toBe("123");
expect(normalizeTelegramAllowFromInput("tg:@alice")).toBe("@alice");
expect(normalizeTelegramAllowFromInput(" @bob ")).toBe("@bob");
});
});
describe("parseTelegramAllowFromId", () => {
it("accepts numeric ids with optional prefixes", () => {
expect(parseTelegramAllowFromId("12345")).toBe("12345");
expect(parseTelegramAllowFromId("telegram:98765")).toBe("98765");
expect(parseTelegramAllowFromId("tg:2468")).toBe("2468");
});
it("rejects non-numeric values", () => {
expect(parseTelegramAllowFromId("@alice")).toBeNull();
expect(parseTelegramAllowFromId("tg:alice")).toBeNull();
});
});

View File

@@ -1,243 +1 @@
import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { inspectTelegramAccount } from "../../../telegram/account-inspect.js";
import {
listTelegramAccountIds,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "../../../telegram/accounts.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import { fetchTelegramChatId } from "../../telegram/api.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import {
applySingleTokenPromptResult,
patchChannelConfigForAccount,
promptResolvedAllowFrom,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
runSingleChannelSecretStep,
setChannelDmPolicyWithAllowFrom,
setOnboardingChannelEnabled,
splitOnboardingEntries,
} from "./helpers.js";
const channel = "telegram" as const;
async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Open Telegram and chat with @BotFather",
"2) Run /newbot (or /mybots)",
"3) Copy the token (looks like 123456:ABC...)",
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
`Docs: ${formatDocsLink("/telegram")}`,
"Website: https://openclaw.ai",
].join("\n"),
"Telegram bot token",
);
}
async function noteTelegramUserIdHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
`1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`,
"2) Or call https://api.telegram.org/bot<bot_token>/getUpdates and read message.from.id",
"3) Third-party: DM @userinfobot or @getidsbot",
`Docs: ${formatDocsLink("/telegram")}`,
"Website: https://openclaw.ai",
].join("\n"),
"Telegram user id",
);
}
export function normalizeTelegramAllowFromInput(raw: string): string {
return raw
.trim()
.replace(/^(telegram|tg):/i, "")
.trim();
}
export function parseTelegramAllowFromId(raw: string): string | null {
const stripped = normalizeTelegramAllowFromInput(raw);
return /^\d+$/.test(stripped) ? stripped : null;
}
async function promptTelegramAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId: string;
tokenOverride?: string;
}): Promise<OpenClawConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveTelegramAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
await noteTelegramUserIdHelp(prompter);
const token = params.tokenOverride?.trim() || resolved.token;
if (!token) {
await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram");
}
const unique = await promptResolvedAllowFrom({
prompter,
existing: existingAllowFrom,
token,
message: "Telegram allowFrom (numeric sender id; @username resolves to id)",
placeholder: "@username",
label: "Telegram allowlist",
parseInputs: splitOnboardingEntries,
parseId: parseTelegramAllowFromId,
invalidWithoutTokenNote:
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
resolveEntries: async ({ token: tokenValue, entries }) => {
const results = await Promise.all(
entries.map(async (entry) => {
const numericId = parseTelegramAllowFromId(entry);
if (numericId) {
return { input: entry, resolved: true, id: numericId };
}
const stripped = normalizeTelegramAllowFromInput(entry);
if (!stripped) {
return { input: entry, resolved: false, id: null };
}
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const id = await fetchTelegramChatId({ token: tokenValue, chatId: username });
return { input: entry, resolved: Boolean(id), id };
}),
);
return results;
},
});
return patchChannelConfigForAccount({
cfg,
channel: "telegram",
accountId,
patch: { dmPolicy: "allowlist", allowFrom: unique },
});
}
async function promptTelegramAllowFromForAccount(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveOnboardingAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultTelegramAccountId(params.cfg),
});
return promptTelegramAllowFrom({
cfg: params.cfg,
prompter: params.prompter,
accountId,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Telegram",
channel,
policyKey: "channels.telegram.dmPolicy",
allowFromKey: "channels.telegram.allowFrom",
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel: "telegram",
dmPolicy: policy,
}),
promptAllowFrom: promptTelegramAllowFromForAccount,
};
export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listTelegramAccountIds(cfg).some((accountId) => {
const account = inspectTelegramAccount({ cfg, accountId });
return account.configured;
});
return {
channel,
configured,
statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`],
selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly",
quickstartScore: configured ? 1 : 10,
};
},
configure: async ({
cfg,
prompter,
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
const telegramAccountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "Telegram",
accountOverride: accountOverrides.telegram,
shouldPromptAccountIds,
listAccountIds: listTelegramAccountIds,
defaultAccountId: defaultTelegramAccountId,
});
let next = cfg;
const resolvedAccount = resolveTelegramAccount({
cfg: next,
accountId: telegramAccountId,
});
const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken);
const hasConfigToken =
hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim());
const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID;
const tokenStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "telegram",
credentialLabel: "Telegram bot token",
secretInputMode: options?.secretInputMode,
accountConfigured: Boolean(resolvedAccount.token) || hasConfigToken,
hasConfigToken,
allowEnv,
envValue: process.env.TELEGRAM_BOT_TOKEN,
envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?",
keepPrompt: "Telegram token already configured. Keep it?",
inputPrompt: "Enter Telegram bot token",
preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined,
onMissingConfigured: async () => await noteTelegramTokenHelp(prompter),
applyUseEnv: async (cfg) =>
applySingleTokenPromptResult({
cfg,
channel: "telegram",
accountId: telegramAccountId,
tokenPatchKey: "botToken",
tokenResult: { useEnv: true, token: null },
}),
applySet: async (cfg, value) =>
applySingleTokenPromptResult({
cfg,
channel: "telegram",
accountId: telegramAccountId,
tokenPatchKey: "botToken",
tokenResult: { useEnv: false, token: value },
}),
});
next = tokenStep.cfg;
if (forceAllowFrom) {
next = await promptTelegramAllowFrom({
cfg: next,
prompter,
accountId: telegramAccountId,
tokenOverride: tokenStep.resolvedValue,
});
}
return { cfg: next, accountId: telegramAccountId };
},
dmPolicy,
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
};
export * from "../../../../extensions/telegram/src/onboarding.js";

View File

@@ -1,142 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { telegramOutbound } from "./telegram.js";
describe("telegramOutbound", () => {
it("passes parsed reply/thread ids for sendText", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-text-1", chatId: "123" });
const sendText = telegramOutbound.sendText;
expect(sendText).toBeDefined();
const result = await sendText!({
cfg: {},
to: "123",
text: "<b>hello</b>",
accountId: "work",
replyToId: "44",
threadId: "55",
deps: { telegram: sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledWith(
"123",
"<b>hello</b>",
expect.objectContaining({
textMode: "html",
verbose: false,
accountId: "work",
replyToMessageId: 44,
messageThreadId: 55,
}),
);
expect(result).toEqual({ channel: "telegram", messageId: "tg-text-1", chatId: "123" });
});
it("parses scoped DM thread ids for sendText", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-text-2", chatId: "12345" });
const sendText = telegramOutbound.sendText;
expect(sendText).toBeDefined();
await sendText!({
cfg: {},
to: "12345",
text: "<b>hello</b>",
accountId: "work",
threadId: "12345:99",
deps: { telegram: sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledWith(
"12345",
"<b>hello</b>",
expect.objectContaining({
textMode: "html",
verbose: false,
accountId: "work",
messageThreadId: 99,
}),
);
});
it("passes media options for sendMedia", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-media-1", chatId: "123" });
const sendMedia = telegramOutbound.sendMedia;
expect(sendMedia).toBeDefined();
const result = await sendMedia!({
cfg: {},
to: "123",
text: "caption",
mediaUrl: "https://example.com/a.jpg",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
deps: { telegram: sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledWith(
"123",
"caption",
expect.objectContaining({
textMode: "html",
verbose: false,
mediaUrl: "https://example.com/a.jpg",
mediaLocalRoots: ["/tmp/media"],
}),
);
expect(result).toEqual({ channel: "telegram", messageId: "tg-media-1", chatId: "123" });
});
it("sends payload media list and applies buttons only to first message", async () => {
const sendTelegram = vi
.fn()
.mockResolvedValueOnce({ messageId: "tg-1", chatId: "123" })
.mockResolvedValueOnce({ messageId: "tg-2", chatId: "123" });
const sendPayload = telegramOutbound.sendPayload;
expect(sendPayload).toBeDefined();
const payload: ReplyPayload = {
text: "caption",
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
channelData: {
telegram: {
quoteText: "quoted",
buttons: [[{ text: "Approve", callback_data: "ok" }]],
},
},
};
const result = await sendPayload!({
cfg: {},
to: "123",
text: "",
payload,
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
deps: { telegram: sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledTimes(2);
expect(sendTelegram).toHaveBeenNthCalledWith(
1,
"123",
"caption",
expect.objectContaining({
mediaUrl: "https://example.com/1.jpg",
quoteText: "quoted",
buttons: [[{ text: "Approve", callback_data: "ok" }]],
}),
);
expect(sendTelegram).toHaveBeenNthCalledWith(
2,
"123",
"",
expect.objectContaining({
mediaUrl: "https://example.com/2.jpg",
quoteText: "quoted",
}),
);
const secondCallOpts = sendTelegram.mock.calls[1]?.[2] as Record<string, unknown>;
expect(secondCallOpts?.buttons).toBeUndefined();
expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "123" });
});
});

View File

@@ -1,159 +1 @@
import type { ReplyPayload } from "../../../auto-reply/types.js";
import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js";
import type { TelegramInlineButtons } from "../../../telegram/button-types.js";
import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
import {
parseTelegramReplyToMessageId,
parseTelegramThreadId,
} from "../../../telegram/outbound-params.js";
import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
import { resolvePayloadMediaUrls, sendPayloadMediaSequence } from "./direct-text-media.js";
type TelegramSendFn = typeof sendMessageTelegram;
type TelegramSendOpts = Parameters<TelegramSendFn>[2];
function resolveTelegramSendContext(params: {
cfg: NonNullable<TelegramSendOpts>["cfg"];
deps?: OutboundSendDeps;
accountId?: string | null;
replyToId?: string | null;
threadId?: string | number | null;
}): {
send: TelegramSendFn;
baseOpts: {
cfg: NonNullable<TelegramSendOpts>["cfg"];
verbose: false;
textMode: "html";
messageThreadId?: number;
replyToMessageId?: number;
accountId?: string;
};
} {
const send =
resolveOutboundSendDep<typeof sendMessageTelegram>(params.deps, "telegram") ??
sendMessageTelegram;
return {
send,
baseOpts: {
verbose: false,
textMode: "html",
cfg: params.cfg,
messageThreadId: parseTelegramThreadId(params.threadId),
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
accountId: params.accountId ?? undefined,
},
};
}
export async function sendTelegramPayloadMessages(params: {
send: TelegramSendFn;
to: string;
payload: ReplyPayload;
baseOpts: Omit<NonNullable<TelegramSendOpts>, "buttons" | "mediaUrl" | "quoteText">;
}): Promise<Awaited<ReturnType<TelegramSendFn>>> {
const telegramData = params.payload.channelData?.telegram as
| { buttons?: TelegramInlineButtons; quoteText?: string }
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const text = params.payload.text ?? "";
const mediaUrls = resolvePayloadMediaUrls(params.payload);
const payloadOpts = {
...params.baseOpts,
quoteText,
};
if (mediaUrls.length === 0) {
return await params.send(params.to, text, {
...payloadOpts,
buttons: telegramData?.buttons,
});
}
// Telegram allows reply_markup on media; attach buttons only to the first send.
const finalResult = await sendPayloadMediaSequence({
text,
mediaUrls,
send: async ({ text, mediaUrl, isFirst }) =>
await params.send(params.to, text, {
...payloadOpts,
mediaUrl,
...(isFirst ? { buttons: telegramData?.buttons } : {}),
}),
});
return finalResult ?? { messageId: "unknown", chatId: params.to };
}
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
const result = await send(to, text, {
...baseOpts,
});
return { channel: "telegram", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
}) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
const result = await send(to, text, {
...baseOpts,
mediaUrl,
mediaLocalRoots,
});
return { channel: "telegram", ...result };
},
sendPayload: async ({
cfg,
to,
payload,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
}) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
const result = await sendTelegramPayloadMessages({
send,
to,
payload,
baseOpts: {
...baseOpts,
mediaLocalRoots,
},
});
return { channel: "telegram", ...result };
},
};
export * from "../../../../extensions/telegram/src/outbound-adapter.js";

View File

@@ -1,145 +1 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import {
appendMatchMetadata,
asString,
isRecord,
resolveEnabledConfiguredAccountId,
} from "./shared.js";
type TelegramAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
allowUnmentionedGroups?: unknown;
audit?: unknown;
};
type TelegramGroupMembershipAuditSummary = {
unresolvedGroups?: number;
hasWildcardUnmentionedGroups?: boolean;
groups?: Array<{
chatId: string;
ok?: boolean;
status?: string | null;
error?: string | null;
matchKey?: string;
matchSource?: string;
}>;
};
function readTelegramAccountStatus(value: ChannelAccountSnapshot): TelegramAccountStatus | null {
if (!isRecord(value)) {
return null;
}
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
allowUnmentionedGroups: value.allowUnmentionedGroups,
audit: value.audit,
};
}
function readTelegramGroupMembershipAuditSummary(
value: unknown,
): TelegramGroupMembershipAuditSummary {
if (!isRecord(value)) {
return {};
}
const unresolvedGroups =
typeof value.unresolvedGroups === "number" && Number.isFinite(value.unresolvedGroups)
? value.unresolvedGroups
: undefined;
const hasWildcardUnmentionedGroups =
typeof value.hasWildcardUnmentionedGroups === "boolean"
? value.hasWildcardUnmentionedGroups
: undefined;
const groupsRaw = value.groups;
const groups = Array.isArray(groupsRaw)
? (groupsRaw
.map((entry) => {
if (!isRecord(entry)) {
return null;
}
const chatId = asString(entry.chatId);
if (!chatId) {
return null;
}
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
const status = asString(entry.status) ?? null;
const error = asString(entry.error) ?? null;
const matchKey = asString(entry.matchKey) ?? undefined;
const matchSource = asString(entry.matchSource) ?? undefined;
return { chatId, ok, status, error, matchKey, matchSource };
})
.filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"])
: undefined;
return { unresolvedGroups, hasWildcardUnmentionedGroups, groups };
}
export function collectTelegramStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readTelegramAccountStatus(entry);
if (!account) {
continue;
}
const accountId = resolveEnabledConfiguredAccountId(account);
if (!accountId) {
continue;
}
if (account.allowUnmentionedGroups === true) {
issues.push({
channel: "telegram",
accountId,
kind: "config",
message:
"Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.",
fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).",
});
}
const audit = readTelegramGroupMembershipAuditSummary(account.audit);
if (audit.hasWildcardUnmentionedGroups === true) {
issues.push({
channel: "telegram",
accountId,
kind: "config",
message:
'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.',
fix: "Add explicit numeric group ids under channels.telegram.groups (or per-account groups) to enable probing.",
});
}
if (audit.unresolvedGroups && audit.unresolvedGroups > 0) {
issues.push({
channel: "telegram",
accountId,
kind: "config",
message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`,
fix: "Use numeric chat IDs (e.g. -100...) as keys in channels.telegram.groups for requireMention=false groups.",
});
}
for (const group of audit.groups ?? []) {
if (group.ok === true) {
continue;
}
const status = group.status ? ` status=${group.status}` : "";
const err = group.error ? `: ${group.error}` : "";
const baseMessage = `Group ${group.chatId} not reachable by bot.${status}${err}`;
issues.push({
channel: "telegram",
accountId,
kind: "runtime",
message: appendMatchMetadata(baseMessage, {
matchKey: group.matchKey,
matchSource: group.matchSource,
}),
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
});
}
}
return issues;
}
export * from "../../../../extensions/telegram/src/status-issues.js";

View File

@@ -1,107 +1,2 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withEnv } from "../test-utils/env.js";
import { inspectTelegramAccount } from "./account-inspect.js";
describe("inspectTelegramAccount SecretRef resolution", () => {
it("resolves default env SecretRef templates in read-only status paths", () => {
withEnv({ TG_STATUS_TOKEN: "123:token" }, () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
botToken: "${TG_STATUS_TOKEN}",
},
},
};
const account = inspectTelegramAccount({ cfg, accountId: "default" });
expect(account.tokenSource).toBe("env");
expect(account.tokenStatus).toBe("available");
expect(account.token).toBe("123:token");
});
});
it("respects env provider allowlists in read-only status paths", () => {
withEnv({ TG_NOT_ALLOWED: "123:token" }, () => {
const cfg: OpenClawConfig = {
secrets: {
defaults: {
env: "secure-env",
},
providers: {
"secure-env": {
source: "env",
allowlist: ["TG_ALLOWED"],
},
},
},
channels: {
telegram: {
botToken: "${TG_NOT_ALLOWED}",
},
},
};
const account = inspectTelegramAccount({ cfg, accountId: "default" });
expect(account.tokenSource).toBe("env");
expect(account.tokenStatus).toBe("configured_unavailable");
expect(account.token).toBe("");
});
});
it("does not read env values for non-env providers", () => {
withEnv({ TG_EXEC_PROVIDER: "123:token" }, () => {
const cfg: OpenClawConfig = {
secrets: {
defaults: {
env: "exec-provider",
},
providers: {
"exec-provider": {
source: "exec",
command: "/usr/bin/env",
},
},
},
channels: {
telegram: {
botToken: "${TG_EXEC_PROVIDER}",
},
},
};
const account = inspectTelegramAccount({ cfg, accountId: "default" });
expect(account.tokenSource).toBe("env");
expect(account.tokenStatus).toBe("configured_unavailable");
expect(account.token).toBe("");
});
});
it.runIf(process.platform !== "win32")(
"treats symlinked token files as configured_unavailable",
() => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-inspect-"));
const tokenFile = path.join(dir, "token.txt");
const tokenLink = path.join(dir, "token-link.txt");
fs.writeFileSync(tokenFile, "123:token\n", "utf8");
fs.symlinkSync(tokenFile, tokenLink);
const cfg: OpenClawConfig = {
channels: {
telegram: {
tokenFile: tokenLink,
},
},
};
const account = inspectTelegramAccount({ cfg, accountId: "default" });
expect(account.tokenSource).toBe("tokenFile");
expect(account.tokenStatus).toBe("configured_unavailable");
expect(account.token).toBe("");
fs.rmSync(dir, { recursive: true, force: true });
},
);
});
// Shim: re-exports from extensions/telegram/src/account-inspect.test.ts
export * from "../../extensions/telegram/src/account-inspect.test.js";

View File

@@ -1,232 +1 @@
import type { OpenClawConfig } from "../config/config.js";
import {
coerceSecretRef,
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../config/types.secrets.js";
import type { TelegramAccountConfig } from "../config/types.telegram.js";
import { tryReadSecretFileSync } from "../infra/secret-file.js";
import { resolveAccountWithDefaultFallback } from "../plugin-sdk/account-resolution.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
import {
mergeTelegramAccountConfig,
resolveDefaultTelegramAccountId,
resolveTelegramAccountConfig,
} from "./accounts.js";
export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing";
export type InspectedTelegramAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "env" | "tokenFile" | "config" | "none";
tokenStatus: TelegramCredentialStatus;
configured: boolean;
config: TelegramAccountConfig;
};
function inspectTokenFile(pathValue: unknown): {
token: string;
tokenSource: "tokenFile" | "none";
tokenStatus: TelegramCredentialStatus;
} | null {
const tokenFile = typeof pathValue === "string" ? pathValue.trim() : "";
if (!tokenFile) {
return null;
}
const token = tryReadSecretFileSync(tokenFile, "Telegram bot token", {
rejectSymlink: true,
});
return {
token: token ?? "",
tokenSource: "tokenFile",
tokenStatus: token ? "available" : "configured_unavailable",
};
}
function canResolveEnvSecretRefInReadOnlyPath(params: {
cfg: OpenClawConfig;
provider: string;
id: string;
}): boolean {
const providerConfig = params.cfg.secrets?.providers?.[params.provider];
if (!providerConfig) {
return params.provider === resolveDefaultSecretProviderAlias(params.cfg, "env");
}
if (providerConfig.source !== "env") {
return false;
}
const allowlist = providerConfig.allowlist;
return !allowlist || allowlist.includes(params.id);
}
function inspectTokenValue(params: { cfg: OpenClawConfig; value: unknown }): {
token: string;
tokenSource: "config" | "env" | "none";
tokenStatus: TelegramCredentialStatus;
} | null {
// Try to resolve env-based SecretRefs from process.env for read-only inspection
const ref = coerceSecretRef(params.value, params.cfg.secrets?.defaults);
if (ref?.source === "env") {
if (
!canResolveEnvSecretRefInReadOnlyPath({
cfg: params.cfg,
provider: ref.provider,
id: ref.id,
})
) {
return {
token: "",
tokenSource: "env",
tokenStatus: "configured_unavailable",
};
}
const envValue = process.env[ref.id];
if (envValue && envValue.trim()) {
return {
token: envValue.trim(),
tokenSource: "env",
tokenStatus: "available",
};
}
return {
token: "",
tokenSource: "env",
tokenStatus: "configured_unavailable",
};
}
const token = normalizeSecretInputString(params.value);
if (token) {
return {
token,
tokenSource: "config",
tokenStatus: "available",
};
}
if (hasConfiguredSecretInput(params.value, params.cfg.secrets?.defaults)) {
return {
token: "",
tokenSource: "config",
tokenStatus: "configured_unavailable",
};
}
return null;
}
function inspectTelegramAccountPrimary(params: {
cfg: OpenClawConfig;
accountId: string;
envToken?: string | null;
}): InspectedTelegramAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = mergeTelegramAccountConfig(params.cfg, accountId);
const enabled = params.cfg.channels?.telegram?.enabled !== false && merged.enabled !== false;
const accountConfig = resolveTelegramAccountConfig(params.cfg, accountId);
const accountTokenFile = inspectTokenFile(accountConfig?.tokenFile);
if (accountTokenFile) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: accountTokenFile.token,
tokenSource: accountTokenFile.tokenSource,
tokenStatus: accountTokenFile.tokenStatus,
configured: accountTokenFile.tokenStatus !== "missing",
config: merged,
};
}
const accountToken = inspectTokenValue({ cfg: params.cfg, value: accountConfig?.botToken });
if (accountToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: accountToken.token,
tokenSource: accountToken.tokenSource,
tokenStatus: accountToken.tokenStatus,
configured: accountToken.tokenStatus !== "missing",
config: merged,
};
}
const channelTokenFile = inspectTokenFile(params.cfg.channels?.telegram?.tokenFile);
if (channelTokenFile) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: channelTokenFile.token,
tokenSource: channelTokenFile.tokenSource,
tokenStatus: channelTokenFile.tokenStatus,
configured: channelTokenFile.tokenStatus !== "missing",
config: merged,
};
}
const channelToken = inspectTokenValue({
cfg: params.cfg,
value: params.cfg.channels?.telegram?.botToken,
});
if (channelToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: channelToken.token,
tokenSource: channelToken.tokenSource,
tokenStatus: channelToken.tokenStatus,
configured: channelToken.tokenStatus !== "missing",
config: merged,
};
}
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv ? (params.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() : "";
if (envToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: envToken,
tokenSource: "env",
tokenStatus: "available",
configured: true,
config: merged,
};
}
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config: merged,
};
}
export function inspectTelegramAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
envToken?: string | null;
}): InspectedTelegramAccount {
return resolveAccountWithDefaultFallback({
accountId: params.accountId,
normalizeAccountId,
resolvePrimary: (accountId) =>
inspectTelegramAccountPrimary({
cfg: params.cfg,
accountId,
envToken: params.envToken,
}),
hasCredential: (account) => account.tokenSource !== "none",
resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg),
});
}
export * from "../../extensions/telegram/src/account-inspect.js";

View File

@@ -1,416 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withEnv } from "../test-utils/env.js";
import {
listTelegramAccountIds,
resetMissingDefaultWarnFlag,
resolveTelegramPollActionGateState,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "./accounts.js";
const { warnMock } = vi.hoisted(() => ({
warnMock: vi.fn(),
}));
function warningLines(): string[] {
return warnMock.mock.calls.map(([line]) => String(line));
}
function expectNoMissingDefaultWarning() {
expect(warningLines().every((line) => !line.includes("accounts.default is missing"))).toBe(true);
}
function resolveAccountWithEnv(
env: Record<string, string>,
cfg: OpenClawConfig,
accountId?: string,
) {
return withEnv(env, () => resolveTelegramAccount({ cfg, ...(accountId ? { accountId } : {}) }));
}
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: () => {
const logger = {
warn: warnMock,
child: () => logger,
};
return logger;
},
}));
describe("resolveTelegramAccount", () => {
afterEach(() => {
warnMock.mockClear();
resetMissingDefaultWarnFlag();
});
it("falls back to the first configured account when accountId is omitted", () => {
const account = resolveAccountWithEnv(
{ TELEGRAM_BOT_TOKEN: "" },
{
channels: {
telegram: { accounts: { work: { botToken: "tok-work" } } },
},
},
);
expect(account.accountId).toBe("work");
expect(account.token).toBe("tok-work");
expect(account.tokenSource).toBe("config");
});
it("uses TELEGRAM_BOT_TOKEN when default account config is missing", () => {
const account = resolveAccountWithEnv(
{ TELEGRAM_BOT_TOKEN: "tok-env" },
{
channels: {
telegram: { accounts: { work: { botToken: "tok-work" } } },
},
},
);
expect(account.accountId).toBe("default");
expect(account.token).toBe("tok-env");
expect(account.tokenSource).toBe("env");
});
it("prefers default config token over TELEGRAM_BOT_TOKEN", () => {
const account = resolveAccountWithEnv(
{ TELEGRAM_BOT_TOKEN: "tok-env" },
{
channels: {
telegram: { botToken: "tok-config" },
},
},
);
expect(account.accountId).toBe("default");
expect(account.token).toBe("tok-config");
expect(account.tokenSource).toBe("config");
});
it("does not fall back when accountId is explicitly provided", () => {
const account = resolveAccountWithEnv(
{ TELEGRAM_BOT_TOKEN: "" },
{
channels: {
telegram: { accounts: { work: { botToken: "tok-work" } } },
},
},
"default",
);
expect(account.accountId).toBe("default");
expect(account.tokenSource).toBe("none");
expect(account.token).toBe("");
});
it("formats debug logs with inspect-style output when debug env is enabled", () => {
withEnv({ TELEGRAM_BOT_TOKEN: "", OPENCLAW_DEBUG_TELEGRAM_ACCOUNTS: "1" }, () => {
const cfg: OpenClawConfig = {
channels: {
telegram: { accounts: { work: { botToken: "tok-work" } } },
},
};
expect(listTelegramAccountIds(cfg)).toEqual(["work"]);
resolveTelegramAccount({ cfg, accountId: "work" });
});
const lines = warnMock.mock.calls.map(([line]) => String(line));
expect(lines).toContain("listTelegramAccountIds [ 'work' ]");
expect(lines).toContain("resolve { accountId: 'work', enabled: true, tokenSource: 'config' }");
});
});
describe("resolveDefaultTelegramAccountId", () => {
beforeEach(() => {
resetMissingDefaultWarnFlag();
});
afterEach(() => {
warnMock.mockClear();
resetMissingDefaultWarnFlag();
});
it("warns when accounts.default is missing in multi-account setup (#32137)", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: { work: { botToken: "tok-work" }, alerts: { botToken: "tok-alerts" } },
},
},
};
const result = resolveDefaultTelegramAccountId(cfg);
expect(result).toBe("alerts");
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining("accounts.default is missing"));
});
it("does not warn when accounts.default exists", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
},
},
};
resolveDefaultTelegramAccountId(cfg);
expectNoMissingDefaultWarning();
});
it("does not warn when defaultAccount is explicitly set", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "work",
accounts: { work: { botToken: "tok-work" } },
},
},
};
resolveDefaultTelegramAccountId(cfg);
expectNoMissingDefaultWarning();
});
it("does not warn when only one non-default account is configured", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: { work: { botToken: "tok-work" } },
},
},
};
resolveDefaultTelegramAccountId(cfg);
expectNoMissingDefaultWarning();
});
it("warns only once per process lifetime", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: { work: { botToken: "tok-work" }, alerts: { botToken: "tok-alerts" } },
},
},
};
resolveDefaultTelegramAccountId(cfg);
resolveDefaultTelegramAccountId(cfg);
resolveDefaultTelegramAccountId(cfg);
const missingDefaultWarns = warningLines().filter((line) =>
line.includes("accounts.default is missing"),
);
expect(missingDefaultWarns).toHaveLength(1);
});
it("prefers channels.telegram.defaultAccount when it matches a configured account", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "work",
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
},
},
};
expect(resolveDefaultTelegramAccountId(cfg)).toBe("work");
});
it("normalizes channels.telegram.defaultAccount before lookup", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "Router D",
accounts: { "router-d": { botToken: "tok-work" } },
},
},
};
expect(resolveDefaultTelegramAccountId(cfg)).toBe("router-d");
});
it("falls back when channels.telegram.defaultAccount is not configured", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "missing",
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
},
},
};
expect(resolveDefaultTelegramAccountId(cfg)).toBe("default");
});
});
describe("resolveTelegramAccount allowFrom precedence", () => {
it("prefers accounts.default allowlists over top-level for default account", () => {
const resolved = resolveTelegramAccount({
cfg: {
channels: {
telegram: {
allowFrom: ["top"],
groupAllowFrom: ["top-group"],
accounts: {
default: {
botToken: "123:default",
allowFrom: ["default"],
groupAllowFrom: ["default-group"],
},
},
},
},
},
accountId: "default",
});
expect(resolved.config.allowFrom).toEqual(["default"]);
expect(resolved.config.groupAllowFrom).toEqual(["default-group"]);
});
it("falls back to top-level allowlists for named account without overrides", () => {
const resolved = resolveTelegramAccount({
cfg: {
channels: {
telegram: {
allowFrom: ["top"],
groupAllowFrom: ["top-group"],
accounts: {
work: { botToken: "123:work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.allowFrom).toEqual(["top"]);
expect(resolved.config.groupAllowFrom).toEqual(["top-group"]);
});
it("does not inherit default account allowlists for named account when top-level is absent", () => {
const resolved = resolveTelegramAccount({
cfg: {
channels: {
telegram: {
accounts: {
default: {
botToken: "123:default",
allowFrom: ["default"],
groupAllowFrom: ["default-group"],
},
work: { botToken: "123:work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.allowFrom).toBeUndefined();
expect(resolved.config.groupAllowFrom).toBeUndefined();
});
});
describe("resolveTelegramPollActionGateState", () => {
it("requires both sendMessage and poll actions", () => {
const state = resolveTelegramPollActionGateState((key) => key !== "poll");
expect(state).toEqual({
sendMessageEnabled: true,
pollEnabled: false,
enabled: false,
});
});
it("returns enabled only when both actions are enabled", () => {
const state = resolveTelegramPollActionGateState(() => true);
expect(state).toEqual({
sendMessageEnabled: true,
pollEnabled: true,
enabled: true,
});
});
});
describe("resolveTelegramAccount groups inheritance (#30673)", () => {
const createMultiAccountGroupsConfig = (): OpenClawConfig => ({
channels: {
telegram: {
groups: { "-100123": { requireMention: false } },
accounts: {
default: { botToken: "123:default" },
dev: { botToken: "456:dev" },
},
},
},
});
const createDefaultAccountGroupsConfig = (includeDevAccount: boolean): OpenClawConfig => ({
channels: {
telegram: {
groups: { "-100999": { requireMention: true } },
accounts: {
default: {
botToken: "123:default",
groups: { "-100123": { requireMention: false } },
},
...(includeDevAccount ? { dev: { botToken: "456:dev" } } : {}),
},
},
},
});
it("inherits channel-level groups in single-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: {
channels: {
telegram: {
groups: { "-100123": { requireMention: false } },
accounts: {
default: { botToken: "123:default" },
},
},
},
},
accountId: "default",
});
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
});
it("does NOT inherit channel-level groups to secondary account in multi-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: createMultiAccountGroupsConfig(),
accountId: "dev",
});
expect(resolved.config.groups).toBeUndefined();
});
it("does NOT inherit channel-level groups to default account in multi-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: createMultiAccountGroupsConfig(),
accountId: "default",
});
expect(resolved.config.groups).toBeUndefined();
});
it("uses account-level groups even in multi-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: createDefaultAccountGroupsConfig(true),
accountId: "default",
});
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
});
it("account-level groups takes priority over channel-level in single-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: createDefaultAccountGroupsConfig(false),
accountId: "default",
});
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
});
});

View File

@@ -1,208 +1 @@
import util from "node:util";
import { createAccountActionGate } from "../channels/plugins/account-action-gate.js";
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
resolveAccountWithDefaultFallback,
} from "../plugin-sdk/account-resolution.js";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js";
import { formatSetExplicitDefaultInstruction } from "../routing/default-account-warnings.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../routing/session-key.js";
import { resolveTelegramToken } from "./token.js";
const log = createSubsystemLogger("telegram/accounts");
function formatDebugArg(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value instanceof Error) {
return value.stack ?? value.message;
}
return util.inspect(value, { colors: false, depth: null, compact: true, breakLength: Infinity });
}
const debugAccounts = (...args: unknown[]) => {
if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_TELEGRAM_ACCOUNTS)) {
const parts = args.map((arg) => formatDebugArg(arg));
log.warn(parts.join(" ").trim());
}
};
export type ResolvedTelegramAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "env" | "tokenFile" | "config" | "none";
config: TelegramAccountConfig;
};
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
return listConfiguredAccountIdsFromSection({
accounts: cfg.channels?.telegram?.accounts,
normalizeAccountId,
});
}
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
const ids = Array.from(
new Set([...listConfiguredAccountIds(cfg), ...listBoundAccountIds(cfg, "telegram")]),
);
debugAccounts("listTelegramAccountIds", ids);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
let emittedMissingDefaultWarn = false;
/** @internal Reset the once-per-process warning flag. Exported for tests only. */
export function resetMissingDefaultWarnFlag(): void {
emittedMissingDefaultWarn = false;
}
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
if (boundDefault) {
return boundDefault;
}
const preferred = normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount);
if (
preferred &&
listTelegramAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listTelegramAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
if (ids.length > 1 && !emittedMissingDefaultWarn) {
emittedMissingDefaultWarn = true;
log.warn(
`channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` +
`${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`,
);
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function resolveTelegramAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): TelegramAccountConfig | undefined {
const normalized = normalizeAccountId(accountId);
return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized);
}
export function mergeTelegramAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): TelegramAccountConfig {
const {
accounts: _ignored,
defaultAccount: _ignoredDefaultAccount,
groups: channelGroups,
...base
} = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const account = resolveTelegramAccountConfig(cfg, accountId) ?? {};
// In multi-account setups, channel-level `groups` must NOT be inherited by
// accounts that don't have their own `groups` config. A bot that is not a
// member of a configured group will fail when handling group messages, and
// this failure disrupts message delivery for *all* accounts.
// Single-account setups keep backward compat: channel-level groups still
// applies when the account has no override.
// See: https://github.com/openclaw/openclaw/issues/30673
const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {});
const isMultiAccount = configuredAccountIds.length > 1;
const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups);
return { ...base, ...account, groups };
}
export function createTelegramActionGate(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean {
const accountId = normalizeAccountId(params.accountId);
return createAccountActionGate({
baseActions: params.cfg.channels?.telegram?.actions,
accountActions: resolveTelegramAccountConfig(params.cfg, accountId)?.actions,
});
}
export type TelegramPollActionGateState = {
sendMessageEnabled: boolean;
pollEnabled: boolean;
enabled: boolean;
};
export function resolveTelegramPollActionGateState(
isActionEnabled: (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean,
): TelegramPollActionGateState {
const sendMessageEnabled = isActionEnabled("sendMessage");
const pollEnabled = isActionEnabled("poll");
return {
sendMessageEnabled,
pollEnabled,
enabled: sendMessageEnabled && pollEnabled,
};
}
export function resolveTelegramAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedTelegramAccount {
const baseEnabled = params.cfg.channels?.telegram?.enabled !== false;
const resolve = (accountId: string) => {
const merged = mergeTelegramAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const tokenResolution = resolveTelegramToken(params.cfg, { accountId });
debugAccounts("resolve", {
accountId,
enabled,
tokenSource: tokenResolution.source,
});
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: tokenResolution.token,
tokenSource: tokenResolution.source,
config: merged,
} satisfies ResolvedTelegramAccount;
};
// If accountId is omitted, prefer a configured account token over failing on
// the implicit "default" account. This keeps env-based setups working while
// making config-only tokens work for things like heartbeats.
return resolveAccountWithDefaultFallback({
accountId: params.accountId,
normalizeAccountId,
resolvePrimary: resolve,
hasCredential: (account) => account.tokenSource !== "none",
resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg),
});
}
export function listEnabledTelegramAccounts(cfg: OpenClawConfig): ResolvedTelegramAccount[] {
return listTelegramAccountIds(cfg)
.map((accountId) => resolveTelegramAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
export * from "../../extensions/telegram/src/accounts.js";

View File

@@ -1,14 +1 @@
import { API_CONSTANTS } from "grammy";
type TelegramUpdateType = (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number];
export function resolveTelegramAllowedUpdates(): ReadonlyArray<TelegramUpdateType> {
const updates = [...API_CONSTANTS.DEFAULT_UPDATE_TYPES] as TelegramUpdateType[];
if (!updates.includes("message_reaction")) {
updates.push("message_reaction");
}
if (!updates.includes("channel_post")) {
updates.push("channel_post");
}
return updates;
}
export * from "../../extensions/telegram/src/allowed-updates.js";

View File

@@ -1,45 +1 @@
import { danger } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { RuntimeEnv } from "../runtime.js";
export type TelegramApiLogger = (message: string) => void;
type TelegramApiLoggingParams<T> = {
operation: string;
fn: () => Promise<T>;
runtime?: RuntimeEnv;
logger?: TelegramApiLogger;
shouldLog?: (err: unknown) => boolean;
};
const fallbackLogger = createSubsystemLogger("telegram/api");
function resolveTelegramApiLogger(runtime?: RuntimeEnv, logger?: TelegramApiLogger) {
if (logger) {
return logger;
}
if (runtime?.error) {
return runtime.error;
}
return (message: string) => fallbackLogger.error(message);
}
export async function withTelegramApiErrorLogging<T>({
operation,
fn,
runtime,
logger,
shouldLog,
}: TelegramApiLoggingParams<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (!shouldLog || shouldLog(err)) {
const errText = formatErrorMessage(err);
const log = resolveTelegramApiLogger(runtime, logger);
log(danger(`telegram ${operation} failed: ${errText}`));
}
throw err;
}
}
export * from "../../extensions/telegram/src/api-logging.js";

View File

@@ -1,18 +1,2 @@
import { describe, expect, it } from "vitest";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
describe("telegram approval buttons", () => {
it("builds allow-once/allow-always/deny buttons", () => {
expect(buildTelegramExecApprovalButtons("fbd8daf7")).toEqual([
[
{ text: "Allow Once", callback_data: "/approve fbd8daf7 allow-once" },
{ text: "Allow Always", callback_data: "/approve fbd8daf7 allow-always" },
],
[{ text: "Deny", callback_data: "/approve fbd8daf7 deny" }],
]);
});
it("skips buttons when callback_data exceeds Telegram limit", () => {
expect(buildTelegramExecApprovalButtons(`a${"b".repeat(60)}`)).toBeUndefined();
});
});
// Shim: re-exports from extensions/telegram/src/approval-buttons.test.ts
export * from "../../extensions/telegram/src/approval-buttons.test.js";

View File

@@ -1,42 +1,2 @@
import type { ExecApprovalReplyDecision } from "../infra/exec-approval-reply.js";
import type { TelegramInlineButtons } from "./button-types.js";
const MAX_CALLBACK_DATA_BYTES = 64;
function fitsCallbackData(value: string): boolean {
return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES;
}
export function buildTelegramExecApprovalButtons(
approvalId: string,
): TelegramInlineButtons | undefined {
return buildTelegramExecApprovalButtonsForDecisions(approvalId, [
"allow-once",
"allow-always",
"deny",
]);
}
function buildTelegramExecApprovalButtonsForDecisions(
approvalId: string,
allowedDecisions: readonly ExecApprovalReplyDecision[],
): TelegramInlineButtons | undefined {
const allowOnce = `/approve ${approvalId} allow-once`;
if (!allowedDecisions.includes("allow-once") || !fitsCallbackData(allowOnce)) {
return undefined;
}
const primaryRow: Array<{ text: string; callback_data: string }> = [
{ text: "Allow Once", callback_data: allowOnce },
];
const allowAlways = `/approve ${approvalId} allow-always`;
if (allowedDecisions.includes("allow-always") && fitsCallbackData(allowAlways)) {
primaryRow.push({ text: "Allow Always", callback_data: allowAlways });
}
const rows: Array<Array<{ text: string; callback_data: string }>> = [primaryRow];
const deny = `/approve ${approvalId} deny`;
if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) {
rows.push([{ text: "Deny", callback_data: deny }]);
}
return rows;
}
// Shim: re-exports from extensions/telegram/src/approval-buttons.ts
export * from "../../extensions/telegram/src/approval-buttons.js";

View File

@@ -1,76 +1 @@
import { isRecord } from "../utils.js";
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
import type {
AuditTelegramGroupMembershipParams,
TelegramGroupMembershipAudit,
TelegramGroupMembershipAuditEntry,
} from "./audit.js";
import { resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
const TELEGRAM_API_BASE = "https://api.telegram.org";
type TelegramApiOk<T> = { ok: true; result: T };
type TelegramApiErr = { ok: false; description?: string };
type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elapsedMs">;
export async function auditTelegramGroupMembershipImpl(
params: AuditTelegramGroupMembershipParams,
): Promise<TelegramGroupMembershipAuditData> {
const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined;
const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network });
const base = `${TELEGRAM_API_BASE}/bot${params.token}`;
const groups: TelegramGroupMembershipAuditEntry[] = [];
for (const chatId of params.groupIds) {
try {
const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`;
const res = await fetchWithTimeout(url, {}, params.timeoutMs, fetcher);
const json = (await res.json()) as TelegramApiOk<{ status?: string }> | TelegramApiErr;
if (!res.ok || !isRecord(json) || !json.ok) {
const desc =
isRecord(json) && !json.ok && typeof json.description === "string"
? json.description
: `getChatMember failed (${res.status})`;
groups.push({
chatId,
ok: false,
status: null,
error: desc,
matchKey: chatId,
matchSource: "id",
});
continue;
}
const status = isRecord((json as TelegramApiOk<unknown>).result)
? ((json as TelegramApiOk<{ status?: string }>).result.status ?? null)
: null;
const ok = status === "creator" || status === "administrator" || status === "member";
groups.push({
chatId,
ok,
status,
error: ok ? null : "bot not in group",
matchKey: chatId,
matchSource: "id",
});
} catch (err) {
groups.push({
chatId,
ok: false,
status: null,
error: err instanceof Error ? err.message : String(err),
matchKey: chatId,
matchSource: "id",
});
}
}
return {
ok: groups.every((g) => g.ok),
checkedGroups: groups.length,
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
groups,
};
}
export * from "../../extensions/telegram/src/audit-membership-runtime.js";

View File

@@ -1,71 +0,0 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds;
let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership;
const undiciFetch = vi.hoisted(() => vi.fn());
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: undiciFetch,
};
});
function mockGetChatMemberStatus(status: string) {
undiciFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, result: { status } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
async function auditSingleGroup() {
return auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
}
describe("telegram audit", () => {
beforeAll(async () => {
({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } =
await import("./audit.js"));
});
beforeEach(() => {
undiciFetch.mockReset();
});
it("collects unmentioned numeric group ids and flags wildcard", async () => {
const res = collectTelegramUnmentionedGroupIds({
"*": { requireMention: false },
"-1001": { requireMention: false },
"@group": { requireMention: false },
"-1002": { requireMention: true },
"-1003": { requireMention: false, enabled: false },
});
expect(res.hasWildcardUnmentionedGroups).toBe(true);
expect(res.groupIds).toEqual(["-1001"]);
expect(res.unresolvedGroups).toBe(1);
});
it("audits membership via getChatMember", async () => {
mockGetChatMemberStatus("member");
const res = await auditSingleGroup();
expect(res.ok).toBe(true);
expect(res.groups[0]?.chatId).toBe("-1001");
expect(res.groups[0]?.status).toBe("member");
});
it("reports bot not in group when status is left", async () => {
mockGetChatMemberStatus("left");
const res = await auditSingleGroup();
expect(res.ok).toBe(false);
expect(res.groups[0]?.ok).toBe(false);
expect(res.groups[0]?.status).toBe("left");
});
});

View File

@@ -1,107 +1 @@
import type { TelegramGroupConfig } from "../config/types.js";
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
export type TelegramGroupMembershipAuditEntry = {
chatId: string;
ok: boolean;
status?: string | null;
error?: string | null;
matchKey?: string;
matchSource?: "id";
};
export type TelegramGroupMembershipAudit = {
ok: boolean;
checkedGroups: number;
unresolvedGroups: number;
hasWildcardUnmentionedGroups: boolean;
groups: TelegramGroupMembershipAuditEntry[];
elapsedMs: number;
};
export function collectTelegramUnmentionedGroupIds(
groups: Record<string, TelegramGroupConfig> | undefined,
) {
if (!groups || typeof groups !== "object") {
return {
groupIds: [] as string[],
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
};
}
const hasWildcardUnmentionedGroups =
Boolean(groups["*"]?.requireMention === false) && groups["*"]?.enabled !== false;
const groupIds: string[] = [];
let unresolvedGroups = 0;
for (const [key, value] of Object.entries(groups)) {
if (key === "*") {
continue;
}
if (!value || typeof value !== "object") {
continue;
}
if (value.enabled === false) {
continue;
}
if (value.requireMention !== false) {
continue;
}
const id = String(key).trim();
if (!id) {
continue;
}
if (/^-?\d+$/.test(id)) {
groupIds.push(id);
} else {
unresolvedGroups += 1;
}
}
groupIds.sort((a, b) => a.localeCompare(b));
return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups };
}
export type AuditTelegramGroupMembershipParams = {
token: string;
botId: number;
groupIds: string[];
proxyUrl?: string;
network?: TelegramNetworkConfig;
timeoutMs: number;
};
let auditMembershipRuntimePromise: Promise<typeof import("./audit-membership-runtime.js")> | null =
null;
function loadAuditMembershipRuntime() {
auditMembershipRuntimePromise ??= import("./audit-membership-runtime.js");
return auditMembershipRuntimePromise;
}
export async function auditTelegramGroupMembership(
params: AuditTelegramGroupMembershipParams,
): Promise<TelegramGroupMembershipAudit> {
const started = Date.now();
const token = params.token?.trim() ?? "";
if (!token || params.groupIds.length === 0) {
return {
ok: true,
checkedGroups: 0,
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
groups: [],
elapsedMs: Date.now() - started,
};
}
// Lazy import to avoid pulling `undici` (ProxyAgent) into cold-path callers that only need
// `collectTelegramUnmentionedGroupIds` (e.g. config audits).
const { auditTelegramGroupMembershipImpl } = await loadAuditMembershipRuntime();
const result = await auditTelegramGroupMembershipImpl({
...params,
token,
});
return {
...result,
elapsedMs: Date.now() - started,
};
}
export * from "../../extensions/telegram/src/audit.js";

View File

@@ -1,15 +1,2 @@
import { describe, expect, it } from "vitest";
import { normalizeAllowFrom } from "./bot-access.js";
describe("normalizeAllowFrom", () => {
it("accepts sender IDs and keeps negative chat IDs invalid", () => {
const result = normalizeAllowFrom(["-1001234567890", " tg:-100999 ", "745123456", "@someone"]);
expect(result).toEqual({
entries: ["745123456"],
hasWildcard: false,
hasEntries: true,
invalidEntries: ["-1001234567890", "-100999", "@someone"],
});
});
});
// Shim: re-exports from extensions/telegram/src/bot-access.test.ts
export * from "../../extensions/telegram/src/bot-access.test.js";

View File

@@ -1,94 +1 @@
import {
firstDefined,
isSenderIdAllowed,
mergeDmAllowFromSources,
} from "../channels/allow-from.js";
import type { AllowlistMatch } from "../channels/allowlist-match.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
export type NormalizedAllowFrom = {
entries: string[];
hasWildcard: boolean;
hasEntries: boolean;
invalidEntries: string[];
};
export type AllowFromMatch = AllowlistMatch<"wildcard" | "id">;
const warnedInvalidEntries = new Set<string>();
const log = createSubsystemLogger("telegram/bot-access");
function warnInvalidAllowFromEntries(entries: string[]) {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return;
}
for (const entry of entries) {
if (warnedInvalidEntries.has(entry)) {
continue;
}
warnedInvalidEntries.add(entry);
log.warn(
[
"Invalid allowFrom entry:",
JSON.stringify(entry),
"- allowFrom/groupAllowFrom authorization expects numeric Telegram sender user IDs only.",
'To allow a Telegram group or supergroup, add its negative chat ID under "channels.telegram.groups" instead.',
'If you had "@username" entries, re-run onboarding (it resolves @username to IDs) or replace them manually.',
].join(" "),
);
}
}
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean);
const hasWildcard = entries.includes("*");
const normalized = entries
.filter((value) => value !== "*")
.map((value) => value.replace(/^(telegram|tg):/i, ""));
const invalidEntries = normalized.filter((value) => !/^\d+$/.test(value));
if (invalidEntries.length > 0) {
warnInvalidAllowFromEntries([...new Set(invalidEntries)]);
}
const ids = normalized.filter((value) => /^\d+$/.test(value));
return {
entries: ids,
hasWildcard,
hasEntries: entries.length > 0,
invalidEntries,
};
};
export const normalizeDmAllowFromWithStore = (params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: string[];
dmPolicy?: string;
}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params));
export const isSenderAllowed = (params: {
allow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
}) => {
const { allow, senderId } = params;
return isSenderIdAllowed(allow, senderId, true);
};
export { firstDefined };
export const resolveSenderAllowMatch = (params: {
allow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
}): AllowFromMatch => {
const { allow, senderId } = params;
if (allow.hasWildcard) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
if (!allow.hasEntries) {
return { allowed: false };
}
if (senderId && allow.entries.includes(senderId)) {
return { allowed: true, matchKey: senderId, matchSource: "id" };
}
return { allowed: false };
};
export * from "../../extensions/telegram/src/bot-access.js";

File diff suppressed because it is too large Load Diff

View File

@@ -1,136 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
vi.mock("../acp/persistent-bindings.js", () => ({
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
ensureConfiguredAcpBindingSessionMock(...args),
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
resolveConfiguredAcpBindingRecordMock(...args),
}));
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
function createConfiguredTelegramBinding() {
return {
spec: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:work:-1001234567890:topic:42",
targetSessionKey: "agent:codex:acp:binding:telegram:work:abc123",
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
metadata: {
source: "config",
mode: "persistent",
agentId: "codex",
},
},
} as const;
}
describe("buildTelegramMessageContext ACP configured bindings", () => {
beforeEach(() => {
ensureConfiguredAcpBindingSessionMock.mockReset();
resolveConfiguredAcpBindingRecordMock.mockReset();
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredTelegramBinding());
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
ok: true,
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
});
});
it("treats configured topic bindings as explicit route matches on non-default accounts", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
});
expect(ctx).not.toBeNull();
expect(ctx?.route.accountId).toBe("work");
expect(ctx?.route.matchedBy).toBe("binding.channel");
expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123");
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
});
it("skips ACP session initialization when topic access is denied", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: { enabled: false },
}),
});
expect(ctx).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
});
it("defers ACP session initialization for unauthorized control commands", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "/new",
},
cfg: {
channels: {
telegram: {},
},
commands: {
useAccessGroups: true,
},
},
});
expect(ctx).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
});
it("drops inbound processing when configured ACP binding initialization fails", async () => {
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
ok: false,
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
error: "gateway unavailable",
});
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
});
expect(ctx).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,153 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const transcribeFirstAudioMock = vi.fn();
const DEFAULT_MODEL = "anthropic/claude-opus-4-5";
const DEFAULT_WORKSPACE = "/tmp/openclaw";
const DEFAULT_MENTION_PATTERN = "\\bbot\\b";
vi.mock("../media-understanding/audio-preflight.js", () => ({
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
}));
async function buildGroupVoiceContext(params: {
messageId: number;
chatId: number;
title: string;
date: number;
fromId: number;
firstName: string;
fileId: string;
mediaPath: string;
groupDisableAudioPreflight?: boolean;
topicDisableAudioPreflight?: boolean;
}) {
const groupConfig = {
requireMention: true,
...(params.groupDisableAudioPreflight === undefined
? {}
: { disableAudioPreflight: params.groupDisableAudioPreflight }),
};
const topicConfig =
params.topicDisableAudioPreflight === undefined
? undefined
: { disableAudioPreflight: params.topicDisableAudioPreflight };
return buildTelegramMessageContextForTest({
message: {
message_id: params.messageId,
chat: { id: params.chatId, type: "supergroup", title: params.title },
date: params.date,
text: undefined,
from: { id: params.fromId, first_name: params.firstName },
voice: { file_id: params.fileId },
},
allMedia: [{ path: params.mediaPath, contentType: "audio/ogg" }],
options: { forceWasMentioned: true },
cfg: {
agents: { defaults: { model: DEFAULT_MODEL, workspace: DEFAULT_WORKSPACE } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [DEFAULT_MENTION_PATTERN] } },
},
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig,
topicConfig,
}),
});
}
function expectTranscriptRendered(
ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>,
transcript: string,
) {
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.BodyForAgent).toBe(transcript);
expect(ctx?.ctxPayload?.Body).toContain(transcript);
expect(ctx?.ctxPayload?.Body).not.toContain("<media:audio>");
}
function expectAudioPlaceholderRendered(ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>) {
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.Body).toContain("<media:audio>");
}
describe("buildTelegramMessageContext audio transcript body", () => {
it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
const ctx = await buildGroupVoiceContext({
messageId: 1,
chatId: -1001234567890,
title: "Test Group",
date: 1700000000,
fromId: 42,
firstName: "Alice",
fileId: "voice-1",
mediaPath: "/tmp/voice.ogg",
});
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expectTranscriptRendered(ctx, "hey bot please help");
});
it("skips preflight transcription when disableAudioPreflight is true", async () => {
transcribeFirstAudioMock.mockClear();
const ctx = await buildGroupVoiceContext({
messageId: 2,
chatId: -1001234567891,
title: "Test Group 2",
date: 1700000100,
fromId: 43,
firstName: "Bob",
fileId: "voice-2",
mediaPath: "/tmp/voice2.ogg",
groupDisableAudioPreflight: true,
});
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
expectAudioPlaceholderRendered(ctx);
});
it("uses topic disableAudioPreflight=false to override group disableAudioPreflight=true", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce("topic override transcript");
const ctx = await buildGroupVoiceContext({
messageId: 3,
chatId: -1001234567892,
title: "Test Group 3",
date: 1700000200,
fromId: 44,
firstName: "Cara",
fileId: "voice-3",
mediaPath: "/tmp/voice3.ogg",
groupDisableAudioPreflight: true,
topicDisableAudioPreflight: false,
});
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expectTranscriptRendered(ctx, "topic override transcript");
});
it("uses topic disableAudioPreflight=true to override group disableAudioPreflight=false", async () => {
transcribeFirstAudioMock.mockClear();
const ctx = await buildGroupVoiceContext({
messageId: 4,
chatId: -1001234567893,
title: "Test Group 4",
date: 1700000300,
fromId: 45,
firstName: "Dan",
fileId: "voice-4",
mediaPath: "/tmp/voice4.ogg",
groupDisableAudioPreflight: false,
topicDisableAudioPreflight: true,
});
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
expectAudioPlaceholderRendered(ctx);
});
});

View File

@@ -1,284 +1,2 @@
import {
findModelInCatalog,
loadModelCatalog,
modelSupportsVision,
} from "../agents/model-catalog.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js";
import type { MsgContext } from "../auto-reply/templating.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
import { formatLocationText, type NormalizedLocation } from "../channels/location.js";
import { logInboundDrop } from "../channels/logging.js";
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
import type { OpenClawConfig } from "../config/config.js";
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../config/types.js";
import { logVerbose } from "../globals.js";
import type { NormalizedAllowFrom } from "./bot-access.js";
import { isSenderAllowed } from "./bot-access.js";
import type {
TelegramLogger,
TelegramMediaRef,
TelegramMessageContextOptions,
} from "./bot-message-context.types.js";
import {
buildSenderLabel,
buildTelegramGroupPeerId,
expandTextLinks,
extractTelegramLocation,
getTelegramTextParts,
hasBotMention,
resolveTelegramMediaPlaceholder,
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { isTelegramForumServiceMessage } from "./forum-service-message.js";
export type TelegramInboundBodyResult = {
bodyText: string;
rawBody: string;
historyKey?: string;
commandAuthorized: boolean;
effectiveWasMentioned: boolean;
canDetectMention: boolean;
shouldBypassMention: boolean;
stickerCacheHit: boolean;
locationData?: NormalizedLocation;
};
async function resolveStickerVisionSupport(params: {
cfg: OpenClawConfig;
agentId?: string;
}): Promise<boolean> {
try {
const catalog = await loadModelCatalog({ config: params.cfg });
const defaultModel = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: params.agentId,
});
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
if (!entry) {
return false;
}
return modelSupportsVision(entry);
} catch {
return false;
}
}
export async function resolveTelegramInboundBody(params: {
cfg: OpenClawConfig;
primaryCtx: TelegramContext;
msg: TelegramContext["message"];
allMedia: TelegramMediaRef[];
isGroup: boolean;
chatId: number | string;
senderId: string;
senderUsername: string;
resolvedThreadId?: number;
routeAgentId?: string;
effectiveGroupAllow: NormalizedAllowFrom;
effectiveDmAllow: NormalizedAllowFrom;
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
requireMention?: boolean;
options?: TelegramMessageContextOptions;
groupHistories: Map<string, HistoryEntry[]>;
historyLimit: number;
logger: TelegramLogger;
}): Promise<TelegramInboundBodyResult | null> {
const {
cfg,
primaryCtx,
msg,
allMedia,
isGroup,
chatId,
senderId,
senderUsername,
resolvedThreadId,
routeAgentId,
effectiveGroupAllow,
effectiveDmAllow,
groupConfig,
topicConfig,
requireMention,
options,
groupHistories,
historyLimit,
logger,
} = params;
const botUsername = primaryCtx.me?.username?.toLowerCase();
const mentionRegexes = buildMentionRegexes(cfg, routeAgentId);
const messageTextParts = getTelegramTextParts(msg);
const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow;
const senderAllowedForCommands = isSenderAllowed({
allow: allowForCommands,
senderId,
senderUsername,
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const hasControlCommandInMessage = hasControlCommand(messageTextParts.text, cfg, {
botUsername,
});
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
let placeholder = resolveTelegramMediaPlaceholder(msg) ?? "";
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
const stickerSupportsVision = msg.sticker
? await resolveStickerVisionSupport({ cfg, agentId: routeAgentId })
: false;
const stickerCacheHit = Boolean(cachedStickerDescription) && !stickerSupportsVision;
if (stickerCacheHit) {
const emoji = allMedia[0]?.stickerMetadata?.emoji;
const setName = allMedia[0]?.stickerMetadata?.setName;
const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" ");
placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`;
}
const locationData = extractTelegramLocation(msg);
const locationText = locationData ? formatLocationText(locationData) : undefined;
const rawText = expandTextLinks(messageTextParts.text, messageTextParts.entities).trim();
const hasUserText = Boolean(rawText || locationText);
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
if (!rawBody) {
rawBody = placeholder;
}
if (!rawBody && allMedia.length === 0) {
return null;
}
let bodyText = rawBody;
const hasAudio = allMedia.some((media) => media.contentType?.startsWith("audio/"));
const disableAudioPreflight =
(topicConfig?.disableAudioPreflight ??
(groupConfig as TelegramGroupConfig | undefined)?.disableAudioPreflight) === true;
let preflightTranscript: string | undefined;
const needsPreflightTranscription =
isGroup &&
requireMention &&
hasAudio &&
!hasUserText &&
mentionRegexes.length > 0 &&
!disableAudioPreflight;
if (needsPreflightTranscription) {
try {
const { transcribeFirstAudio } = await import("../media-understanding/audio-preflight.js");
const tempCtx: MsgContext = {
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
MediaTypes:
allMedia.length > 0
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
: undefined,
};
preflightTranscript = await transcribeFirstAudio({
ctx: tempCtx,
cfg,
agentDir: undefined,
});
} catch (err) {
logVerbose(`telegram: audio preflight transcription failed: ${String(err)}`);
}
}
if (hasAudio && bodyText === "<media:audio>" && preflightTranscript) {
bodyText = preflightTranscript;
}
if (!bodyText && allMedia.length > 0) {
if (hasAudio) {
bodyText = preflightTranscript || "<media:audio>";
} else {
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
}
}
const hasAnyMention = messageTextParts.entities.some((ent) => ent.type === "mention");
const explicitlyMentioned = botUsername ? hasBotMention(msg, botUsername) : false;
const computedWasMentioned = matchesMentionWithExplicit({
text: messageTextParts.text,
mentionRegexes,
explicit: {
hasAnyMention,
isExplicitlyMentioned: explicitlyMentioned,
canResolveExplicit: Boolean(botUsername),
},
transcript: preflightTranscript,
});
const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned;
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: logVerbose,
channel: "telegram",
reason: "control command (unauthorized)",
target: senderId ?? "unknown",
});
return null;
}
const botId = primaryCtx.me?.id;
const replyFromId = msg.reply_to_message?.from?.id;
const replyToBotMessage = botId != null && replyFromId === botId;
const isReplyToServiceMessage =
replyToBotMessage && isTelegramForumServiceMessage(msg.reply_to_message);
const implicitMention = replyToBotMessage && !isReplyToServiceMessage;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGatingWithBypass({
isGroup,
requireMention: Boolean(requireMention),
canDetectMention,
wasMentioned,
implicitMention: isGroup && Boolean(requireMention) && implicitMention,
hasAnyMention,
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention && mentionGate.shouldSkip) {
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
recordPendingHistoryEntryIfEnabled({
historyMap: groupHistories,
historyKey: historyKey ?? "",
limit: historyLimit,
entry: historyKey
? {
sender: buildSenderLabel(msg, senderId || chatId),
body: rawBody,
timestamp: msg.date ? msg.date * 1000 : undefined,
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
}
: null,
});
return null;
}
return {
bodyText,
rawBody,
historyKey,
commandAuthorized,
effectiveWasMentioned,
canDetectMention,
shouldBypassMention: mentionGate.shouldBypassMention,
stickerCacheHit,
locationData: locationData ?? undefined,
};
}
// Shim: re-exports from extensions/telegram/src/bot-message-context.body.ts
export * from "../../extensions/telegram/src/bot-message-context.body.js";

View File

@@ -1,149 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
describe("buildTelegramMessageContext dm thread sessions", () => {
const buildContext = async (message: Record<string, unknown>) =>
await buildTelegramMessageContextForTest({
message,
});
it("uses thread session key for dm topics", async () => {
const ctx = await buildContext({
message_id: 1,
chat: { id: 1234, type: "private" },
date: 1700000000,
text: "hello",
message_thread_id: 42,
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageThreadId).toBe(42);
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42");
});
it("keeps legacy dm session key when no thread id", async () => {
const ctx = await buildContext({
message_id: 2,
chat: { id: 1234, type: "private" },
date: 1700000001,
text: "hello",
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
});
});
describe("buildTelegramMessageContext group sessions without forum", () => {
const buildContext = async (message: Record<string, unknown>) =>
await buildTelegramMessageContextForTest({
message,
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
});
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);
});
});
describe("buildTelegramMessageContext direct peer routing", () => {
afterEach(() => {
clearRuntimeConfigSnapshot();
});
it("isolates dm sessions by sender id when chat id differs", async () => {
const runtimeCfg = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
session: { dmScope: "per-channel-peer" as const },
};
setRuntimeConfigSnapshot(runtimeCfg);
const baseMessage = {
chat: { id: 777777777, type: "private" as const },
date: 1700000000,
text: "hello",
};
const first = await buildTelegramMessageContextForTest({
cfg: runtimeCfg,
message: {
...baseMessage,
message_id: 1,
from: { id: 123456789, first_name: "Alice" },
},
});
const second = await buildTelegramMessageContextForTest({
cfg: runtimeCfg,
message: {
...baseMessage,
message_id: 2,
from: { id: 987654321, first_name: "Bob" },
},
});
expect(first?.ctxPayload?.SessionKey).toBe("agent:main:telegram:direct:123456789");
expect(second?.ctxPayload?.SessionKey).toBe("agent:main:telegram:direct:987654321");
});
});

View File

@@ -1,84 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
// Mock recordInboundSession to capture updateLastRoute parameter
const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined);
vi.mock("../channels/session.js", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
}));
describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#8891)", () => {
async function buildCtx(params: {
message: Record<string, unknown>;
options?: Record<string, unknown>;
resolveGroupActivation?: () => boolean | undefined;
}) {
return await buildTelegramMessageContextForTest({
message: params.message,
options: params.options,
resolveGroupActivation: params.resolveGroupActivation,
});
}
function getUpdateLastRoute(): unknown {
const callArgs = recordInboundSessionMock.mock.calls[0]?.[0] as { updateLastRoute?: unknown };
return callArgs?.updateLastRoute;
}
beforeEach(() => {
recordInboundSessionMock.mockClear();
});
it("passes threadId to updateLastRoute for DM topics", async () => {
const ctx = await buildCtx({
message: {
chat: { id: 1234, type: "private" },
message_thread_id: 42, // DM Topic ID
},
});
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
// Check that updateLastRoute includes threadId
const updateLastRoute = getUpdateLastRoute() as { threadId?: string; to?: string } | undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe("telegram:1234");
expect(updateLastRoute?.threadId).toBe("42");
});
it("does not pass threadId for regular DM without topic", async () => {
const ctx = await buildCtx({
message: {
chat: { id: 1234, type: "private" },
},
});
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
// Check that updateLastRoute does NOT include threadId
const updateLastRoute = getUpdateLastRoute() as { threadId?: string; to?: string } | undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe("telegram:1234");
expect(updateLastRoute?.threadId).toBeUndefined();
});
it("does not set updateLastRoute for group messages", async () => {
const ctx = await buildCtx({
message: {
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
text: "@bot hello",
message_thread_id: 99,
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
});
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
// Check that updateLastRoute is undefined for groups
expect(getUpdateLastRoute()).toBeUndefined();
});
});

View File

@@ -1,147 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
import { TELEGRAM_FORUM_SERVICE_FIELDS } from "./forum-service-message.js";
describe("buildTelegramMessageContext implicitMention forum service messages", () => {
/**
* Build a group message context where the user sends a message inside a
* forum topic that has `reply_to_message` pointing to a message from the
* bot. Callers control whether the reply target looks like a forum service
* message (carries `forum_topic_created` etc.) or a real bot reply.
*/
async function buildGroupReplyCtx(params: {
replyToMessageText?: string;
replyToMessageCaption?: string;
replyFromIsBot?: boolean;
replyFromId?: number;
/** Extra fields on reply_to_message (e.g. forum_topic_created). */
replyToMessageExtra?: Record<string, unknown>;
}) {
const BOT_ID = 7; // matches test harness primaryCtx.me.id
return await buildTelegramMessageContextForTest({
message: {
message_id: 100,
chat: { id: -1001234567890, type: "supergroup", title: "Forum Group" },
date: 1700000000,
text: "hello everyone",
from: { id: 42, first_name: "Alice" },
reply_to_message: {
message_id: 1,
text: params.replyToMessageText ?? undefined,
...(params.replyToMessageCaption != null
? { caption: params.replyToMessageCaption }
: {}),
from: {
id: params.replyFromId ?? BOT_ID,
first_name: "OpenClaw",
is_bot: params.replyFromIsBot ?? true,
},
...params.replyToMessageExtra,
},
},
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: true },
topicConfig: undefined,
}),
});
}
it("does NOT trigger implicitMention for forum_topic_created service message", async () => {
// Bot auto-generated "Topic created" message carries forum_topic_created.
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
replyToMessageExtra: {
forum_topic_created: { name: "New Topic", icon_color: 0x6fb9f0 },
},
});
// With requireMention and no explicit @mention, the message should be
// skipped (null) because implicitMention should NOT fire.
expect(ctx).toBeNull();
});
it.each(TELEGRAM_FORUM_SERVICE_FIELDS)(
"does NOT trigger implicitMention for %s service message",
async (field) => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
replyToMessageExtra: { [field]: {} },
});
expect(ctx).toBeNull();
},
);
it("does NOT trigger implicitMention for forum_topic_closed service message", async () => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
replyToMessageExtra: { forum_topic_closed: {} },
});
expect(ctx).toBeNull();
});
it("does NOT trigger implicitMention for general_forum_topic_hidden service message", async () => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
replyToMessageExtra: { general_forum_topic_hidden: {} },
});
expect(ctx).toBeNull();
});
it("DOES trigger implicitMention for real bot replies (non-empty text)", async () => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: "Here is my answer",
replyFromIsBot: true,
});
// Real bot reply → implicitMention fires → message is NOT skipped.
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
});
it("DOES trigger implicitMention for bot media messages with caption", async () => {
// Media messages from the bot have caption but no text — they should
// still count as real bot replies, not service messages.
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyToMessageCaption: "Check out this image",
replyFromIsBot: true,
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
});
it("DOES trigger implicitMention for bot sticker/voice (no text, no caption, no service field)", async () => {
// Stickers, voice notes, and captionless photos have neither text nor
// caption, but they are NOT service messages — they are legitimate bot
// replies that should trigger implicitMention.
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
// No forum_topic_* fields → not a service message
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
});
it("does NOT trigger implicitMention when reply is from a different user", async () => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: "some message",
replyFromIsBot: false,
replyFromId: 999,
});
// Different user's message → not an implicit mention → skipped.
expect(ctx).toBeNull();
});
});

View File

@@ -1,152 +1,2 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined);
vi.mock("../channels/session.js", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
}));
describe("buildTelegramMessageContext named-account DM fallback", () => {
const baseCfg = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
};
afterEach(() => {
clearRuntimeConfigSnapshot();
recordInboundSessionMock.mockClear();
});
function getLastUpdateLastRoute(): { sessionKey?: string } | undefined {
const callArgs = recordInboundSessionMock.mock.calls.at(-1)?.[0] as {
updateLastRoute?: { sessionKey?: string };
};
return callArgs?.updateLastRoute;
}
function buildNamedAccountDmMessage(messageId = 1) {
return {
message_id: messageId,
chat: { id: 814912386, type: "private" as const },
date: 1700000000 + messageId - 1,
text: "hello",
from: { id: 814912386, first_name: "Alice" },
};
}
async function buildNamedAccountDmContext(accountId = "atlas", messageId = 1) {
setRuntimeConfigSnapshot(baseCfg);
return await buildTelegramMessageContextForTest({
cfg: baseCfg,
accountId,
message: buildNamedAccountDmMessage(messageId),
});
}
it("allows DM through for a named account with no explicit binding", async () => {
setRuntimeConfigSnapshot(baseCfg);
const ctx = await buildTelegramMessageContextForTest({
cfg: baseCfg,
accountId: "atlas",
message: {
message_id: 1,
chat: { id: 814912386, type: "private" },
date: 1700000000,
text: "hello",
from: { id: 814912386, first_name: "Alice" },
},
});
expect(ctx).not.toBeNull();
expect(ctx?.route.matchedBy).toBe("default");
expect(ctx?.route.accountId).toBe("atlas");
});
it("uses a per-account session key for named-account DMs", async () => {
const ctx = await buildNamedAccountDmContext();
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
});
it("keeps named-account fallback lastRoute on the isolated DM session", async () => {
const ctx = await buildNamedAccountDmContext();
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
expect(getLastUpdateLastRoute()?.sessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
});
it("isolates sessions between named accounts that share the default agent", async () => {
const atlas = await buildNamedAccountDmContext("atlas", 1);
const skynet = await buildNamedAccountDmContext("skynet", 2);
expect(atlas?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
expect(skynet?.ctxPayload?.SessionKey).toBe("agent:main:telegram:skynet:direct:814912386");
expect(atlas?.ctxPayload?.SessionKey).not.toBe(skynet?.ctxPayload?.SessionKey);
});
it("keeps identity-linked peer canonicalization in the named-account fallback path", async () => {
const cfg = {
...baseCfg,
session: {
identityLinks: {
"alice-shared": ["telegram:814912386"],
},
},
};
setRuntimeConfigSnapshot(cfg);
const ctx = await buildTelegramMessageContextForTest({
cfg,
accountId: "atlas",
message: {
message_id: 1,
chat: { id: 999999999, type: "private" },
date: 1700000000,
text: "hello",
from: { id: 814912386, first_name: "Alice" },
},
});
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:alice-shared");
});
it("still drops named-account group messages without an explicit binding", async () => {
setRuntimeConfigSnapshot(baseCfg);
const ctx = await buildTelegramMessageContextForTest({
cfg: baseCfg,
accountId: "atlas",
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
message: {
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: "@bot hello",
from: { id: 814912386, first_name: "Alice" },
},
});
expect(ctx).toBeNull();
});
it("does not change the default-account DM session key", async () => {
setRuntimeConfigSnapshot(baseCfg);
const ctx = await buildTelegramMessageContextForTest({
cfg: baseCfg,
message: {
message_id: 1,
chat: { id: 42, type: "private" },
date: 1700000000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
});
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
});
});
// Shim: re-exports from extensions/telegram/src/bot-message-context.named-account-dm.test.ts
export * from "../../extensions/telegram/src/bot-message-context.named-account-dm.test.js";

View File

@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
describe("buildTelegramMessageContext sender prefix", () => {
async function buildCtx(params: { messageId: number; options?: Record<string, unknown> }) {
return await buildTelegramMessageContextForTest({
message: {
message_id: params.messageId,
chat: { id: -99, type: "supergroup", title: "Dev Chat" },
date: 1700000000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
options: params.options,
});
}
it("prefixes group bodies with sender label", async () => {
const ctx = await buildCtx({ messageId: 1 });
expect(ctx).not.toBeNull();
const body = ctx?.ctxPayload?.Body ?? "";
expect(body).toContain("Alice (42): hello");
});
it("sets MessageSid from message_id", async () => {
const ctx = await buildCtx({ messageId: 12345 });
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageSid).toBe("12345");
});
it("respects messageIdOverride option", async () => {
const ctx = await buildCtx({
messageId: 12345,
options: { messageIdOverride: "67890" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageSid).toBe("67890");
});
});

View File

@@ -1,317 +1,2 @@
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
type HistoryEntry,
} from "../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { toLocationContext } from "../channels/location.js";
import { recordInboundSession } from "../channels/session.js";
import type { OpenClawConfig } from "../config/config.js";
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../config/types.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
import { resolveInboundLastRouteSessionKey } from "../routing/resolve-route.js";
import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
import { normalizeAllowFrom } from "./bot-access.js";
import type {
TelegramMediaRef,
TelegramMessageContextOptions,
} from "./bot-message-context.types.js";
import {
buildGroupLabel,
buildSenderLabel,
buildSenderName,
buildTelegramGroupFrom,
describeReplyTarget,
normalizeForwardedContext,
type TelegramThreadSpec,
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
export async function buildTelegramInboundContextPayload(params: {
cfg: OpenClawConfig;
primaryCtx: TelegramContext;
msg: TelegramContext["message"];
allMedia: TelegramMediaRef[];
replyMedia: TelegramMediaRef[];
isGroup: boolean;
isForum: boolean;
chatId: number | string;
senderId: string;
senderUsername: string;
resolvedThreadId?: number;
dmThreadId?: number;
threadSpec: TelegramThreadSpec;
route: ResolvedAgentRoute;
rawBody: string;
bodyText: string;
historyKey?: string;
historyLimit: number;
groupHistories: Map<string, HistoryEntry[]>;
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
stickerCacheHit: boolean;
effectiveWasMentioned: boolean;
commandAuthorized: boolean;
locationData?: import("../channels/location.js").NormalizedLocation;
options?: TelegramMessageContextOptions;
dmAllowFrom?: Array<string | number>;
}): Promise<{
ctxPayload: ReturnType<typeof finalizeInboundContext>;
skillFilter: string[] | undefined;
}> {
const {
cfg,
primaryCtx,
msg,
allMedia,
replyMedia,
isGroup,
isForum,
chatId,
senderId,
senderUsername,
resolvedThreadId,
dmThreadId,
threadSpec,
route,
rawBody,
bodyText,
historyKey,
historyLimit,
groupHistories,
groupConfig,
topicConfig,
stickerCacheHit,
effectiveWasMentioned,
commandAuthorized,
locationData,
options,
dmAllowFrom,
} = params;
const replyTarget = describeReplyTarget(msg);
const forwardOrigin = normalizeForwardedContext(msg);
const replyForwardAnnotation = replyTarget?.forwardedFrom
? `[Forwarded from ${replyTarget.forwardedFrom.from}${
replyTarget.forwardedFrom.date
? ` at ${new Date(replyTarget.forwardedFrom.date * 1000).toISOString()}`
: ""
}]\n`
: "";
const replySuffix = replyTarget
? replyTarget.kind === "quote"
? `\n\n[Quoting ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n${replyForwardAnnotation}"${replyTarget.body}"\n[/Quoting]`
: `\n\n[Replying to ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n${replyForwardAnnotation}${replyTarget.body}\n[/Replying]`
: "";
const forwardPrefix = forwardOrigin
? `[Forwarded from ${forwardOrigin.from}${
forwardOrigin.date ? ` at ${new Date(forwardOrigin.date * 1000).toISOString()}` : ""
}]\n`
: "";
const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined;
const senderName = buildSenderName(msg);
const conversationLabel = isGroup
? (groupLabel ?? `group:${chatId}`)
: buildSenderLabel(msg, senderId || chatId);
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "Telegram",
from: conversationLabel,
timestamp: msg.date ? msg.date * 1000 : undefined,
body: `${forwardPrefix}${bodyText}${replySuffix}`,
chatType: isGroup ? "group" : "direct",
sender: {
name: senderName,
username: senderUsername || undefined,
id: senderId || undefined,
},
previousTimestamp,
envelope: envelopeOptions,
});
let combinedBody = body;
if (isGroup && historyKey && historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: groupHistories,
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatInboundEnvelope({
channel: "Telegram",
from: groupLabel ?? `group:${chatId}`,
timestamp: entry.timestamp,
body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`,
chatType: "group",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
groupConfig,
topicConfig,
});
const commandBody = normalizeCommandBody(rawBody, {
botUsername: primaryCtx.me?.username?.toLowerCase(),
});
const inboundHistory =
isGroup && historyKey && historyLimit > 0
? (groupHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const currentMediaForContext = stickerCacheHit ? [] : allMedia;
const contextMedia = [...currentMediaForContext, ...replyMedia];
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
BodyForAgent: bodyText,
InboundHistory: inboundHistory,
RawBody: rawBody,
CommandBody: commandBody,
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
To: `telegram:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: conversationLabel,
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
SenderName: senderName,
SenderId: senderId || undefined,
SenderUsername: senderUsername || undefined,
Provider: "telegram",
Surface: "telegram",
BotUsername: primaryCtx.me?.username ?? undefined,
MessageSid: options?.messageIdOverride ?? String(msg.message_id),
ReplyToId: replyTarget?.id,
ReplyToBody: replyTarget?.body,
ReplyToSender: replyTarget?.sender,
ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined,
ReplyToForwardedFrom: replyTarget?.forwardedFrom?.from,
ReplyToForwardedFromType: replyTarget?.forwardedFrom?.fromType,
ReplyToForwardedFromId: replyTarget?.forwardedFrom?.fromId,
ReplyToForwardedFromUsername: replyTarget?.forwardedFrom?.fromUsername,
ReplyToForwardedFromTitle: replyTarget?.forwardedFrom?.fromTitle,
ReplyToForwardedDate: replyTarget?.forwardedFrom?.date
? replyTarget.forwardedFrom.date * 1000
: undefined,
ForwardedFrom: forwardOrigin?.from,
ForwardedFromType: forwardOrigin?.fromType,
ForwardedFromId: forwardOrigin?.fromId,
ForwardedFromUsername: forwardOrigin?.fromUsername,
ForwardedFromTitle: forwardOrigin?.fromTitle,
ForwardedFromSignature: forwardOrigin?.fromSignature,
ForwardedFromChatType: forwardOrigin?.fromChatType,
ForwardedFromMessageId: forwardOrigin?.fromMessageId,
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
MediaPath: contextMedia.length > 0 ? contextMedia[0]?.path : undefined,
MediaType: contextMedia.length > 0 ? contextMedia[0]?.contentType : undefined,
MediaUrl: contextMedia.length > 0 ? contextMedia[0]?.path : undefined,
MediaPaths: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined,
MediaUrls: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined,
MediaTypes:
contextMedia.length > 0
? (contextMedia.map((m) => m.contentType).filter(Boolean) as string[])
: undefined,
Sticker: allMedia[0]?.stickerMetadata,
StickerMediaIncluded: allMedia[0]?.stickerMetadata ? !stickerCacheHit : undefined,
...(locationData ? toLocationContext(locationData) : undefined),
CommandAuthorized: commandAuthorized,
MessageThreadId: threadSpec.id,
IsForum: isForum,
OriginatingChannel: "telegram" as const,
OriginatingTo: `telegram:${chatId}`,
});
const pinnedMainDmOwner = !isGroup
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: cfg.session?.dmScope,
allowFrom: dmAllowFrom,
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
})
: null;
const updateLastRouteSessionKey = resolveInboundLastRouteSessionKey({
route,
sessionKey: route.sessionKey,
});
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: !isGroup
? {
sessionKey: updateLastRouteSessionKey,
channel: "telegram",
to: `telegram:${chatId}`,
accountId: route.accountId,
threadId: dmThreadId != null ? String(dmThreadId) : undefined,
mainDmOwnerPin:
updateLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner && senderId
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: senderId,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`telegram: failed updating session meta: ${String(err)}`);
},
});
if (replyTarget && shouldLogVerbose()) {
const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120);
logVerbose(
`telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`,
);
}
if (forwardOrigin && shouldLogVerbose()) {
logVerbose(
`telegram forward-context: forwardedFrom="${forwardOrigin.from}" type=${forwardOrigin.fromType}`,
);
}
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
const topicInfo = resolvedThreadId != null ? ` topic=${resolvedThreadId}` : "";
logVerbose(
`telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`,
);
}
return {
ctxPayload,
skillFilter,
};
}
// Shim: re-exports from extensions/telegram/src/bot-message-context.session.ts
export * from "../../extensions/telegram/src/bot-message-context.session.js";

View File

@@ -1,67 +0,0 @@
import { vi } from "vitest";
import {
buildTelegramMessageContext,
type BuildTelegramMessageContextParams,
type TelegramMediaRef,
} from "./bot-message-context.js";
export const baseTelegramMessageContextConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
} as never;
type BuildTelegramMessageContextForTestParams = {
message: Record<string, unknown>;
allMedia?: TelegramMediaRef[];
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record<string, unknown>;
accountId?: string;
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
resolveGroupRequireMention?: BuildTelegramMessageContextParams["resolveGroupRequireMention"];
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
};
export async function buildTelegramMessageContextForTest(
params: BuildTelegramMessageContextForTestParams,
): Promise<Awaited<ReturnType<typeof buildTelegramMessageContext>>> {
return await buildTelegramMessageContext({
primaryCtx: {
message: {
message_id: 1,
date: 1_700_000_000,
text: "hello",
from: { id: 42, first_name: "Alice" },
...params.message,
},
me: { id: 7, username: "bot" },
} as never,
allMedia: params.allMedia ?? [],
storeAllowFrom: [],
options: params.options ?? {},
bot: {
api: {
sendChatAction: vi.fn(),
setMessageReaction: vi.fn(),
},
} as never,
cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
account: { accountId: params.accountId ?? "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "off",
logger: { info: vi.fn() },
resolveGroupActivation: params.resolveGroupActivation ?? (() => undefined),
resolveGroupRequireMention: params.resolveGroupRequireMention ?? (() => false),
resolveTelegramGroupConfig:
params.resolveTelegramGroupConfig ??
(() => ({
groupConfig: { requireMention: false },
topicConfig: undefined,
})),
sendChatActionHandler: { sendChatAction: vi.fn() } as never,
});
}

View File

@@ -1,116 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => {
const resolveByConversationMock = vi.fn();
const touchMock = vi.fn();
return {
resolveByConversationMock,
touchMock,
};
});
vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({
bind: vi.fn(),
getCapabilities: vi.fn(),
listBySession: vi.fn(),
resolveByConversation: (ref: unknown) => hoisted.resolveByConversationMock(ref),
touch: (bindingId: string, at?: number) => hoisted.touchMock(bindingId, at),
unbind: vi.fn(),
}),
};
});
const { buildTelegramMessageContextForTest } =
await import("./bot-message-context.test-harness.js");
describe("buildTelegramMessageContext bound conversation override", () => {
beforeEach(() => {
hoisted.resolveByConversationMock.mockReset().mockReturnValue(null);
hoisted.touchMock.mockReset();
});
it("routes forum topic messages to the bound session", async () => {
hoisted.resolveByConversationMock.mockReturnValue({
bindingId: "default:-100200300:topic:77",
targetSessionKey: "agent:codex-acp:session-1",
});
const ctx = await buildTelegramMessageContextForTest({
message: {
message_id: 1,
chat: { id: -100200300, type: "supergroup", is_forum: true },
message_thread_id: 77,
date: 1_700_000_000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
});
expect(hoisted.resolveByConversationMock).toHaveBeenCalledWith({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
});
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-1");
expect(hoisted.touchMock).toHaveBeenCalledWith("default:-100200300:topic:77", undefined);
});
it("treats named-account bound conversations as explicit route matches", async () => {
hoisted.resolveByConversationMock.mockReturnValue({
bindingId: "work:-100200300:topic:77",
targetSessionKey: "agent:codex-acp:session-2",
});
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
message_id: 1,
chat: { id: -100200300, type: "supergroup", is_forum: true },
message_thread_id: 77,
date: 1_700_000_000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
});
expect(ctx).not.toBeNull();
expect(ctx?.route.accountId).toBe("work");
expect(ctx?.route.matchedBy).toBe("binding.channel");
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-2");
expect(hoisted.touchMock).toHaveBeenCalledWith("work:-100200300:topic:77", undefined);
});
it("routes dm messages to the bound session", async () => {
hoisted.resolveByConversationMock.mockReturnValue({
bindingId: "default:1234",
targetSessionKey: "agent:codex-acp:session-dm",
});
const ctx = await buildTelegramMessageContextForTest({
message: {
message_id: 1,
chat: { id: 1234, type: "private" },
date: 1_700_000_000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
});
expect(hoisted.resolveByConversationMock).toHaveBeenCalledWith({
channel: "telegram",
accountId: "default",
conversationId: "1234",
});
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-dm");
expect(hoisted.touchMock).toHaveBeenCalledWith("default:1234", undefined);
});
});

View File

@@ -1,140 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadConfig } from "../config/config.js";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const { defaultRouteConfig } = vi.hoisted(() => ({
defaultRouteConfig: {
agents: {
list: [{ id: "main", default: true }, { id: "zu" }, { id: "q" }, { id: "support" }],
},
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
},
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: vi.fn(() => defaultRouteConfig),
};
});
describe("buildTelegramMessageContext per-topic agentId routing", () => {
function buildForumMessage(threadId = 3) {
return {
message_id: 1,
chat: {
id: -1001234567890,
type: "supergroup" as const,
title: "Forum",
is_forum: true,
},
date: 1700000000,
text: "@bot hello",
message_thread_id: threadId,
from: { id: 42, first_name: "Alice" },
};
}
async function buildForumContext(params: {
threadId?: number;
topicConfig?: Record<string, unknown>;
}) {
return await buildTelegramMessageContextForTest({
message: buildForumMessage(params.threadId),
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
...(params.topicConfig ? { topicConfig: params.topicConfig } : {}),
}),
});
}
beforeEach(() => {
vi.mocked(loadConfig).mockReturnValue(defaultRouteConfig as never);
});
it("uses group-level agent when no topic agentId is set", async () => {
const ctx = await buildForumContext({ topicConfig: { systemPrompt: "Be nice" } });
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3");
});
it("routes to topic-specific agent when agentId is set", async () => {
const ctx = await buildForumContext({
topicConfig: { agentId: "zu", systemPrompt: "I am Zu" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:zu:");
expect(ctx?.ctxPayload?.SessionKey).toContain("telegram:group:-1001234567890:topic:3");
});
it("different topics route to different agents", async () => {
const buildForTopic = async (threadId: number, agentId: string) =>
await buildForumContext({ threadId, topicConfig: { agentId } });
const ctxA = await buildForTopic(1, "main");
const ctxB = await buildForTopic(3, "zu");
const ctxC = await buildForTopic(5, "q");
expect(ctxA?.ctxPayload?.SessionKey).toContain("agent:main:");
expect(ctxB?.ctxPayload?.SessionKey).toContain("agent:zu:");
expect(ctxC?.ctxPayload?.SessionKey).toContain("agent:q:");
expect(ctxA?.ctxPayload?.SessionKey).not.toBe(ctxB?.ctxPayload?.SessionKey);
expect(ctxB?.ctxPayload?.SessionKey).not.toBe(ctxC?.ctxPayload?.SessionKey);
});
it("ignores whitespace-only agentId and uses group-level agent", async () => {
const ctx = await buildForumContext({
topicConfig: { agentId: " ", systemPrompt: "Be nice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:");
});
it("falls back to default agent when topic agentId does not exist", async () => {
vi.mocked(loadConfig).mockReturnValue({
agents: {
list: [{ id: "main", default: true }, { id: "zu" }],
},
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
} as never);
const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } });
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:");
});
it("routes DM topic to specific agent when agentId is set", async () => {
const ctx = await buildTelegramMessageContextForTest({
message: {
message_id: 1,
chat: {
id: 123456789,
type: "private",
},
date: 1700000000,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: { agentId: "support", systemPrompt: "I am support" },
}),
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:support:");
});
});

View File

@@ -1,473 +1 @@
import { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js";
import { resolveAckReaction } from "../agents/identity.js";
import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js";
import { logInboundDrop } from "../channels/logging.js";
import {
createStatusReactionController,
type StatusReactionController,
} from "../channels/status-reactions.js";
import { loadConfig } from "../config/config.js";
import type { TelegramDirectConfig, TelegramGroupConfig } from "../config/types.js";
import { logVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { buildAgentSessionKey, deriveLastRoutePolicy } from "../routing/resolve-route.js";
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js";
import { resolveTelegramInboundBody } from "./bot-message-context.body.js";
import { buildTelegramInboundContextPayload } from "./bot-message-context.session.js";
import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js";
import {
buildTypingThreadParams,
resolveTelegramDirectPeerId,
resolveTelegramThreadSpec,
} from "./bot/helpers.js";
import { resolveTelegramConversationRoute } from "./conversation-route.js";
import { enforceTelegramDmAccess } from "./dm-access.js";
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
import {
buildTelegramStatusReactionVariants,
resolveTelegramAllowedEmojiReactions,
resolveTelegramReactionVariant,
resolveTelegramStatusReactionEmojis,
} from "./status-reaction-variants.js";
export type {
BuildTelegramMessageContextParams,
TelegramMediaRef,
} from "./bot-message-context.types.js";
export const buildTelegramMessageContext = async ({
primaryCtx,
allMedia,
replyMedia = [],
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
sendChatActionHandler,
}: BuildTelegramMessageContextParams) => {
const msg = primaryCtx.message;
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const senderId = msg.from?.id ? String(msg.from.id) : "";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const threadSpec = resolveTelegramThreadSpec({
isGroup,
isForum,
messageThreadId,
});
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
const replyThreadId = threadSpec.id;
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
const threadIdForConfig = resolvedThreadId ?? dmThreadId;
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, threadIdForConfig);
// Use direct config dmPolicy override if available for DMs
const effectiveDmPolicy =
!isGroup && groupConfig && "dmPolicy" in groupConfig
? (groupConfig.dmPolicy ?? dmPolicy)
: dmPolicy;
// Fresh config for bindings lookup; other routing inputs are payload-derived.
const freshCfg = loadConfig();
let { route, configuredBinding, configuredBindingSessionKey } = resolveTelegramConversationRoute({
cfg: freshCfg,
accountId: account.accountId,
chatId,
isGroup,
resolvedThreadId,
replyThreadId,
senderId,
topicAgentId: topicConfig?.agentId,
});
const requiresExplicitAccountBinding = (
candidate: ReturnType<typeof resolveTelegramConversationRoute>["route"],
): boolean => candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default";
const isNamedAccountFallback = requiresExplicitAccountBinding(route);
// Named-account groups still require an explicit binding; DMs get a
// per-account fallback session key below to preserve isolation.
if (isNamedAccountFallback && isGroup) {
logInboundDrop({
log: logVerbose,
channel: "telegram",
reason: "non-default account requires explicit binding",
target: route.accountId,
});
return null;
}
// Calculate groupAllowOverride first - it's needed for both DM and group allowlist checks
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
const dmAllowFrom = groupAllowOverride ?? allowFrom;
const effectiveDmAllow = normalizeDmAllowFromWithStore({
allowFrom: dmAllowFrom,
storeAllowFrom,
dmPolicy: effectiveDmPolicy,
});
// Group sender checks are explicit and must not inherit DM pairing-store entries.
const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom);
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
const senderUsername = msg.from?.username ?? "";
const baseAccess = evaluateTelegramGroupBaseAccess({
isGroup,
groupConfig,
topicConfig,
hasGroupAllowOverride,
effectiveGroupAllow,
senderId,
senderUsername,
enforceAllowOverride: true,
requireSenderForAllowOverride: false,
});
if (!baseAccess.allowed) {
if (baseAccess.reason === "group-disabled") {
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
return null;
}
if (baseAccess.reason === "topic-disabled") {
logVerbose(
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
);
return null;
}
logVerbose(
isGroup
? `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`
: `Blocked telegram DM sender ${senderId || "unknown"} (DM allowFrom override)`,
);
return null;
}
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null;
if (topicRequiredButMissing) {
logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`);
return null;
}
const sendTyping = async () => {
await withTelegramApiErrorLogging({
operation: "sendChatAction",
fn: () =>
sendChatActionHandler.sendChatAction(
chatId,
"typing",
buildTypingThreadParams(replyThreadId),
),
});
};
const sendRecordVoice = async () => {
try {
await withTelegramApiErrorLogging({
operation: "sendChatAction",
fn: () =>
sendChatActionHandler.sendChatAction(
chatId,
"record_voice",
buildTypingThreadParams(replyThreadId),
),
});
} catch (err) {
logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`);
}
};
if (
!(await enforceTelegramDmAccess({
isGroup,
dmPolicy: effectiveDmPolicy,
msg,
chatId,
effectiveDmAllow,
accountId: account.accountId,
bot,
logger,
}))
) {
return null;
}
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
if (!configuredBinding) {
return true;
}
const ensured = await ensureConfiguredAcpRouteReady({
cfg: freshCfg,
configuredBinding,
});
if (ensured.ok) {
logVerbose(
`telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`,
);
return true;
}
logVerbose(
`telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`,
);
logInboundDrop({
log: logVerbose,
channel: "telegram",
reason: "configured ACP binding unavailable",
target: configuredBinding.spec.conversationId,
});
return false;
};
const baseSessionKey = isNamedAccountFallback
? buildAgentSessionKey({
agentId: route.agentId,
channel: "telegram",
accountId: route.accountId,
peer: {
kind: "direct",
id: resolveTelegramDirectPeerId({
chatId,
senderId,
}),
},
dmScope: "per-account-channel-peer",
identityLinks: freshCfg.session?.identityLinks,
}).toLowerCase()
: route.sessionKey;
// DMs: use thread suffix for session isolation (works regardless of dmScope)
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
route = {
...route,
sessionKey,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey,
mainSessionKey: route.mainSessionKey,
}),
};
// Compute requireMention after access checks and final route selection.
const activationOverride = resolveGroupActivation({
chatId,
messageThreadId: resolvedThreadId,
sessionKey: sessionKey,
agentId: route.agentId,
});
const baseRequireMention = resolveGroupRequireMention(chatId);
const requireMention = firstDefined(
activationOverride,
topicConfig?.requireMention,
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
baseRequireMention,
);
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "inbound",
});
const bodyResult = await resolveTelegramInboundBody({
cfg,
primaryCtx,
msg,
allMedia,
isGroup,
chatId,
senderId,
senderUsername,
resolvedThreadId,
routeAgentId: route.agentId,
effectiveGroupAllow,
effectiveDmAllow,
groupConfig,
topicConfig,
requireMention,
options,
groupHistories,
historyLimit,
logger,
});
if (!bodyResult) {
return null;
}
if (!(await ensureConfiguredBindingReady())) {
return null;
}
// ACK reactions
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "telegram",
accountId: account.accountId,
});
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () =>
Boolean(
ackReaction &&
shouldAckReactionGate({
scope: ackReactionScope,
isDirect: !isGroup,
isGroup,
isMentionableGroup: isGroup,
requireMention: Boolean(requireMention),
canDetectMention: bodyResult.canDetectMention,
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
shouldBypassMention: bodyResult.shouldBypassMention,
}),
);
const api = bot.api as unknown as {
setMessageReaction?: (
chatId: number | string,
messageId: number,
reactions: Array<{ type: "emoji"; emoji: string }>,
) => Promise<void>;
getChat?: (chatId: number | string) => Promise<unknown>;
};
const reactionApi =
typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null;
const getChatApi = typeof api.getChat === "function" ? api.getChat.bind(api) : null;
// Status Reactions controller (lifecycle reactions)
const statusReactionsConfig = cfg.messages?.statusReactions;
const statusReactionsEnabled =
statusReactionsConfig?.enabled === true && Boolean(reactionApi) && shouldAckReaction();
const resolvedStatusReactionEmojis = resolveTelegramStatusReactionEmojis({
initialEmoji: ackReaction,
overrides: statusReactionsConfig?.emojis,
});
const statusReactionVariantsByEmoji = buildTelegramStatusReactionVariants(
resolvedStatusReactionEmojis,
);
let allowedStatusReactionEmojisPromise: Promise<Set<string> | null> | null = null;
const statusReactionController: StatusReactionController | null =
statusReactionsEnabled && msg.message_id
? createStatusReactionController({
enabled: true,
adapter: {
setReaction: async (emoji: string) => {
if (reactionApi) {
if (!allowedStatusReactionEmojisPromise) {
allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({
chat: msg.chat,
chatId,
getChat: getChatApi ?? undefined,
}).catch((err) => {
logVerbose(
`telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`,
);
return null;
});
}
const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise;
const resolvedEmoji = resolveTelegramReactionVariant({
requestedEmoji: emoji,
variantsByRequestedEmoji: statusReactionVariantsByEmoji,
allowedEmojiReactions: allowedStatusReactionEmojis,
});
if (!resolvedEmoji) {
return;
}
await reactionApi(chatId, msg.message_id, [
{ type: "emoji", emoji: resolvedEmoji },
]);
}
},
// Telegram replaces atomically — no removeReaction needed
},
initialEmoji: ackReaction,
emojis: resolvedStatusReactionEmojis,
timing: statusReactionsConfig?.timing,
onError: (err) => {
logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`);
},
})
: null;
// When status reactions are enabled, setQueued() replaces the simple ack reaction
const ackReactionPromise = statusReactionController
? shouldAckReaction()
? Promise.resolve(statusReactionController.setQueued()).then(
() => true,
() => false,
)
: null
: shouldAckReaction() && msg.message_id && reactionApi
? withTelegramApiErrorLogging({
operation: "setMessageReaction",
fn: () => reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]),
}).then(
() => true,
(err) => {
logVerbose(`telegram react failed for chat ${chatId}: ${String(err)}`);
return false;
},
)
: null;
const { ctxPayload, skillFilter } = await buildTelegramInboundContextPayload({
cfg,
primaryCtx,
msg,
allMedia,
replyMedia,
isGroup,
isForum,
chatId,
senderId,
senderUsername,
resolvedThreadId,
dmThreadId,
threadSpec,
route,
rawBody: bodyResult.rawBody,
bodyText: bodyResult.bodyText,
historyKey: bodyResult.historyKey,
historyLimit,
groupHistories,
groupConfig,
topicConfig,
stickerCacheHit: bodyResult.stickerCacheHit,
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
locationData: bodyResult.locationData,
options,
dmAllowFrom,
commandAuthorized: bodyResult.commandAuthorized,
});
return {
ctxPayload,
primaryCtx,
msg,
chatId,
isGroup,
resolvedThreadId,
threadSpec,
replyThreadId,
isForum,
historyKey: bodyResult.historyKey,
historyLimit,
groupHistories,
route,
skillFilter,
sendTyping,
sendRecordVoice,
ackReactionPromise,
reactionApi,
removeAckAfterReply,
statusReactionController,
accountId: account.accountId,
};
};
export type TelegramMessageContext = NonNullable<
Awaited<ReturnType<typeof buildTelegramMessageContext>>
>;
export * from "../../extensions/telegram/src/bot-message-context.js";

View File

@@ -1,65 +1,2 @@
import type { Bot } from "grammy";
import type { HistoryEntry } from "../auto-reply/reply/history.js";
import type { OpenClawConfig } from "../config/config.js";
import type {
DmPolicy,
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../config/types.js";
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
export type TelegramMediaRef = {
path: string;
contentType?: string;
stickerMetadata?: StickerMetadata;
};
export type TelegramMessageContextOptions = {
forceWasMentioned?: boolean;
messageIdOverride?: string;
};
export type TelegramLogger = {
info: (obj: Record<string, unknown>, msg: string) => void;
};
export type ResolveTelegramGroupConfig = (
chatId: string | number,
messageThreadId?: number,
) => {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
};
export type ResolveGroupActivation = (params: {
chatId: string | number;
agentId?: string;
messageThreadId?: number;
sessionKey?: string;
}) => boolean | undefined;
export type ResolveGroupRequireMention = (chatId: string | number) => boolean;
export type BuildTelegramMessageContextParams = {
primaryCtx: TelegramContext;
allMedia: TelegramMediaRef[];
replyMedia?: TelegramMediaRef[];
storeAllowFrom: string[];
options?: TelegramMessageContextOptions;
bot: Bot;
cfg: OpenClawConfig;
account: { accountId: string };
historyLimit: number;
groupHistories: Map<string, HistoryEntry[]>;
dmPolicy: DmPolicy;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
ackReactionScope: "off" | "none" | "group-mentions" | "group-all" | "direct" | "all";
logger: TelegramLogger;
resolveGroupActivation: ResolveGroupActivation;
resolveGroupRequireMention: ResolveGroupRequireMention;
resolveTelegramGroupConfig: ResolveTelegramGroupConfig;
/** Global (per-account) handler for sendChatAction 401 backoff (#27092). */
sendChatActionHandler: import("./sendchataction-401-backoff.js").TelegramSendChatActionHandler;
};
// Shim: re-exports from extensions/telegram/src/bot-message-context.types.ts
export * from "../../extensions/telegram/src/bot-message-context.types.js";

View File

@@ -1,72 +0,0 @@
import { describe, expect, it } from "vitest";
import { pruneStickerMediaFromContext } from "./bot-message-dispatch.js";
type MediaCtx = {
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
};
function expectSingleImageMedia(ctx: MediaCtx, mediaPath: string) {
expect(ctx.MediaPath).toBe(mediaPath);
expect(ctx.MediaUrl).toBe(mediaPath);
expect(ctx.MediaType).toBe("image/jpeg");
expect(ctx.MediaPaths).toEqual([mediaPath]);
expect(ctx.MediaUrls).toEqual([mediaPath]);
expect(ctx.MediaTypes).toEqual(["image/jpeg"]);
}
describe("pruneStickerMediaFromContext", () => {
it("preserves appended reply media while removing primary sticker media", () => {
const ctx: MediaCtx = {
MediaPath: "/tmp/sticker.webp",
MediaUrl: "/tmp/sticker.webp",
MediaType: "image/webp",
MediaPaths: ["/tmp/sticker.webp", "/tmp/replied.jpg"],
MediaUrls: ["/tmp/sticker.webp", "/tmp/replied.jpg"],
MediaTypes: ["image/webp", "image/jpeg"],
};
pruneStickerMediaFromContext(ctx);
expectSingleImageMedia(ctx, "/tmp/replied.jpg");
});
it("clears media fields when sticker is the only media", () => {
const ctx: MediaCtx = {
MediaPath: "/tmp/sticker.webp",
MediaUrl: "/tmp/sticker.webp",
MediaType: "image/webp",
MediaPaths: ["/tmp/sticker.webp"],
MediaUrls: ["/tmp/sticker.webp"],
MediaTypes: ["image/webp"],
};
pruneStickerMediaFromContext(ctx);
expect(ctx.MediaPath).toBeUndefined();
expect(ctx.MediaUrl).toBeUndefined();
expect(ctx.MediaType).toBeUndefined();
expect(ctx.MediaPaths).toBeUndefined();
expect(ctx.MediaUrls).toBeUndefined();
expect(ctx.MediaTypes).toBeUndefined();
});
it("does not prune when sticker media is already omitted from context", () => {
const ctx: MediaCtx = {
MediaPath: "/tmp/replied.jpg",
MediaUrl: "/tmp/replied.jpg",
MediaType: "image/jpeg",
MediaPaths: ["/tmp/replied.jpg"],
MediaUrls: ["/tmp/replied.jpg"],
MediaTypes: ["image/jpeg"],
};
pruneStickerMediaFromContext(ctx, { stickerMediaIncluded: false });
expectSingleImageMedia(ctx, "/tmp/replied.jpg");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,849 +1 @@
import type { Bot } from "grammy";
import { resolveAgentDir } from "../agents/agent-scope.js";
import {
findModelInCatalog,
loadModelCatalog,
modelSupportsVision,
} from "../agents/model-catalog.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { removeAckReactionAfterReply } from "../channels/ack-reactions.js";
import { logAckFailure, logTypingFailure } from "../channels/logging.js";
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
import { createTypingCallbacks } from "../channels/typing.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import {
loadSessionStore,
resolveSessionStoreEntry,
resolveStorePath,
} from "../config/sessions.js";
import type { OpenClawConfig, ReplyToMode, TelegramAccountConfig } from "../config/types.js";
import { danger, logVerbose } from "../globals.js";
import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js";
import type { RuntimeEnv } from "../runtime.js";
import type { TelegramMessageContext } from "./bot-message-context.js";
import type { TelegramBotOptions } from "./bot.js";
import { deliverReplies } from "./bot/delivery.js";
import type { TelegramStreamMode } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import { renderTelegramHtmlText } from "./format.js";
import {
type ArchivedPreview,
createLaneDeliveryStateTracker,
createLaneTextDeliverer,
type DraftLaneState,
type LaneName,
type LanePreviewLifecycle,
} from "./lane-delivery.js";
import {
createTelegramReasoningStepState,
splitTelegramReasoningText,
} from "./reasoning-lane-coordinator.js";
import { editMessageTelegram } from "./send.js";
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
/** Minimum chars before sending first streaming message (improves push notification UX) */
const DRAFT_MIN_INITIAL_CHARS = 30;
async function resolveStickerVisionSupport(cfg: OpenClawConfig, agentId: string) {
try {
const catalog = await loadModelCatalog({ config: cfg });
const defaultModel = resolveDefaultModelForAgent({ cfg, agentId });
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
if (!entry) {
return false;
}
return modelSupportsVision(entry);
} catch {
return false;
}
}
export function pruneStickerMediaFromContext(
ctxPayload: {
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
},
opts?: { stickerMediaIncluded?: boolean },
) {
if (opts?.stickerMediaIncluded === false) {
return;
}
const nextMediaPaths = Array.isArray(ctxPayload.MediaPaths)
? ctxPayload.MediaPaths.slice(1)
: undefined;
const nextMediaUrls = Array.isArray(ctxPayload.MediaUrls)
? ctxPayload.MediaUrls.slice(1)
: undefined;
const nextMediaTypes = Array.isArray(ctxPayload.MediaTypes)
? ctxPayload.MediaTypes.slice(1)
: undefined;
ctxPayload.MediaPaths = nextMediaPaths && nextMediaPaths.length > 0 ? nextMediaPaths : undefined;
ctxPayload.MediaUrls = nextMediaUrls && nextMediaUrls.length > 0 ? nextMediaUrls : undefined;
ctxPayload.MediaTypes = nextMediaTypes && nextMediaTypes.length > 0 ? nextMediaTypes : undefined;
ctxPayload.MediaPath = ctxPayload.MediaPaths?.[0];
ctxPayload.MediaUrl = ctxPayload.MediaUrls?.[0] ?? ctxPayload.MediaPath;
ctxPayload.MediaType = ctxPayload.MediaTypes?.[0];
}
type DispatchTelegramMessageParams = {
context: TelegramMessageContext;
bot: Bot;
cfg: OpenClawConfig;
runtime: RuntimeEnv;
replyToMode: ReplyToMode;
streamMode: TelegramStreamMode;
textLimit: number;
telegramCfg: TelegramAccountConfig;
opts: Pick<TelegramBotOptions, "token">;
};
type TelegramReasoningLevel = "off" | "on" | "stream";
function resolveTelegramReasoningLevel(params: {
cfg: OpenClawConfig;
sessionKey?: string;
agentId: string;
}): TelegramReasoningLevel {
const { cfg, sessionKey, agentId } = params;
if (!sessionKey) {
return "off";
}
try {
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath, { skipCache: true });
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
const level = entry?.reasoningLevel;
if (level === "on" || level === "stream") {
return level;
}
} catch {
// Fall through to default.
}
return "off";
}
export const dispatchTelegramMessage = async ({
context,
bot,
cfg,
runtime,
replyToMode,
streamMode,
textLimit,
telegramCfg,
opts,
}: DispatchTelegramMessageParams) => {
const {
ctxPayload,
msg,
chatId,
isGroup,
threadSpec,
historyKey,
historyLimit,
groupHistories,
route,
skillFilter,
sendTyping,
sendRecordVoice,
ackReactionPromise,
reactionApi,
removeAckAfterReply,
statusReactionController,
} = context;
const draftMaxChars = Math.min(textLimit, 4096);
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: route.accountId,
});
const renderDraftPreview = (text: string) => ({
text: renderTelegramHtmlText(text, { tableMode }),
parseMode: "HTML" as const,
});
const accountBlockStreamingEnabled =
typeof telegramCfg.blockStreaming === "boolean"
? telegramCfg.blockStreaming
: cfg.agents?.defaults?.blockStreamingDefault === "on";
const resolvedReasoningLevel = resolveTelegramReasoningLevel({
cfg,
sessionKey: ctxPayload.SessionKey,
agentId: route.agentId,
});
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
const streamReasoningDraft = resolvedReasoningLevel === "stream";
const previewStreamingEnabled = streamMode !== "off";
const canStreamAnswerDraft =
previewStreamingEnabled && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning;
const canStreamReasoningDraft = canStreamAnswerDraft || streamReasoningDraft;
const draftReplyToMessageId =
replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined;
const draftMinInitialChars = DRAFT_MIN_INITIAL_CHARS;
// Keep DM preview lanes on real message transport. Native draft previews still
// require a draft->message materialize hop, and that overlap keeps reintroducing
// a visible duplicate flash at finalize time.
const useMessagePreviewTransportForDm = threadSpec?.scope === "dm" && canStreamAnswerDraft;
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
const archivedAnswerPreviews: ArchivedPreview[] = [];
const archivedReasoningPreviewIds: number[] = [];
const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => {
const stream = enabled
? createTelegramDraftStream({
api: bot.api,
chatId,
maxChars: draftMaxChars,
thread: threadSpec,
previewTransport: useMessagePreviewTransportForDm ? "message" : "auto",
replyToMessageId: draftReplyToMessageId,
minInitialChars: draftMinInitialChars,
renderText: renderDraftPreview,
onSupersededPreview:
laneName === "answer" || laneName === "reasoning"
? (preview) => {
if (laneName === "reasoning") {
if (!archivedReasoningPreviewIds.includes(preview.messageId)) {
archivedReasoningPreviewIds.push(preview.messageId);
}
return;
}
archivedAnswerPreviews.push({
messageId: preview.messageId,
textSnapshot: preview.textSnapshot,
deleteIfUnused: true,
});
}
: undefined,
log: logVerbose,
warn: logVerbose,
})
: undefined;
return {
stream,
lastPartialText: "",
hasStreamedMessage: false,
};
};
const lanes: Record<LaneName, DraftLaneState> = {
answer: createDraftLane("answer", canStreamAnswerDraft),
reasoning: createDraftLane("reasoning", canStreamReasoningDraft),
};
// Active preview lifecycle answers "can this current preview still be
// finalized?" Cleanup retention is separate so archived-preview decisions do
// not poison the active lane.
const activePreviewLifecycleByLane: Record<LaneName, LanePreviewLifecycle> = {
answer: "transient",
reasoning: "transient",
};
const retainPreviewOnCleanupByLane: Record<LaneName, boolean> = {
answer: false,
reasoning: false,
};
const answerLane = lanes.answer;
const reasoningLane = lanes.reasoning;
let splitReasoningOnNextStream = false;
let skipNextAnswerMessageStartRotation = false;
let draftLaneEventQueue = Promise.resolve();
const reasoningStepState = createTelegramReasoningStepState();
const enqueueDraftLaneEvent = (task: () => Promise<void>): Promise<void> => {
const next = draftLaneEventQueue.then(task);
draftLaneEventQueue = next.catch((err) => {
logVerbose(`telegram: draft lane callback failed: ${String(err)}`);
});
return draftLaneEventQueue;
};
type SplitLaneSegment = { lane: LaneName; text: string };
type SplitLaneSegmentsResult = {
segments: SplitLaneSegment[];
suppressedReasoningOnly: boolean;
};
const splitTextIntoLaneSegments = (text?: string): SplitLaneSegmentsResult => {
const split = splitTelegramReasoningText(text);
const segments: SplitLaneSegment[] = [];
const suppressReasoning = resolvedReasoningLevel === "off";
if (split.reasoningText && !suppressReasoning) {
segments.push({ lane: "reasoning", text: split.reasoningText });
}
if (split.answerText) {
segments.push({ lane: "answer", text: split.answerText });
}
return {
segments,
suppressedReasoningOnly:
Boolean(split.reasoningText) && suppressReasoning && !split.answerText,
};
};
const resetDraftLaneState = (lane: DraftLaneState) => {
lane.lastPartialText = "";
lane.hasStreamedMessage = false;
};
const rotateAnswerLaneForNewAssistantMessage = async () => {
let didForceNewMessage = false;
if (answerLane.hasStreamedMessage) {
// Materialize the current streamed draft into a permanent message
// so it remains visible across tool boundaries.
const materializedId = await answerLane.stream?.materialize?.();
const previewMessageId = materializedId ?? answerLane.stream?.messageId();
if (
typeof previewMessageId === "number" &&
activePreviewLifecycleByLane.answer === "transient"
) {
archivedAnswerPreviews.push({
messageId: previewMessageId,
textSnapshot: answerLane.lastPartialText,
deleteIfUnused: false,
});
}
answerLane.stream?.forceNewMessage();
didForceNewMessage = true;
}
resetDraftLaneState(answerLane);
if (didForceNewMessage) {
// New assistant message boundary: this lane now tracks a fresh preview lifecycle.
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = false;
}
return didForceNewMessage;
};
const updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => {
const laneStream = lane.stream;
if (!laneStream || !text) {
return;
}
if (text === lane.lastPartialText) {
return;
}
// Mark that we've received streaming content (for forceNewMessage decision).
lane.hasStreamedMessage = true;
// Some providers briefly emit a shorter prefix snapshot (for example
// "Sure." -> "Sure" -> "Sure."). Keep the longer preview to avoid
// visible punctuation flicker.
if (
lane.lastPartialText &&
lane.lastPartialText.startsWith(text) &&
text.length < lane.lastPartialText.length
) {
return;
}
lane.lastPartialText = text;
laneStream.update(text);
};
const ingestDraftLaneSegments = async (text: string | undefined) => {
const split = splitTextIntoLaneSegments(text);
const hasAnswerSegment = split.segments.some((segment) => segment.lane === "answer");
if (hasAnswerSegment && activePreviewLifecycleByLane.answer !== "transient") {
// Some providers can emit the first partial of a new assistant message before
// onAssistantMessageStart() arrives. Rotate preemptively so we do not edit
// the previously finalized preview message with the next message's text.
skipNextAnswerMessageStartRotation = await rotateAnswerLaneForNewAssistantMessage();
}
for (const segment of split.segments) {
if (segment.lane === "reasoning") {
reasoningStepState.noteReasoningHint();
reasoningStepState.noteReasoningDelivered();
}
updateDraftFromPartial(lanes[segment.lane], segment.text);
}
};
const flushDraftLane = async (lane: DraftLaneState) => {
if (!lane.stream) {
return;
}
await lane.stream.flush();
};
const disableBlockStreaming = !previewStreamingEnabled
? true
: forceBlockStreamingForReasoning
? false
: typeof telegramCfg.blockStreaming === "boolean"
? !telegramCfg.blockStreaming
: canStreamAnswerDraft
? true
: undefined;
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "telegram",
accountId: route.accountId,
});
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
// Handle uncached stickers: get a dedicated vision description before dispatch
// This ensures we cache a raw description rather than a conversational response
const sticker = ctxPayload.Sticker;
if (sticker?.fileId && sticker.fileUniqueId && ctxPayload.MediaPath) {
const agentDir = resolveAgentDir(cfg, route.agentId);
const stickerSupportsVision = await resolveStickerVisionSupport(cfg, route.agentId);
let description = sticker.cachedDescription ?? null;
if (!description) {
description = await describeStickerImage({
imagePath: ctxPayload.MediaPath,
cfg,
agentDir,
agentId: route.agentId,
});
}
if (description) {
// Format the description with sticker context
const stickerContext = [sticker.emoji, sticker.setName ? `from "${sticker.setName}"` : null]
.filter(Boolean)
.join(" ");
const formattedDesc = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`;
sticker.cachedDescription = description;
if (!stickerSupportsVision) {
// Update context to use description instead of image
ctxPayload.Body = formattedDesc;
ctxPayload.BodyForAgent = formattedDesc;
// Drop only the sticker attachment; keep replied media context if present.
pruneStickerMediaFromContext(ctxPayload, {
stickerMediaIncluded: ctxPayload.StickerMediaIncluded,
});
}
// Cache the description for future encounters
if (sticker.fileId) {
cacheSticker({
fileId: sticker.fileId,
fileUniqueId: sticker.fileUniqueId,
emoji: sticker.emoji,
setName: sticker.setName,
description,
cachedAt: new Date().toISOString(),
receivedFrom: ctxPayload.From,
});
logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`);
} else {
logVerbose(`telegram: skipped sticker cache (missing fileId)`);
}
}
}
const replyQuoteText =
ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
? ctxPayload.ReplyToBody.trim() || undefined
: undefined;
const deliveryState = createLaneDeliveryStateTracker();
const clearGroupHistory = () => {
if (isGroup && historyKey) {
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
}
};
const deliveryBaseOptions = {
chatId: String(chatId),
accountId: route.accountId,
sessionKeyForInternalHooks: ctxPayload.SessionKey,
mirrorIsGroup: isGroup,
mirrorGroupId: isGroup ? String(chatId) : undefined,
token: opts.token,
runtime,
bot,
mediaLocalRoots,
replyToMode,
textLimit,
thread: threadSpec,
tableMode,
chunkMode,
linkPreview: telegramCfg.linkPreview,
replyQuoteText,
};
const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
if (payload.text === text) {
return payload;
}
return { ...payload, text };
};
const sendPayload = async (payload: ReplyPayload) => {
const result = await deliverReplies({
...deliveryBaseOptions,
replies: [payload],
onVoiceRecording: sendRecordVoice,
});
if (result.delivered) {
deliveryState.markDelivered();
}
return result.delivered;
};
const deliverLaneText = createLaneTextDeliverer({
lanes,
archivedAnswerPreviews,
activePreviewLifecycleByLane,
retainPreviewOnCleanupByLane,
draftMaxChars,
applyTextToPayload,
sendPayload,
flushDraftLane,
stopDraftLane: async (lane) => {
await lane.stream?.stop();
},
editPreview: async ({ messageId, text, previewButtons }) => {
await editMessageTelegram(chatId, messageId, text, {
api: bot.api,
cfg,
accountId: route.accountId,
linkPreview: telegramCfg.linkPreview,
buttons: previewButtons,
});
},
deletePreviewMessage: async (messageId) => {
await bot.api.deleteMessage(chatId, messageId);
},
log: logVerbose,
markDelivered: () => {
deliveryState.markDelivered();
},
});
let queuedFinal = false;
if (statusReactionController) {
void statusReactionController.setThinking();
}
const typingCallbacks = createTypingCallbacks({
start: sendTyping,
onStartError: (err) => {
logTypingFailure({
log: logVerbose,
channel: "telegram",
target: String(chatId),
error: err,
});
},
});
let dispatchError: unknown;
try {
({ queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
...prefixOptions,
typingCallbacks,
deliver: async (payload, info) => {
if (info.kind === "final") {
// Assistant callbacks are fire-and-forget; ensure queued boundary
// rotations/partials are applied before final delivery mapping.
await enqueueDraftLaneEvent(async () => {});
}
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload,
})
) {
queuedFinal = true;
return;
}
const previewButtons = (
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
)?.buttons;
const split = splitTextIntoLaneSegments(payload.text);
const segments = split.segments;
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const flushBufferedFinalAnswer = async () => {
const buffered = reasoningStepState.takeBufferedFinalAnswer();
if (!buffered) {
return;
}
const bufferedButtons = (
buffered.payload.channelData?.telegram as
| { buttons?: TelegramInlineButtons }
| undefined
)?.buttons;
await deliverLaneText({
laneName: "answer",
text: buffered.text,
payload: buffered.payload,
infoKind: "final",
previewButtons: bufferedButtons,
});
reasoningStepState.resetForNextStep();
};
for (const segment of segments) {
if (
segment.lane === "answer" &&
info.kind === "final" &&
reasoningStepState.shouldBufferFinalAnswer()
) {
reasoningStepState.bufferFinalAnswer({
payload,
text: segment.text,
});
continue;
}
if (segment.lane === "reasoning") {
reasoningStepState.noteReasoningHint();
}
const result = await deliverLaneText({
laneName: segment.lane,
text: segment.text,
payload,
infoKind: info.kind,
previewButtons,
allowPreviewUpdateForNonFinal: segment.lane === "reasoning",
});
if (segment.lane === "reasoning") {
if (result !== "skipped") {
reasoningStepState.noteReasoningDelivered();
await flushBufferedFinalAnswer();
}
continue;
}
if (info.kind === "final") {
if (reasoningLane.hasStreamedMessage) {
activePreviewLifecycleByLane.reasoning = "complete";
retainPreviewOnCleanupByLane.reasoning = true;
}
reasoningStepState.resetForNextStep();
}
}
if (segments.length > 0) {
return;
}
if (split.suppressedReasoningOnly) {
if (hasMedia) {
const payloadWithoutSuppressedReasoning =
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
await sendPayload(payloadWithoutSuppressedReasoning);
}
if (info.kind === "final") {
await flushBufferedFinalAnswer();
}
return;
}
if (info.kind === "final") {
await answerLane.stream?.stop();
await reasoningLane.stream?.stop();
reasoningStepState.resetForNextStep();
}
const canSendAsIs =
hasMedia || (typeof payload.text === "string" && payload.text.length > 0);
if (!canSendAsIs) {
if (info.kind === "final") {
await flushBufferedFinalAnswer();
}
return;
}
await sendPayload(payload);
if (info.kind === "final") {
await flushBufferedFinalAnswer();
}
},
onSkip: (_payload, info) => {
if (info.reason !== "silent") {
deliveryState.markNonSilentSkip();
}
},
onError: (err, info) => {
deliveryState.markNonSilentFailure();
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: {
skillFilter,
disableBlockStreaming,
onPartialReply:
answerLane.stream || reasoningLane.stream
? (payload) =>
enqueueDraftLaneEvent(async () => {
await ingestDraftLaneSegments(payload.text);
})
: undefined,
onReasoningStream: reasoningLane.stream
? (payload) =>
enqueueDraftLaneEvent(async () => {
// Split between reasoning blocks only when the next reasoning
// stream starts. Splitting at reasoning-end can orphan the active
// preview and cause duplicate reasoning sends on reasoning final.
if (splitReasoningOnNextStream) {
reasoningLane.stream?.forceNewMessage();
resetDraftLaneState(reasoningLane);
splitReasoningOnNextStream = false;
}
await ingestDraftLaneSegments(payload.text);
})
: undefined,
onAssistantMessageStart: answerLane.stream
? () =>
enqueueDraftLaneEvent(async () => {
reasoningStepState.resetForNextStep();
if (skipNextAnswerMessageStartRotation) {
skipNextAnswerMessageStartRotation = false;
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = false;
return;
}
await rotateAnswerLaneForNewAssistantMessage();
// Message-start is an explicit assistant-message boundary.
// Even when no forceNewMessage happened (e.g. prior answer had no
// streamed partials), the next partial belongs to a fresh lifecycle
// and must not trigger late pre-rotation mid-message.
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = false;
})
: undefined,
onReasoningEnd: reasoningLane.stream
? () =>
enqueueDraftLaneEvent(async () => {
// Split when/if a later reasoning block begins.
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
})
: undefined,
onToolStart: statusReactionController
? async (payload) => {
await statusReactionController.setTool(payload.name);
}
: undefined,
onCompactionStart: statusReactionController
? () => statusReactionController.setCompacting()
: undefined,
onCompactionEnd: statusReactionController
? async () => {
statusReactionController.cancelPending();
await statusReactionController.setThinking();
}
: undefined,
onModelSelected,
},
}));
} catch (err) {
dispatchError = err;
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
} finally {
// Upstream assistant callbacks are fire-and-forget; drain queued lane work
// before stream cleanup so boundary rotations/materialization complete first.
await draftLaneEventQueue;
// Must stop() first to flush debounced content before clear() wipes state.
const streamCleanupStates = new Map<
NonNullable<DraftLaneState["stream"]>,
{ shouldClear: boolean }
>();
const lanesToCleanup: Array<{ laneName: LaneName; lane: DraftLaneState }> = [
{ laneName: "answer", lane: answerLane },
{ laneName: "reasoning", lane: reasoningLane },
];
for (const laneState of lanesToCleanup) {
const stream = laneState.lane.stream;
if (!stream) {
continue;
}
// Don't clear (delete) the stream if: (a) it was finalized, or
// (b) the active stream message is itself a boundary-finalized archive.
const activePreviewMessageId = stream.messageId();
const hasBoundaryFinalizedActivePreview =
laneState.laneName === "answer" &&
typeof activePreviewMessageId === "number" &&
archivedAnswerPreviews.some(
(p) => p.deleteIfUnused === false && p.messageId === activePreviewMessageId,
);
const shouldClear =
!retainPreviewOnCleanupByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview;
const existing = streamCleanupStates.get(stream);
if (!existing) {
streamCleanupStates.set(stream, { shouldClear });
continue;
}
existing.shouldClear = existing.shouldClear && shouldClear;
}
for (const [stream, cleanupState] of streamCleanupStates) {
await stream.stop();
if (cleanupState.shouldClear) {
await stream.clear();
}
}
for (const archivedPreview of archivedAnswerPreviews) {
if (archivedPreview.deleteIfUnused === false) {
continue;
}
try {
await bot.api.deleteMessage(chatId, archivedPreview.messageId);
} catch (err) {
logVerbose(
`telegram: archived answer preview cleanup failed (${archivedPreview.messageId}): ${String(err)}`,
);
}
}
for (const messageId of archivedReasoningPreviewIds) {
try {
await bot.api.deleteMessage(chatId, messageId);
} catch (err) {
logVerbose(
`telegram: archived reasoning preview cleanup failed (${messageId}): ${String(err)}`,
);
}
}
}
let sentFallback = false;
const deliverySummary = deliveryState.snapshot();
if (
dispatchError ||
(!deliverySummary.delivered &&
(deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0))
) {
const fallbackText = dispatchError
? "Something went wrong while processing your request. Please try again."
: EMPTY_RESPONSE_FALLBACK;
const result = await deliverReplies({
replies: [{ text: fallbackText }],
...deliveryBaseOptions,
});
sentFallback = result.delivered;
}
const hasFinalResponse = queuedFinal || sentFallback;
if (statusReactionController && !hasFinalResponse) {
void statusReactionController.setError().catch((err) => {
logVerbose(`telegram: status reaction error finalize failed: ${String(err)}`);
});
}
if (!hasFinalResponse) {
clearGroupHistory();
return;
}
if (statusReactionController) {
void statusReactionController.setDone().catch((err) => {
logVerbose(`telegram: status reaction finalize failed: ${String(err)}`);
});
} else {
removeAckReactionAfterReply({
removeAfterReply: removeAckAfterReply,
ackReactionPromise,
ackReactionValue: ackReactionPromise ? "ack" : null,
remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(),
onError: (err) => {
if (!msg.message_id) {
return;
}
logAckFailure({
log: logVerbose,
channel: "telegram",
target: `${chatId}/${msg.message_id}`,
error: err,
});
},
});
}
clearGroupHistory();
};
export * from "../../extensions/telegram/src/bot-message-dispatch.js";

View File

@@ -1,129 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const buildTelegramMessageContext = vi.hoisted(() => vi.fn());
const dispatchTelegramMessage = vi.hoisted(() => vi.fn());
vi.mock("./bot-message-context.js", () => ({
buildTelegramMessageContext,
}));
vi.mock("./bot-message-dispatch.js", () => ({
dispatchTelegramMessage,
}));
import { createTelegramMessageProcessor } from "./bot-message.js";
describe("telegram bot message processor", () => {
beforeEach(() => {
buildTelegramMessageContext.mockClear();
dispatchTelegramMessage.mockClear();
});
const baseDeps = {
bot: {},
cfg: {},
account: {},
telegramCfg: {},
historyLimit: 0,
groupHistories: {},
dmPolicy: {},
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "none",
logger: {},
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({}),
runtime: {},
replyToMode: "auto",
streamMode: "partial",
textLimit: 4096,
opts: {},
} as unknown as Parameters<typeof createTelegramMessageProcessor>[0];
async function processSampleMessage(
processMessage: ReturnType<typeof createTelegramMessageProcessor>,
) {
await processMessage(
{
message: {
chat: { id: 123, type: "private", title: "chat" },
message_id: 456,
},
} as unknown as Parameters<typeof processMessage>[0],
[],
[],
{},
);
}
function createDispatchFailureHarness(
context: Record<string, unknown>,
sendMessage: ReturnType<typeof vi.fn>,
) {
const runtimeError = vi.fn();
buildTelegramMessageContext.mockResolvedValue(context);
dispatchTelegramMessage.mockRejectedValue(new Error("dispatch exploded"));
const processMessage = createTelegramMessageProcessor({
...baseDeps,
bot: { api: { sendMessage } },
runtime: { error: runtimeError },
} as unknown as Parameters<typeof createTelegramMessageProcessor>[0]);
return { processMessage, runtimeError };
}
it("dispatches when context is available", async () => {
buildTelegramMessageContext.mockResolvedValue({ route: { sessionKey: "agent:main:main" } });
const processMessage = createTelegramMessageProcessor(baseDeps);
await processSampleMessage(processMessage);
expect(dispatchTelegramMessage).toHaveBeenCalledTimes(1);
});
it("skips dispatch when no context is produced", async () => {
buildTelegramMessageContext.mockResolvedValue(null);
const processMessage = createTelegramMessageProcessor(baseDeps);
await processSampleMessage(processMessage);
expect(dispatchTelegramMessage).not.toHaveBeenCalled();
});
it("sends user-visible fallback when dispatch throws", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const { processMessage, runtimeError } = createDispatchFailureHarness(
{
chatId: 123,
threadSpec: { id: 456 },
route: { sessionKey: "agent:main:main" },
},
sendMessage,
);
await expect(processSampleMessage(processMessage)).resolves.toBeUndefined();
expect(sendMessage).toHaveBeenCalledWith(
123,
"Something went wrong while processing your request. Please try again.",
{ message_thread_id: 456 },
);
expect(runtimeError).toHaveBeenCalledWith(expect.stringContaining("dispatch exploded"));
});
it("swallows fallback delivery failures after dispatch throws", async () => {
const sendMessage = vi.fn().mockRejectedValue(new Error("blocked by user"));
const { processMessage, runtimeError } = createDispatchFailureHarness(
{
chatId: 123,
route: { sessionKey: "agent:main:main" },
},
sendMessage,
);
await expect(processSampleMessage(processMessage)).resolves.toBeUndefined();
expect(sendMessage).toHaveBeenCalledWith(
123,
"Something went wrong while processing your request. Please try again.",
undefined,
);
expect(runtimeError).toHaveBeenCalledWith(expect.stringContaining("dispatch exploded"));
});
});

View File

@@ -1,107 +1 @@
import type { ReplyToMode } from "../config/config.js";
import type { TelegramAccountConfig } from "../config/types.telegram.js";
import { danger } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import {
buildTelegramMessageContext,
type BuildTelegramMessageContextParams,
type TelegramMediaRef,
} from "./bot-message-context.js";
import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
import type { TelegramBotOptions } from "./bot.js";
import type { TelegramContext, TelegramStreamMode } from "./bot/types.js";
/** Dependencies injected once when creating the message processor. */
type TelegramMessageProcessorDeps = Omit<
BuildTelegramMessageContextParams,
"primaryCtx" | "allMedia" | "storeAllowFrom" | "options"
> & {
telegramCfg: TelegramAccountConfig;
runtime: RuntimeEnv;
replyToMode: ReplyToMode;
streamMode: TelegramStreamMode;
textLimit: number;
opts: Pick<TelegramBotOptions, "token">;
};
export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDeps) => {
const {
bot,
cfg,
account,
telegramCfg,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
sendChatActionHandler,
runtime,
replyToMode,
streamMode,
textLimit,
opts,
} = deps;
return async (
primaryCtx: TelegramContext,
allMedia: TelegramMediaRef[],
storeAllowFrom: string[],
options?: { messageIdOverride?: string; forceWasMentioned?: boolean },
replyMedia?: TelegramMediaRef[],
) => {
const context = await buildTelegramMessageContext({
primaryCtx,
allMedia,
replyMedia,
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
sendChatActionHandler,
});
if (!context) {
return;
}
try {
await dispatchTelegramMessage({
context,
bot,
cfg,
runtime,
replyToMode,
streamMode,
textLimit,
telegramCfg,
opts,
});
} catch (err) {
runtime.error?.(danger(`telegram message processing failed: ${String(err)}`));
try {
await bot.api.sendMessage(
context.chatId,
"Something went wrong while processing your request. Please try again.",
context.threadSpec?.id != null ? { message_thread_id: context.threadSpec.id } : undefined,
);
} catch {
// Best-effort fallback; delivery may fail if the bot was blocked or the chat is invalid.
}
}
};
};
export * from "../../extensions/telegram/src/bot-message.js";

View File

@@ -1,282 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
buildCappedTelegramMenuCommands,
buildPluginTelegramMenuCommands,
hashCommandList,
syncTelegramMenuCommands,
} from "./bot-native-command-menu.js";
type SyncMenuOptions = {
deleteMyCommands: ReturnType<typeof vi.fn>;
setMyCommands: ReturnType<typeof vi.fn>;
commandsToRegister: Parameters<typeof syncTelegramMenuCommands>[0]["commandsToRegister"];
accountId: string;
botIdentity: string;
runtimeLog?: ReturnType<typeof vi.fn>;
runtimeError?: ReturnType<typeof vi.fn>;
};
function syncMenuCommandsWithMocks(options: SyncMenuOptions): void {
syncTelegramMenuCommands({
bot: {
api: { deleteMyCommands: options.deleteMyCommands, setMyCommands: options.setMyCommands },
} as unknown as Parameters<typeof syncTelegramMenuCommands>[0]["bot"],
runtime: {
log: options.runtimeLog ?? vi.fn(),
error: options.runtimeError ?? vi.fn(),
exit: vi.fn(),
} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
commandsToRegister: options.commandsToRegister,
accountId: options.accountId,
botIdentity: options.botIdentity,
});
}
describe("bot-native-command-menu", () => {
it("caps menu entries to Telegram limit", () => {
const allCommands = Array.from({ length: 105 }, (_, i) => ({
command: `cmd_${i}`,
description: `Command ${i}`,
}));
const result = buildCappedTelegramMenuCommands({ allCommands });
expect(result.commandsToRegister).toHaveLength(100);
expect(result.totalCommands).toBe(105);
expect(result.maxCommands).toBe(100);
expect(result.overflowCount).toBe(5);
expect(result.commandsToRegister[0]).toEqual({ command: "cmd_0", description: "Command 0" });
expect(result.commandsToRegister[99]).toEqual({
command: "cmd_99",
description: "Command 99",
});
});
it("validates plugin command specs and reports conflicts", () => {
const existingCommands = new Set(["native"]);
const result = buildPluginTelegramMenuCommands({
specs: [
{ name: "valid", description: " Works " },
{ name: "bad-name!", description: "Bad" },
{ name: "native", description: "Conflicts with native" },
{ name: "valid", description: "Duplicate plugin name" },
{ name: "empty", description: " " },
],
existingCommands,
});
expect(result.commands).toEqual([{ command: "valid", description: "Works" }]);
expect(result.issues).toContain(
'Plugin command "/bad-name!" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).',
);
expect(result.issues).toContain(
'Plugin command "/native" conflicts with an existing Telegram command.',
);
expect(result.issues).toContain('Plugin command "/valid" is duplicated.');
expect(result.issues).toContain('Plugin command "/empty" is missing a description.');
});
it("normalizes hyphenated plugin command names", () => {
const result = buildPluginTelegramMenuCommands({
specs: [{ name: "agent-run", description: "Run agent" }],
existingCommands: new Set<string>(),
});
expect(result.commands).toEqual([{ command: "agent_run", description: "Run agent" }]);
expect(result.issues).toEqual([]);
});
it("ignores malformed plugin specs without crashing", () => {
const malformedSpecs = [
{ name: "valid", description: " Works " },
{ name: "missing-description", description: undefined },
{ name: undefined, description: "Missing name" },
] as unknown as Parameters<typeof buildPluginTelegramMenuCommands>[0]["specs"];
const result = buildPluginTelegramMenuCommands({
specs: malformedSpecs,
existingCommands: new Set<string>(),
});
expect(result.commands).toEqual([{ command: "valid", description: "Works" }]);
expect(result.issues).toContain(
'Plugin command "/missing_description" is missing a description.',
);
expect(result.issues).toContain(
'Plugin command "/<unknown>" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).',
);
});
it("deletes stale commands before setting new menu", async () => {
const callOrder: string[] = [];
const deleteMyCommands = vi.fn(async () => {
callOrder.push("delete");
});
const setMyCommands = vi.fn(async () => {
callOrder.push("set");
});
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
commandsToRegister: [{ command: "cmd", description: "Command" }],
accountId: `test-delete-${Date.now()}`,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
expect(callOrder).toEqual(["delete", "set"]);
});
it("produces a stable hash regardless of command order (#32017)", () => {
const commands = [
{ command: "bravo", description: "B" },
{ command: "alpha", description: "A" },
];
const reversed = [...commands].toReversed();
expect(hashCommandList(commands)).toBe(hashCommandList(reversed));
});
it("produces different hashes for different command lists (#32017)", () => {
const a = [{ command: "alpha", description: "A" }];
const b = [{ command: "alpha", description: "Changed" }];
expect(hashCommandList(a)).not.toBe(hashCommandList(b));
});
it("skips sync when command hash is unchanged (#32017)", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeLog = vi.fn();
// Use a unique accountId so cached hashes from other tests don't interfere.
const accountId = `test-skip-${Date.now()}`;
const commands = [{ command: "skip_test", description: "Skip test command" }];
// First sync — no cached hash, should call setMyCommands.
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: commands,
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalledTimes(1);
});
// Second sync with the same commands — hash is cached, should skip.
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: commands,
accountId,
botIdentity: "bot-a",
});
// setMyCommands should NOT have been called a second time.
expect(setMyCommands).toHaveBeenCalledTimes(1);
});
it("does not reuse cached hash across different bot identities", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeLog = vi.fn();
const accountId = `test-bot-identity-${Date.now()}`;
const commands = [{ command: "same", description: "Same" }];
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: commands,
accountId,
botIdentity: "token-bot-a",
});
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(1));
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: commands,
accountId,
botIdentity: "token-bot-b",
});
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
});
it("does not cache empty-menu hash when deleteMyCommands fails", async () => {
const deleteMyCommands = vi
.fn()
.mockRejectedValueOnce(new Error("transient failure"))
.mockResolvedValue(undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeLog = vi.fn();
const accountId = `test-empty-delete-fail-${Date.now()}`;
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: [],
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(1));
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: [],
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2));
});
it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi
.fn()
.mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH"))
.mockResolvedValue(undefined);
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
runtimeError,
commandsToRegister: Array.from({ length: 100 }, (_, i) => ({
command: `cmd_${i}`,
description: `Command ${i}`,
})),
accountId: `test-retry-${Date.now()}`,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalledTimes(2);
});
const firstPayload = setMyCommands.mock.calls[0]?.[0] as Array<unknown>;
const secondPayload = setMyCommands.mock.calls[1]?.[0] as Array<unknown>;
expect(firstPayload).toHaveLength(100);
expect(secondPayload).toHaveLength(80);
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 80.",
);
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram accepted 80 commands after BOT_COMMANDS_TOO_MUCH (started with 100; omitted 20). Reduce plugin/skill/custom commands to expose more menu entries.",
);
expect(runtimeError).not.toHaveBeenCalled();
});
});

View File

@@ -1,254 +1 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Bot } from "grammy";
import { resolveStateDir } from "../config/paths.js";
import {
normalizeTelegramCommandName,
TELEGRAM_COMMAND_NAME_PATTERN,
} from "../config/telegram-custom-commands.js";
import { logVerbose } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
export const TELEGRAM_MAX_COMMANDS = 100;
const TELEGRAM_COMMAND_RETRY_RATIO = 0.8;
export type TelegramMenuCommand = {
command: string;
description: string;
};
type TelegramPluginCommandSpec = {
name: unknown;
description: unknown;
};
function isBotCommandsTooMuchError(err: unknown): boolean {
if (!err) {
return false;
}
const pattern = /\bBOT_COMMANDS_TOO_MUCH\b/i;
if (typeof err === "string") {
return pattern.test(err);
}
if (err instanceof Error) {
if (pattern.test(err.message)) {
return true;
}
}
if (typeof err === "object") {
const maybe = err as { description?: unknown; message?: unknown };
if (typeof maybe.description === "string" && pattern.test(maybe.description)) {
return true;
}
if (typeof maybe.message === "string" && pattern.test(maybe.message)) {
return true;
}
}
return false;
}
function formatTelegramCommandRetrySuccessLog(params: {
initialCount: number;
acceptedCount: number;
}): string {
const omittedCount = Math.max(0, params.initialCount - params.acceptedCount);
return (
`Telegram accepted ${params.acceptedCount} commands after BOT_COMMANDS_TOO_MUCH ` +
`(started with ${params.initialCount}; omitted ${omittedCount}). ` +
"Reduce plugin/skill/custom commands to expose more menu entries."
);
}
export function buildPluginTelegramMenuCommands(params: {
specs: TelegramPluginCommandSpec[];
existingCommands: Set<string>;
}): { commands: TelegramMenuCommand[]; issues: string[] } {
const { specs, existingCommands } = params;
const commands: TelegramMenuCommand[] = [];
const issues: string[] = [];
const pluginCommandNames = new Set<string>();
for (const spec of specs) {
const rawName = typeof spec.name === "string" ? spec.name : "";
const normalized = normalizeTelegramCommandName(rawName);
if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
const invalidName = rawName.trim() ? rawName : "<unknown>";
issues.push(
`Plugin command "/${invalidName}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
);
continue;
}
const description = typeof spec.description === "string" ? spec.description.trim() : "";
if (!description) {
issues.push(`Plugin command "/${normalized}" is missing a description.`);
continue;
}
if (existingCommands.has(normalized)) {
if (pluginCommandNames.has(normalized)) {
issues.push(`Plugin command "/${normalized}" is duplicated.`);
} else {
issues.push(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`);
}
continue;
}
pluginCommandNames.add(normalized);
existingCommands.add(normalized);
commands.push({ command: normalized, description });
}
return { commands, issues };
}
export function buildCappedTelegramMenuCommands(params: {
allCommands: TelegramMenuCommand[];
maxCommands?: number;
}): {
commandsToRegister: TelegramMenuCommand[];
totalCommands: number;
maxCommands: number;
overflowCount: number;
} {
const { allCommands } = params;
const maxCommands = params.maxCommands ?? TELEGRAM_MAX_COMMANDS;
const totalCommands = allCommands.length;
const overflowCount = Math.max(0, totalCommands - maxCommands);
const commandsToRegister = allCommands.slice(0, maxCommands);
return { commandsToRegister, totalCommands, maxCommands, overflowCount };
}
/** Compute a stable hash of the command list for change detection. */
export function hashCommandList(commands: TelegramMenuCommand[]): string {
const sorted = [...commands].toSorted((a, b) => a.command.localeCompare(b.command));
return createHash("sha256").update(JSON.stringify(sorted)).digest("hex").slice(0, 16);
}
function hashBotIdentity(botIdentity?: string): string {
const normalized = botIdentity?.trim();
if (!normalized) {
return "no-bot";
}
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
}
function resolveCommandHashPath(accountId?: string, botIdentity?: string): string {
const stateDir = resolveStateDir(process.env, os.homedir);
const normalizedAccount = accountId?.trim().replace(/[^a-z0-9._-]+/gi, "_") || "default";
const botHash = hashBotIdentity(botIdentity);
return path.join(stateDir, "telegram", `command-hash-${normalizedAccount}-${botHash}.txt`);
}
async function readCachedCommandHash(
accountId?: string,
botIdentity?: string,
): Promise<string | null> {
try {
return (await fs.readFile(resolveCommandHashPath(accountId, botIdentity), "utf-8")).trim();
} catch {
return null;
}
}
async function writeCachedCommandHash(
accountId: string | undefined,
botIdentity: string | undefined,
hash: string,
): Promise<void> {
const filePath = resolveCommandHashPath(accountId, botIdentity);
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, hash, "utf-8");
} catch {
// Best-effort: failing to cache the hash just means the next restart
// will sync commands again, which is the pre-fix behaviour.
}
}
export function syncTelegramMenuCommands(params: {
bot: Bot;
runtime: RuntimeEnv;
commandsToRegister: TelegramMenuCommand[];
accountId?: string;
botIdentity?: string;
}): void {
const { bot, runtime, commandsToRegister, accountId, botIdentity } = params;
const sync = async () => {
// Skip sync if the command list hasn't changed since the last successful
// sync. This prevents hitting Telegram's 429 rate limit when the gateway
// is restarted several times in quick succession.
// See: openclaw/openclaw#32017
const currentHash = hashCommandList(commandsToRegister);
const cachedHash = await readCachedCommandHash(accountId, botIdentity);
if (cachedHash === currentHash) {
logVerbose("telegram: command menu unchanged; skipping sync");
return;
}
// Keep delete -> set ordering to avoid stale deletions racing after fresh registrations.
let deleteSucceeded = true;
if (typeof bot.api.deleteMyCommands === "function") {
deleteSucceeded = await withTelegramApiErrorLogging({
operation: "deleteMyCommands",
runtime,
fn: () => bot.api.deleteMyCommands(),
})
.then(() => true)
.catch(() => false);
}
if (commandsToRegister.length === 0) {
if (!deleteSucceeded) {
runtime.log?.("telegram: deleteMyCommands failed; skipping empty-menu hash cache write");
return;
}
await writeCachedCommandHash(accountId, botIdentity, currentHash);
return;
}
let retryCommands = commandsToRegister;
const initialCommandCount = commandsToRegister.length;
while (retryCommands.length > 0) {
try {
await withTelegramApiErrorLogging({
operation: "setMyCommands",
runtime,
shouldLog: (err) => !isBotCommandsTooMuchError(err),
fn: () => bot.api.setMyCommands(retryCommands),
});
if (retryCommands.length < initialCommandCount) {
runtime.log?.(
formatTelegramCommandRetrySuccessLog({
initialCount: initialCommandCount,
acceptedCount: retryCommands.length,
}),
);
}
await writeCachedCommandHash(accountId, botIdentity, currentHash);
return;
} catch (err) {
if (!isBotCommandsTooMuchError(err)) {
throw err;
}
const nextCount = Math.floor(retryCommands.length * TELEGRAM_COMMAND_RETRY_RATIO);
const reducedCount =
nextCount < retryCommands.length ? nextCount : retryCommands.length - 1;
if (reducedCount <= 0) {
runtime.error?.(
"Telegram rejected native command registration (BOT_COMMANDS_TOO_MUCH); leaving menu empty. Reduce commands or disable channels.telegram.commands.native.",
);
return;
}
runtime.log?.(
`Telegram rejected ${retryCommands.length} commands (BOT_COMMANDS_TOO_MUCH); retrying with ${reducedCount}.`,
);
retryCommands = retryCommands.slice(0, reducedCount);
}
}
};
void sync().catch((err) => {
runtime.error?.(`Telegram command sync failed: ${String(err)}`);
});
}
export * from "../../extensions/telegram/src/bot-native-command-menu.js";

View File

@@ -1,194 +1,2 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import type { TelegramAccountConfig } from "../config/types.js";
import {
createNativeCommandsHarness,
createTelegramGroupCommandContext,
findNotAuthorizedCalls,
} from "./bot-native-commands.test-helpers.js";
describe("native command auth in groups", () => {
function setup(params: {
cfg?: OpenClawConfig;
telegramCfg?: TelegramAccountConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
useAccessGroups?: boolean;
groupConfig?: Record<string, unknown>;
resolveGroupPolicy?: () => ChannelGroupPolicy;
}) {
return createNativeCommandsHarness({
cfg: params.cfg ?? ({} as OpenClawConfig),
telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig),
allowFrom: params.allowFrom ?? [],
groupAllowFrom: params.groupAllowFrom ?? [],
useAccessGroups: params.useAccessGroups ?? false,
resolveGroupPolicy:
params.resolveGroupPolicy ??
(() =>
({
allowlistEnabled: false,
allowed: true,
}) as ChannelGroupPolicy),
groupConfig: params.groupConfig,
});
}
it("authorizes native commands in groups when sender is in groupAllowFrom", async () => {
const { handlers, sendMessage } = setup({
groupAllowFrom: ["12345"],
useAccessGroups: true,
// no allowFrom — sender is NOT in DM allowlist
});
const ctx = createTelegramGroupCommandContext();
await handlers.status?.(ctx);
const notAuthCalls = findNotAuthorizedCalls(sendMessage);
expect(notAuthCalls).toHaveLength(0);
});
it("authorizes native commands in groups from commands.allowFrom.telegram", async () => {
const { handlers, sendMessage } = setup({
cfg: {
commands: {
allowFrom: {
telegram: ["12345"],
},
},
} as OpenClawConfig,
allowFrom: ["99999"],
groupAllowFrom: ["99999"],
useAccessGroups: true,
});
const ctx = createTelegramGroupCommandContext();
await handlers.status?.(ctx);
const notAuthCalls = findNotAuthorizedCalls(sendMessage);
expect(notAuthCalls).toHaveLength(0);
});
it("uses commands.allowFrom.telegram as the sole auth source when configured", async () => {
const { handlers, sendMessage } = setup({
cfg: {
commands: {
allowFrom: {
telegram: ["99999"],
},
},
} as OpenClawConfig,
groupAllowFrom: ["12345"],
useAccessGroups: true,
});
const ctx = createTelegramGroupCommandContext();
await handlers.status?.(ctx);
expect(sendMessage).toHaveBeenCalledWith(
-100999,
"You are not authorized to use this command.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
it("keeps groupPolicy disabled enforced when commands.allowFrom is configured", async () => {
const { handlers, sendMessage } = setup({
cfg: {
commands: {
allowFrom: {
telegram: ["12345"],
},
},
} as OpenClawConfig,
telegramCfg: {
groupPolicy: "disabled",
} as TelegramAccountConfig,
useAccessGroups: true,
resolveGroupPolicy: () =>
({
allowlistEnabled: false,
allowed: false,
}) as ChannelGroupPolicy,
});
const ctx = createTelegramGroupCommandContext();
await handlers.status?.(ctx);
expect(sendMessage).toHaveBeenCalledWith(
-100999,
"Telegram group commands are disabled.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
it("keeps group chat allowlists enforced when commands.allowFrom is configured", async () => {
const { handlers, sendMessage } = setup({
cfg: {
commands: {
allowFrom: {
telegram: ["12345"],
},
},
} as OpenClawConfig,
useAccessGroups: true,
resolveGroupPolicy: () =>
({
allowlistEnabled: true,
allowed: false,
}) as ChannelGroupPolicy,
});
const ctx = createTelegramGroupCommandContext();
await handlers.status?.(ctx);
expect(sendMessage).toHaveBeenCalledWith(
-100999,
"This group is not allowed.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
it("rejects native commands in groups when sender is in neither allowlist", async () => {
const { handlers, sendMessage } = setup({
allowFrom: ["99999"],
groupAllowFrom: ["99999"],
useAccessGroups: true,
});
const ctx = createTelegramGroupCommandContext({
username: "intruder",
});
await handlers.status?.(ctx);
const notAuthCalls = findNotAuthorizedCalls(sendMessage);
expect(notAuthCalls.length).toBeGreaterThan(0);
});
it("replies in the originating forum topic when auth is rejected", async () => {
const { handlers, sendMessage } = setup({
allowFrom: ["99999"],
groupAllowFrom: ["99999"],
useAccessGroups: true,
});
const ctx = createTelegramGroupCommandContext({
username: "intruder",
});
await handlers.status?.(ctx);
expect(sendMessage).toHaveBeenCalledWith(
-100999,
"You are not authorized to use this command.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
});
// Shim: re-exports from extensions/telegram/src/bot-native-commands.group-auth.test.ts
export * from "../../extensions/telegram/src/bot-native-commands.group-auth.test.js";

View File

@@ -1,99 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramAccountConfig } from "../config/types.js";
import {
createNativeCommandsHarness,
deliverReplies,
executePluginCommand,
getPluginCommandSpecs,
matchPluginCommand,
} from "./bot-native-commands.test-helpers.js";
type GetPluginCommandSpecsMock = {
mockReturnValue: (
value: ReturnType<typeof import("../plugins/commands.js").getPluginCommandSpecs>,
) => unknown;
};
type MatchPluginCommandMock = {
mockReturnValue: (
value: ReturnType<typeof import("../plugins/commands.js").matchPluginCommand>,
) => unknown;
};
type ExecutePluginCommandMock = {
mockResolvedValue: (
value: Awaited<ReturnType<typeof import("../plugins/commands.js").executePluginCommand>>,
) => unknown;
};
const getPluginCommandSpecsMock = getPluginCommandSpecs as unknown as GetPluginCommandSpecsMock;
const matchPluginCommandMock = matchPluginCommand as unknown as MatchPluginCommandMock;
const executePluginCommandMock = executePluginCommand as unknown as ExecutePluginCommandMock;
describe("registerTelegramNativeCommands (plugin auth)", () => {
it("does not register plugin commands in menu when native=false but keeps handlers available", () => {
const specs = Array.from({ length: 101 }, (_, i) => ({
name: `cmd_${i}`,
description: `Command ${i}`,
acceptsArgs: false,
}));
getPluginCommandSpecsMock.mockReturnValue(specs);
const { handlers, setMyCommands, log } = createNativeCommandsHarness({
cfg: {} as OpenClawConfig,
telegramCfg: {} as TelegramAccountConfig,
nativeEnabled: false,
});
expect(setMyCommands).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("registering first 100"));
expect(Object.keys(handlers)).toHaveLength(101);
});
it("allows requireAuth:false plugin command even when sender is unauthorized", async () => {
const command = {
name: "plugin",
description: "Plugin command",
pluginId: "test-plugin",
requireAuth: false,
handler: vi.fn(),
} as const;
getPluginCommandSpecsMock.mockReturnValue([
{ name: "plugin", description: "Plugin command", acceptsArgs: false },
]);
matchPluginCommandMock.mockReturnValue({ command, args: undefined });
executePluginCommandMock.mockResolvedValue({ text: "ok" });
const { handlers, bot } = createNativeCommandsHarness({
cfg: {} as OpenClawConfig,
telegramCfg: {} as TelegramAccountConfig,
allowFrom: ["999"],
nativeEnabled: false,
});
const ctx = {
message: {
chat: { id: 123, type: "private" },
from: { id: 111, username: "nope" },
message_id: 10,
date: 123456,
},
match: "",
};
await handlers.plugin?.(ctx);
expect(matchPluginCommand).toHaveBeenCalled();
expect(executePluginCommand).toHaveBeenCalledWith(
expect.objectContaining({
isAuthorizedSender: false,
}),
);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [{ text: "ok" }],
}),
);
expect(bot.api.sendMessage).not.toHaveBeenCalled();
});
});

View File

@@ -1,593 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
registerTelegramNativeCommands,
type RegisterTelegramHandlerParams,
} from "./bot-native-commands.js";
type RegisterTelegramNativeCommandsParams = Parameters<typeof registerTelegramNativeCommands>[0];
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
type ResolveConfiguredAcpBindingRecordFn =
typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
type EnsureConfiguredAcpBindingSessionFn =
typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherParams =
Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies;
type DeliverRepliesParams = Parameters<DeliverRepliesFn>[0];
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
queuedFinal: false,
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
};
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
ok: true,
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
})),
}));
const sessionMocks = vi.hoisted(() => ({
recordSessionMetaFromInbound: vi.fn(),
resolveStorePath: vi.fn(),
}));
const replyMocks = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
async () => dispatchReplyResult,
),
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn<DeliverRepliesFn>(async () => ({ delivered: true })),
}));
const sessionBindingMocks = vi.hoisted(() => ({
resolveByConversation: vi.fn<
(ref: unknown) => { bindingId: string; targetSessionKey: string } | null
>(() => null),
touch: vi.fn(),
}));
vi.mock("../acp/persistent-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../acp/persistent-bindings.js")>();
return {
...actual,
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
};
});
vi.mock("../config/sessions.js", () => ({
recordSessionMetaFromInbound: sessionMocks.recordSessionMetaFromInbound,
resolveStorePath: sessionMocks.resolveStorePath,
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn(async () => []),
}));
vi.mock("../auto-reply/reply/inbound-context.js", () => ({
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
}));
vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
}));
vi.mock("../channels/reply-prefix.js", () => ({
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
}));
vi.mock("../infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({
bind: vi.fn(),
getCapabilities: vi.fn(),
listBySession: vi.fn(),
resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref),
touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at),
unbind: vi.fn(),
}),
}));
vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../auto-reply/skill-commands.js")>();
return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) };
});
vi.mock("../plugins/commands.js", () => ({
getPluginCommandSpecs: vi.fn(() => []),
matchPluginCommand: vi.fn(() => null),
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
}));
vi.mock("./bot/delivery.js", () => ({
deliverReplies: deliveryMocks.deliverReplies,
}));
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function createNativeCommandTestParams(
params: Partial<RegisterTelegramNativeCommandsParams> = {},
): RegisterTelegramNativeCommandsParams {
const log = vi.fn();
return {
bot:
params.bot ??
({
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as RegisterTelegramNativeCommandsParams["bot"]),
cfg: params.cfg ?? ({} as OpenClawConfig),
runtime:
params.runtime ?? ({ log } as unknown as RegisterTelegramNativeCommandsParams["runtime"]),
accountId: params.accountId ?? "default",
telegramCfg: params.telegramCfg ?? ({} as RegisterTelegramNativeCommandsParams["telegramCfg"]),
allowFrom: params.allowFrom ?? [],
groupAllowFrom: params.groupAllowFrom ?? [],
replyToMode: params.replyToMode ?? "off",
textLimit: params.textLimit ?? 4000,
useAccessGroups: params.useAccessGroups ?? false,
nativeEnabled: params.nativeEnabled ?? true,
nativeSkillsEnabled: params.nativeSkillsEnabled ?? false,
nativeDisabledExplicit: params.nativeDisabledExplicit ?? false,
resolveGroupPolicy:
params.resolveGroupPolicy ??
(() =>
({
allowlistEnabled: false,
allowed: true,
}) as ReturnType<RegisterTelegramNativeCommandsParams["resolveGroupPolicy"]>),
resolveTelegramGroupConfig:
params.resolveTelegramGroupConfig ??
(() => ({ groupConfig: undefined, topicConfig: undefined })),
shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false),
opts: params.opts ?? { token: "token" },
};
}
type TelegramCommandHandler = (ctx: unknown) => Promise<void>;
function buildStatusCommandContext() {
return {
match: "",
message: {
message_id: 1,
date: Math.floor(Date.now() / 1000),
chat: { id: 100, type: "private" as const },
from: { id: 200, username: "bob" },
},
};
}
function buildStatusTopicCommandContext() {
return {
match: "",
message: {
message_id: 2,
date: Math.floor(Date.now() / 1000),
chat: {
id: -1001234567890,
type: "supergroup" as const,
title: "OpenClaw",
is_forum: true,
},
message_thread_id: 42,
from: { id: 200, username: "bob" },
},
};
}
function registerAndResolveStatusHandler(params: {
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params;
return registerAndResolveCommandHandlerBase({
commandName: "status",
cfg,
allowFrom: allowFrom ?? ["*"],
groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: true,
resolveTelegramGroupConfig,
});
}
function registerAndResolveCommandHandlerBase(params: {
commandName: string;
cfg: OpenClawConfig;
allowFrom: string[];
groupAllowFrom: string[];
useAccessGroups: boolean;
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const {
commandName,
cfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveTelegramGroupConfig,
} = params;
const commandHandlers = new Map<string, TelegramCommandHandler>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
registerTelegramNativeCommands({
...createNativeCommandTestParams({
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage,
},
command: vi.fn((name: string, cb: TelegramCommandHandler) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveTelegramGroupConfig,
}),
});
const handler = commandHandlers.get(commandName);
expect(handler).toBeTruthy();
return { handler: handler as TelegramCommandHandler, sendMessage };
}
function registerAndResolveCommandHandler(params: {
commandName: string;
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
useAccessGroups?: boolean;
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const {
commandName,
cfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveTelegramGroupConfig,
} = params;
return registerAndResolveCommandHandlerBase({
commandName,
cfg,
allowFrom: allowFrom ?? [],
groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: useAccessGroups ?? true,
resolveTelegramGroupConfig,
});
}
function createConfiguredAcpTopicBinding(boundSessionKey: string) {
return {
spec: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
},
} satisfies import("../acp/persistent-bindings.js").ResolvedConfiguredAcpBinding;
}
function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.fn>) {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"You are not authorized to use this command.",
expect.objectContaining({ message_thread_id: 42 }),
);
}
describe("registerTelegramNativeCommands — session metadata", () => {
beforeEach(() => {
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear();
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockClear();
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
});
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
replyMocks.dispatchReplyWithBufferedBlockDispatcher
.mockClear()
.mockResolvedValue(dispatchReplyResult);
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
sessionBindingMocks.touch.mockReset();
deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true });
});
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
const cfg: OpenClawConfig = {};
const { handler } = registerAndResolveStatusHandler({ cfg });
await handler(buildStatusCommandContext());
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
const call = (
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
[{ sessionKey?: string; ctx?: { OriginatingChannel?: string; Provider?: string } }]
>
)[0]?.[0];
expect(call?.ctx?.OriginatingChannel).toBe("telegram");
expect(call?.ctx?.Provider).toBe("telegram");
expect(call?.sessionKey).toBe("agent:main:telegram:slash:200");
});
it("awaits session metadata persistence before dispatch", async () => {
const deferred = createDeferred<void>();
sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise);
const cfg: OpenClawConfig = {};
const { handler } = registerAndResolveStatusHandler({ cfg });
const runPromise = handler(buildStatusCommandContext());
await vi.waitFor(() => {
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
});
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
deferred.resolve();
await runPromise;
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver(
{
text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).",
},
{ kind: "final" },
);
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
});
await handler(buildStatusCommandContext());
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
| DeliverRepliesParams
| undefined;
const deliveredPayload = deliveredCall?.replies?.[0];
expect(deliveredPayload).toBeTruthy();
expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once");
expect(deliveredPayload?.["channelData"]).toBeUndefined();
});
it("suppresses local structured exec approval replies for native commands", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver(
{
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
channelData: {
execApproval: {
approvalId: "7f423fdc-1111-2222-3333-444444444444",
approvalSlug: "7f423fdc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
},
{ kind: "tool" },
);
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
});
await handler(buildStatusCommandContext());
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
});
it("routes Telegram native commands through configured ACP topic bindings", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
createConfiguredAcpTopicBinding(boundSessionKey),
);
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: boundSessionKey,
});
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(buildStatusTopicCommandContext());
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[{ ctx?: { CommandTargetSessionKey?: string } }]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
const sessionMetaCall = (
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
[{ sessionKey?: string }]
>
)[0]?.[0];
expect(sessionMetaCall?.sessionKey).toBe("agent:codex:telegram:slash:200");
});
it("routes Telegram native commands through topic-specific agent sessions", async () => {
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: { agentId: "zu" },
}),
});
await handler(buildStatusTopicCommandContext());
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[{ ctx?: { CommandTargetSessionKey?: string } }]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(
"agent:zu:telegram:group:-1001234567890:topic:42",
);
});
it("routes Telegram native commands through bound topic sessions", async () => {
sessionBindingMocks.resolveByConversation.mockReturnValue({
bindingId: "default:-1001234567890:topic:42",
targetSessionKey: "agent:codex-acp:session-1",
});
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(buildStatusTopicCommandContext());
expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
});
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[{ ctx?: { CommandTargetSessionKey?: string } }]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-1");
expect(sessionBindingMocks.touch).toHaveBeenCalledWith(
"default:-1001234567890:topic:42",
undefined,
);
});
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
createConfiguredAcpTopicBinding(boundSessionKey),
);
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: false,
sessionKey: boundSessionKey,
error: "gateway unavailable",
});
const { handler, sendMessage } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(buildStatusTopicCommandContext());
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"Configured ACP binding is unavailable right now. Please try again.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
createConfiguredAcpTopicBinding(boundSessionKey),
);
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: boundSessionKey,
});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "new",
cfg: {},
allowFrom: [],
groupAllowFrom: [],
useAccessGroups: true,
});
await handler(buildStatusTopicCommandContext());
expectUnauthorizedNewCommandBlocked(sendMessage);
});
it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => {
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "new",
cfg: {},
allowFrom: [],
groupAllowFrom: [],
useAccessGroups: true,
});
await handler(buildStatusTopicCommandContext());
expectUnauthorizedNewCommandBlocked(sendMessage);
});
});

View File

@@ -1,126 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { writeSkill } from "../agents/skills.e2e-test-helpers.js";
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramAccountConfig } from "../config/types.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
const pluginCommandMocks = vi.hoisted(() => ({
getPluginCommandSpecs: vi.fn(() => []),
matchPluginCommand: vi.fn(() => null),
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn(async () => ({ delivered: true })),
}));
vi.mock("../plugins/commands.js", () => ({
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
executePluginCommand: pluginCommandMocks.executePluginCommand,
}));
vi.mock("./bot/delivery.js", () => ({
deliverReplies: deliveryMocks.deliverReplies,
}));
const tempDirs: string[] = [];
async function makeWorkspace(prefix: string) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
describe("registerTelegramNativeCommands skill allowlist integration", () => {
afterEach(async () => {
pluginCommandMocks.getPluginCommandSpecs.mockClear().mockReturnValue([]);
pluginCommandMocks.matchPluginCommand.mockClear().mockReturnValue(null);
pluginCommandMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" });
deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true });
await Promise.all(
tempDirs
.splice(0, tempDirs.length)
.map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("registers only allowlisted skills for the bound agent menu", async () => {
const workspaceDir = await makeWorkspace("openclaw-telegram-skills-");
await writeSkill({
dir: path.join(workspaceDir, "skills", "alpha-skill"),
name: "alpha-skill",
description: "Alpha skill",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "beta-skill"),
name: "beta-skill",
description: "Beta skill",
});
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "alpha", workspace: workspaceDir, skills: ["alpha-skill"] },
{ id: "beta", workspace: workspaceDir, skills: ["beta-skill"] },
],
},
bindings: [
{
agentId: "alpha",
match: { channel: "telegram", accountId: "bot-a" },
},
],
};
registerTelegramNativeCommands({
bot: {
api: {
setMyCommands,
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
runtime: { log: vi.fn() } as unknown as Parameters<
typeof registerTelegramNativeCommands
>[0]["runtime"],
accountId: "bot-a",
telegramCfg: {} as TelegramAccountConfig,
allowFrom: [],
groupAllowFrom: [],
replyToMode: "off",
textLimit: 4000,
useAccessGroups: false,
nativeEnabled: true,
nativeSkillsEnabled: true,
nativeDisabledExplicit: false,
resolveGroupPolicy: () =>
({
allowlistEnabled: false,
allowed: true,
}) as ReturnType<
Parameters<typeof registerTelegramNativeCommands>[0]["resolveGroupPolicy"]
>,
resolveTelegramGroupConfig: () => ({
groupConfig: undefined,
topicConfig: undefined,
}),
shouldSkipUpdate: () => false,
opts: { token: "token" },
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
command: string;
description: string;
}>;
expect(registeredCommands.some((entry) => entry.command === "alpha_skill")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "beta_skill")).toBe(false);
});
});

View File

@@ -1,176 +0,0 @@
import { vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import type { TelegramAccountConfig } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
type RegisterTelegramNativeCommandsParams = Parameters<typeof registerTelegramNativeCommands>[0];
type GetPluginCommandSpecsFn = typeof import("../plugins/commands.js").getPluginCommandSpecs;
type MatchPluginCommandFn = typeof import("../plugins/commands.js").matchPluginCommand;
type ExecutePluginCommandFn = typeof import("../plugins/commands.js").executePluginCommand;
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
type NativeCommandHarness = {
handlers: Record<string, (ctx: unknown) => Promise<void>>;
sendMessage: AnyAsyncMock;
setMyCommands: AnyAsyncMock;
log: AnyMock;
bot: {
api: {
setMyCommands: AnyAsyncMock;
sendMessage: AnyAsyncMock;
};
command: (name: string, handler: (ctx: unknown) => Promise<void>) => void;
};
};
const pluginCommandMocks = vi.hoisted(() => ({
getPluginCommandSpecs: vi.fn<GetPluginCommandSpecsFn>(() => []),
matchPluginCommand: vi.fn<MatchPluginCommandFn>(() => null),
executePluginCommand: vi.fn<ExecutePluginCommandFn>(async () => ({ text: "ok" })),
}));
export const getPluginCommandSpecs = pluginCommandMocks.getPluginCommandSpecs;
export const matchPluginCommand = pluginCommandMocks.matchPluginCommand;
export const executePluginCommand = pluginCommandMocks.executePluginCommand;
vi.mock("../plugins/commands.js", () => ({
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
executePluginCommand: pluginCommandMocks.executePluginCommand,
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn(async () => {}),
}));
export const deliverReplies = deliveryMocks.deliverReplies;
vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies }));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn(async () => []),
}));
export function createNativeCommandTestParams(
params: Partial<RegisterTelegramNativeCommandsParams> = {},
): RegisterTelegramNativeCommandsParams {
const log = vi.fn();
return {
bot:
params.bot ??
({
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as RegisterTelegramNativeCommandsParams["bot"]),
cfg: params.cfg ?? ({} as OpenClawConfig),
runtime:
params.runtime ?? ({ log } as unknown as RegisterTelegramNativeCommandsParams["runtime"]),
accountId: params.accountId ?? "default",
telegramCfg: params.telegramCfg ?? ({} as RegisterTelegramNativeCommandsParams["telegramCfg"]),
allowFrom: params.allowFrom ?? [],
groupAllowFrom: params.groupAllowFrom ?? [],
replyToMode: params.replyToMode ?? "off",
textLimit: params.textLimit ?? 4000,
useAccessGroups: params.useAccessGroups ?? false,
nativeEnabled: params.nativeEnabled ?? true,
nativeSkillsEnabled: params.nativeSkillsEnabled ?? false,
nativeDisabledExplicit: params.nativeDisabledExplicit ?? false,
resolveGroupPolicy:
params.resolveGroupPolicy ??
(() =>
({
allowlistEnabled: false,
allowed: true,
}) as ReturnType<RegisterTelegramNativeCommandsParams["resolveGroupPolicy"]>),
resolveTelegramGroupConfig:
params.resolveTelegramGroupConfig ??
(() => ({ groupConfig: undefined, topicConfig: undefined })),
shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false),
opts: params.opts ?? { token: "token" },
};
}
export function createNativeCommandsHarness(params?: {
cfg?: OpenClawConfig;
runtime?: RuntimeEnv;
telegramCfg?: TelegramAccountConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
useAccessGroups?: boolean;
nativeEnabled?: boolean;
groupConfig?: Record<string, unknown>;
resolveGroupPolicy?: () => ChannelGroupPolicy;
}): NativeCommandHarness {
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
const sendMessage: AnyAsyncMock = vi.fn(async () => undefined);
const setMyCommands: AnyAsyncMock = vi.fn(async () => undefined);
const log: AnyMock = vi.fn();
const bot: NativeCommandHarness["bot"] = {
api: {
setMyCommands,
sendMessage,
},
command: (name: string, handler: (ctx: unknown) => Promise<void>) => {
handlers[name] = handler;
},
} as const;
registerTelegramNativeCommands({
bot: bot as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg: params?.cfg ?? ({} as OpenClawConfig),
runtime: params?.runtime ?? ({ log } as unknown as RuntimeEnv),
accountId: "default",
telegramCfg: params?.telegramCfg ?? ({} as TelegramAccountConfig),
allowFrom: params?.allowFrom ?? [],
groupAllowFrom: params?.groupAllowFrom ?? [],
replyToMode: "off",
textLimit: 4000,
useAccessGroups: params?.useAccessGroups ?? false,
nativeEnabled: params?.nativeEnabled ?? true,
nativeSkillsEnabled: false,
nativeDisabledExplicit: false,
resolveGroupPolicy:
params?.resolveGroupPolicy ??
(() =>
({
allowlistEnabled: false,
allowed: true,
}) as ChannelGroupPolicy),
resolveTelegramGroupConfig: () => ({
groupConfig: params?.groupConfig as undefined,
topicConfig: undefined,
}),
shouldSkipUpdate: () => false,
opts: { token: "token" },
});
return { handlers, sendMessage, setMyCommands, log, bot };
}
export function createTelegramGroupCommandContext(params?: {
senderId?: number;
username?: string;
threadId?: number;
}) {
return {
message: {
chat: { id: -100999, type: "supergroup", is_forum: true },
from: {
id: params?.senderId ?? 12345,
username: params?.username ?? "testuser",
},
message_thread_id: params?.threadId ?? 42,
message_id: 1,
date: 1700000000,
},
match: "",
};
}
export function findNotAuthorizedCalls(sendMessage: AnyAsyncMock) {
return sendMessage.mock.calls.filter(
(call) => typeof call[1] === "string" && call[1].includes("not authorized"),
);
}

View File

@@ -1,293 +0,0 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { STATE_DIR } from "../config/paths.js";
import { TELEGRAM_COMMAND_NAME_PATTERN } from "../config/telegram-custom-commands.js";
import type { TelegramAccountConfig } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
const { listSkillCommandsForAgents } = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn(() => []),
}));
const pluginCommandMocks = vi.hoisted(() => ({
getPluginCommandSpecs: vi.fn(() => []),
matchPluginCommand: vi.fn(() => null),
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn(async () => ({ delivered: true })),
}));
vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../auto-reply/skill-commands.js")>();
return {
...actual,
listSkillCommandsForAgents,
};
});
vi.mock("../plugins/commands.js", () => ({
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
executePluginCommand: pluginCommandMocks.executePluginCommand,
}));
vi.mock("./bot/delivery.js", () => ({
deliverReplies: deliveryMocks.deliverReplies,
}));
describe("registerTelegramNativeCommands", () => {
type RegisteredCommand = {
command: string;
description: string;
};
async function waitForRegisteredCommands(
setMyCommands: ReturnType<typeof vi.fn>,
): Promise<RegisteredCommand[]> {
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[];
}
beforeEach(() => {
listSkillCommandsForAgents.mockClear();
listSkillCommandsForAgents.mockReturnValue([]);
pluginCommandMocks.getPluginCommandSpecs.mockClear();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([]);
pluginCommandMocks.matchPluginCommand.mockClear();
pluginCommandMocks.matchPluginCommand.mockReturnValue(null);
pluginCommandMocks.executePluginCommand.mockClear();
pluginCommandMocks.executePluginCommand.mockResolvedValue({ text: "ok" });
deliveryMocks.deliverReplies.mockClear();
deliveryMocks.deliverReplies.mockResolvedValue({ delivered: true });
});
const buildParams = (cfg: OpenClawConfig, accountId = "default") =>
({
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
runtime: {} as RuntimeEnv,
accountId,
telegramCfg: {} as TelegramAccountConfig,
allowFrom: [],
groupAllowFrom: [],
replyToMode: "off",
textLimit: 4000,
useAccessGroups: false,
nativeEnabled: true,
nativeSkillsEnabled: true,
nativeDisabledExplicit: false,
resolveGroupPolicy: () =>
({
allowlistEnabled: false,
allowed: true,
}) as ReturnType<
Parameters<typeof registerTelegramNativeCommands>[0]["resolveGroupPolicy"]
>,
resolveTelegramGroupConfig: () => ({
groupConfig: undefined,
topicConfig: undefined,
}),
shouldSkipUpdate: () => false,
opts: { token: "token" },
}) satisfies Parameters<typeof registerTelegramNativeCommands>[0];
it("scopes skill commands when account binding exists", () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true }, { id: "butler" }],
},
bindings: [
{
agentId: "butler",
match: { channel: "telegram", accountId: "bot-a" },
},
],
};
registerTelegramNativeCommands(buildParams(cfg, "bot-a"));
expect(listSkillCommandsForAgents).toHaveBeenCalledWith({
cfg,
agentIds: ["butler"],
});
});
it("scopes skill commands to default agent without a matching binding (#15599)", () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true }, { id: "butler" }],
},
};
registerTelegramNativeCommands(buildParams(cfg, "bot-a"));
expect(listSkillCommandsForAgents).toHaveBeenCalledWith({
cfg,
agentIds: ["main"],
});
});
it("truncates Telegram command registration to 100 commands", async () => {
const cfg: OpenClawConfig = {
commands: { native: false },
};
const customCommands = Array.from({ length: 120 }, (_, index) => ({
command: `cmd_${index}`,
description: `Command ${index}`,
}));
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const runtimeLog = vi.fn();
registerTelegramNativeCommands({
...buildParams(cfg),
bot: {
api: {
setMyCommands,
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
runtime: { log: runtimeLog } as unknown as RuntimeEnv,
telegramCfg: { customCommands } as TelegramAccountConfig,
nativeEnabled: false,
nativeSkillsEnabled: false,
});
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands).toHaveLength(100);
expect(registeredCommands).toEqual(customCommands.slice(0, 100));
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram limits bots to 100 commands. 120 configured; registering first 100. Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.",
);
});
it("normalizes hyphenated native command names for Telegram registration", async () => {
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const command = vi.fn();
registerTelegramNativeCommands({
...buildParams({}),
bot: {
api: {
setMyCommands,
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command,
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
});
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false);
const registeredHandlers = command.mock.calls.map(([name]) => name);
expect(registeredHandlers).toContain("export_session");
expect(registeredHandlers).not.toContain("export-session");
});
it("registers only Telegram-safe command names across native, custom, and plugin sources", async () => {
const setMyCommands = vi.fn().mockResolvedValue(undefined);
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{ name: "plugin-status", description: "Plugin status" },
{ name: "plugin@bad", description: "Bad plugin command" },
] as never);
registerTelegramNativeCommands({
...buildParams({}),
bot: {
api: {
setMyCommands,
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
telegramCfg: {
customCommands: [
{ command: "custom-backup", description: "Custom backup" },
{ command: "custom!bad", description: "Bad custom command" },
],
} as TelegramAccountConfig,
});
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands.length).toBeGreaterThan(0);
for (const entry of registeredCommands) {
expect(entry.command.includes("-")).toBe(false);
expect(TELEGRAM_COMMAND_NAME_PATTERN.test(entry.command)).toBe(true);
}
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "custom_backup")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "plugin_status")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "plugin-status")).toBe(false);
expect(registeredCommands.some((entry) => entry.command === "custom-bad")).toBe(false);
});
it("passes agent-scoped media roots for plugin command replies with media", async () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true }, { id: "work" }],
},
bindings: [{ agentId: "work", match: { channel: "telegram", accountId: "default" } }],
};
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: { key: "plug", requireAuth: false },
args: undefined,
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "with media",
mediaUrl: "/tmp/workspace-work/render.png",
} as never);
registerTelegramNativeCommands({
...buildParams(cfg),
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage,
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.({
match: "",
message: {
message_id: 1,
date: Math.floor(Date.now() / 1000),
chat: { id: 123, type: "private" },
from: { id: 456, username: "alice" },
},
});
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
mediaLocalRoots: expect.arrayContaining([path.join(STATE_DIR, "workspace-work")]),
}),
);
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
});

View File

@@ -1,900 +1 @@
import type { Bot, Context } from "grammy";
import { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import { resolveCommandAuthorization } from "../auto-reply/command-auth.js";
import type { CommandArgs } from "../auto-reply/commands-registry.js";
import {
buildCommandTextFromArgs,
findCommandByNativeName,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
parseCommandArgs,
resolveCommandArgMenu,
} from "../auto-reply/commands-registry.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import { resolveNativeCommandSessionTargets } from "../channels/native-command-session-targets.js";
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
import { recordInboundSessionMetaSafe } from "../channels/session-meta.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import {
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
TELEGRAM_COMMAND_NAME_PATTERN,
} from "../config/telegram-custom-commands.js";
import type {
ReplyToMode,
TelegramAccountConfig,
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../config/types.js";
import { danger, logVerbose } from "../globals.js";
import { getChildLogger } from "../logging.js";
import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js";
import {
executePluginCommand,
getPluginCommandSpecs,
matchPluginCommand,
} from "../plugins/commands.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import {
buildCappedTelegramMenuCommands,
buildPluginTelegramMenuCommands,
syncTelegramMenuCommands,
} from "./bot-native-command-menu.js";
import { TelegramUpdateKeyContext } from "./bot-updates.js";
import { TelegramBotOptions } from "./bot.js";
import { deliverReplies } from "./bot/delivery.js";
import {
buildTelegramThreadParams,
buildSenderName,
buildTelegramGroupFrom,
resolveTelegramGroupAllowFromContext,
resolveTelegramThreadSpec,
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveTelegramConversationRoute } from "./conversation-route.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import type { TelegramTransport } from "./fetch.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
} from "./group-access.js";
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
import { buildInlineKeyboard } from "./send.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
type TelegramNativeCommandContext = Context & { match?: string };
type TelegramCommandAuthResult = {
chatId: number;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
senderId: string;
senderUsername: string;
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
commandAuthorized: boolean;
};
export type RegisterTelegramHandlerParams = {
cfg: OpenClawConfig;
accountId: string;
bot: Bot;
mediaMaxBytes: number;
opts: TelegramBotOptions;
telegramTransport?: TelegramTransport;
runtime: RuntimeEnv;
telegramCfg: TelegramAccountConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
processMessage: (
ctx: TelegramContext,
allMedia: TelegramMediaRef[],
storeAllowFrom: string[],
options?: {
messageIdOverride?: string;
forceWasMentioned?: boolean;
},
replyMedia?: TelegramMediaRef[],
) => Promise<void>;
logger: ReturnType<typeof getChildLogger>;
};
type RegisterTelegramNativeCommandsParams = {
bot: Bot;
cfg: OpenClawConfig;
runtime: RuntimeEnv;
accountId: string;
telegramCfg: TelegramAccountConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
replyToMode: ReplyToMode;
textLimit: number;
useAccessGroups: boolean;
nativeEnabled: boolean;
nativeSkillsEnabled: boolean;
nativeDisabledExplicit: boolean;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
opts: { token: string };
};
async function resolveTelegramCommandAuth(params: {
msg: NonNullable<TelegramNativeCommandContext["message"]>;
bot: Bot;
cfg: OpenClawConfig;
accountId: string;
telegramCfg: TelegramAccountConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
useAccessGroups: boolean;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
requireAuth: boolean;
}): Promise<TelegramCommandAuthResult | null> {
const {
msg,
bot,
cfg,
accountId,
telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth,
} = params;
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const threadSpec = resolveTelegramThreadSpec({
isGroup,
isForum,
messageThreadId,
});
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
chatId,
accountId,
isGroup,
isForum,
messageThreadId,
groupAllowFrom,
resolveTelegramGroupConfig,
});
const {
resolvedThreadId,
dmThreadId,
storeAllowFrom,
groupConfig,
topicConfig,
groupAllowOverride,
effectiveGroupAllow,
hasGroupAllowOverride,
} = groupAllowContext;
// Use direct config dmPolicy override if available for DMs
const effectiveDmPolicy =
!isGroup && groupConfig && "dmPolicy" in groupConfig
? (groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing")
: (telegramCfg.dmPolicy ?? "pairing");
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
if (!isGroup && requireTopic === true && dmThreadId == null) {
logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`);
return null;
}
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
const dmAllowFrom = groupAllowOverride ?? allowFrom;
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
const commandsAllowFrom = cfg.commands?.allowFrom;
const commandsAllowFromConfigured =
commandsAllowFrom != null &&
typeof commandsAllowFrom === "object" &&
(Array.isArray(commandsAllowFrom.telegram) || Array.isArray(commandsAllowFrom["*"]));
const commandsAllowFromAccess = commandsAllowFromConfigured
? resolveCommandAuthorization({
ctx: {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
AccountId: accountId,
ChatType: isGroup ? "group" : "direct",
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
SenderId: senderId || undefined,
SenderUsername: senderUsername || undefined,
},
cfg,
// commands.allowFrom is the only auth source when configured.
commandAuthorized: false,
})
: null;
const sendAuthMessage = async (text: string) => {
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, text, threadParams),
});
return null;
};
const rejectNotAuthorized = async () => {
return await sendAuthMessage("You are not authorized to use this command.");
};
const baseAccess = evaluateTelegramGroupBaseAccess({
isGroup,
groupConfig,
topicConfig,
hasGroupAllowOverride,
effectiveGroupAllow,
senderId,
senderUsername,
enforceAllowOverride: requireAuth,
requireSenderForAllowOverride: true,
});
if (!baseAccess.allowed) {
if (baseAccess.reason === "group-disabled") {
return await sendAuthMessage("This group is disabled.");
}
if (baseAccess.reason === "topic-disabled") {
return await sendAuthMessage("This topic is disabled.");
}
return await rejectNotAuthorized();
}
const policyAccess = evaluateTelegramGroupPolicyAccess({
isGroup,
chatId,
cfg,
telegramCfg,
topicConfig,
groupConfig,
effectiveGroupAllow,
senderId,
senderUsername,
resolveGroupPolicy,
enforcePolicy: useAccessGroups,
useTopicAndGroupOverrides: false,
enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured,
allowEmptyAllowlistEntries: true,
requireSenderForAllowlistAuthorization: true,
checkChatAllowlist: useAccessGroups,
});
if (!policyAccess.allowed) {
if (policyAccess.reason === "group-policy-disabled") {
return await sendAuthMessage("Telegram group commands are disabled.");
}
if (
policyAccess.reason === "group-policy-allowlist-no-sender" ||
policyAccess.reason === "group-policy-allowlist-unauthorized"
) {
return await rejectNotAuthorized();
}
if (policyAccess.reason === "group-chat-not-allowed") {
return await sendAuthMessage("This group is not allowed.");
}
}
const dmAllow = normalizeDmAllowFromWithStore({
allowFrom: dmAllowFrom,
storeAllowFrom: isGroup ? [] : storeAllowFrom,
dmPolicy: effectiveDmPolicy,
});
const senderAllowed = isSenderAllowed({
allow: dmAllow,
senderId,
senderUsername,
});
const groupSenderAllowed = isGroup
? isSenderAllowed({ allow: effectiveGroupAllow, senderId, senderUsername })
: false;
const commandAuthorized = commandsAllowFromConfigured
? Boolean(commandsAllowFromAccess?.isAuthorizedSender)
: resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: dmAllow.hasEntries, allowed: senderAllowed },
...(isGroup
? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }]
: []),
],
modeWhenAccessGroupsOff: "configured",
});
if (requireAuth && !commandAuthorized) {
return await rejectNotAuthorized();
}
return {
chatId,
isGroup,
isForum,
resolvedThreadId,
senderId,
senderUsername,
groupConfig,
topicConfig,
commandAuthorized,
};
}
export const registerTelegramNativeCommands = ({
bot,
cfg,
runtime,
accountId,
telegramCfg,
allowFrom,
groupAllowFrom,
replyToMode,
textLimit,
useAccessGroups,
nativeEnabled,
nativeSkillsEnabled,
nativeDisabledExplicit,
resolveGroupPolicy,
resolveTelegramGroupConfig,
shouldSkipUpdate,
opts,
}: RegisterTelegramNativeCommandsParams) => {
const boundRoute =
nativeEnabled && nativeSkillsEnabled
? resolveAgentRoute({ cfg, channel: "telegram", accountId })
: null;
if (nativeEnabled && nativeSkillsEnabled && !boundRoute) {
runtime.log?.(
"nativeSkillsEnabled is true but no agent route is bound for this Telegram account; skill commands will not appear in the native menu.",
);
}
const skillCommands =
nativeEnabled && nativeSkillsEnabled && boundRoute
? listSkillCommandsForAgents({ cfg, agentIds: [boundRoute.agentId] })
: [];
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, {
skillCommands,
provider: "telegram",
})
: [];
const reservedCommands = new Set(
listNativeCommandSpecs().map((command) => normalizeTelegramCommandName(command.name)),
);
for (const command of skillCommands) {
reservedCommands.add(command.name.toLowerCase());
}
const customResolution = resolveTelegramCustomCommands({
commands: telegramCfg.customCommands,
reservedCommands,
});
for (const issue of customResolution.issues) {
runtime.error?.(danger(issue.message));
}
const customCommands = customResolution.commands;
const pluginCommandSpecs = getPluginCommandSpecs("telegram");
const existingCommands = new Set(
[
...nativeCommands.map((command) => normalizeTelegramCommandName(command.name)),
...customCommands.map((command) => command.command),
].map((command) => command.toLowerCase()),
);
const pluginCatalog = buildPluginTelegramMenuCommands({
specs: pluginCommandSpecs,
existingCommands,
});
for (const issue of pluginCatalog.issues) {
runtime.error?.(danger(issue));
}
const allCommandsFull: Array<{ command: string; description: string }> = [
...nativeCommands
.map((command) => {
const normalized = normalizeTelegramCommandName(command.name);
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
runtime.error?.(
danger(
`Native command "${command.name}" is invalid for Telegram (resolved to "${normalized}"). Skipping.`,
),
);
return null;
}
return {
command: normalized,
description: command.description,
};
})
.filter((cmd): cmd is { command: string; description: string } => cmd !== null),
...(nativeEnabled ? pluginCatalog.commands : []),
...customCommands,
];
const { commandsToRegister, totalCommands, maxCommands, overflowCount } =
buildCappedTelegramMenuCommands({
allCommands: allCommandsFull,
});
if (overflowCount > 0) {
runtime.log?.(
`Telegram limits bots to ${maxCommands} commands. ` +
`${totalCommands} configured; registering first ${maxCommands}. ` +
`Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.`,
);
}
// Telegram only limits the setMyCommands payload (menu entries).
// Keep hidden commands callable by registering handlers for the full catalog.
syncTelegramMenuCommands({
bot,
runtime,
commandsToRegister,
accountId,
botIdentity: opts.token,
});
const resolveCommandRuntimeContext = async (params: {
msg: NonNullable<TelegramNativeCommandContext["message"]>;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
senderId?: string;
topicAgentId?: string;
}): Promise<{
chatId: number;
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
route: ReturnType<typeof resolveTelegramConversationRoute>["route"];
mediaLocalRoots: readonly string[] | undefined;
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
chunkMode: ReturnType<typeof resolveChunkMode>;
} | null> => {
const { msg, isGroup, isForum, resolvedThreadId, senderId, topicAgentId } = params;
const chatId = msg.chat.id;
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const threadSpec = resolveTelegramThreadSpec({
isGroup,
isForum,
messageThreadId,
});
let { route, configuredBinding } = resolveTelegramConversationRoute({
cfg,
accountId,
chatId,
isGroup,
resolvedThreadId,
replyThreadId: threadSpec.id,
senderId,
topicAgentId,
});
if (configuredBinding) {
const ensured = await ensureConfiguredAcpRouteReady({
cfg,
configuredBinding,
});
if (!ensured.ok) {
logVerbose(
`telegram native command: configured ACP binding unavailable for topic ${configuredBinding.spec.conversationId}: ${ensured.error}`,
);
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(
chatId,
"Configured ACP binding is unavailable right now. Please try again.",
buildTelegramThreadParams(threadSpec) ?? {},
),
});
return null;
}
}
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: route.accountId,
});
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
return { chatId, threadSpec, route, mediaLocalRoots, tableMode, chunkMode };
};
const buildCommandDeliveryBaseOptions = (params: {
chatId: string | number;
accountId: string;
sessionKeyForInternalHooks?: string;
mirrorIsGroup?: boolean;
mirrorGroupId?: string;
mediaLocalRoots?: readonly string[];
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
chunkMode: ReturnType<typeof resolveChunkMode>;
}) => ({
chatId: String(params.chatId),
accountId: params.accountId,
sessionKeyForInternalHooks: params.sessionKeyForInternalHooks,
mirrorIsGroup: params.mirrorIsGroup,
mirrorGroupId: params.mirrorGroupId,
token: opts.token,
runtime,
bot,
mediaLocalRoots: params.mediaLocalRoots,
replyToMode,
textLimit,
thread: params.threadSpec,
tableMode: params.tableMode,
chunkMode: params.chunkMode,
linkPreview: telegramCfg.linkPreview,
});
if (commandsToRegister.length > 0 || pluginCatalog.commands.length > 0) {
if (typeof (bot as unknown as { command?: unknown }).command !== "function") {
logVerbose("telegram: bot.command unavailable; skipping native handlers");
} else {
for (const command of nativeCommands) {
const normalizedCommandName = normalizeTelegramCommandName(command.name);
bot.command(normalizedCommandName, async (ctx: TelegramNativeCommandContext) => {
const msg = ctx.message;
if (!msg) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const auth = await resolveTelegramCommandAuth({
msg,
bot,
cfg,
accountId,
telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth: true,
});
if (!auth) {
return;
}
const {
chatId,
isGroup,
isForum,
resolvedThreadId,
senderId,
senderUsername,
groupConfig,
topicConfig,
commandAuthorized,
} = auth;
const runtimeContext = await resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
senderId,
topicAgentId: topicConfig?.agentId,
});
if (!runtimeContext) {
return;
}
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? "";
const commandArgs = commandDefinition
? parseCommandArgs(commandDefinition, rawText)
: rawText
? ({ raw: rawText } satisfies CommandArgs)
: undefined;
const prompt = commandDefinition
? buildCommandTextFromArgs(commandDefinition, commandArgs)
: rawText
? `/${command.name} ${rawText}`
: `/${command.name}`;
const menu = commandDefinition
? resolveCommandArgMenu({
command: commandDefinition,
args: commandArgs,
cfg,
})
: null;
if (menu && commandDefinition) {
const title =
menu.title ??
`Choose ${menu.arg.description || menu.arg.name} for /${commandDefinition.nativeName ?? commandDefinition.key}.`;
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
for (let i = 0; i < menu.choices.length; i += 2) {
const slice = menu.choices.slice(i, i + 2);
rows.push(
slice.map((choice) => {
const args: CommandArgs = {
values: { [menu.arg.name]: choice.value },
};
return {
text: choice.label,
callback_data: buildCommandTextFromArgs(commandDefinition, args),
};
}),
);
}
const replyMarkup = buildInlineKeyboard(rows);
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(chatId, title, {
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
...threadParams,
}),
});
return;
}
const baseSessionKey = route.sessionKey;
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({
baseSessionKey,
threadId: `${chatId}:${dmThreadId}`,
})
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
groupConfig,
topicConfig,
});
const { sessionKey: commandSessionKey, commandTargetSessionKey } =
resolveNativeCommandSessionTargets({
agentId: route.agentId,
sessionPrefix: "telegram:slash",
userId: String(senderId || chatId),
targetSessionKey: sessionKey,
});
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
chatId,
accountId: route.accountId,
sessionKeyForInternalHooks: commandSessionKey,
mirrorIsGroup: isGroup,
mirrorGroupId: isGroup ? String(chatId) : undefined,
mediaLocalRoots,
threadSpec,
tableMode,
chunkMode,
});
const conversationLabel = isGroup
? msg.chat.title
? `${msg.chat.title} id:${chatId}`
: `group:${chatId}`
: (buildSenderName(msg) ?? String(senderId || chatId));
const ctxPayload = finalizeInboundContext({
Body: prompt,
BodyForAgent: prompt,
RawBody: prompt,
CommandBody: prompt,
CommandArgs: commandArgs,
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
To: `slash:${senderId || chatId}`,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: conversationLabel,
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
SenderName: buildSenderName(msg),
SenderId: senderId || undefined,
SenderUsername: senderUsername || undefined,
Surface: "telegram",
Provider: "telegram",
MessageSid: String(msg.message_id),
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: true,
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
SessionKey: commandSessionKey,
AccountId: route.accountId,
CommandTargetSessionKey: commandTargetSessionKey,
MessageThreadId: threadSpec.id,
IsForum: isForum,
// Originating context for sub-agent announce routing
OriginatingChannel: "telegram" as const,
OriginatingTo: `telegram:${chatId}`,
});
await recordInboundSessionMetaSafe({
cfg,
agentId: route.agentId,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onError: (err) =>
runtime.error?.(
danger(`telegram slash: failed updating session meta: ${String(err)}`),
),
});
const disableBlockStreaming =
typeof telegramCfg.blockStreaming === "boolean"
? !telegramCfg.blockStreaming
: undefined;
const deliveryState = {
delivered: false,
skippedNonSilent: 0,
};
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "telegram",
accountId: route.accountId,
});
await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
...prefixOptions,
deliver: async (payload, _info) => {
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload,
})
) {
deliveryState.delivered = true;
return;
}
const result = await deliverReplies({
replies: [payload],
...deliveryBaseOptions,
});
if (result.delivered) {
deliveryState.delivered = true;
}
},
onSkip: (_payload, info) => {
if (info.reason !== "silent") {
deliveryState.skippedNonSilent += 1;
}
},
onError: (err, info) => {
runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: {
skillFilter,
disableBlockStreaming,
onModelSelected,
},
});
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
await deliverReplies({
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
...deliveryBaseOptions,
});
}
});
}
for (const pluginCommand of pluginCatalog.commands) {
bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => {
const msg = ctx.message;
if (!msg) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const chatId = msg.chat.id;
const rawText = ctx.match?.trim() ?? "";
const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
const match = matchPluginCommand(commandBody);
if (!match) {
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () => bot.api.sendMessage(chatId, "Command not found."),
});
return;
}
const auth = await resolveTelegramCommandAuth({
msg,
bot,
cfg,
accountId,
telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth: match.command.requireAuth !== false,
});
if (!auth) {
return;
}
const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth;
const runtimeContext = await resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
senderId,
topicAgentId: auth.topicConfig?.agentId,
});
if (!runtimeContext) {
return;
}
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
chatId,
accountId: route.accountId,
sessionKeyForInternalHooks: route.sessionKey,
mirrorIsGroup: isGroup,
mirrorGroupId: isGroup ? String(chatId) : undefined,
mediaLocalRoots,
threadSpec,
tableMode,
chunkMode,
});
const from = isGroup
? buildTelegramGroupFrom(chatId, threadSpec.id)
: `telegram:${chatId}`;
const to = `telegram:${chatId}`;
const result = await executePluginCommand({
command: match.command,
args: match.args,
senderId,
channel: "telegram",
isAuthorizedSender: commandAuthorized,
commandBody,
config: cfg,
from,
to,
accountId,
messageThreadId: threadSpec.id,
});
if (
!shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload: result,
})
) {
await deliverReplies({
replies: [result],
...deliveryBaseOptions,
});
}
});
}
}
} else if (nativeDisabledExplicit) {
withTelegramApiErrorLogging({
operation: "setMyCommands",
runtime,
fn: () => bot.api.setMyCommands([]),
}).catch(() => {});
}
};
export * from "../../extensions/telegram/src/bot-native-commands.js";

View File

@@ -1,67 +1 @@
import type { Message } from "@grammyjs/types";
import { createDedupeCache } from "../infra/dedupe.js";
import type { TelegramContext } from "./bot/types.js";
const MEDIA_GROUP_TIMEOUT_MS = 500;
const RECENT_TELEGRAM_UPDATE_TTL_MS = 5 * 60_000;
const RECENT_TELEGRAM_UPDATE_MAX = 2000;
export type MediaGroupEntry = {
messages: Array<{
msg: Message;
ctx: TelegramContext;
}>;
timer: ReturnType<typeof setTimeout>;
};
export type TelegramUpdateKeyContext = {
update?: {
update_id?: number;
message?: Message;
edited_message?: Message;
channel_post?: Message;
edited_channel_post?: Message;
};
update_id?: number;
message?: Message;
channelPost?: Message;
editedChannelPost?: Message;
callbackQuery?: { id?: string; message?: Message };
};
export const resolveTelegramUpdateId = (ctx: TelegramUpdateKeyContext) =>
ctx.update?.update_id ?? ctx.update_id;
export const buildTelegramUpdateKey = (ctx: TelegramUpdateKeyContext) => {
const updateId = resolveTelegramUpdateId(ctx);
if (typeof updateId === "number") {
return `update:${updateId}`;
}
const callbackId = ctx.callbackQuery?.id;
if (callbackId) {
return `callback:${callbackId}`;
}
const msg =
ctx.message ??
ctx.channelPost ??
ctx.editedChannelPost ??
ctx.update?.message ??
ctx.update?.edited_message ??
ctx.update?.channel_post ??
ctx.update?.edited_channel_post ??
ctx.callbackQuery?.message;
const chatId = msg?.chat?.id;
const messageId = msg?.message_id;
if (typeof chatId !== "undefined" && typeof messageId === "number") {
return `message:${chatId}:${messageId}`;
}
return undefined;
};
export const createTelegramUpdateDedupe = () =>
createDedupeCache({
ttlMs: RECENT_TELEGRAM_UPDATE_TTL_MS,
maxSize: RECENT_TELEGRAM_UPDATE_MAX,
});
export { MEDIA_GROUP_TIMEOUT_MS };
export * from "../../extensions/telegram/src/bot-updates.js";

View File

@@ -1,334 +0,0 @@
import { beforeEach, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import type { MsgContext } from "../auto-reply/templating.js";
import type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
const { sessionStorePath } = vi.hoisted(() => ({
sessionStorePath: `/tmp/openclaw-telegram-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}.json`,
}));
const { loadWebMedia } = vi.hoisted((): { loadWebMedia: AnyMock } => ({
loadWebMedia: vi.fn(),
}));
export function getLoadWebMediaMock(): AnyMock {
return loadWebMedia;
}
vi.mock("../web/media.js", () => ({
loadWebMedia,
}));
const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({
loadConfig: vi.fn(() => ({})),
}));
export function getLoadConfigMock(): AnyMock {
return loadConfig;
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig,
};
});
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
...actual,
resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
};
});
const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted(
(): {
readChannelAllowFromStore: AnyAsyncMock;
upsertChannelPairingRequest: AnyAsyncMock;
} => ({
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
upsertChannelPairingRequest: vi.fn(async () => ({
code: "PAIRCODE",
created: true,
})),
}),
);
export function getReadChannelAllowFromStoreMock(): AnyAsyncMock {
return readChannelAllowFromStore;
}
export function getUpsertChannelPairingRequestMock(): AnyAsyncMock {
return upsertChannelPairingRequest;
}
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore,
upsertChannelPairingRequest,
}));
const skillCommandsHoisted = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn(() => []),
}));
export const listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents;
vi.mock("../auto-reply/skill-commands.js", () => ({
listSkillCommandsForAgents,
}));
const systemEventsHoisted = vi.hoisted(() => ({
enqueueSystemEventSpy: vi.fn(),
}));
export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy;
vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent: enqueueSystemEventSpy,
}));
const sentMessageCacheHoisted = vi.hoisted(() => ({
wasSentByBot: vi.fn(() => false),
}));
export const wasSentByBot = sentMessageCacheHoisted.wasSentByBot;
vi.mock("./sent-message-cache.js", () => ({
wasSentByBot,
recordSentMessage: vi.fn(),
clearSentMessageCache: vi.fn(),
}));
export const useSpy: MockFn<(arg: unknown) => void> = vi.fn();
export const middlewareUseSpy: AnyMock = vi.fn();
export const onSpy: AnyMock = vi.fn();
export const stopSpy: AnyMock = vi.fn();
export const commandSpy: AnyMock = vi.fn();
export const botCtorSpy: AnyMock = vi.fn();
export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn(async () => undefined);
export const sendChatActionSpy: AnyMock = vi.fn();
export const editMessageTextSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 }));
export const editMessageReplyMarkupSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 }));
export const sendMessageDraftSpy: AnyAsyncMock = vi.fn(async () => true);
export const setMessageReactionSpy: AnyAsyncMock = vi.fn(async () => undefined);
export const setMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined);
export const getMeSpy: AnyAsyncMock = vi.fn(async () => ({
username: "openclaw_bot",
has_topics_enabled: true,
}));
export const sendMessageSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 77 }));
export const sendAnimationSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 78 }));
export const sendPhotoSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 79 }));
export const getFileSpy: AnyAsyncMock = vi.fn(async () => ({ file_path: "media/file.jpg" }));
type ApiStub = {
config: { use: (arg: unknown) => void };
answerCallbackQuery: typeof answerCallbackQuerySpy;
sendChatAction: typeof sendChatActionSpy;
editMessageText: typeof editMessageTextSpy;
editMessageReplyMarkup: typeof editMessageReplyMarkupSpy;
sendMessageDraft: typeof sendMessageDraftSpy;
setMessageReaction: typeof setMessageReactionSpy;
setMyCommands: typeof setMyCommandsSpy;
getMe: typeof getMeSpy;
sendMessage: typeof sendMessageSpy;
sendAnimation: typeof sendAnimationSpy;
sendPhoto: typeof sendPhotoSpy;
getFile: typeof getFileSpy;
};
const apiStub: ApiStub = {
config: { use: useSpy },
answerCallbackQuery: answerCallbackQuerySpy,
sendChatAction: sendChatActionSpy,
editMessageText: editMessageTextSpy,
editMessageReplyMarkup: editMessageReplyMarkupSpy,
sendMessageDraft: sendMessageDraftSpy,
setMessageReaction: setMessageReactionSpy,
setMyCommands: setMyCommandsSpy,
getMe: getMeSpy,
sendMessage: sendMessageSpy,
sendAnimation: sendAnimationSpy,
sendPhoto: sendPhotoSpy,
getFile: getFileSpy,
};
vi.mock("grammy", () => ({
Bot: class {
api = apiStub;
use = middlewareUseSpy;
on = onSpy;
stop = stopSpy;
command = commandSpy;
catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
) {
botCtorSpy(token, options);
}
},
InputFile: class {},
}));
const sequentializeMiddleware = vi.fn();
export const sequentializeSpy: AnyMock = vi.fn(() => sequentializeMiddleware);
export let sequentializeKey: ((ctx: unknown) => string) | undefined;
vi.mock("@grammyjs/runner", () => ({
sequentialize: (keyFn: (ctx: unknown) => string) => {
sequentializeKey = keyFn;
return sequentializeSpy();
},
}));
export const throttlerSpy: AnyMock = vi.fn(() => "throttler");
vi.mock("@grammyjs/transformer-throttler", () => ({
apiThrottler: () => throttlerSpy(),
}));
export const replySpy: MockFn<
(
ctx: MsgContext,
opts?: GetReplyOptions,
configOverride?: OpenClawConfig,
) => Promise<ReplyPayload | ReplyPayload[] | undefined>
> = vi.fn(async (_ctx, opts) => {
await opts?.onReplyStart?.();
return undefined;
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: replySpy,
__replySpy: replySpy,
}));
export const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
if (!handler) {
throw new Error(`Missing handler for event: ${event}`);
}
return handler as (ctx: Record<string, unknown>) => Promise<void>;
};
const DEFAULT_TELEGRAM_TEST_CONFIG: OpenClawConfig = {
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
};
export function makeTelegramMessageCtx(params: {
chat: {
id: number;
type: string;
title?: string;
is_forum?: boolean;
};
from: { id: number; username?: string };
text: string;
date?: number;
messageId?: number;
messageThreadId?: number;
}) {
return {
message: {
chat: params.chat,
from: params.from,
text: params.text,
date: params.date ?? 1736380800,
message_id: params.messageId ?? 42,
...(params.messageThreadId === undefined
? {}
: { message_thread_id: params.messageThreadId }),
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
};
}
export function makeForumGroupMessageCtx(params?: {
chatId?: number;
threadId?: number;
text?: string;
fromId?: number;
username?: string;
title?: string;
}) {
return makeTelegramMessageCtx({
chat: {
id: params?.chatId ?? -1001234567890,
type: "supergroup",
title: params?.title ?? "Forum Group",
is_forum: true,
},
from: { id: params?.fromId ?? 12345, username: params?.username ?? "testuser" },
text: params?.text ?? "hello",
messageThreadId: params?.threadId,
});
}
beforeEach(() => {
resetInboundDedupe();
loadConfig.mockReset();
loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG);
loadWebMedia.mockReset();
readChannelAllowFromStore.mockReset();
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockReset();
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true } as const);
onSpy.mockReset();
commandSpy.mockReset();
stopSpy.mockReset();
useSpy.mockReset();
replySpy.mockReset();
replySpy.mockImplementation(async (_ctx, opts) => {
await opts?.onReplyStart?.();
return undefined;
});
sendAnimationSpy.mockReset();
sendAnimationSpy.mockResolvedValue({ message_id: 78 });
sendPhotoSpy.mockReset();
sendPhotoSpy.mockResolvedValue({ message_id: 79 });
sendMessageSpy.mockReset();
sendMessageSpy.mockResolvedValue({ message_id: 77 });
getFileSpy.mockReset();
getFileSpy.mockResolvedValue({ file_path: "media/file.jpg" });
setMessageReactionSpy.mockReset();
setMessageReactionSpy.mockResolvedValue(undefined);
answerCallbackQuerySpy.mockReset();
answerCallbackQuerySpy.mockResolvedValue(undefined);
sendChatActionSpy.mockReset();
sendChatActionSpy.mockResolvedValue(undefined);
setMyCommandsSpy.mockReset();
setMyCommandsSpy.mockResolvedValue(undefined);
getMeSpy.mockReset();
getMeSpy.mockResolvedValue({
username: "openclaw_bot",
has_topics_enabled: true,
});
editMessageTextSpy.mockReset();
editMessageTextSpy.mockResolvedValue({ message_id: 88 });
editMessageReplyMarkupSpy.mockReset();
editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 });
sendMessageDraftSpy.mockReset();
sendMessageDraftSpy.mockResolvedValue(true);
enqueueSystemEventSpy.mockReset();
wasSentByBot.mockReset();
wasSentByBot.mockReturnValue(false);
listSkillCommandsForAgents.mockReset();
listSkillCommandsForAgents.mockReturnValue([]);
middlewareUseSpy.mockReset();
sequentializeSpy.mockReset();
botCtorSpy.mockReset();
sequentializeKey = undefined;
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,79 +1,2 @@
import { describe, expect, it, vi } from "vitest";
import { botCtorSpy } from "./bot.create-telegram-bot.test-harness.js";
import { createTelegramBot } from "./bot.js";
import { getTelegramNetworkErrorOrigin } from "./network-errors.js";
function createWrappedTelegramClientFetch(proxyFetch: typeof fetch) {
const shutdown = new AbortController();
botCtorSpy.mockClear();
createTelegramBot({
token: "tok",
fetchAbortSignal: shutdown.signal,
proxyFetch,
});
const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } })
?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise<unknown>;
expect(clientFetch).toBeTypeOf("function");
return { clientFetch, shutdown };
}
describe("createTelegramBot fetch abort", () => {
it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => {
const fetchSpy = vi.fn(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<AbortSignal>((resolve) => {
const signal = init?.signal as AbortSignal;
signal.addEventListener("abort", () => resolve(signal), { once: true });
}),
);
const { clientFetch, shutdown } = createWrappedTelegramClientFetch(
fetchSpy as unknown as typeof fetch,
);
const observedSignalPromise = clientFetch("https://example.test");
shutdown.abort(new Error("shutdown"));
const observedSignal = (await observedSignalPromise) as AbortSignal;
expect(observedSignal).toBeInstanceOf(AbortSignal);
expect(observedSignal.aborted).toBe(true);
});
it("tags wrapped Telegram fetch failures with the Bot API method", async () => {
const fetchError = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect timeout"), {
code: "UND_ERR_CONNECT_TIMEOUT",
}),
});
const fetchSpy = vi.fn(async () => {
throw fetchError;
});
const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch);
await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe(
fetchError,
);
expect(getTelegramNetworkErrorOrigin(fetchError)).toEqual({
method: "getupdates",
url: "https://api.telegram.org/bot123456:ABC/getUpdates",
});
});
it("preserves the original fetch error when tagging cannot attach metadata", async () => {
const frozenError = Object.freeze(
Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect timeout"), {
code: "UND_ERR_CONNECT_TIMEOUT",
}),
}),
);
const fetchSpy = vi.fn(async () => {
throw frozenError;
});
const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch);
await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe(
frozenError,
);
expect(getTelegramNetworkErrorOrigin(frozenError)).toBeNull();
});
});
// Shim: re-exports from extensions/telegram/src/bot.fetch-abort.test.ts
export * from "../../extensions/telegram/src/bot.fetch-abort.test.js";

View File

@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveTelegramStreamMode } from "./bot/helpers.js";
describe("resolveTelegramStreamMode", () => {
it("defaults to partial when telegram streaming is unset", () => {
expect(resolveTelegramStreamMode(undefined)).toBe("partial");
expect(resolveTelegramStreamMode({})).toBe("partial");
});
it("prefers explicit streaming boolean", () => {
expect(resolveTelegramStreamMode({ streaming: true })).toBe("partial");
expect(resolveTelegramStreamMode({ streaming: false })).toBe("off");
});
it("maps legacy streamMode values", () => {
expect(resolveTelegramStreamMode({ streamMode: "off" })).toBe("off");
expect(resolveTelegramStreamMode({ streamMode: "partial" })).toBe("partial");
expect(resolveTelegramStreamMode({ streamMode: "block" })).toBe("block");
});
it("maps unified progress mode to partial on Telegram", () => {
expect(resolveTelegramStreamMode({ streaming: "progress" })).toBe("partial");
});
});

View File

@@ -1,385 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { setNextSavedMediaPath } from "./bot.media.e2e-harness.js";
import {
TELEGRAM_TEST_TIMINGS,
createBotHandler,
createBotHandlerWithOptions,
mockTelegramFileDownload,
mockTelegramPngDownload,
} from "./bot.media.test-utils.js";
describe("telegram inbound media", () => {
// Parallel vitest shards can make this suite slower than the standalone run.
const INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 90_000;
it(
"handles file_path media downloads and missing file_path safely",
async () => {
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
const { handler, replySpy } = await createBotHandlerWithOptions({
runtimeLog,
runtimeError,
});
for (const scenario of [
{
name: "downloads via file_path",
messageId: 1,
getFile: async () => ({ file_path: "photos/1.jpg" }),
setupFetch: () =>
mockTelegramFileDownload({
contentType: "image/jpeg",
bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]),
}),
assert: (params: {
fetchSpy: ReturnType<typeof vi.spyOn>;
replySpy: ReturnType<typeof vi.fn>;
runtimeError: ReturnType<typeof vi.fn>;
}) => {
expect(params.runtimeError).not.toHaveBeenCalled();
expect(params.fetchSpy).toHaveBeenCalledWith(
"https://api.telegram.org/file/bottok/photos/1.jpg",
expect.objectContaining({ redirect: "manual" }),
);
expect(params.replySpy).toHaveBeenCalledTimes(1);
const payload = params.replySpy.mock.calls[0][0];
expect(payload.Body).toContain("<media:image>");
},
},
{
name: "skips when file_path is missing",
messageId: 2,
getFile: async () => ({}),
setupFetch: () => vi.spyOn(globalThis, "fetch"),
assert: (params: {
fetchSpy: ReturnType<typeof vi.spyOn>;
replySpy: ReturnType<typeof vi.fn>;
runtimeError: ReturnType<typeof vi.fn>;
}) => {
expect(params.fetchSpy).not.toHaveBeenCalled();
expect(params.replySpy).not.toHaveBeenCalled();
expect(params.runtimeError).not.toHaveBeenCalled();
},
},
]) {
replySpy.mockClear();
runtimeError.mockClear();
const fetchSpy = scenario.setupFetch();
await handler({
message: {
message_id: scenario.messageId,
chat: { id: 1234, type: "private" },
photo: [{ file_id: "fid" }],
date: 1736380800, // 2025-01-09T00:00:00Z
},
me: { username: "openclaw_bot" },
getFile: scenario.getFile,
});
scenario.assert({ fetchSpy, replySpy, runtimeError });
fetchSpy.mockRestore();
}
},
INBOUND_MEDIA_TEST_TIMEOUT_MS,
);
it(
"keeps Telegram inbound media paths with triple-dash ids",
async () => {
const runtimeError = vi.fn();
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
const fetchSpy = mockTelegramFileDownload({
contentType: "image/jpeg",
bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]),
});
const inboundPath = "/tmp/media/inbound/file_1095---f00a04a2-99a0-4d98-99b0-dfe61c5a4198.jpg";
setNextSavedMediaPath({
path: inboundPath,
size: 4,
contentType: "image/jpeg",
});
try {
await handler({
message: {
message_id: 1001,
chat: { id: 1234, type: "private" },
photo: [{ file_id: "fid" }],
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/1.jpg" }),
});
expect(runtimeError).not.toHaveBeenCalled();
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] };
expect(payload.Body).toContain("<media:image>");
expect(payload.MediaPaths).toContain(inboundPath);
} finally {
fetchSpy.mockRestore();
}
},
INBOUND_MEDIA_TEST_TIMEOUT_MS,
);
it("prefers proxyFetch over global fetch", async () => {
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
const globalFetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
throw new Error("global fetch should not be called");
});
const proxyFetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => "image/jpeg" },
arrayBuffer: async () => new Uint8Array([0xff, 0xd8, 0xff]).buffer,
} as unknown as Response);
const { handler } = await createBotHandlerWithOptions({
proxyFetch: proxyFetch as unknown as typeof fetch,
runtimeLog,
runtimeError,
});
await handler({
message: {
message_id: 2,
chat: { id: 1234, type: "private" },
photo: [{ file_id: "fid" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/2.jpg" }),
});
expect(runtimeError).not.toHaveBeenCalled();
expect(proxyFetch).toHaveBeenCalledWith(
"https://api.telegram.org/file/bottok/photos/2.jpg",
expect.objectContaining({ redirect: "manual" }),
);
globalFetchSpy.mockRestore();
});
it("captures pin and venue location payload fields", async () => {
const { handler, replySpy } = await createBotHandler();
const cases = [
{
message: {
chat: { id: 42, type: "private" as const },
message_id: 5,
caption: "Meet here",
date: 1736380800,
location: {
latitude: 48.858844,
longitude: 2.294351,
horizontal_accuracy: 12,
},
},
assert: (payload: Record<string, unknown>) => {
expect(payload.Body).toContain("Meet here");
expect(payload.Body).toContain("48.858844");
expect(payload.LocationLat).toBe(48.858844);
expect(payload.LocationLon).toBe(2.294351);
expect(payload.LocationSource).toBe("pin");
expect(payload.LocationIsLive).toBe(false);
},
},
{
message: {
chat: { id: 42, type: "private" as const },
message_id: 6,
date: 1736380800,
venue: {
title: "Eiffel Tower",
address: "Champ de Mars, Paris",
location: { latitude: 48.858844, longitude: 2.294351 },
},
},
assert: (payload: Record<string, unknown>) => {
expect(payload.Body).toContain("Eiffel Tower");
expect(payload.LocationName).toBe("Eiffel Tower");
expect(payload.LocationAddress).toBe("Champ de Mars, Paris");
expect(payload.LocationSource).toBe("place");
},
},
] as const;
for (const testCase of cases) {
replySpy.mockClear();
await handler({
message: testCase.message,
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "unused" }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0] as Record<string, unknown>;
testCase.assert(payload);
}
});
});
describe("telegram media groups", () => {
afterEach(() => {
vi.clearAllTimers();
});
const MEDIA_GROUP_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
const MEDIA_GROUP_FLUSH_MS = TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs + 40;
it(
"handles same-group buffering and separate-group independence",
async () => {
const runtimeError = vi.fn();
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
const fetchSpy = mockTelegramPngDownload();
try {
for (const scenario of [
{
messages: [
{
chat: { id: 42, type: "private" as const },
message_id: 1,
caption: "Here are my photos",
date: 1736380800,
media_group_id: "album123",
photo: [{ file_id: "photo1" }],
filePath: "photos/photo1.jpg",
},
{
chat: { id: 42, type: "private" as const },
message_id: 2,
date: 1736380801,
media_group_id: "album123",
photo: [{ file_id: "photo2" }],
filePath: "photos/photo2.jpg",
},
],
expectedReplyCount: 1,
assert: (replySpy: ReturnType<typeof vi.fn>) => {
const payload = replySpy.mock.calls[0]?.[0];
expect(payload?.Body).toContain("Here are my photos");
expect(payload?.MediaPaths).toHaveLength(2);
},
},
{
messages: [
{
chat: { id: 42, type: "private" as const },
message_id: 11,
caption: "Album A",
date: 1736380800,
media_group_id: "albumA",
photo: [{ file_id: "photoA1" }],
filePath: "photos/photoA1.jpg",
},
{
chat: { id: 42, type: "private" as const },
message_id: 12,
caption: "Album B",
date: 1736380801,
media_group_id: "albumB",
photo: [{ file_id: "photoB1" }],
filePath: "photos/photoB1.jpg",
},
],
expectedReplyCount: 2,
assert: () => {},
},
]) {
replySpy.mockClear();
runtimeError.mockClear();
await Promise.all(
scenario.messages.map((message) =>
handler({
message,
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: message.filePath }),
}),
),
);
expect(replySpy).not.toHaveBeenCalled();
await vi.waitFor(
() => {
expect(replySpy).toHaveBeenCalledTimes(scenario.expectedReplyCount);
},
{ timeout: MEDIA_GROUP_FLUSH_MS * 4, interval: 2 },
);
expect(runtimeError).not.toHaveBeenCalled();
scenario.assert(replySpy);
}
} finally {
fetchSpy.mockRestore();
}
},
MEDIA_GROUP_TEST_TIMEOUT_MS,
);
});
describe("telegram forwarded bursts", () => {
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
const FORWARD_BURST_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
it(
"coalesces forwarded text + forwarded attachment into a single processing turn with default debounce config",
async () => {
const runtimeError = vi.fn();
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
const fetchSpy = mockTelegramPngDownload();
vi.useFakeTimers();
try {
await handler({
message: {
chat: { id: 42, type: "private" },
from: { id: 777, is_bot: false, first_name: "N" },
message_id: 21,
text: "Look at this",
date: 1736380800,
forward_origin: { type: "hidden_user", date: 1736380700, sender_user_name: "A" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
await handler({
message: {
chat: { id: 42, type: "private" },
from: { id: 777, is_bot: false, first_name: "N" },
message_id: 22,
date: 1736380801,
photo: [{ file_id: "fwd_photo_1" }],
forward_origin: { type: "hidden_user", date: 1736380701, sender_user_name: "A" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/fwd1.jpg" }),
});
await vi.runAllTimersAsync();
expect(replySpy).toHaveBeenCalledTimes(1);
expect(runtimeError).not.toHaveBeenCalled();
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("Look at this");
expect(payload.MediaPaths).toHaveLength(1);
} finally {
fetchSpy.mockRestore();
vi.useRealTimers();
}
},
FORWARD_BURST_TEST_TIMEOUT_MS,
);
});

View File

@@ -1,140 +0,0 @@
import { beforeEach, vi, type Mock } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
export const useSpy: Mock = vi.fn();
export const middlewareUseSpy: Mock = vi.fn();
export const onSpy: Mock = vi.fn();
export const stopSpy: Mock = vi.fn();
export const sendChatActionSpy: Mock = vi.fn();
export const undiciFetchSpy: Mock = vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
globalThis.fetch(input, init),
);
async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) {
return {
id: "media",
path: "/tmp/telegram-media",
size: buffer.byteLength,
contentType: contentType ?? "application/octet-stream",
};
}
const saveMediaBufferSpy: Mock = vi.fn(defaultSaveMediaBuffer);
export function setNextSavedMediaPath(params: {
path: string;
id?: string;
contentType?: string;
size?: number;
}) {
saveMediaBufferSpy.mockImplementationOnce(
async (buffer: Buffer, detectedContentType?: string) => ({
id: params.id ?? "media",
path: params.path,
size: params.size ?? buffer.byteLength,
contentType: params.contentType ?? detectedContentType ?? "application/octet-stream",
}),
);
}
export function resetSaveMediaBufferMock() {
saveMediaBufferSpy.mockReset();
saveMediaBufferSpy.mockImplementation(defaultSaveMediaBuffer);
}
type ApiStub = {
config: { use: (arg: unknown) => void };
sendChatAction: Mock;
sendMessage: Mock;
setMyCommands: (commands: Array<{ command: string; description: string }>) => Promise<void>;
};
const apiStub: ApiStub = {
config: { use: useSpy },
sendChatAction: sendChatActionSpy,
sendMessage: vi.fn(async () => ({ message_id: 1 })),
setMyCommands: vi.fn(async () => undefined),
};
beforeEach(() => {
resetInboundDedupe();
resetSaveMediaBufferMock();
});
vi.mock("grammy", () => ({
Bot: class {
api = apiStub;
use = middlewareUseSpy;
on = onSpy;
command = vi.fn();
stop = stopSpy;
catch = vi.fn();
constructor(public token: string) {}
},
InputFile: class {},
webhookCallback: vi.fn(),
}));
vi.mock("@grammyjs/runner", () => ({
sequentialize: () => vi.fn(),
}));
const throttlerSpy = vi.fn(() => "throttler");
vi.mock("@grammyjs/transformer-throttler", () => ({
apiThrottler: () => throttlerSpy(),
}));
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (...args: Parameters<typeof undiciFetchSpy>) => undiciFetchSpy(...args),
};
});
vi.mock("../media/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../media/store.js")>();
const mockModule = Object.create(null) as Record<string, unknown>;
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
Object.defineProperty(mockModule, "saveMediaBuffer", {
configurable: true,
enumerable: true,
writable: true,
value: (...args: Parameters<typeof saveMediaBufferSpy>) => saveMediaBufferSpy(...args),
});
return mockModule;
});
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
}),
};
});
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
...actual,
updateLastRoute: vi.fn(async () => undefined),
};
});
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
upsertChannelPairingRequest: vi.fn(async () => ({
code: "PAIRCODE",
created: true,
})),
}));
vi.mock("../auto-reply/reply.js", () => {
const replySpy = vi.fn(async (_ctx, opts) => {
await opts?.onReplyStart?.();
return undefined;
});
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});

View File

@@ -1,245 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
TELEGRAM_TEST_TIMINGS,
cacheStickerSpy,
createBotHandler,
createBotHandlerWithOptions,
describeStickerImageSpy,
getCachedStickerSpy,
mockTelegramFileDownload,
} from "./bot.media.test-utils.js";
describe("telegram stickers", () => {
const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
beforeEach(() => {
cacheStickerSpy.mockClear();
getCachedStickerSpy.mockClear();
describeStickerImageSpy.mockClear();
// Re-seed defaults so per-test overrides do not leak when using mockClear.
getCachedStickerSpy.mockReturnValue(undefined);
describeStickerImageSpy.mockReturnValue(undefined);
});
it(
"downloads static sticker (WEBP) and includes sticker metadata",
async () => {
const { handler, replySpy, runtimeError } = await createBotHandler();
const fetchSpy = mockTelegramFileDownload({
contentType: "image/webp",
bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]), // RIFF header
});
await handler({
message: {
message_id: 100,
chat: { id: 1234, type: "private" },
sticker: {
file_id: "sticker_file_id_123",
file_unique_id: "sticker_unique_123",
type: "regular",
width: 512,
height: 512,
is_animated: false,
is_video: false,
emoji: "🎉",
set_name: "TestStickerPack",
},
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "stickers/sticker.webp" }),
});
expect(runtimeError).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledWith(
"https://api.telegram.org/file/bottok/stickers/sticker.webp",
expect.objectContaining({ redirect: "manual" }),
);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("<media:sticker>");
expect(payload.Sticker?.emoji).toBe("🎉");
expect(payload.Sticker?.setName).toBe("TestStickerPack");
expect(payload.Sticker?.fileId).toBe("sticker_file_id_123");
fetchSpy.mockRestore();
},
STICKER_TEST_TIMEOUT_MS,
);
it(
"refreshes cached sticker metadata on cache hit",
async () => {
const { handler, replySpy, runtimeError } = await createBotHandler();
getCachedStickerSpy.mockReturnValue({
fileId: "old_file_id",
fileUniqueId: "sticker_unique_456",
emoji: "😴",
setName: "OldSet",
description: "Cached description",
cachedAt: "2026-01-20T10:00:00.000Z",
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => "image/webp" },
arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer,
} as unknown as Response);
await handler({
message: {
message_id: 103,
chat: { id: 1234, type: "private" },
sticker: {
file_id: "new_file_id",
file_unique_id: "sticker_unique_456",
type: "regular",
width: 512,
height: 512,
is_animated: false,
is_video: false,
emoji: "🔥",
set_name: "NewSet",
},
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "stickers/sticker.webp" }),
});
expect(runtimeError).not.toHaveBeenCalled();
expect(cacheStickerSpy).toHaveBeenCalledWith(
expect.objectContaining({
fileId: "new_file_id",
emoji: "🔥",
setName: "NewSet",
}),
);
const payload = replySpy.mock.calls[0][0];
expect(payload.Sticker?.fileId).toBe("new_file_id");
expect(payload.Sticker?.cachedDescription).toBe("Cached description");
fetchSpy.mockRestore();
},
STICKER_TEST_TIMEOUT_MS,
);
it(
"skips animated and video sticker formats that cannot be downloaded",
async () => {
const { handler, replySpy, runtimeError } = await createBotHandler();
for (const scenario of [
{
messageId: 101,
filePath: "stickers/animated.tgs",
sticker: {
file_id: "animated_sticker_id",
file_unique_id: "animated_unique",
type: "regular",
width: 512,
height: 512,
is_animated: true,
is_video: false,
emoji: "😎",
set_name: "AnimatedPack",
},
},
{
messageId: 102,
filePath: "stickers/video.webm",
sticker: {
file_id: "video_sticker_id",
file_unique_id: "video_unique",
type: "regular",
width: 512,
height: 512,
is_animated: false,
is_video: true,
emoji: "🎬",
set_name: "VideoPack",
},
},
]) {
replySpy.mockClear();
runtimeError.mockClear();
const fetchSpy = vi.spyOn(globalThis, "fetch");
await handler({
message: {
message_id: scenario.messageId,
chat: { id: 1234, type: "private" },
sticker: scenario.sticker,
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: scenario.filePath }),
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
expect(runtimeError).not.toHaveBeenCalled();
fetchSpy.mockRestore();
}
},
STICKER_TEST_TIMEOUT_MS,
);
});
describe("telegram text fragments", () => {
afterEach(() => {
vi.clearAllTimers();
});
const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
const TEXT_FRAGMENT_FLUSH_MS = TELEGRAM_TEST_TIMINGS.textFragmentGapMs + 80;
it(
"buffers near-limit text and processes sequential parts as one message",
async () => {
const { handler, replySpy } = await createBotHandlerWithOptions({});
vi.useFakeTimers();
try {
const part1 = "A".repeat(4050);
const part2 = "B".repeat(50);
await handler({
message: {
chat: { id: 42, type: "private" },
message_id: 10,
date: 1736380800,
text: part1,
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
await handler({
message: {
chat: { id: 42, type: "private" },
message_id: 11,
date: 1736380801,
text: part2,
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
expect(replySpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(TEXT_FRAGMENT_FLUSH_MS * 2);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0] as { RawBody?: string };
expect(payload.RawBody).toContain(part1.slice(0, 32));
expect(payload.RawBody).toContain(part2.slice(0, 32));
} finally {
vi.useRealTimers();
}
},
TEXT_FRAGMENT_TEST_TIMEOUT_MS,
);
});

View File

@@ -1,114 +0,0 @@
import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest";
import * as ssrf from "../infra/net/ssrf.js";
import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js";
type StickerSpy = Mock<(...args: unknown[]) => unknown>;
export const cacheStickerSpy: StickerSpy = vi.fn();
export const getCachedStickerSpy: StickerSpy = vi.fn();
export const describeStickerImageSpy: StickerSpy = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
const lookupMock = vi.fn();
let resolvePinnedHostnameSpy: ReturnType<typeof vi.spyOn> = null;
export const TELEGRAM_TEST_TIMINGS = {
mediaGroupFlushMs: 20,
textFragmentGapMs: 30,
} as const;
const TELEGRAM_BOT_IMPORT_TIMEOUT_MS = process.platform === "win32" ? 180_000 : 150_000;
let createTelegramBotRef: typeof import("./bot.js").createTelegramBot;
let replySpyRef: ReturnType<typeof vi.fn>;
export async function createBotHandler(): Promise<{
handler: (ctx: Record<string, unknown>) => Promise<void>;
replySpy: ReturnType<typeof vi.fn>;
runtimeError: ReturnType<typeof vi.fn>;
}> {
return createBotHandlerWithOptions({});
}
export async function createBotHandlerWithOptions(options: {
proxyFetch?: typeof fetch;
runtimeLog?: ReturnType<typeof vi.fn>;
runtimeError?: ReturnType<typeof vi.fn>;
}): Promise<{
handler: (ctx: Record<string, unknown>) => Promise<void>;
replySpy: ReturnType<typeof vi.fn>;
runtimeError: ReturnType<typeof vi.fn>;
}> {
onSpy.mockClear();
replySpyRef.mockClear();
sendChatActionSpy.mockClear();
const runtimeError = options.runtimeError ?? vi.fn();
const runtimeLog = options.runtimeLog ?? vi.fn();
createTelegramBotRef({
token: "tok",
testTimings: TELEGRAM_TEST_TIMINGS,
...(options.proxyFetch ? { proxyFetch: options.proxyFetch } : {}),
runtime: {
log: runtimeLog as (...data: unknown[]) => void,
error: runtimeError as (...data: unknown[]) => void,
exit: () => {
throw new Error("exit");
},
},
});
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(handler).toBeDefined();
return { handler, replySpy: replySpyRef, runtimeError };
}
export function mockTelegramFileDownload(params: {
contentType: string;
bytes: Uint8Array;
}): ReturnType<typeof vi.spyOn> {
return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => params.contentType },
arrayBuffer: async () => params.bytes.buffer,
} as unknown as Response);
}
export function mockTelegramPngDownload(): ReturnType<typeof vi.spyOn> {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => "image/png" },
arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer,
} as unknown as Response);
}
beforeEach(() => {
vi.useRealTimers();
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
resolvePinnedHostnameSpy = vi
.spyOn(ssrf, "resolvePinnedHostname")
.mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock));
});
afterEach(() => {
lookupMock.mockClear();
resolvePinnedHostnameSpy?.mockRestore();
resolvePinnedHostnameSpy = null;
});
beforeAll(async () => {
({ createTelegramBot: createTelegramBotRef } = await import("./bot.js"));
const replyModule = await import("../auto-reply/reply.js");
replySpyRef = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> }).__replySpy;
}, TELEGRAM_BOT_IMPORT_TIMEOUT_MS);
vi.mock("./sticker-cache.js", () => ({
cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args),
getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args),
describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args),
}));

File diff suppressed because it is too large Load Diff

View File

@@ -1,518 +1 @@
import { sequentialize } from "@grammyjs/runner";
import { apiThrottler } from "@grammyjs/transformer-throttler";
import type { ApiClientOptions } from "grammy";
import { Bot } from "grammy";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
import {
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../channels/thread-bindings-policy.js";
import {
isNativeCommandsExplicitlyDisabled,
resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "../config/commands.js";
import type { OpenClawConfig, ReplyToMode } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../config/group-policy.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { formatUncaughtError } from "../infra/errors.js";
import { getChildLogger } from "../logging.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { createNonExitingRuntime, type RuntimeEnv } from "../runtime.js";
import { resolveTelegramAccount } from "./accounts.js";
import { registerTelegramHandlers } from "./bot-handlers.js";
import { createTelegramMessageProcessor } from "./bot-message.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
import {
buildTelegramUpdateKey,
createTelegramUpdateDedupe,
resolveTelegramUpdateId,
type TelegramUpdateKeyContext,
} from "./bot-updates.js";
import { buildTelegramGroupPeerId, resolveTelegramStreamMode } from "./bot/helpers.js";
import { resolveTelegramTransport } from "./fetch.js";
import { tagTelegramNetworkError } from "./network-errors.js";
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
import { getTelegramSequentialKey } from "./sequential-key.js";
import { createTelegramThreadBindingManager } from "./thread-bindings.js";
export type TelegramBotOptions = {
token: string;
accountId?: string;
runtime?: RuntimeEnv;
requireMention?: boolean;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
mediaMaxMb?: number;
replyToMode?: ReplyToMode;
proxyFetch?: typeof fetch;
config?: OpenClawConfig;
/** Signal to abort in-flight Telegram API fetch requests (e.g. getUpdates) on shutdown. */
fetchAbortSignal?: AbortSignal;
updateOffset?: {
lastUpdateId?: number | null;
onUpdateId?: (updateId: number) => void | Promise<void>;
};
testTimings?: {
mediaGroupFlushMs?: number;
textFragmentGapMs?: number;
};
};
export { getTelegramSequentialKey };
type TelegramFetchInput = Parameters<NonNullable<ApiClientOptions["fetch"]>>[0];
type TelegramFetchInit = Parameters<NonNullable<ApiClientOptions["fetch"]>>[1];
type GlobalFetchInput = Parameters<typeof globalThis.fetch>[0];
type GlobalFetchInit = Parameters<typeof globalThis.fetch>[1];
function readRequestUrl(input: TelegramFetchInput): string | null {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof input === "object" && input !== null && "url" in input) {
const url = (input as { url?: unknown }).url;
return typeof url === "string" ? url : null;
}
return null;
}
function extractTelegramApiMethod(input: TelegramFetchInput): string | null {
const url = readRequestUrl(input);
if (!url) {
return null;
}
try {
const pathname = new URL(url).pathname;
const segments = pathname.split("/").filter(Boolean);
return segments.length > 0 ? (segments.at(-1) ?? null) : null;
} catch {
return null;
}
}
export function createTelegramBot(opts: TelegramBotOptions) {
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
const cfg = opts.config ?? loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const threadBindingPolicy = resolveThreadBindingSpawnPolicy({
cfg,
channel: "telegram",
accountId: account.accountId,
kind: "subagent",
});
const threadBindingManager = threadBindingPolicy.enabled
? createTelegramThreadBindingManager({
accountId: account.accountId,
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
cfg,
channel: "telegram",
accountId: account.accountId,
}),
maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({
cfg,
channel: "telegram",
accountId: account.accountId,
}),
})
: null;
const telegramCfg = account.config;
const telegramTransport = resolveTelegramTransport(opts.proxyFetch, {
network: telegramCfg.network,
});
const shouldProvideFetch = Boolean(telegramTransport.fetch);
// grammY's ApiClientOptions types still track `node-fetch` types; Node 22+ global fetch
// (undici) is structurally compatible at runtime but not assignable in TS.
const fetchForClient = telegramTransport.fetch as unknown as NonNullable<
ApiClientOptions["fetch"]
>;
// When a shutdown abort signal is provided, wrap fetch so every Telegram API request
// (especially long-polling getUpdates) aborts immediately on shutdown. Without this,
// the in-flight getUpdates hangs for up to 30s, and a new gateway instance starting
// its own poll triggers a 409 Conflict from Telegram.
let finalFetch = shouldProvideFetch ? fetchForClient : undefined;
if (opts.fetchAbortSignal) {
const baseFetch =
finalFetch ?? (globalThis.fetch as unknown as NonNullable<ApiClientOptions["fetch"]>);
const shutdownSignal = opts.fetchAbortSignal;
// Cast baseFetch to global fetch to avoid node-fetch ↔ global-fetch type divergence;
// they are runtime-compatible (the codebase already casts at every fetch boundary).
const callFetch = baseFetch as unknown as typeof globalThis.fetch;
// Use manual event forwarding instead of AbortSignal.any() to avoid the cross-realm
// AbortSignal issue in Node.js (grammY's signal may come from a different module context,
// causing "signals[0] must be an instance of AbortSignal" errors).
finalFetch = ((input: TelegramFetchInput, init?: TelegramFetchInit) => {
const controller = new AbortController();
const abortWith = (signal: AbortSignal) => controller.abort(signal.reason);
const onShutdown = () => abortWith(shutdownSignal);
let onRequestAbort: (() => void) | undefined;
if (shutdownSignal.aborted) {
abortWith(shutdownSignal);
} else {
shutdownSignal.addEventListener("abort", onShutdown, { once: true });
}
if (init?.signal) {
if (init.signal.aborted) {
abortWith(init.signal as unknown as AbortSignal);
} else {
onRequestAbort = () => abortWith(init.signal as AbortSignal);
init.signal.addEventListener("abort", onRequestAbort);
}
}
return callFetch(input as GlobalFetchInput, {
...(init as GlobalFetchInit),
signal: controller.signal,
}).finally(() => {
shutdownSignal.removeEventListener("abort", onShutdown);
if (init?.signal && onRequestAbort) {
init.signal.removeEventListener("abort", onRequestAbort);
}
});
}) as unknown as NonNullable<ApiClientOptions["fetch"]>;
}
if (finalFetch) {
const baseFetch = finalFetch;
finalFetch = ((input: TelegramFetchInput, init?: TelegramFetchInit) => {
return Promise.resolve(baseFetch(input, init)).catch((err: unknown) => {
try {
tagTelegramNetworkError(err, {
method: extractTelegramApiMethod(input),
url: readRequestUrl(input),
});
} catch {
// Tagging is best-effort; preserve the original fetch failure if the
// error object cannot accept extra metadata.
}
throw err;
});
}) as unknown as NonNullable<ApiClientOptions["fetch"]>;
}
const timeoutSeconds =
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
: undefined;
const client: ApiClientOptions | undefined =
finalFetch || timeoutSeconds
? {
...(finalFetch ? { fetch: finalFetch } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
}
: undefined;
const bot = new Bot(opts.token, client ? { client } : undefined);
bot.api.config.use(apiThrottler());
// Catch all errors from bot middleware to prevent unhandled rejections
bot.catch((err) => {
runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`));
});
const recentUpdates = createTelegramUpdateDedupe();
const initialUpdateId =
typeof opts.updateOffset?.lastUpdateId === "number" ? opts.updateOffset.lastUpdateId : null;
// Track update_ids that have entered the middleware pipeline but have not completed yet.
// This includes updates that are "queued" behind sequentialize(...) for a chat/topic key.
// We only persist a watermark that is strictly less than the smallest pending update_id,
// so we never write an offset that would skip an update still waiting to run.
const pendingUpdateIds = new Set<number>();
let highestCompletedUpdateId: number | null = initialUpdateId;
let highestPersistedUpdateId: number | null = initialUpdateId;
const maybePersistSafeWatermark = () => {
if (typeof opts.updateOffset?.onUpdateId !== "function") {
return;
}
if (highestCompletedUpdateId === null) {
return;
}
let safe = highestCompletedUpdateId;
if (pendingUpdateIds.size > 0) {
let minPending: number | null = null;
for (const id of pendingUpdateIds) {
if (minPending === null || id < minPending) {
minPending = id;
}
}
if (minPending !== null) {
safe = Math.min(safe, minPending - 1);
}
}
if (highestPersistedUpdateId !== null && safe <= highestPersistedUpdateId) {
return;
}
highestPersistedUpdateId = safe;
void opts.updateOffset.onUpdateId(safe);
};
const shouldSkipUpdate = (ctx: TelegramUpdateKeyContext) => {
const updateId = resolveTelegramUpdateId(ctx);
const skipCutoff = highestPersistedUpdateId ?? initialUpdateId;
if (typeof updateId === "number" && skipCutoff !== null && updateId <= skipCutoff) {
return true;
}
const key = buildTelegramUpdateKey(ctx);
const skipped = recentUpdates.check(key);
if (skipped && key && shouldLogVerbose()) {
logVerbose(`telegram dedupe: skipped ${key}`);
}
return skipped;
};
bot.use(async (ctx, next) => {
const updateId = resolveTelegramUpdateId(ctx);
if (typeof updateId === "number") {
pendingUpdateIds.add(updateId);
}
try {
await next();
} finally {
if (typeof updateId === "number") {
pendingUpdateIds.delete(updateId);
if (highestCompletedUpdateId === null || updateId > highestCompletedUpdateId) {
highestCompletedUpdateId = updateId;
}
maybePersistSafeWatermark();
}
}
});
bot.use(sequentialize(getTelegramSequentialKey));
const rawUpdateLogger = createSubsystemLogger("gateway/channels/telegram/raw-update");
const MAX_RAW_UPDATE_CHARS = 8000;
const MAX_RAW_UPDATE_STRING = 500;
const MAX_RAW_UPDATE_ARRAY = 20;
const stringifyUpdate = (update: unknown) => {
const seen = new WeakSet();
return JSON.stringify(update ?? null, (key, value) => {
if (typeof value === "string" && value.length > MAX_RAW_UPDATE_STRING) {
return `${value.slice(0, MAX_RAW_UPDATE_STRING)}...`;
}
if (Array.isArray(value) && value.length > MAX_RAW_UPDATE_ARRAY) {
return [
...value.slice(0, MAX_RAW_UPDATE_ARRAY),
`...(${value.length - MAX_RAW_UPDATE_ARRAY} more)`,
];
}
if (value && typeof value === "object") {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
}
return value;
});
};
bot.use(async (ctx, next) => {
if (shouldLogVerbose()) {
try {
const raw = stringifyUpdate(ctx.update);
const preview =
raw.length > MAX_RAW_UPDATE_CHARS ? `${raw.slice(0, MAX_RAW_UPDATE_CHARS)}...` : raw;
rawUpdateLogger.debug(`telegram update: ${preview}`);
} catch (err) {
rawUpdateLogger.debug(`telegram update log failed: ${String(err)}`);
}
}
await next();
});
const historyLimit = Math.max(
0,
telegramCfg.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
);
const groupHistories = new Map<string, HistoryEntry[]>();
const textLimit = resolveTextChunkLimit(cfg, "telegram", account.accountId);
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;
const groupAllowFrom =
opts.groupAllowFrom ?? telegramCfg.groupAllowFrom ?? telegramCfg.allowFrom ?? allowFrom;
const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "off";
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "telegram",
providerSetting: telegramCfg.commands?.native,
globalSetting: cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "telegram",
providerSetting: telegramCfg.commands?.nativeSkills,
globalSetting: cfg.commands?.nativeSkills,
});
const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({
providerSetting: telegramCfg.commands?.native,
globalSetting: cfg.commands?.native,
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes = (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 100) * 1024 * 1024;
const logger = getChildLogger({ module: "telegram-auto-reply" });
const streamMode = resolveTelegramStreamMode(telegramCfg);
const resolveGroupPolicy = (chatId: string | number) =>
resolveChannelGroupPolicy({
cfg,
channel: "telegram",
accountId: account.accountId,
groupId: String(chatId),
});
const resolveGroupActivation = (params: {
chatId: string | number;
agentId?: string;
messageThreadId?: number;
sessionKey?: string;
}) => {
const agentId = params.agentId ?? resolveDefaultAgentId(cfg);
const sessionKey =
params.sessionKey ??
`agent:${agentId}:telegram:group:${buildTelegramGroupPeerId(params.chatId, params.messageThreadId)}`;
const storePath = resolveStorePath(cfg.session?.store, { agentId });
try {
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
if (entry?.groupActivation === "always") {
return false;
}
if (entry?.groupActivation === "mention") {
return true;
}
} catch (err) {
logVerbose(`Failed to load session for activation check: ${String(err)}`);
}
return undefined;
};
const resolveGroupRequireMention = (chatId: string | number) =>
resolveChannelGroupRequireMention({
cfg,
channel: "telegram",
accountId: account.accountId,
groupId: String(chatId),
requireMentionOverride: opts.requireMention,
overrideOrder: "after-config",
});
const resolveTelegramGroupConfig = (chatId: string | number, messageThreadId?: number) => {
const groups = telegramCfg.groups;
const direct = telegramCfg.direct;
const chatIdStr = String(chatId);
const isDm = !chatIdStr.startsWith("-");
if (isDm) {
const directConfig = direct?.[chatIdStr] ?? direct?.["*"];
if (directConfig) {
const topicConfig =
messageThreadId != null ? directConfig.topics?.[String(messageThreadId)] : undefined;
return { groupConfig: directConfig, topicConfig };
}
// DMs without direct config: don't fall through to groups lookup
return { groupConfig: undefined, topicConfig: undefined };
}
if (!groups) {
return { groupConfig: undefined, topicConfig: undefined };
}
const groupConfig = groups[chatIdStr] ?? groups["*"];
const topicConfig =
messageThreadId != null ? groupConfig?.topics?.[String(messageThreadId)] : undefined;
return { groupConfig, topicConfig };
};
// Global sendChatAction handler with 401 backoff / circuit breaker (issue #27092).
// Created BEFORE the message processor so it can be injected into every message context.
// Shared across all message contexts for this account so that consecutive 401s
// from ANY chat are tracked together — prevents infinite retry storms.
const sendChatActionHandler = createTelegramSendChatActionHandler({
sendChatActionFn: (chatId, action, threadParams) =>
bot.api.sendChatAction(
chatId,
action,
threadParams as Parameters<typeof bot.api.sendChatAction>[2],
),
logger: (message) => logVerbose(`telegram: ${message}`),
});
const processMessage = createTelegramMessageProcessor({
bot,
cfg,
account,
telegramCfg,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
sendChatActionHandler,
runtime,
replyToMode,
streamMode,
textLimit,
opts,
});
registerTelegramNativeCommands({
bot,
cfg,
runtime,
accountId: account.accountId,
telegramCfg,
allowFrom,
groupAllowFrom,
replyToMode,
textLimit,
useAccessGroups,
nativeEnabled,
nativeSkillsEnabled,
nativeDisabledExplicit,
resolveGroupPolicy,
resolveTelegramGroupConfig,
shouldSkipUpdate,
opts,
});
registerTelegramHandlers({
cfg,
accountId: account.accountId,
bot,
opts,
telegramTransport,
runtime,
mediaMaxBytes,
telegramCfg,
allowFrom,
groupAllowFrom,
resolveGroupPolicy,
resolveTelegramGroupConfig,
shouldSkipUpdate,
processMessage,
logger,
});
const originalStop = bot.stop.bind(bot);
bot.stop = ((...args: Parameters<typeof originalStop>) => {
threadBindingManager?.stop();
return originalStop(...args);
}) as typeof bot.stop;
return bot;
}
export * from "../../extensions/telegram/src/bot.js";

View File

@@ -1,699 +1 @@
import { type Bot, GrammyError, InputFile } from "grammy";
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { ReplyToMode } from "../../config/config.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import { danger, logVerbose } from "../../globals.js";
import { fireAndForgetHook } from "../../hooks/fire-and-forget.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import {
buildCanonicalSentMessageHookContext,
toInternalMessageSentContext,
toPluginMessageContext,
toPluginMessageSentEvent,
} from "../../hooks/message-hook-mappers.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { buildOutboundMediaLoadOptions } from "../../media/load-options.js";
import { isGifMedia, kindFromMime } from "../../media/mime.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import type { RuntimeEnv } from "../../runtime.js";
import { loadWebMedia } from "../../web/media.js";
import type { TelegramInlineButtons } from "../button-types.js";
import { splitTelegramCaption } from "../caption.js";
import {
markdownToTelegramChunks,
markdownToTelegramHtml,
renderTelegramHtmlText,
wrapFileReferencesInHtml,
} from "../format.js";
import { buildInlineKeyboard } from "../send.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import {
buildTelegramSendParams,
sendTelegramText,
sendTelegramWithThreadFallback,
} from "./delivery.send.js";
import { resolveTelegramReplyId, type TelegramThreadSpec } from "./helpers.js";
import {
markReplyApplied,
resolveReplyToForSend,
sendChunkedTelegramReplyText,
type DeliveryProgress as ReplyThreadDeliveryProgress,
} from "./reply-threading.js";
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
const CAPTION_TOO_LONG_RE = /caption is too long/i;
type DeliveryProgress = ReplyThreadDeliveryProgress & {
deliveredCount: number;
};
type TelegramReplyChannelData = {
buttons?: TelegramInlineButtons;
pin?: boolean;
};
type ChunkTextFn = (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
function buildChunkTextResolver(params: {
textLimit: number;
chunkMode: ChunkMode;
tableMode?: MarkdownTableMode;
}): ChunkTextFn {
return (markdown: string) => {
const markdownChunks =
params.chunkMode === "newline"
? chunkMarkdownTextWithMode(markdown, params.textLimit, params.chunkMode)
: [markdown];
const chunks: ReturnType<typeof markdownToTelegramChunks> = [];
for (const chunk of markdownChunks) {
const nested = markdownToTelegramChunks(chunk, params.textLimit, {
tableMode: params.tableMode,
});
if (!nested.length && chunk) {
chunks.push({
html: wrapFileReferencesInHtml(
markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }),
),
text: chunk,
});
continue;
}
chunks.push(...nested);
}
return chunks;
};
}
function markDelivered(progress: DeliveryProgress): void {
progress.hasDelivered = true;
progress.deliveredCount += 1;
}
async function deliverTextReply(params: {
bot: Bot;
chatId: string;
runtime: RuntimeEnv;
thread?: TelegramThreadSpec | null;
chunkText: ChunkTextFn;
replyText: string;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string;
linkPreview?: boolean;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): Promise<number | undefined> {
let firstDeliveredMessageId: number | undefined;
await sendChunkedTelegramReplyText({
chunks: params.chunkText(params.replyText),
progress: params.progress,
replyToId: params.replyToId,
replyToMode: params.replyToMode,
replyMarkup: params.replyMarkup,
replyQuoteText: params.replyQuoteText,
markDelivered,
sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
const messageId = await sendTelegramText(
params.bot,
params.chatId,
chunk.html,
params.runtime,
{
replyToMessageId,
replyQuoteText,
thread: params.thread,
textMode: "html",
plainText: chunk.text,
linkPreview: params.linkPreview,
replyMarkup,
},
);
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = messageId;
}
},
});
return firstDeliveredMessageId;
}
async function sendPendingFollowUpText(params: {
bot: Bot;
chatId: string;
runtime: RuntimeEnv;
thread?: TelegramThreadSpec | null;
chunkText: ChunkTextFn;
text: string;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
linkPreview?: boolean;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): Promise<void> {
await sendChunkedTelegramReplyText({
chunks: params.chunkText(params.text),
progress: params.progress,
replyToId: params.replyToId,
replyToMode: params.replyToMode,
replyMarkup: params.replyMarkup,
markDelivered,
sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => {
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
replyToMessageId,
thread: params.thread,
textMode: "html",
plainText: chunk.text,
linkPreview: params.linkPreview,
replyMarkup,
});
},
});
}
function isVoiceMessagesForbidden(err: unknown): boolean {
if (err instanceof GrammyError) {
return VOICE_FORBIDDEN_RE.test(err.description);
}
return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err));
}
function isCaptionTooLong(err: unknown): boolean {
if (err instanceof GrammyError) {
return CAPTION_TOO_LONG_RE.test(err.description);
}
return CAPTION_TOO_LONG_RE.test(formatErrorMessage(err));
}
async function sendTelegramVoiceFallbackText(opts: {
bot: Bot;
chatId: string;
runtime: RuntimeEnv;
text: string;
chunkText: (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
replyToId?: number;
thread?: TelegramThreadSpec | null;
linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string;
}): Promise<number | undefined> {
let firstDeliveredMessageId: number | undefined;
const chunks = opts.chunkText(opts.text);
let appliedReplyTo = false;
for (let i = 0; i < chunks.length; i += 1) {
const chunk = chunks[i];
// Only apply reply reference, quote text, and buttons to the first chunk.
const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined;
const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
replyToMessageId: replyToForChunk,
replyQuoteText: !appliedReplyTo ? opts.replyQuoteText : undefined,
thread: opts.thread,
textMode: "html",
plainText: chunk.text,
linkPreview: opts.linkPreview,
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = messageId;
}
if (replyToForChunk) {
appliedReplyTo = true;
}
}
return firstDeliveredMessageId;
}
async function deliverMediaReply(params: {
reply: ReplyPayload;
mediaList: string[];
bot: Bot;
chatId: string;
runtime: RuntimeEnv;
thread?: TelegramThreadSpec | null;
tableMode?: MarkdownTableMode;
mediaLocalRoots?: readonly string[];
chunkText: ChunkTextFn;
onVoiceRecording?: () => Promise<void> | void;
linkPreview?: boolean;
replyQuoteText?: string;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): Promise<number | undefined> {
let firstDeliveredMessageId: number | undefined;
let first = true;
let pendingFollowUpText: string | undefined;
for (const mediaUrl of params.mediaList) {
const isFirstMedia = first;
const media = await loadWebMedia(
mediaUrl,
buildOutboundMediaLoadOptions({ mediaLocalRoots: params.mediaLocalRoots }),
);
const kind = kindFromMime(media.contentType ?? undefined);
const isGif = isGifMedia({
contentType: media.contentType,
fileName: media.fileName,
});
const fileName = media.fileName ?? (isGif ? "animation.gif" : "file");
const file = new InputFile(media.buffer, fileName);
const { caption, followUpText } = splitTelegramCaption(
isFirstMedia ? (params.reply.text ?? undefined) : undefined,
);
const htmlCaption = caption
? renderTelegramHtmlText(caption, { tableMode: params.tableMode })
: undefined;
if (followUpText) {
pendingFollowUpText = followUpText;
}
first = false;
const replyToMessageId = resolveReplyToForSend({
replyToId: params.replyToId,
replyToMode: params.replyToMode,
progress: params.progress,
});
const shouldAttachButtonsToMedia = isFirstMedia && params.replyMarkup && !followUpText;
const mediaParams: Record<string, unknown> = {
caption: htmlCaption,
...(htmlCaption ? { parse_mode: "HTML" } : {}),
...(shouldAttachButtonsToMedia ? { reply_markup: params.replyMarkup } : {}),
...buildTelegramSendParams({
replyToMessageId,
thread: params.thread,
}),
};
if (isGif) {
const result = await sendTelegramWithThreadFallback({
operation: "sendAnimation",
runtime: params.runtime,
thread: params.thread,
requestParams: mediaParams,
send: (effectiveParams) =>
params.bot.api.sendAnimation(params.chatId, file, { ...effectiveParams }),
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = result.message_id;
}
markDelivered(params.progress);
} else if (kind === "image") {
const result = await sendTelegramWithThreadFallback({
operation: "sendPhoto",
runtime: params.runtime,
thread: params.thread,
requestParams: mediaParams,
send: (effectiveParams) =>
params.bot.api.sendPhoto(params.chatId, file, { ...effectiveParams }),
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = result.message_id;
}
markDelivered(params.progress);
} else if (kind === "video") {
const result = await sendTelegramWithThreadFallback({
operation: "sendVideo",
runtime: params.runtime,
thread: params.thread,
requestParams: mediaParams,
send: (effectiveParams) =>
params.bot.api.sendVideo(params.chatId, file, { ...effectiveParams }),
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = result.message_id;
}
markDelivered(params.progress);
} else if (kind === "audio") {
const { useVoice } = resolveTelegramVoiceSend({
wantsVoice: params.reply.audioAsVoice === true,
contentType: media.contentType,
fileName,
logFallback: logVerbose,
});
if (useVoice) {
const sendVoiceMedia = async (
requestParams: typeof mediaParams,
shouldLog?: (err: unknown) => boolean,
) => {
const result = await sendTelegramWithThreadFallback({
operation: "sendVoice",
runtime: params.runtime,
thread: params.thread,
requestParams,
shouldLog,
send: (effectiveParams) =>
params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }),
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = result.message_id;
}
markDelivered(params.progress);
};
await params.onVoiceRecording?.();
try {
await sendVoiceMedia(mediaParams, (err) => !isVoiceMessagesForbidden(err));
} catch (voiceErr) {
if (isVoiceMessagesForbidden(voiceErr)) {
const fallbackText = params.reply.text;
if (!fallbackText || !fallbackText.trim()) {
throw voiceErr;
}
logVerbose(
"telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text",
);
const voiceFallbackReplyTo = resolveReplyToForSend({
replyToId: params.replyToId,
replyToMode: params.replyToMode,
progress: params.progress,
});
const fallbackMessageId = await sendTelegramVoiceFallbackText({
bot: params.bot,
chatId: params.chatId,
runtime: params.runtime,
text: fallbackText,
chunkText: params.chunkText,
replyToId: voiceFallbackReplyTo,
thread: params.thread,
linkPreview: params.linkPreview,
replyMarkup: params.replyMarkup,
replyQuoteText: params.replyQuoteText,
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = fallbackMessageId;
}
markReplyApplied(params.progress, voiceFallbackReplyTo);
markDelivered(params.progress);
continue;
}
if (isCaptionTooLong(voiceErr)) {
logVerbose(
"telegram sendVoice caption too long; resending voice without caption + text separately",
);
const noCaptionParams = { ...mediaParams };
delete noCaptionParams.caption;
delete noCaptionParams.parse_mode;
await sendVoiceMedia(noCaptionParams);
const fallbackText = params.reply.text;
if (fallbackText?.trim()) {
await sendTelegramVoiceFallbackText({
bot: params.bot,
chatId: params.chatId,
runtime: params.runtime,
text: fallbackText,
chunkText: params.chunkText,
replyToId: undefined,
thread: params.thread,
linkPreview: params.linkPreview,
replyMarkup: params.replyMarkup,
});
}
markReplyApplied(params.progress, replyToMessageId);
continue;
}
throw voiceErr;
}
} else {
const result = await sendTelegramWithThreadFallback({
operation: "sendAudio",
runtime: params.runtime,
thread: params.thread,
requestParams: mediaParams,
send: (effectiveParams) =>
params.bot.api.sendAudio(params.chatId, file, { ...effectiveParams }),
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = result.message_id;
}
markDelivered(params.progress);
}
} else {
const result = await sendTelegramWithThreadFallback({
operation: "sendDocument",
runtime: params.runtime,
thread: params.thread,
requestParams: mediaParams,
send: (effectiveParams) =>
params.bot.api.sendDocument(params.chatId, file, { ...effectiveParams }),
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = result.message_id;
}
markDelivered(params.progress);
}
markReplyApplied(params.progress, replyToMessageId);
if (pendingFollowUpText && isFirstMedia) {
await sendPendingFollowUpText({
bot: params.bot,
chatId: params.chatId,
runtime: params.runtime,
thread: params.thread,
chunkText: params.chunkText,
text: pendingFollowUpText,
replyMarkup: params.replyMarkup,
linkPreview: params.linkPreview,
replyToId: params.replyToId,
replyToMode: params.replyToMode,
progress: params.progress,
});
pendingFollowUpText = undefined;
}
}
return firstDeliveredMessageId;
}
async function maybePinFirstDeliveredMessage(params: {
shouldPin: boolean;
bot: Bot;
chatId: string;
runtime: RuntimeEnv;
firstDeliveredMessageId?: number;
}): Promise<void> {
if (!params.shouldPin || typeof params.firstDeliveredMessageId !== "number") {
return;
}
try {
await params.bot.api.pinChatMessage(params.chatId, params.firstDeliveredMessageId, {
disable_notification: true,
});
} catch (err) {
logVerbose(
`telegram pinChatMessage failed chat=${params.chatId} message=${params.firstDeliveredMessageId}: ${formatErrorMessage(err)}`,
);
}
}
function emitMessageSentHooks(params: {
hookRunner: ReturnType<typeof getGlobalHookRunner>;
enabled: boolean;
sessionKeyForInternalHooks?: string;
chatId: string;
accountId?: string;
content: string;
success: boolean;
error?: string;
messageId?: number;
isGroup?: boolean;
groupId?: string;
}): void {
if (!params.enabled && !params.sessionKeyForInternalHooks) {
return;
}
const canonical = buildCanonicalSentMessageHookContext({
to: params.chatId,
content: params.content,
success: params.success,
error: params.error,
channelId: "telegram",
accountId: params.accountId,
conversationId: params.chatId,
messageId: typeof params.messageId === "number" ? String(params.messageId) : undefined,
isGroup: params.isGroup,
groupId: params.groupId,
});
if (params.enabled) {
fireAndForgetHook(
Promise.resolve(
params.hookRunner!.runMessageSent(
toPluginMessageSentEvent(canonical),
toPluginMessageContext(canonical),
),
),
"telegram: message_sent plugin hook failed",
);
}
if (!params.sessionKeyForInternalHooks) {
return;
}
fireAndForgetHook(
triggerInternalHook(
createInternalHookEvent(
"message",
"sent",
params.sessionKeyForInternalHooks,
toInternalMessageSentContext(canonical),
),
),
"telegram: message:sent internal hook failed",
);
}
export async function deliverReplies(params: {
replies: ReplyPayload[];
chatId: string;
accountId?: string;
sessionKeyForInternalHooks?: string;
mirrorIsGroup?: boolean;
mirrorGroupId?: string;
token: string;
runtime: RuntimeEnv;
bot: Bot;
mediaLocalRoots?: readonly string[];
replyToMode: ReplyToMode;
textLimit: number;
thread?: TelegramThreadSpec | null;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
/** Callback invoked before sending a voice message to switch typing indicator. */
onVoiceRecording?: () => Promise<void> | void;
/** Controls whether link previews are shown. Default: true (previews enabled). */
linkPreview?: boolean;
/** Optional quote text for Telegram reply_parameters. */
replyQuoteText?: string;
}): Promise<{ delivered: boolean }> {
const progress: DeliveryProgress = {
hasReplied: false,
hasDelivered: false,
deliveredCount: 0,
};
const hookRunner = getGlobalHookRunner();
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
const chunkText = buildChunkTextResolver({
textLimit: params.textLimit,
chunkMode: params.chunkMode ?? "length",
tableMode: params.tableMode,
});
for (const originalReply of params.replies) {
let reply = originalReply;
const mediaList = reply?.mediaUrls?.length
? reply.mediaUrls
: reply?.mediaUrl
? [reply.mediaUrl]
: [];
const hasMedia = mediaList.length > 0;
if (!reply?.text && !hasMedia) {
if (reply?.audioAsVoice) {
logVerbose("telegram reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.(danger("reply missing text/media"));
continue;
}
const rawContent = reply.text || "";
if (hasMessageSendingHooks) {
const hookResult = await hookRunner?.runMessageSending(
{
to: params.chatId,
content: rawContent,
metadata: {
channel: "telegram",
mediaUrls: mediaList,
threadId: params.thread?.id,
},
},
{
channelId: "telegram",
accountId: params.accountId,
conversationId: params.chatId,
},
);
if (hookResult?.cancel) {
continue;
}
if (typeof hookResult?.content === "string" && hookResult.content !== rawContent) {
reply = { ...reply, text: hookResult.content };
}
}
const contentForSentHook = reply.text || "";
try {
const deliveredCountBeforeReply = progress.deliveredCount;
const replyToId =
params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
const telegramData = reply.channelData?.telegram as TelegramReplyChannelData | undefined;
const shouldPinFirstMessage = telegramData?.pin === true;
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
let firstDeliveredMessageId: number | undefined;
if (mediaList.length === 0) {
firstDeliveredMessageId = await deliverTextReply({
bot: params.bot,
chatId: params.chatId,
runtime: params.runtime,
thread: params.thread,
chunkText,
replyText: reply.text || "",
replyMarkup,
replyQuoteText: params.replyQuoteText,
linkPreview: params.linkPreview,
replyToId,
replyToMode: params.replyToMode,
progress,
});
} else {
firstDeliveredMessageId = await deliverMediaReply({
reply,
mediaList,
bot: params.bot,
chatId: params.chatId,
runtime: params.runtime,
thread: params.thread,
tableMode: params.tableMode,
mediaLocalRoots: params.mediaLocalRoots,
chunkText,
onVoiceRecording: params.onVoiceRecording,
linkPreview: params.linkPreview,
replyQuoteText: params.replyQuoteText,
replyMarkup,
replyToId,
replyToMode: params.replyToMode,
progress,
});
}
await maybePinFirstDeliveredMessage({
shouldPin: shouldPinFirstMessage,
bot: params.bot,
chatId: params.chatId,
runtime: params.runtime,
firstDeliveredMessageId,
});
emitMessageSentHooks({
hookRunner,
enabled: hasMessageSentHooks,
sessionKeyForInternalHooks: params.sessionKeyForInternalHooks,
chatId: params.chatId,
accountId: params.accountId,
content: contentForSentHook,
success: progress.deliveredCount > deliveredCountBeforeReply,
messageId: firstDeliveredMessageId,
isGroup: params.mirrorIsGroup,
groupId: params.mirrorGroupId,
});
} catch (error) {
emitMessageSentHooks({
hookRunner,
enabled: hasMessageSentHooks,
sessionKeyForInternalHooks: params.sessionKeyForInternalHooks,
chatId: params.chatId,
accountId: params.accountId,
content: contentForSentHook,
success: false,
error: error instanceof Error ? error.message : String(error),
isGroup: params.mirrorIsGroup,
groupId: params.mirrorGroupId,
});
throw error;
}
}
return { delivered: progress.hasDelivered };
}
export * from "../../../extensions/telegram/src/bot/delivery.replies.js";

View File

@@ -1,479 +0,0 @@
import type { Message } from "@grammyjs/types";
import { GrammyError } from "grammy";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramContext } from "./types.js";
const saveMediaBuffer = vi.fn();
const fetchRemoteMedia = vi.fn();
vi.mock("../../media/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../media/store.js")>();
return {
...actual,
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
};
});
vi.mock("../../media/fetch.js", () => ({
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
}));
vi.mock("../../globals.js", () => ({
danger: (s: string) => s,
warn: (s: string) => s,
logVerbose: () => {},
}));
vi.mock("../sticker-cache.js", () => ({
cacheSticker: () => {},
getCachedSticker: () => null,
}));
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const { resolveMedia } = await import("./delivery.js");
const MAX_MEDIA_BYTES = 10_000_000;
const BOT_TOKEN = "tok123";
function makeCtx(
mediaField: "voice" | "audio" | "photo" | "video" | "document" | "animation" | "sticker",
getFile: TelegramContext["getFile"],
opts?: { file_name?: string },
): TelegramContext {
const msg: Record<string, unknown> = {
message_id: 1,
date: 0,
chat: { id: 1, type: "private" },
};
if (mediaField === "voice") {
msg.voice = { file_id: "v1", duration: 5, file_unique_id: "u1" };
}
if (mediaField === "audio") {
msg.audio = {
file_id: "a1",
duration: 5,
file_unique_id: "u2",
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "photo") {
msg.photo = [{ file_id: "p1", width: 100, height: 100 }];
}
if (mediaField === "video") {
msg.video = {
file_id: "vid1",
duration: 10,
file_unique_id: "u3",
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "document") {
msg.document = {
file_id: "d1",
file_unique_id: "u4",
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "animation") {
msg.animation = {
file_id: "an1",
duration: 3,
file_unique_id: "u5",
width: 200,
height: 200,
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "sticker") {
msg.sticker = {
file_id: "stk1",
file_unique_id: "ustk1",
type: "regular",
width: 512,
height: 512,
is_animated: false,
is_video: false,
};
}
return {
message: msg as unknown as Message,
me: {
id: 1,
is_bot: true,
first_name: "bot",
username: "bot",
} as unknown as TelegramContext["me"],
getFile,
};
}
function setupTransientGetFileRetry() {
const getFile = vi
.fn()
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
.mockResolvedValueOnce({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
contentType: "audio/ogg",
fileName: "file_0.oga",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.oga",
contentType: "audio/ogg",
});
return getFile;
}
function mockPdfFetchAndSave(fileName: string | undefined) {
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName,
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_42---uuid.pdf",
contentType: "application/pdf",
});
}
function createFileTooBigError(): Error {
return new Error("GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)");
}
async function expectTransientGetFileRetrySuccess() {
const getFile = setupTransientGetFileRetry();
const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(2);
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`,
ssrfPolicy: {
allowRfc2544BenchmarkRange: true,
allowedHostnames: ["api.telegram.org"],
},
}),
);
return result;
}
async function flushRetryTimers() {
await vi.runAllTimersAsync();
}
describe("resolveMedia getFile retry", () => {
beforeEach(() => {
vi.useFakeTimers();
fetchRemoteMedia.mockClear();
saveMediaBuffer.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("retries getFile on transient failure and succeeds on second attempt", async () => {
const result = await expectTransientGetFileRetrySuccess();
expect(result).toEqual(
expect.objectContaining({ path: "/tmp/file_0.oga", placeholder: "<media:audio>" }),
);
});
it.each(["voice", "photo", "video"] as const)(
"returns null for %s when getFile exhausts retries so message is not dropped",
async (mediaField) => {
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
const promise = resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(3);
expect(result).toBeNull();
},
);
it("does not catch errors from fetchRemoteMedia (only getFile is retried)", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockRejectedValueOnce(new Error("download failed"));
await expect(
resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("download failed");
expect(getFile).toHaveBeenCalledTimes(1);
});
it("does not retry 'file is too big' error (400 Bad Request) and returns null", async () => {
// Simulate Telegram Bot API error when file exceeds 20MB limit.
const fileTooBigError = createFileTooBigError();
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
// Should NOT retry - "file is too big" is a permanent error, not transient.
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it("does not retry 'file is too big' GrammyError instances and returns null", async () => {
const fileTooBigError = new GrammyError(
"Call to 'getFile' failed!",
{ ok: false, error_code: 400, description: "Bad Request: file is too big" },
"getFile",
{},
);
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it.each(["audio", "voice"] as const)(
"returns null for %s when file is too big",
async (mediaField) => {
const getFile = vi.fn().mockRejectedValue(createFileTooBigError());
const result = await resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
},
);
it("throws when getFile returns no file_path", async () => {
const getFile = vi.fn().mockResolvedValue({});
await expect(
resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("Telegram getFile returned no file_path");
expect(getFile).toHaveBeenCalledTimes(1);
});
it("still retries transient errors even after encountering file too big in different call", async () => {
const result = await expectTransientGetFileRetrySuccess();
// Should retry transient errors.
expect(result).not.toBeNull();
});
it("retries getFile for stickers on transient failure", async () => {
const getFile = vi
.fn()
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
.mockResolvedValueOnce({ file_path: "stickers/file_0.webp" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("sticker-data"),
contentType: "image/webp",
fileName: "file_0.webp",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.webp",
contentType: "image/webp",
});
const ctx = makeCtx("sticker", getFile);
const promise = resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(2);
expect(result).toEqual(
expect.objectContaining({ path: "/tmp/file_0.webp", placeholder: "<media:sticker>" }),
);
});
it("returns null for sticker when getFile exhausts retries", async () => {
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
const ctx = makeCtx("sticker", getFile);
const promise = resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(3);
expect(result).toBeNull();
});
it("uses caller-provided fetch impl for file downloads", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
const callerFetch = vi.fn() as unknown as typeof fetch;
const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch };
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_42---uuid.pdf",
contentType: "application/pdf",
});
const result = await resolveMedia(
makeCtx("document", getFile),
MAX_MEDIA_BYTES,
BOT_TOKEN,
callerTransport,
);
expect(result).not.toBeNull();
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
fetchImpl: callerFetch,
}),
);
});
it("uses caller-provided fetch impl for sticker downloads", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
const callerFetch = vi.fn() as unknown as typeof fetch;
const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch };
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("sticker-data"),
contentType: "image/webp",
fileName: "file_0.webp",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.webp",
contentType: "image/webp",
});
const result = await resolveMedia(
makeCtx("sticker", getFile),
MAX_MEDIA_BYTES,
BOT_TOKEN,
callerTransport,
);
expect(result).not.toBeNull();
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
fetchImpl: callerFetch,
}),
);
});
});
describe("resolveMedia original filename preservation", () => {
beforeEach(() => {
vi.useFakeTimers();
fetchRemoteMedia.mockClear();
saveMediaBuffer.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("passes document.file_name to saveMediaBuffer instead of server-side path", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/business-plan---uuid.pdf",
contentType: "application/pdf",
});
const ctx = makeCtx("document", getFile, { file_name: "business-plan.pdf" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"application/pdf",
"inbound",
MAX_MEDIA_BYTES,
"business-plan.pdf",
);
expect(result).toEqual(expect.objectContaining({ path: "/tmp/business-plan---uuid.pdf" }));
});
it("passes audio.file_name to saveMediaBuffer", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "music/file_99.mp3" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("audio-data"),
contentType: "audio/mpeg",
fileName: "file_99.mp3",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/my-song---uuid.mp3",
contentType: "audio/mpeg",
});
const ctx = makeCtx("audio", getFile, { file_name: "my-song.mp3" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"audio/mpeg",
"inbound",
MAX_MEDIA_BYTES,
"my-song.mp3",
);
expect(result).not.toBeNull();
});
it("passes video.file_name to saveMediaBuffer", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "videos/file_55.mp4" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("video-data"),
contentType: "video/mp4",
fileName: "file_55.mp4",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/presentation---uuid.mp4",
contentType: "video/mp4",
});
const ctx = makeCtx("video", getFile, { file_name: "presentation.mp4" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"video/mp4",
"inbound",
MAX_MEDIA_BYTES,
"presentation.mp4",
);
expect(result).not.toBeNull();
});
it("falls back to fetched.fileName when telegram file_name is absent", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
mockPdfFetchAndSave("file_42.pdf");
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"application/pdf",
"inbound",
MAX_MEDIA_BYTES,
"file_42.pdf",
);
expect(result).not.toBeNull();
});
it("falls back to filePath when neither telegram nor fetched fileName is available", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
mockPdfFetchAndSave(undefined);
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"application/pdf",
"inbound",
MAX_MEDIA_BYTES,
"documents/file_42.pdf",
);
expect(result).not.toBeNull();
});
});

View File

@@ -1,290 +1 @@
import { GrammyError } from "grammy";
import { logVerbose, warn } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { retryAsync } from "../../infra/retry.js";
import { fetchRemoteMedia } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
import { shouldRetryTelegramIpv4Fallback, type TelegramTransport } from "../fetch.js";
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
import { resolveTelegramMediaPlaceholder } from "./helpers.js";
import type { StickerMetadata, TelegramContext } from "./types.js";
const FILE_TOO_BIG_RE = /file is too big/i;
const TELEGRAM_MEDIA_SSRF_POLICY = {
// Telegram file downloads should trust api.telegram.org even when DNS/proxy
// resolution maps to private/internal ranges in restricted networks.
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
};
/**
* Returns true if the error is Telegram's "file is too big" error.
* This happens when trying to download files >20MB via the Bot API.
* Unlike network errors, this is a permanent error and should not be retried.
*/
function isFileTooBigError(err: unknown): boolean {
if (err instanceof GrammyError) {
return FILE_TOO_BIG_RE.test(err.description);
}
return FILE_TOO_BIG_RE.test(formatErrorMessage(err));
}
/**
* Returns true if the error is a transient network error that should be retried.
* Returns false for permanent errors like "file is too big" (400 Bad Request).
*/
function isRetryableGetFileError(err: unknown): boolean {
// Don't retry "file is too big" - it's a permanent 400 error
if (isFileTooBigError(err)) {
return false;
}
// Retry all other errors (network issues, timeouts, etc.)
return true;
}
function resolveMediaFileRef(msg: TelegramContext["message"]) {
return (
msg.photo?.[msg.photo.length - 1] ??
msg.video ??
msg.video_note ??
msg.document ??
msg.audio ??
msg.voice
);
}
function resolveTelegramFileName(msg: TelegramContext["message"]): string | undefined {
return (
msg.document?.file_name ??
msg.audio?.file_name ??
msg.video?.file_name ??
msg.animation?.file_name
);
}
async function resolveTelegramFileWithRetry(
ctx: TelegramContext,
): Promise<{ file_path?: string } | null> {
try {
return await retryAsync(() => ctx.getFile(), {
attempts: 3,
minDelayMs: 1000,
maxDelayMs: 4000,
jitter: 0.2,
label: "telegram:getFile",
shouldRetry: isRetryableGetFileError,
onRetry: ({ attempt, maxAttempts }) =>
logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`),
});
} catch (err) {
// Handle "file is too big" separately - Telegram Bot API has a 20MB download limit
if (isFileTooBigError(err)) {
logVerbose(
warn(
"telegram: getFile failed - file exceeds Telegram Bot API 20MB limit; skipping attachment",
),
);
return null;
}
// All retries exhausted — return null so the message still reaches the agent
// with a type-based placeholder (e.g. <media:audio>) instead of being dropped.
logVerbose(`telegram: getFile failed after retries: ${String(err)}`);
return null;
}
}
function resolveRequiredTelegramTransport(transport?: TelegramTransport): TelegramTransport {
if (transport) {
return transport;
}
const resolvedFetch = globalThis.fetch;
if (!resolvedFetch) {
throw new Error("fetch is not available; set channels.telegram.proxy in config");
}
return {
fetch: resolvedFetch,
sourceFetch: resolvedFetch,
};
}
function resolveOptionalTelegramTransport(transport?: TelegramTransport): TelegramTransport | null {
try {
return resolveRequiredTelegramTransport(transport);
} catch {
return null;
}
}
/** Default idle timeout for Telegram media downloads (30 seconds). */
const TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
async function downloadAndSaveTelegramFile(params: {
filePath: string;
token: string;
transport: TelegramTransport;
maxBytes: number;
telegramFileName?: string;
}) {
const url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`;
const fetched = await fetchRemoteMedia({
url,
fetchImpl: params.transport.sourceFetch,
dispatcherPolicy: params.transport.pinnedDispatcherPolicy,
fallbackDispatcherPolicy: params.transport.fallbackPinnedDispatcherPolicy,
shouldRetryFetchError: shouldRetryTelegramIpv4Fallback,
filePathHint: params.filePath,
maxBytes: params.maxBytes,
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY,
});
const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
return saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
originalName,
);
}
async function resolveStickerMedia(params: {
msg: TelegramContext["message"];
ctx: TelegramContext;
maxBytes: number;
token: string;
transport?: TelegramTransport;
}): Promise<
| {
path: string;
contentType?: string;
placeholder: string;
stickerMetadata?: StickerMetadata;
}
| null
| undefined
> {
const { msg, ctx, maxBytes, token, transport } = params;
if (!msg.sticker) {
return undefined;
}
const sticker = msg.sticker;
// Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported
if (sticker.is_animated || sticker.is_video) {
logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");
return null;
}
if (!sticker.file_id) {
return null;
}
try {
const file = await resolveTelegramFileWithRetry(ctx);
if (!file?.file_path) {
logVerbose("telegram: getFile returned no file_path for sticker");
return null;
}
const resolvedTransport = resolveOptionalTelegramTransport(transport);
if (!resolvedTransport) {
logVerbose("telegram: fetch not available for sticker download");
return null;
}
const saved = await downloadAndSaveTelegramFile({
filePath: file.file_path,
token,
transport: resolvedTransport,
maxBytes,
});
// Check sticker cache for existing description
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
if (cached) {
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
const fileId = sticker.file_id ?? cached.fileId;
const emoji = sticker.emoji ?? cached.emoji;
const setName = sticker.set_name ?? cached.setName;
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
cacheSticker({
...cached,
fileId,
emoji,
setName,
});
}
return {
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:sticker>",
stickerMetadata: {
emoji,
setName,
fileId,
fileUniqueId: sticker.file_unique_id,
cachedDescription: cached.description,
},
};
}
// Cache miss - return metadata for vision processing
return {
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:sticker>",
stickerMetadata: {
emoji: sticker.emoji ?? undefined,
setName: sticker.set_name ?? undefined,
fileId: sticker.file_id,
fileUniqueId: sticker.file_unique_id,
},
};
} catch (err) {
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
return null;
}
}
export async function resolveMedia(
ctx: TelegramContext,
maxBytes: number,
token: string,
transport?: TelegramTransport,
): Promise<{
path: string;
contentType?: string;
placeholder: string;
stickerMetadata?: StickerMetadata;
} | null> {
const msg = ctx.message;
const stickerResolved = await resolveStickerMedia({
msg,
ctx,
maxBytes,
token,
transport,
});
if (stickerResolved !== undefined) {
return stickerResolved;
}
const m = resolveMediaFileRef(msg);
if (!m?.file_id) {
return null;
}
const file = await resolveTelegramFileWithRetry(ctx);
if (!file) {
return null;
}
if (!file.file_path) {
throw new Error("Telegram getFile returned no file_path");
}
const saved = await downloadAndSaveTelegramFile({
filePath: file.file_path,
token,
transport: resolveRequiredTelegramTransport(transport),
maxBytes,
telegramFileName: resolveTelegramFileName(msg),
});
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
return { path: saved.path, contentType: saved.contentType, placeholder };
}
export * from "../../../extensions/telegram/src/bot/delivery.resolve-media.js";

View File

@@ -1,172 +1 @@
import { type Bot, GrammyError } from "grammy";
import { formatErrorMessage } from "../../infra/errors.js";
import type { RuntimeEnv } from "../../runtime.js";
import { withTelegramApiErrorLogging } from "../api-logging.js";
import { markdownToTelegramHtml } from "../format.js";
import { buildInlineKeyboard } from "../send.js";
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./helpers.js";
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
const THREAD_NOT_FOUND_RE = /message thread not found/i;
function isTelegramThreadNotFoundError(err: unknown): boolean {
if (err instanceof GrammyError) {
return THREAD_NOT_FOUND_RE.test(err.description);
}
return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err));
}
function hasMessageThreadIdParam(params: Record<string, unknown> | undefined): boolean {
if (!params) {
return false;
}
return typeof params.message_thread_id === "number";
}
function removeMessageThreadIdParam(
params: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!params) {
return {};
}
const { message_thread_id: _ignored, ...rest } = params;
return rest;
}
export async function sendTelegramWithThreadFallback<T>(params: {
operation: string;
runtime: RuntimeEnv;
thread?: TelegramThreadSpec | null;
requestParams: Record<string, unknown>;
send: (effectiveParams: Record<string, unknown>) => Promise<T>;
shouldLog?: (err: unknown) => boolean;
}): Promise<T> {
const allowThreadlessRetry = params.thread?.scope === "dm";
const hasThreadId = hasMessageThreadIdParam(params.requestParams);
const shouldSuppressFirstErrorLog = (err: unknown) =>
allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err);
const mergedShouldLog = params.shouldLog
? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err)
: (err: unknown) => !shouldSuppressFirstErrorLog(err);
try {
return await withTelegramApiErrorLogging({
operation: params.operation,
runtime: params.runtime,
shouldLog: mergedShouldLog,
fn: () => params.send(params.requestParams),
});
} catch (err) {
if (!allowThreadlessRetry || !hasThreadId || !isTelegramThreadNotFoundError(err)) {
throw err;
}
const retryParams = removeMessageThreadIdParam(params.requestParams);
params.runtime.log?.(
`telegram ${params.operation}: message thread not found; retrying without message_thread_id`,
);
return await withTelegramApiErrorLogging({
operation: `${params.operation} (threadless retry)`,
runtime: params.runtime,
fn: () => params.send(retryParams),
});
}
}
export function buildTelegramSendParams(opts?: {
replyToMessageId?: number;
thread?: TelegramThreadSpec | null;
}): Record<string, unknown> {
const threadParams = buildTelegramThreadParams(opts?.thread);
const params: Record<string, unknown> = {};
if (opts?.replyToMessageId) {
params.reply_to_message_id = opts.replyToMessageId;
}
if (threadParams) {
params.message_thread_id = threadParams.message_thread_id;
}
return params;
}
export async function sendTelegramText(
bot: Bot,
chatId: string,
text: string,
runtime: RuntimeEnv,
opts?: {
replyToMessageId?: number;
replyQuoteText?: string;
thread?: TelegramThreadSpec | null;
textMode?: "markdown" | "html";
plainText?: string;
linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
},
): Promise<number> {
const baseParams = buildTelegramSendParams({
replyToMessageId: opts?.replyToMessageId,
thread: opts?.thread,
});
// Add link_preview_options when link preview is disabled.
const linkPreviewEnabled = opts?.linkPreview ?? true;
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
const textMode = opts?.textMode ?? "markdown";
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
const fallbackText = opts?.plainText ?? text;
const hasFallbackText = fallbackText.trim().length > 0;
const sendPlainFallback = async () => {
const res = await sendTelegramWithThreadFallback({
operation: "sendMessage",
runtime,
thread: opts?.thread,
requestParams: baseParams,
send: (effectiveParams) =>
bot.api.sendMessage(chatId, fallbackText, {
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...effectiveParams,
}),
});
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`);
return res.message_id;
};
// Markdown can render to empty HTML for syntax-only chunks; recover with plain text.
if (!htmlText.trim()) {
if (!hasFallbackText) {
throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback");
}
return await sendPlainFallback();
}
try {
const res = await sendTelegramWithThreadFallback({
operation: "sendMessage",
runtime,
thread: opts?.thread,
requestParams: baseParams,
shouldLog: (err) => {
const errText = formatErrorMessage(err);
return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText);
},
send: (effectiveParams) =>
bot.api.sendMessage(chatId, htmlText, {
parse_mode: "HTML",
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...effectiveParams,
}),
});
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
return res.message_id;
} catch (err) {
const errText = formatErrorMessage(err);
if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) {
if (!hasFallbackText) {
throw err;
}
runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`);
return await sendPlainFallback();
}
throw err;
}
}
export * from "../../../extensions/telegram/src/bot/delivery.send.js";

View File

@@ -1,858 +0,0 @@
import type { Bot } from "grammy";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../runtime.js";
import { deliverReplies } from "./delivery.js";
const loadWebMedia = vi.fn();
const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {}));
const messageHookRunner = vi.hoisted(() => ({
hasHooks: vi.fn<(name: string) => boolean>(() => false),
runMessageSending: vi.fn(),
runMessageSent: vi.fn(),
}));
const baseDeliveryParams = {
chatId: "123",
token: "tok",
replyToMode: "off",
textLimit: 4000,
} as const;
type DeliverRepliesParams = Parameters<typeof deliverReplies>[0];
type DeliverWithParams = Omit<
DeliverRepliesParams,
"chatId" | "token" | "replyToMode" | "textLimit"
> &
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit">>;
type RuntimeStub = Pick<RuntimeEnv, "error" | "log" | "exit">;
vi.mock("../../../extensions/whatsapp/src/media.js", () => ({
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => messageHookRunner,
}));
vi.mock("../../hooks/internal-hooks.js", async () => {
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
"../../hooks/internal-hooks.js",
);
return {
...actual,
triggerInternalHook,
};
});
vi.mock("grammy", () => ({
InputFile: class {
constructor(
public buffer: Buffer,
public fileName?: string,
) {}
},
GrammyError: class GrammyError extends Error {
description = "";
},
}));
function createRuntime(withLog = true): RuntimeStub {
return {
error: vi.fn(),
log: withLog ? vi.fn() : vi.fn(),
exit: vi.fn(),
};
}
function createBot(api: Record<string, unknown> = {}): Bot {
return { api } as unknown as Bot;
}
async function deliverWith(params: DeliverWithParams) {
await deliverReplies({
...baseDeliveryParams,
...params,
});
}
function mockMediaLoad(fileName: string, contentType: string, data: string) {
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from(data),
contentType,
fileName,
});
}
function createSendMessageHarness(messageId = 4) {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: messageId,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
return { runtime, sendMessage, bot };
}
function createVoiceMessagesForbiddenError() {
return new Error(
"GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)",
);
}
function createThreadNotFoundError(operation = "sendMessage") {
return new Error(
`GrammyError: Call to '${operation}' failed! (400: Bad Request: message thread not found)`,
);
}
function createVoiceFailureHarness(params: {
voiceError: Error;
sendMessageResult?: { message_id: number; chat: { id: string } };
}) {
const runtime = createRuntime();
const sendVoice = vi.fn().mockRejectedValue(params.voiceError);
const sendMessage = params.sendMessageResult
? vi.fn().mockResolvedValue(params.sendMessageResult)
: vi.fn();
const bot = createBot({ sendVoice, sendMessage });
return { runtime, sendVoice, sendMessage, bot };
}
describe("deliverReplies", () => {
beforeEach(() => {
loadWebMedia.mockClear();
triggerInternalHook.mockReset();
messageHookRunner.hasHooks.mockReset();
messageHookRunner.hasHooks.mockReturnValue(false);
messageHookRunner.runMessageSending.mockReset();
messageHookRunner.runMessageSent.mockReset();
});
it("skips audioAsVoice-only payloads without logging an error", async () => {
const runtime = createRuntime(false);
await deliverWith({
replies: [{ audioAsVoice: true }],
runtime,
bot: createBot(),
});
expect(runtime.error).not.toHaveBeenCalled();
});
it("skips malformed replies and continues with valid entries", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 1, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
replies: [undefined, { text: "hello" }] as unknown as DeliverRepliesParams["replies"],
runtime,
bot,
});
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls[0]?.[1]).toBe("hello");
});
it("reports message_sent success=false when hooks blank out a text-only reply", async () => {
messageHookRunner.hasHooks.mockImplementation(
(name: string) => name === "message_sending" || name === "message_sent",
);
messageHookRunner.runMessageSending.mockResolvedValue({ content: "" });
const runtime = createRuntime(false);
const sendMessage = vi.fn();
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "hello" }],
runtime,
bot,
});
expect(sendMessage).not.toHaveBeenCalled();
expect(messageHookRunner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({ success: false, content: "" }),
expect.objectContaining({ channelId: "telegram", conversationId: "123" }),
);
});
it("passes accountId into message hooks", async () => {
messageHookRunner.hasHooks.mockImplementation(
(name: string) => name === "message_sending" || name === "message_sent",
);
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
accountId: "work",
replies: [{ text: "hello" }],
runtime,
bot,
});
expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
channelId: "telegram",
accountId: "work",
conversationId: "123",
}),
);
expect(messageHookRunner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({ success: true }),
expect.objectContaining({
channelId: "telegram",
accountId: "work",
conversationId: "123",
}),
);
});
it("emits internal message:sent when session hook context is available", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
sessionKeyForInternalHooks: "agent:test:telegram:123",
mirrorIsGroup: true,
mirrorGroupId: "123",
replies: [{ text: "hello" }],
runtime,
bot,
});
expect(triggerInternalHook).toHaveBeenCalledWith(
expect.objectContaining({
type: "message",
action: "sent",
sessionKey: "agent:test:telegram:123",
context: expect.objectContaining({
to: "123",
content: "hello",
success: true,
channelId: "telegram",
conversationId: "123",
messageId: "9",
isGroup: true,
groupId: "123",
}),
}),
);
});
it("does not emit internal message:sent without a session key", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 11, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "hello" }],
runtime,
bot,
});
expect(triggerInternalHook).not.toHaveBeenCalled();
});
it("emits internal message:sent with success=false on delivery failure", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockRejectedValue(new Error("network error"));
const bot = createBot({ sendMessage });
await expect(
deliverWith({
sessionKeyForInternalHooks: "agent:test:telegram:123",
replies: [{ text: "hello" }],
runtime,
bot,
}),
).rejects.toThrow("network error");
expect(triggerInternalHook).toHaveBeenCalledWith(
expect.objectContaining({
type: "message",
action: "sent",
sessionKey: "agent:test:telegram:123",
context: expect.objectContaining({
to: "123",
content: "hello",
success: false,
error: "network error",
channelId: "telegram",
conversationId: "123",
}),
}),
);
});
it("passes media metadata to message_sending hooks", async () => {
messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
const runtime = createRuntime(false);
const sendPhoto = vi.fn().mockResolvedValue({ message_id: 2, chat: { id: "123" } });
const bot = createBot({ sendPhoto });
mockMediaLoad("photo.jpg", "image/jpeg", "image");
await deliverWith({
replies: [{ text: "caption", mediaUrl: "https://example.com/photo.jpg" }],
runtime,
bot,
});
expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith(
expect.objectContaining({
to: "123",
content: "caption",
metadata: expect.objectContaining({
channel: "telegram",
mediaUrls: ["https://example.com/photo.jpg"],
}),
}),
expect.objectContaining({ channelId: "telegram", conversationId: "123" }),
);
});
it("invokes onVoiceRecording before sending a voice note", async () => {
const events: string[] = [];
const runtime = createRuntime(false);
const sendVoice = vi.fn(async () => {
events.push("sendVoice");
return { message_id: 1, chat: { id: "123" } };
});
const bot = createBot({ sendVoice });
const onVoiceRecording = vi.fn(async () => {
events.push("recordVoice");
});
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await deliverWith({
replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }],
runtime,
bot,
onVoiceRecording,
});
expect(onVoiceRecording).toHaveBeenCalledTimes(1);
expect(sendVoice).toHaveBeenCalledTimes(1);
expect(events).toEqual(["recordVoice", "sendVoice"]);
});
it("renders markdown in media captions", async () => {
const runtime = createRuntime();
const sendPhoto = vi.fn().mockResolvedValue({
message_id: 2,
chat: { id: "123" },
});
const bot = createBot({ sendPhoto });
mockMediaLoad("photo.jpg", "image/jpeg", "image");
await deliverWith({
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "hi **boss**" }],
runtime,
bot,
});
expect(sendPhoto).toHaveBeenCalledWith(
"123",
expect.anything(),
expect.objectContaining({
caption: "hi <b>boss</b>",
parse_mode: "HTML",
}),
);
});
it("passes mediaLocalRoots to media loading", async () => {
const runtime = createRuntime();
const sendPhoto = vi.fn().mockResolvedValue({
message_id: 12,
chat: { id: "123" },
});
const bot = createBot({ sendPhoto });
const mediaLocalRoots = ["/tmp/workspace-work"];
mockMediaLoad("photo.jpg", "image/jpeg", "image");
await deliverWith({
replies: [{ mediaUrl: "/tmp/workspace-work/photo.jpg" }],
runtime,
bot,
mediaLocalRoots,
});
expect(loadWebMedia).toHaveBeenCalledWith("/tmp/workspace-work/photo.jpg", {
localRoots: mediaLocalRoots,
});
});
it("includes link_preview_options when linkPreview is false", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 3,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "Check https://example.com" }],
runtime,
bot,
linkPreview: false,
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
link_preview_options: { is_disabled: true },
}),
);
});
it("includes message_thread_id for DM topics", async () => {
const { runtime, sendMessage, bot } = createSendMessageHarness();
await deliverWith({
replies: [{ text: "Hello" }],
runtime,
bot,
thread: { id: 42, scope: "dm" },
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
message_thread_id: 42,
}),
);
});
it("retries DM topic sends without message_thread_id when thread is missing", async () => {
const runtime = createRuntime();
const sendMessage = vi
.fn()
.mockRejectedValueOnce(createThreadNotFoundError("sendMessage"))
.mockResolvedValueOnce({
message_id: 7,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "hello" }],
runtime,
bot,
thread: { id: 42, scope: "dm" },
});
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({
message_thread_id: 42,
}),
);
expect(sendMessage.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
expect(runtime.error).not.toHaveBeenCalled();
});
it("does not retry forum sends without message_thread_id", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockRejectedValue(createThreadNotFoundError("sendMessage"));
const bot = createBot({ sendMessage });
await expect(
deliverWith({
replies: [{ text: "hello" }],
runtime,
bot,
thread: { id: 42, scope: "forum" },
}),
).rejects.toThrow("message thread not found");
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(runtime.error).toHaveBeenCalledTimes(1);
});
it("retries media sends without message_thread_id for DM topics", async () => {
const runtime = createRuntime();
const sendPhoto = vi
.fn()
.mockRejectedValueOnce(createThreadNotFoundError("sendPhoto"))
.mockResolvedValueOnce({
message_id: 8,
chat: { id: "123" },
});
const bot = createBot({ sendPhoto });
mockMediaLoad("photo.jpg", "image/jpeg", "image");
await deliverWith({
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "caption" }],
runtime,
bot,
thread: { id: 42, scope: "dm" },
});
expect(sendPhoto).toHaveBeenCalledTimes(2);
expect(sendPhoto.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({
message_thread_id: 42,
}),
);
expect(sendPhoto.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
expect(runtime.error).not.toHaveBeenCalled();
});
it("does not include link_preview_options when linkPreview is true", async () => {
const { runtime, sendMessage, bot } = createSendMessageHarness();
await deliverWith({
replies: [{ text: "Check https://example.com" }],
runtime,
bot,
linkPreview: true,
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.not.objectContaining({
link_preview_options: expect.anything(),
}),
);
});
it("falls back to plain text when markdown renders to empty HTML in threaded mode", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn(async (_chatId: string, text: string) => {
if (text === "") {
throw new Error("400: Bad Request: message text is empty");
}
return {
message_id: 6,
chat: { id: "123" },
};
});
const bot = { api: { sendMessage } } as unknown as Bot;
await deliverReplies({
replies: [{ text: ">" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 4000,
thread: { id: 42, scope: "forum" },
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"123",
">",
expect.objectContaining({
message_thread_id: 42,
}),
);
});
it("throws when formatted and plain fallback text are both empty", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn();
const bot = { api: { sendMessage } } as unknown as Bot;
await expect(
deliverReplies({
replies: [{ text: " " }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 4000,
}),
).rejects.toThrow("empty formatted text and empty plain fallback");
expect(sendMessage).not.toHaveBeenCalled();
});
it("uses reply_to_message_id when quote text is provided", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "Hello there", replyToId: "500" }],
runtime,
bot,
replyToMode: "all",
replyQuoteText: "quoted text",
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
reply_to_message_id: 500,
}),
);
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.not.objectContaining({
reply_parameters: expect.anything(),
}),
);
});
it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => {
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
voiceError: createVoiceMessagesForbiddenError(),
sendMessageResult: {
message_id: 5,
chat: { id: "123" },
},
});
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await deliverWith({
replies: [
{ mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true },
],
runtime,
bot,
});
// Voice was attempted but failed
expect(sendVoice).toHaveBeenCalledTimes(1);
// Fallback to text succeeded
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.stringContaining("Hello there"),
expect.any(Object),
);
});
it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
voiceError: createVoiceMessagesForbiddenError(),
sendMessageResult: {
message_id: 6,
chat: { id: "123" },
},
});
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await deliverWith({
replies: [
{
mediaUrl: "https://example.com/note.ogg",
text: "chunk-one\n\nchunk-two",
replyToId: "77",
audioAsVoice: true,
channelData: {
telegram: {
buttons: [[{ text: "Ack", callback_data: "ack" }]],
},
},
},
],
runtime,
bot,
replyToMode: "first",
replyQuoteText: "quoted context",
textLimit: 12,
});
expect(sendVoice).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(sendMessage.mock.calls[0][2]).toEqual(
expect.objectContaining({
reply_to_message_id: 77,
reply_markup: {
inline_keyboard: [[{ text: "Ack", callback_data: "ack" }]],
},
}),
);
expect(sendMessage.mock.calls[1][2]).not.toEqual(
expect.objectContaining({ reply_to_message_id: 77 }),
);
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_parameters");
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_markup");
});
it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => {
const runtime = createRuntime();
const sendVoice = vi.fn().mockRejectedValue(new Error("Network error"));
const sendMessage = vi.fn();
const bot = createBot({ sendVoice, sendMessage });
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await expect(
deliverWith({
replies: [{ mediaUrl: "https://example.com/note.ogg", text: "Hello", audioAsVoice: true }],
runtime,
bot,
}),
).rejects.toThrow("Network error");
expect(sendVoice).toHaveBeenCalledTimes(1);
// Text fallback should NOT be attempted for other errors
expect(sendMessage).not.toHaveBeenCalled();
});
it("replyToMode 'first' only applies reply-to to the first text chunk", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 20,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
// Use a small textLimit to force multiple chunks
await deliverReplies({
replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "700" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "first",
textLimit: 12,
});
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
// First chunk should have reply_to_message_id
expect(sendMessage.mock.calls[0][2]).toEqual(
expect.objectContaining({ reply_to_message_id: 700 }),
);
// Second chunk should NOT have reply_to_message_id
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id");
});
it("replyToMode 'all' applies reply-to to every text chunk", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 21,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverReplies({
replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "800" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "all",
textLimit: 12,
});
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
// Both chunks should have reply_to_message_id
for (const call of sendMessage.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ reply_to_message_id: 800 }));
}
});
it("replyToMode 'first' only applies reply-to to first media item", async () => {
const runtime = createRuntime();
const sendPhoto = vi.fn().mockResolvedValue({
message_id: 30,
chat: { id: "123" },
});
const bot = createBot({ sendPhoto });
mockMediaLoad("a.jpg", "image/jpeg", "img1");
mockMediaLoad("b.jpg", "image/jpeg", "img2");
await deliverReplies({
replies: [{ mediaUrls: ["https://a.jpg", "https://b.jpg"], replyToId: "900" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "first",
textLimit: 4000,
});
expect(sendPhoto).toHaveBeenCalledTimes(2);
// First media should have reply_to_message_id
expect(sendPhoto.mock.calls[0][2]).toEqual(
expect.objectContaining({ reply_to_message_id: 900 }),
);
// Second media should NOT have reply_to_message_id
expect(sendPhoto.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id");
});
it("pins the first delivered text message when telegram pin is requested", async () => {
const runtime = createRuntime();
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 101, chat: { id: "123" } })
.mockResolvedValueOnce({ message_id: 102, chat: { id: "123" } });
const pinChatMessage = vi.fn().mockResolvedValue(true);
const bot = createBot({ sendMessage, pinChatMessage });
await deliverReplies({
replies: [{ text: "chunk-one\n\nchunk-two", channelData: { telegram: { pin: true } } }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 12,
});
expect(pinChatMessage).toHaveBeenCalledTimes(1);
expect(pinChatMessage).toHaveBeenCalledWith("123", 101, { disable_notification: true });
});
it("continues when pinning fails", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({ message_id: 201, chat: { id: "123" } });
const pinChatMessage = vi.fn().mockRejectedValue(new Error("pin failed"));
const bot = createBot({ sendMessage, pinChatMessage });
await deliverWith({
replies: [{ text: "hello", channelData: { telegram: { pin: true } } }],
runtime,
bot,
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(pinChatMessage).toHaveBeenCalledTimes(1);
});
it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => {
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
voiceError: createVoiceMessagesForbiddenError(),
});
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await expect(
deliverWith({
replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }],
runtime,
bot,
}),
).rejects.toThrow("VOICE_MESSAGES_FORBIDDEN");
expect(sendVoice).toHaveBeenCalledTimes(1);
expect(sendMessage).not.toHaveBeenCalled();
});
});

View File

@@ -1,2 +1 @@
export { deliverReplies } from "./delivery.replies.js";
export { resolveMedia } from "./delivery.resolve-media.js";
export * from "../../../extensions/telegram/src/bot/delivery.js";

View File

@@ -1,457 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildTelegramThreadParams,
buildTypingThreadParams,
describeReplyTarget,
expandTextLinks,
getTelegramTextParts,
hasBotMention,
normalizeForwardedContext,
resolveTelegramDirectPeerId,
resolveTelegramForumThreadId,
} from "./helpers.js";
describe("resolveTelegramForumThreadId", () => {
it.each([
{ isForum: false, messageThreadId: 42 },
{ isForum: false, messageThreadId: undefined },
{ isForum: undefined, messageThreadId: 99 },
])("returns undefined for non-forum groups", (params) => {
// Reply threads in regular groups should not create separate sessions.
expect(resolveTelegramForumThreadId(params)).toBeUndefined();
});
it.each([
{ isForum: true, messageThreadId: undefined, expected: 1 },
{ isForum: true, messageThreadId: null, expected: 1 },
{ isForum: true, messageThreadId: 99, expected: 99 },
])("resolves forum topic ids", ({ expected, ...params }) => {
expect(resolveTelegramForumThreadId(params)).toBe(expected);
});
});
describe("buildTelegramThreadParams", () => {
it.each([
{ input: { id: 1, scope: "forum" as const }, expected: undefined },
{ input: { id: 99, scope: "forum" as const }, expected: { message_thread_id: 99 } },
{ input: { id: 1, scope: "dm" as const }, expected: { message_thread_id: 1 } },
{ input: { id: 2, scope: "dm" as const }, expected: { message_thread_id: 2 } },
{ input: { id: 0, scope: "dm" as const }, expected: undefined },
{ input: { id: -1, scope: "dm" as const }, expected: undefined },
{ input: { id: 1.9, scope: "dm" as const }, expected: { message_thread_id: 1 } },
// id=0 should be included for forum and none scopes (not falsy)
{ input: { id: 0, scope: "forum" as const }, expected: { message_thread_id: 0 } },
{ input: { id: 0, scope: "none" as const }, expected: { message_thread_id: 0 } },
])("builds thread params", ({ input, expected }) => {
expect(buildTelegramThreadParams(input)).toEqual(expected);
});
});
describe("buildTypingThreadParams", () => {
it.each([
{ input: undefined, expected: undefined },
{ input: 1, expected: { message_thread_id: 1 } },
])("builds typing params", ({ input, expected }) => {
expect(buildTypingThreadParams(input)).toEqual(expected);
});
});
describe("resolveTelegramDirectPeerId", () => {
it("prefers sender id when available", () => {
expect(resolveTelegramDirectPeerId({ chatId: 777777777, senderId: 123456789 })).toBe(
"123456789",
);
});
it("falls back to chat id when sender id is missing", () => {
expect(resolveTelegramDirectPeerId({ chatId: 777777777, senderId: undefined })).toBe(
"777777777",
);
});
});
describe("thread id normalization", () => {
it.each([
{
build: () => buildTelegramThreadParams({ id: 42.9, scope: "forum" }),
expected: { message_thread_id: 42 },
},
{
build: () => buildTypingThreadParams(42.9),
expected: { message_thread_id: 42 },
},
])("normalizes thread ids to integers", ({ build, expected }) => {
expect(build()).toEqual(expected);
});
});
describe("normalizeForwardedContext", () => {
it("handles forward_origin users", () => {
const ctx = normalizeForwardedContext({
forward_origin: {
type: "user",
sender_user: { first_name: "Ada", last_name: "Lovelace", username: "ada", id: 42 },
date: 123,
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(ctx).not.toBeNull();
expect(ctx?.from).toBe("Ada Lovelace (@ada)");
expect(ctx?.fromType).toBe("user");
expect(ctx?.fromId).toBe("42");
expect(ctx?.fromUsername).toBe("ada");
expect(ctx?.fromTitle).toBe("Ada Lovelace");
expect(ctx?.date).toBe(123);
});
it("handles hidden forward_origin names", () => {
const ctx = normalizeForwardedContext({
forward_origin: { type: "hidden_user", sender_user_name: "Hidden Name", date: 456 },
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(ctx).not.toBeNull();
expect(ctx?.from).toBe("Hidden Name");
expect(ctx?.fromType).toBe("hidden_user");
expect(ctx?.fromTitle).toBe("Hidden Name");
expect(ctx?.date).toBe(456);
});
it("handles forward_origin channel with author_signature and message_id", () => {
const ctx = normalizeForwardedContext({
forward_origin: {
type: "channel",
chat: {
title: "Tech News",
username: "technews",
id: -1001234,
type: "channel",
},
date: 500,
author_signature: "Editor",
message_id: 42,
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(ctx).not.toBeNull();
expect(ctx?.from).toBe("Tech News (Editor)");
expect(ctx?.fromType).toBe("channel");
expect(ctx?.fromId).toBe("-1001234");
expect(ctx?.fromUsername).toBe("technews");
expect(ctx?.fromTitle).toBe("Tech News");
expect(ctx?.fromSignature).toBe("Editor");
expect(ctx?.fromChatType).toBe("channel");
expect(ctx?.fromMessageId).toBe(42);
expect(ctx?.date).toBe(500);
});
it("handles forward_origin chat with sender_chat and author_signature", () => {
const ctx = normalizeForwardedContext({
forward_origin: {
type: "chat",
sender_chat: {
title: "Discussion Group",
id: -1005678,
type: "supergroup",
},
date: 600,
author_signature: "Admin",
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(ctx).not.toBeNull();
expect(ctx?.from).toBe("Discussion Group (Admin)");
expect(ctx?.fromType).toBe("chat");
expect(ctx?.fromId).toBe("-1005678");
expect(ctx?.fromTitle).toBe("Discussion Group");
expect(ctx?.fromSignature).toBe("Admin");
expect(ctx?.fromChatType).toBe("supergroup");
expect(ctx?.date).toBe(600);
});
it("uses author_signature from forward_origin", () => {
const ctx = normalizeForwardedContext({
forward_origin: {
type: "channel",
chat: { title: "My Channel", id: -100999, type: "channel" },
date: 700,
author_signature: "New Sig",
message_id: 1,
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(ctx).not.toBeNull();
expect(ctx?.fromSignature).toBe("New Sig");
expect(ctx?.from).toBe("My Channel (New Sig)");
});
it("returns undefined signature when author_signature is blank", () => {
const ctx = normalizeForwardedContext({
forward_origin: {
type: "channel",
chat: { title: "Updates", id: -100333, type: "channel" },
date: 860,
author_signature: " ",
message_id: 1,
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(ctx).not.toBeNull();
expect(ctx?.fromSignature).toBeUndefined();
expect(ctx?.from).toBe("Updates");
});
it("handles forward_origin channel without author_signature", () => {
const ctx = normalizeForwardedContext({
forward_origin: {
type: "channel",
chat: { title: "News", id: -100111, type: "channel" },
date: 900,
message_id: 1,
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(ctx).not.toBeNull();
expect(ctx?.from).toBe("News");
expect(ctx?.fromSignature).toBeUndefined();
expect(ctx?.fromChatType).toBe("channel");
});
});
describe("describeReplyTarget", () => {
it("returns null when no reply_to_message", () => {
const result = describeReplyTarget(
// oxlint-disable-next-line typescript/no-explicit-any
{ message_id: 1, date: 1000, chat: { id: 1, type: "private" } } as any,
);
expect(result).toBeNull();
});
it("extracts basic reply info", () => {
const result = describeReplyTarget({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private" },
text: "Original message",
from: { id: 42, first_name: "Alice", is_bot: false },
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(result).not.toBeNull();
expect(result?.body).toBe("Original message");
expect(result?.sender).toBe("Alice");
expect(result?.id).toBe("1");
expect(result?.kind).toBe("reply");
});
it("extracts forwarded context from reply_to_message (issue #9619)", () => {
// When user forwards a message with a comment, the comment message has
// reply_to_message pointing to the forwarded message. We should extract
// the forward_origin from the reply target.
const result = describeReplyTarget({
message_id: 3,
date: 1100,
chat: { id: 1, type: "private" },
text: "Here is my comment about this forwarded content",
reply_to_message: {
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
text: "This is the forwarded content",
forward_origin: {
type: "user",
sender_user: {
id: 999,
first_name: "Bob",
last_name: "Smith",
username: "bobsmith",
is_bot: false,
},
date: 500,
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(result).not.toBeNull();
expect(result?.body).toBe("This is the forwarded content");
expect(result?.id).toBe("2");
// The reply target's forwarded context should be included
expect(result?.forwardedFrom).toBeDefined();
expect(result?.forwardedFrom?.from).toBe("Bob Smith (@bobsmith)");
expect(result?.forwardedFrom?.fromType).toBe("user");
expect(result?.forwardedFrom?.fromId).toBe("999");
expect(result?.forwardedFrom?.date).toBe(500);
});
it("extracts forwarded context from channel forward in reply_to_message", () => {
const result = describeReplyTarget({
message_id: 4,
date: 1200,
chat: { id: 1, type: "private" },
text: "Interesting article!",
reply_to_message: {
message_id: 3,
date: 1100,
chat: { id: 1, type: "private" },
text: "Channel post content here",
forward_origin: {
type: "channel",
chat: { id: -1001234567, title: "Tech News", username: "technews", type: "channel" },
date: 800,
message_id: 456,
author_signature: "Editor",
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(result).not.toBeNull();
expect(result?.forwardedFrom).toBeDefined();
expect(result?.forwardedFrom?.from).toBe("Tech News (Editor)");
expect(result?.forwardedFrom?.fromType).toBe("channel");
expect(result?.forwardedFrom?.fromMessageId).toBe(456);
});
it("extracts forwarded context from external_reply", () => {
const result = describeReplyTarget({
message_id: 5,
date: 1300,
chat: { id: 1, type: "private" },
text: "Comment on forwarded message",
external_reply: {
message_id: 4,
date: 1200,
chat: { id: 1, type: "private" },
text: "Forwarded from elsewhere",
forward_origin: {
type: "user",
sender_user: {
id: 123,
first_name: "Eve",
last_name: "Stone",
username: "eve",
is_bot: false,
},
date: 700,
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(result).not.toBeNull();
expect(result?.id).toBe("4");
expect(result?.forwardedFrom?.from).toBe("Eve Stone (@eve)");
expect(result?.forwardedFrom?.fromType).toBe("user");
expect(result?.forwardedFrom?.fromId).toBe("123");
expect(result?.forwardedFrom?.date).toBe(700);
});
});
describe("hasBotMention", () => {
it("prefers caption text and caption entities when message text is absent", () => {
expect(
getTelegramTextParts({
caption: "@gaian hello",
caption_entities: [{ type: "mention", offset: 0, length: 6 }],
chat: { id: 1, type: "private" },
date: 1,
message_id: 1,
// oxlint-disable-next-line typescript/no-explicit-any
} as any),
).toEqual({
text: "@gaian hello",
entities: [{ type: "mention", offset: 0, length: 6 }],
});
});
it("matches exact username mentions from plain text", () => {
expect(
hasBotMention(
{
text: "@gaian what is the group id?",
chat: { id: 1, type: "supergroup" },
// oxlint-disable-next-line typescript/no-explicit-any
} as any,
"gaian",
),
).toBe(true);
});
it("does not match mention prefixes from longer bot usernames", () => {
expect(
hasBotMention(
{
text: "@GaianChat_Bot what is the group id?",
chat: { id: 1, type: "supergroup" },
// oxlint-disable-next-line typescript/no-explicit-any
} as any,
"gaian",
),
).toBe(false);
});
it("still matches exact mention entities", () => {
expect(
hasBotMention(
{
text: "@GaianChat_Bot hi @gaian",
entities: [{ type: "mention", offset: 18, length: 6 }],
chat: { id: 1, type: "supergroup" },
// oxlint-disable-next-line typescript/no-explicit-any
} as any,
"gaian",
),
).toBe(true);
});
});
describe("expandTextLinks", () => {
it("returns text unchanged when no entities are provided", () => {
expect(expandTextLinks("Hello world")).toBe("Hello world");
expect(expandTextLinks("Hello world", null)).toBe("Hello world");
expect(expandTextLinks("Hello world", [])).toBe("Hello world");
});
it("returns text unchanged when there are no text_link entities", () => {
const entities = [
{ type: "mention", offset: 0, length: 5 },
{ type: "bold", offset: 6, length: 5 },
];
expect(expandTextLinks("@user hello", entities)).toBe("@user hello");
});
it("expands a single text_link entity", () => {
const text = "Check this link for details";
const entities = [{ type: "text_link", offset: 11, length: 4, url: "https://example.com" }];
expect(expandTextLinks(text, entities)).toBe(
"Check this [link](https://example.com) for details",
);
});
it("expands multiple text_link entities", () => {
const text = "Visit Google or GitHub for more";
const entities = [
{ type: "text_link", offset: 6, length: 6, url: "https://google.com" },
{ type: "text_link", offset: 16, length: 6, url: "https://github.com" },
];
expect(expandTextLinks(text, entities)).toBe(
"Visit [Google](https://google.com) or [GitHub](https://github.com) for more",
);
});
it("handles adjacent text_link entities", () => {
const text = "AB";
const entities = [
{ type: "text_link", offset: 0, length: 1, url: "https://a.example" },
{ type: "text_link", offset: 1, length: 1, url: "https://b.example" },
];
expect(expandTextLinks(text, entities)).toBe("[A](https://a.example)[B](https://b.example)");
});
it("preserves offsets from the original string", () => {
const text = " Hello world";
const entities = [{ type: "text_link", offset: 1, length: 5, url: "https://example.com" }];
expect(expandTextLinks(text, entities)).toBe(" [Hello](https://example.com) world");
});
});

View File

@@ -1,607 +1 @@
import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types";
import { formatLocationText, type NormalizedLocation } from "../../channels/location.js";
import { resolveTelegramPreviewStreamMode } from "../../config/discord-preview-streaming.js";
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../../config/types.js";
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js";
import type { TelegramStreamMode } from "./types.js";
const TELEGRAM_GENERAL_TOPIC_ID = 1;
export type TelegramThreadSpec = {
id?: number;
scope: "dm" | "forum" | "none";
};
export async function resolveTelegramGroupAllowFromContext(params: {
chatId: string | number;
accountId?: string;
isGroup?: boolean;
isForum?: boolean;
messageThreadId?: number | null;
groupAllowFrom?: Array<string | number>;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
};
}): Promise<{
resolvedThreadId?: number;
dmThreadId?: number;
storeAllowFrom: string[];
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
groupAllowOverride?: Array<string | number>;
effectiveGroupAllow: NormalizedAllowFrom;
hasGroupAllowOverride: boolean;
}> {
const accountId = normalizeAccountId(params.accountId);
// Use resolveTelegramThreadSpec to handle both forum groups AND DM topics
const threadSpec = resolveTelegramThreadSpec({
isGroup: params.isGroup ?? false,
isForum: params.isForum,
messageThreadId: params.messageThreadId,
});
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
const threadIdForConfig = resolvedThreadId ?? dmThreadId;
const storeAllowFrom = await readChannelAllowFromStore("telegram", process.env, accountId).catch(
() => [],
);
const { groupConfig, topicConfig } = params.resolveTelegramGroupConfig(
params.chatId,
threadIdForConfig,
);
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
// Group sender access must remain explicit (groupAllowFrom/per-group allowFrom only).
// DM pairing store entries are not a group authorization source.
const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? params.groupAllowFrom);
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
return {
resolvedThreadId,
dmThreadId,
storeAllowFrom,
groupConfig,
topicConfig,
groupAllowOverride,
effectiveGroupAllow,
hasGroupAllowOverride,
};
}
/**
* 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;
}) {
// 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;
}
export function resolveTelegramThreadSpec(params: {
isGroup: boolean;
isForum?: boolean;
messageThreadId?: number | null;
}): TelegramThreadSpec {
if (params.isGroup) {
const id = resolveTelegramForumThreadId({
isForum: params.isForum,
messageThreadId: params.messageThreadId,
});
return {
id,
scope: params.isForum ? "forum" : "none",
};
}
if (params.messageThreadId == null) {
return { scope: "dm" };
}
return {
id: params.messageThreadId,
scope: "dm",
};
}
/**
* Build thread params for Telegram API calls (messages, media).
*
* IMPORTANT: Thread IDs behave differently based on chat type:
* - DMs (private chats): Include message_thread_id when present (DM topics)
* - Forum topics: Skip thread_id=1 (General topic), include others
* - Regular groups: Thread IDs are ignored by Telegram
*
* General forum topic (id=1) must be treated like a regular supergroup send:
* Telegram rejects sendMessage/sendMedia with message_thread_id=1 ("thread not found").
*
* @param thread - Thread specification with ID and scope
* @returns API params object or undefined if thread_id should be omitted
*/
export function buildTelegramThreadParams(thread?: TelegramThreadSpec | null) {
if (thread?.id == null) {
return undefined;
}
const normalized = Math.trunc(thread.id);
if (thread.scope === "dm") {
return normalized > 0 ? { message_thread_id: normalized } : undefined;
}
// Telegram rejects message_thread_id=1 for General forum topic
if (normalized === TELEGRAM_GENERAL_TOPIC_ID) {
return undefined;
}
return { message_thread_id: normalized };
}
/**
* Build thread params for typing indicators (sendChatAction).
* Empirically, General topic (id=1) needs message_thread_id for typing to appear.
*/
export function buildTypingThreadParams(messageThreadId?: number) {
if (messageThreadId == null) {
return undefined;
}
return { message_thread_id: Math.trunc(messageThreadId) };
}
export function resolveTelegramStreamMode(telegramCfg?: {
streaming?: unknown;
streamMode?: unknown;
}): TelegramStreamMode {
return resolveTelegramPreviewStreamMode(telegramCfg);
}
export function buildTelegramGroupPeerId(chatId: number | string, messageThreadId?: number) {
return messageThreadId != null ? `${chatId}:topic:${messageThreadId}` : String(chatId);
}
/**
* Resolve the direct-message peer identifier for Telegram routing/session keys.
*
* In some Telegram DM deliveries (for example certain business/chat bridge flows),
* `chat.id` can differ from the actual sender user id. Prefer sender id when present
* so per-peer DM scopes isolate users correctly.
*/
export function resolveTelegramDirectPeerId(params: {
chatId: number | string;
senderId?: number | string | null;
}) {
const senderId = params.senderId != null ? String(params.senderId).trim() : "";
if (senderId) {
return senderId;
}
return String(params.chatId);
}
export function buildTelegramGroupFrom(chatId: number | string, messageThreadId?: number) {
return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`;
}
/**
* Build parentPeer for forum topic binding inheritance.
* When a message comes from a forum topic, the peer ID includes the topic suffix
* (e.g., `-1001234567890:topic:99`). To allow bindings configured for the base
* group ID to match, we provide the parent group as `parentPeer` so the routing
* layer can fall back to it when the exact peer doesn't match.
*/
export function buildTelegramParentPeer(params: {
isGroup: boolean;
resolvedThreadId?: number;
chatId: number | string;
}): { kind: "group"; id: string } | undefined {
if (!params.isGroup || params.resolvedThreadId == null) {
return undefined;
}
return { kind: "group", id: String(params.chatId) };
}
export function buildSenderName(msg: Message) {
const name =
[msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() ||
msg.from?.username;
return name || undefined;
}
export function resolveTelegramMediaPlaceholder(
msg:
| Pick<Message, "photo" | "video" | "video_note" | "audio" | "voice" | "document" | "sticker">
| undefined
| null,
): string | undefined {
if (!msg) {
return undefined;
}
if (msg.photo) {
return "<media:image>";
}
if (msg.video || msg.video_note) {
return "<media:video>";
}
if (msg.audio || msg.voice) {
return "<media:audio>";
}
if (msg.document) {
return "<media:document>";
}
if (msg.sticker) {
return "<media:sticker>";
}
return undefined;
}
export function buildSenderLabel(msg: Message, senderId?: number | string) {
const name = buildSenderName(msg);
const username = msg.from?.username ? `@${msg.from.username}` : undefined;
let label = name;
if (name && username) {
label = `${name} (${username})`;
} else if (!name && username) {
label = username;
}
const normalizedSenderId =
senderId != null && `${senderId}`.trim() ? `${senderId}`.trim() : undefined;
const fallbackId = normalizedSenderId ?? (msg.from?.id != null ? String(msg.from.id) : undefined);
const idPart = fallbackId ? `id:${fallbackId}` : undefined;
if (label && idPart) {
return `${label} ${idPart}`;
}
if (label) {
return label;
}
return idPart ?? "id:unknown";
}
export function buildGroupLabel(msg: Message, chatId: number | string, messageThreadId?: number) {
const title = msg.chat?.title;
const topicSuffix = messageThreadId != null ? ` topic:${messageThreadId}` : "";
if (title) {
return `${title} id:${chatId}${topicSuffix}`;
}
return `group:${chatId}${topicSuffix}`;
}
export type TelegramTextEntity = NonNullable<Message["entities"]>[number];
export function getTelegramTextParts(
msg: Pick<Message, "text" | "caption" | "entities" | "caption_entities">,
): {
text: string;
entities: TelegramTextEntity[];
} {
const text = msg.text ?? msg.caption ?? "";
const entities = msg.entities ?? msg.caption_entities ?? [];
return { text, entities };
}
function isTelegramMentionWordChar(char: string | undefined): boolean {
return char != null && /[a-z0-9_]/i.test(char);
}
function hasStandaloneTelegramMention(text: string, mention: string): boolean {
let startIndex = 0;
while (startIndex < text.length) {
const idx = text.indexOf(mention, startIndex);
if (idx === -1) {
return false;
}
const prev = idx > 0 ? text[idx - 1] : undefined;
const next = text[idx + mention.length];
if (!isTelegramMentionWordChar(prev) && !isTelegramMentionWordChar(next)) {
return true;
}
startIndex = idx + 1;
}
return false;
}
export function hasBotMention(msg: Message, botUsername: string) {
const { text, entities } = getTelegramTextParts(msg);
const mention = `@${botUsername}`.toLowerCase();
if (hasStandaloneTelegramMention(text.toLowerCase(), mention)) {
return true;
}
for (const ent of entities) {
if (ent.type !== "mention") {
continue;
}
const slice = text.slice(ent.offset, ent.offset + ent.length);
if (slice.toLowerCase() === mention) {
return true;
}
}
return false;
}
type TelegramTextLinkEntity = {
type: string;
offset: number;
length: number;
url?: string;
};
export function expandTextLinks(text: string, entities?: TelegramTextLinkEntity[] | null): string {
if (!text || !entities?.length) {
return text;
}
const textLinks = entities
.filter(
(entity): entity is TelegramTextLinkEntity & { url: string } =>
entity.type === "text_link" && Boolean(entity.url),
)
.toSorted((a, b) => b.offset - a.offset);
if (textLinks.length === 0) {
return text;
}
let result = text;
for (const entity of textLinks) {
const linkText = text.slice(entity.offset, entity.offset + entity.length);
const markdown = `[${linkText}](${entity.url})`;
result =
result.slice(0, entity.offset) + markdown + result.slice(entity.offset + entity.length);
}
return result;
}
export function resolveTelegramReplyId(raw?: string): number | undefined {
if (!raw) {
return undefined;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return undefined;
}
return parsed;
}
export type TelegramReplyTarget = {
id?: string;
sender: string;
body: string;
kind: "reply" | "quote";
/** Forward context if the reply target was itself a forwarded message (issue #9619). */
forwardedFrom?: TelegramForwardedContext;
};
export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
const reply = msg.reply_to_message;
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
const quoteText =
msg.quote?.text ??
(externalReply as (Message & { quote?: { text?: string } }) | undefined)?.quote?.text;
let body = "";
let kind: TelegramReplyTarget["kind"] = "reply";
if (typeof quoteText === "string") {
body = quoteText.trim();
if (body) {
kind = "quote";
}
}
const replyLike = reply ?? externalReply;
if (!body && replyLike) {
const replyBody = (replyLike.text ?? replyLike.caption ?? "").trim();
body = replyBody;
if (!body) {
body = resolveTelegramMediaPlaceholder(replyLike) ?? "";
if (!body) {
const locationData = extractTelegramLocation(replyLike);
if (locationData) {
body = formatLocationText(locationData);
}
}
}
}
if (!body) {
return null;
}
const sender = replyLike ? buildSenderName(replyLike) : undefined;
const senderLabel = sender ?? "unknown sender";
// Extract forward context from the resolved reply target (reply_to_message or external_reply).
const forwardedFrom = replyLike?.forward_origin
? (resolveForwardOrigin(replyLike.forward_origin) ?? undefined)
: undefined;
return {
id: replyLike?.message_id ? String(replyLike.message_id) : undefined,
sender: senderLabel,
body,
kind,
forwardedFrom,
};
}
export type TelegramForwardedContext = {
from: string;
date?: number;
fromType: string;
fromId?: string;
fromUsername?: string;
fromTitle?: string;
fromSignature?: string;
/** Original chat type from forward_from_chat (e.g. "channel", "supergroup", "group"). */
fromChatType?: Chat["type"];
/** Original message ID in the source chat (channel forwards). */
fromMessageId?: number;
};
function normalizeForwardedUserLabel(user: User) {
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
const username = user.username?.trim() || undefined;
const id = String(user.id);
const display =
(name && username
? `${name} (@${username})`
: name || (username ? `@${username}` : undefined)) || `user:${id}`;
return { display, name: name || undefined, username, id };
}
function normalizeForwardedChatLabel(chat: Chat, fallbackKind: "chat" | "channel") {
const title = chat.title?.trim() || undefined;
const username = chat.username?.trim() || undefined;
const id = String(chat.id);
const display = title || (username ? `@${username}` : undefined) || `${fallbackKind}:${id}`;
return { display, title, username, id };
}
function buildForwardedContextFromUser(params: {
user: User;
date?: number;
type: string;
}): TelegramForwardedContext | null {
const { display, name, username, id } = normalizeForwardedUserLabel(params.user);
if (!display) {
return null;
}
return {
from: display,
date: params.date,
fromType: params.type,
fromId: id,
fromUsername: username,
fromTitle: name,
};
}
function buildForwardedContextFromHiddenName(params: {
name?: string;
date?: number;
type: string;
}): TelegramForwardedContext | null {
const trimmed = params.name?.trim();
if (!trimmed) {
return null;
}
return {
from: trimmed,
date: params.date,
fromType: params.type,
fromTitle: trimmed,
};
}
function buildForwardedContextFromChat(params: {
chat: Chat;
date?: number;
type: string;
signature?: string;
messageId?: number;
}): TelegramForwardedContext | null {
const fallbackKind = params.type === "channel" ? "channel" : "chat";
const { display, title, username, id } = normalizeForwardedChatLabel(params.chat, fallbackKind);
if (!display) {
return null;
}
const signature = params.signature?.trim() || undefined;
const from = signature ? `${display} (${signature})` : display;
const chatType = (params.chat.type?.trim() || undefined) as Chat["type"] | undefined;
return {
from,
date: params.date,
fromType: params.type,
fromId: id,
fromUsername: username,
fromTitle: title,
fromSignature: signature,
fromChatType: chatType,
fromMessageId: params.messageId,
};
}
function resolveForwardOrigin(origin: MessageOrigin): TelegramForwardedContext | null {
switch (origin.type) {
case "user":
return buildForwardedContextFromUser({
user: origin.sender_user,
date: origin.date,
type: "user",
});
case "hidden_user":
return buildForwardedContextFromHiddenName({
name: origin.sender_user_name,
date: origin.date,
type: "hidden_user",
});
case "chat":
return buildForwardedContextFromChat({
chat: origin.sender_chat,
date: origin.date,
type: "chat",
signature: origin.author_signature,
});
case "channel":
return buildForwardedContextFromChat({
chat: origin.chat,
date: origin.date,
type: "channel",
signature: origin.author_signature,
messageId: origin.message_id,
});
default:
// Exhaustiveness guard: if Grammy adds a new MessageOrigin variant,
// TypeScript will flag this assignment as an error.
origin satisfies never;
return null;
}
}
/** Extract forwarded message origin info from Telegram message. */
export function normalizeForwardedContext(msg: Message): TelegramForwardedContext | null {
if (!msg.forward_origin) {
return null;
}
return resolveForwardOrigin(msg.forward_origin);
}
export function extractTelegramLocation(msg: Message): NormalizedLocation | null {
const { venue, location } = msg;
if (venue) {
return {
latitude: venue.location.latitude,
longitude: venue.location.longitude,
accuracy: venue.location.horizontal_accuracy,
name: venue.title,
address: venue.address,
source: "place",
isLive: false,
};
}
if (location) {
const isLive = typeof location.live_period === "number" && location.live_period > 0;
return {
latitude: location.latitude,
longitude: location.longitude,
accuracy: location.horizontal_accuracy,
source: isLive ? "live" : "pin",
isLive,
};
}
return null;
}
export * from "../../../extensions/telegram/src/bot/helpers.js";

View File

@@ -1,82 +1 @@
import type { ReplyToMode } from "../../config/config.js";
export type DeliveryProgress = {
hasReplied: boolean;
hasDelivered: boolean;
};
export function createDeliveryProgress(): DeliveryProgress {
return {
hasReplied: false,
hasDelivered: false,
};
}
export function resolveReplyToForSend(params: {
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): number | undefined {
return params.replyToId && (params.replyToMode === "all" || !params.progress.hasReplied)
? params.replyToId
: undefined;
}
export function markReplyApplied(progress: DeliveryProgress, replyToId?: number): void {
if (replyToId && !progress.hasReplied) {
progress.hasReplied = true;
}
}
export function markDelivered(progress: DeliveryProgress): void {
progress.hasDelivered = true;
}
export async function sendChunkedTelegramReplyText<
TChunk,
TReplyMarkup = unknown,
TProgress extends DeliveryProgress = DeliveryProgress,
>(params: {
chunks: readonly TChunk[];
progress: TProgress;
replyToId?: number;
replyToMode: ReplyToMode;
replyMarkup?: TReplyMarkup;
replyQuoteText?: string;
quoteOnlyOnFirstChunk?: boolean;
markDelivered?: (progress: TProgress) => void;
sendChunk: (opts: {
chunk: TChunk;
isFirstChunk: boolean;
replyToMessageId?: number;
replyMarkup?: TReplyMarkup;
replyQuoteText?: string;
}) => Promise<void>;
}): Promise<void> {
const applyDelivered = params.markDelivered ?? markDelivered;
for (let i = 0; i < params.chunks.length; i += 1) {
const chunk = params.chunks[i];
if (!chunk) {
continue;
}
const isFirstChunk = i === 0;
const replyToMessageId = resolveReplyToForSend({
replyToId: params.replyToId,
replyToMode: params.replyToMode,
progress: params.progress,
});
const shouldAttachQuote =
Boolean(replyToMessageId) &&
Boolean(params.replyQuoteText) &&
(params.quoteOnlyOnFirstChunk !== true || isFirstChunk);
await params.sendChunk({
chunk,
isFirstChunk,
replyToMessageId,
replyMarkup: isFirstChunk ? params.replyMarkup : undefined,
replyQuoteText: shouldAttachQuote ? params.replyQuoteText : undefined,
});
markReplyApplied(params.progress, replyToMessageId);
applyDelivered(params.progress);
}
}
export * from "../../../extensions/telegram/src/bot/reply-threading.js";

View File

@@ -1,29 +1 @@
import type { Message, UserFromGetMe } from "@grammyjs/types";
/** App-specific stream mode for Telegram stream previews. */
export type TelegramStreamMode = "off" | "partial" | "block";
/**
* Minimal context projection from Grammy's Context class.
* Decouples the message processing pipeline from Grammy's full Context,
* and allows constructing synthetic contexts for debounced/combined messages.
*/
export type TelegramContext = {
message: Message;
me?: UserFromGetMe;
getFile: () => Promise<{ file_path?: string }>;
};
/** Telegram sticker metadata for context enrichment and caching. */
export interface StickerMetadata {
/** Emoji associated with the sticker. */
emoji?: string;
/** Name of the sticker set the sticker belongs to. */
setName?: string;
/** Telegram file_id for sending the sticker back. */
fileId?: string;
/** Stable file_unique_id for cache deduplication. */
fileUniqueId?: string;
/** Cached description from previous vision processing (skip re-processing if present). */
cachedDescription?: string;
}
export * from "../../../extensions/telegram/src/bot/types.js";

View File

@@ -1,9 +1 @@
export type TelegramButtonStyle = "danger" | "success" | "primary";
export type TelegramInlineButton = {
text: string;
callback_data: string;
style?: TelegramButtonStyle;
};
export type TelegramInlineButtons = ReadonlyArray<ReadonlyArray<TelegramInlineButton>>;
export * from "../../extensions/telegram/src/button-types.js";

View File

@@ -1,15 +1 @@
export const TELEGRAM_MAX_CAPTION_LENGTH = 1024;
export function splitTelegramCaption(text?: string): {
caption?: string;
followUpText?: string;
} {
const trimmed = text?.trim() ?? "";
if (!trimmed) {
return { caption: undefined, followUpText: undefined };
}
if (trimmed.length > TELEGRAM_MAX_CAPTION_LENGTH) {
return { caption: undefined, followUpText: trimmed };
}
return { caption: trimmed, followUpText: undefined };
}
export * from "../../extensions/telegram/src/caption.js";

View File

@@ -1,140 +1 @@
import { resolveConfiguredAcpRoute } from "../acp/persistent-bindings.route.js";
import type { OpenClawConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
import {
buildAgentSessionKey,
deriveLastRoutePolicy,
pickFirstExistingAgentId,
resolveAgentRoute,
} from "../routing/resolve-route.js";
import { buildAgentMainSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import {
buildTelegramGroupPeerId,
buildTelegramParentPeer,
resolveTelegramDirectPeerId,
} from "./bot/helpers.js";
export function resolveTelegramConversationRoute(params: {
cfg: OpenClawConfig;
accountId: string;
chatId: number | string;
isGroup: boolean;
resolvedThreadId?: number;
replyThreadId?: number;
senderId?: string | number | null;
topicAgentId?: string | null;
}): {
route: ReturnType<typeof resolveAgentRoute>;
configuredBinding: ReturnType<typeof resolveConfiguredAcpRoute>["configuredBinding"];
configuredBindingSessionKey: string;
} {
const peerId = params.isGroup
? buildTelegramGroupPeerId(params.chatId, params.resolvedThreadId)
: resolveTelegramDirectPeerId({
chatId: params.chatId,
senderId: params.senderId,
});
const parentPeer = buildTelegramParentPeer({
isGroup: params.isGroup,
resolvedThreadId: params.resolvedThreadId,
chatId: params.chatId,
});
let route = resolveAgentRoute({
cfg: params.cfg,
channel: "telegram",
accountId: params.accountId,
peer: {
kind: params.isGroup ? "group" : "direct",
id: peerId,
},
parentPeer,
});
const rawTopicAgentId = params.topicAgentId?.trim();
if (rawTopicAgentId) {
const topicAgentId = pickFirstExistingAgentId(params.cfg, rawTopicAgentId);
route = {
...route,
agentId: topicAgentId,
sessionKey: buildAgentSessionKey({
agentId: topicAgentId,
channel: "telegram",
accountId: params.accountId,
peer: { kind: params.isGroup ? "group" : "direct", id: peerId },
dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
}).toLowerCase(),
mainSessionKey: buildAgentMainSessionKey({
agentId: topicAgentId,
}).toLowerCase(),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: buildAgentSessionKey({
agentId: topicAgentId,
channel: "telegram",
accountId: params.accountId,
peer: { kind: params.isGroup ? "group" : "direct", id: peerId },
dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
}).toLowerCase(),
mainSessionKey: buildAgentMainSessionKey({
agentId: topicAgentId,
}).toLowerCase(),
}),
};
logVerbose(
`telegram: topic route override: topic=${params.resolvedThreadId ?? params.replyThreadId} agent=${topicAgentId} sessionKey=${route.sessionKey}`,
);
}
const configuredRoute = resolveConfiguredAcpRoute({
cfg: params.cfg,
route,
channel: "telegram",
accountId: params.accountId,
conversationId: peerId,
parentConversationId: params.isGroup ? String(params.chatId) : undefined,
});
let configuredBinding = configuredRoute.configuredBinding;
let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
route = configuredRoute.route;
const threadBindingConversationId =
params.replyThreadId != null
? `${params.chatId}:topic:${params.replyThreadId}`
: !params.isGroup
? String(params.chatId)
: undefined;
if (threadBindingConversationId) {
const threadBinding = getSessionBindingService().resolveByConversation({
channel: "telegram",
accountId: params.accountId,
conversationId: threadBindingConversationId,
});
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
if (threadBinding && boundSessionKey) {
route = {
...route,
sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: route.mainSessionKey,
}),
matchedBy: "binding.channel",
};
configuredBinding = null;
configuredBindingSessionKey = "";
getSessionBindingService().touch(threadBinding.bindingId);
logVerbose(
`telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`,
);
}
}
return {
route,
configuredBinding,
configuredBindingSessionKey,
};
}
export * from "../../extensions/telegram/src/conversation-route.js";

View File

@@ -1,123 +1 @@
import type { Message } from "@grammyjs/types";
import type { Bot } from "grammy";
import type { DmPolicy } from "../config/types.js";
import { logVerbose } from "../globals.js";
import { issuePairingChallenge } from "../pairing/pairing-challenge.js";
import { upsertChannelPairingRequest } from "../pairing/pairing-store.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { resolveSenderAllowMatch, type NormalizedAllowFrom } from "./bot-access.js";
type TelegramDmAccessLogger = {
info: (obj: Record<string, unknown>, msg: string) => void;
};
type TelegramSenderIdentity = {
username: string;
userId: string | null;
candidateId: string;
firstName?: string;
lastName?: string;
};
function resolveTelegramSenderIdentity(msg: Message, chatId: number): TelegramSenderIdentity {
const from = msg.from;
const userId = from?.id != null ? String(from.id) : null;
return {
username: from?.username ?? "",
userId,
candidateId: userId ?? String(chatId),
firstName: from?.first_name,
lastName: from?.last_name,
};
}
export async function enforceTelegramDmAccess(params: {
isGroup: boolean;
dmPolicy: DmPolicy;
msg: Message;
chatId: number;
effectiveDmAllow: NormalizedAllowFrom;
accountId: string;
bot: Bot;
logger: TelegramDmAccessLogger;
}): Promise<boolean> {
const { isGroup, dmPolicy, msg, chatId, effectiveDmAllow, accountId, bot, logger } = params;
if (isGroup) {
return true;
}
if (dmPolicy === "disabled") {
return false;
}
if (dmPolicy === "open") {
return true;
}
const sender = resolveTelegramSenderIdentity(msg, chatId);
const allowMatch = resolveSenderAllowMatch({
allow: effectiveDmAllow,
senderId: sender.candidateId,
senderUsername: sender.username,
});
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
allowMatch.matchSource ?? "none"
}`;
const allowed =
effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed);
if (allowed) {
return true;
}
if (dmPolicy === "pairing") {
try {
const telegramUserId = sender.userId ?? sender.candidateId;
await issuePairingChallenge({
channel: "telegram",
senderId: telegramUserId,
senderIdLine: `Your Telegram user id: ${telegramUserId}`,
meta: {
username: sender.username || undefined,
firstName: sender.firstName,
lastName: sender.lastName,
},
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "telegram",
id,
accountId,
meta,
}),
onCreated: () => {
logger.info(
{
chatId: String(chatId),
senderUserId: sender.userId ?? undefined,
username: sender.username || undefined,
firstName: sender.firstName,
lastName: sender.lastName,
matchKey: allowMatch.matchKey ?? "none",
matchSource: allowMatch.matchSource ?? "none",
},
"telegram pairing request",
);
},
sendPairingReply: async (text) => {
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, text),
});
},
onReplyError: (err) => {
logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`);
},
});
} catch (err) {
logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`);
}
return false;
}
logVerbose(
`Blocked unauthorized telegram sender ${sender.candidateId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
return false;
}
export * from "../../extensions/telegram/src/dm-access.js";

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
describe("resolveTelegramDraftStreamingChunking", () => {
it("uses smaller defaults than block streaming", () => {
const chunking = resolveTelegramDraftStreamingChunking(undefined, "default");
expect(chunking).toEqual({
minChars: 200,
maxChars: 800,
breakPreference: "paragraph",
});
});
it("clamps to telegram.textChunkLimit", () => {
const cfg: OpenClawConfig = {
channels: { telegram: { allowFrom: ["*"], textChunkLimit: 150 } },
};
const chunking = resolveTelegramDraftStreamingChunking(cfg, "default");
expect(chunking).toEqual({
minChars: 150,
maxChars: 150,
breakPreference: "paragraph",
});
});
it("supports per-account overrides", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
allowFrom: ["*"],
accounts: {
default: {
allowFrom: ["*"],
draftChunk: {
minChars: 10,
maxChars: 20,
breakPreference: "sentence",
},
},
},
},
},
};
const chunking = resolveTelegramDraftStreamingChunking(cfg, "default");
expect(chunking).toEqual({
minChars: 10,
maxChars: 20,
breakPreference: "sentence",
});
});
});

View File

@@ -1,41 +1 @@
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { getChannelDock } from "../channels/dock.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import { normalizeAccountId } from "../routing/session-key.js";
const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200;
const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800;
export function resolveTelegramDraftStreamingChunking(
cfg: OpenClawConfig | undefined,
accountId?: string | null,
): {
minChars: number;
maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence";
} {
const providerChunkLimit = getChannelDock("telegram")?.outbound?.textChunkLimit;
const textLimit = resolveTextChunkLimit(cfg, "telegram", accountId, {
fallbackLimit: providerChunkLimit,
});
const normalizedAccountId = normalizeAccountId(accountId);
const accountCfg = resolveAccountEntry(cfg?.channels?.telegram?.accounts, normalizedAccountId);
const draftCfg = accountCfg?.draftChunk ?? cfg?.channels?.telegram?.draftChunk;
const maxRequested = Math.max(
1,
Math.floor(draftCfg?.maxChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MAX),
);
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
const minRequested = Math.max(
1,
Math.floor(draftCfg?.minChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MIN),
);
const minChars = Math.min(minRequested, maxChars);
const breakPreference =
draftCfg?.breakPreference === "newline" || draftCfg?.breakPreference === "sentence"
? draftCfg.breakPreference
: "paragraph";
return { minChars, maxChars, breakPreference };
}
export * from "../../extensions/telegram/src/draft-chunking.js";

View File

@@ -1,87 +0,0 @@
import { vi } from "vitest";
type DraftPreviewMode = "message" | "draft";
export type TestDraftStream = {
update: ReturnType<typeof vi.fn<(text: string) => void>>;
flush: ReturnType<typeof vi.fn<() => Promise<void>>>;
messageId: ReturnType<typeof vi.fn<() => number | undefined>>;
previewMode: ReturnType<typeof vi.fn<() => DraftPreviewMode>>;
previewRevision: ReturnType<typeof vi.fn<() => number>>;
lastDeliveredText: ReturnType<typeof vi.fn<() => string>>;
clear: ReturnType<typeof vi.fn<() => Promise<void>>>;
stop: ReturnType<typeof vi.fn<() => Promise<void>>>;
materialize: ReturnType<typeof vi.fn<() => Promise<number | undefined>>>;
forceNewMessage: ReturnType<typeof vi.fn<() => void>>;
sendMayHaveLanded: ReturnType<typeof vi.fn<() => boolean>>;
setMessageId: (value: number | undefined) => void;
};
export function createTestDraftStream(params?: {
messageId?: number;
previewMode?: DraftPreviewMode;
onUpdate?: (text: string) => void;
onStop?: () => void | Promise<void>;
clearMessageIdOnForceNew?: boolean;
}): TestDraftStream {
let messageId = params?.messageId;
let previewRevision = 0;
let lastDeliveredText = "";
return {
update: vi.fn().mockImplementation((text: string) => {
previewRevision += 1;
lastDeliveredText = text.trimEnd();
params?.onUpdate?.(text);
}),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
previewMode: vi.fn().mockReturnValue(params?.previewMode ?? "message"),
previewRevision: vi.fn().mockImplementation(() => previewRevision),
lastDeliveredText: vi.fn().mockImplementation(() => lastDeliveredText),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockImplementation(async () => {
await params?.onStop?.();
}),
materialize: vi.fn().mockImplementation(async () => messageId),
forceNewMessage: vi.fn().mockImplementation(() => {
if (params?.clearMessageIdOnForceNew) {
messageId = undefined;
}
}),
sendMayHaveLanded: vi.fn().mockReturnValue(false),
setMessageId: (value: number | undefined) => {
messageId = value;
},
};
}
export function createSequencedTestDraftStream(startMessageId = 1001): TestDraftStream {
let activeMessageId: number | undefined;
let nextMessageId = startMessageId;
let previewRevision = 0;
let lastDeliveredText = "";
return {
update: vi.fn().mockImplementation((text: string) => {
if (activeMessageId == null) {
activeMessageId = nextMessageId++;
}
previewRevision += 1;
lastDeliveredText = text.trimEnd();
}),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => activeMessageId),
previewMode: vi.fn().mockReturnValue("message"),
previewRevision: vi.fn().mockImplementation(() => previewRevision),
lastDeliveredText: vi.fn().mockImplementation(() => lastDeliveredText),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
materialize: vi.fn().mockImplementation(async () => activeMessageId),
forceNewMessage: vi.fn().mockImplementation(() => {
activeMessageId = undefined;
}),
sendMayHaveLanded: vi.fn().mockReturnValue(false),
setMessageId: (value: number | undefined) => {
activeMessageId = value;
},
};
}

View File

@@ -1,671 +0,0 @@
import type { Bot } from "grammy";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { importFreshModule } from "../../test/helpers/import-fresh.js";
import { __testing, createTelegramDraftStream } from "./draft-stream.js";
type TelegramDraftStreamParams = Parameters<typeof createTelegramDraftStream>[0];
function createMockDraftApi(sendMessageImpl?: () => Promise<{ message_id: number }>) {
return {
sendMessage: vi.fn(sendMessageImpl ?? (async () => ({ message_id: 17 }))),
sendMessageDraft: vi.fn().mockResolvedValue(true),
editMessageText: vi.fn().mockResolvedValue(true),
deleteMessage: vi.fn().mockResolvedValue(true),
};
}
function createForumDraftStream(api: ReturnType<typeof createMockDraftApi>) {
return createThreadedDraftStream(api, { id: 99, scope: "forum" });
}
function createThreadedDraftStream(
api: ReturnType<typeof createMockDraftApi>,
thread: { id: number; scope: "forum" | "dm" },
) {
return createDraftStream(api, { thread });
}
function createDraftStream(
api: ReturnType<typeof createMockDraftApi>,
overrides: Omit<Partial<TelegramDraftStreamParams>, "api" | "chatId"> = {},
) {
return createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
...overrides,
});
}
async function expectInitialForumSend(
api: ReturnType<typeof createMockDraftApi>,
text = "Hello",
): Promise<void> {
await vi.waitFor(() =>
expect(api.sendMessage).toHaveBeenCalledWith(123, text, { message_thread_id: 99 }),
);
}
function expectDmMessagePreviewViaSendMessage(
api: ReturnType<typeof createMockDraftApi>,
text = "Hello",
): void {
expect(api.sendMessage).toHaveBeenCalledWith(123, text, { message_thread_id: 42 });
expect(api.editMessageText).not.toHaveBeenCalled();
}
async function createDmDraftTransportStream(params: {
api?: ReturnType<typeof createMockDraftApi>;
previewTransport?: "draft" | "message";
warn?: (message: string) => void;
}) {
const api = params.api ?? createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: params.previewTransport ?? "draft",
...(params.warn ? { warn: params.warn } : {}),
});
stream.update("Hello");
await stream.flush();
return { api, stream };
}
function createForceNewMessageHarness(params: { throttleMs?: number } = {}) {
const api = createMockDraftApi();
api.sendMessage
.mockResolvedValueOnce({ message_id: 17 })
.mockResolvedValueOnce({ message_id: 42 });
const stream = createDraftStream(
api,
params.throttleMs != null ? { throttleMs: params.throttleMs } : {},
);
return { api, stream };
}
describe("createTelegramDraftStream", () => {
afterEach(() => {
__testing.resetTelegramDraftStreamForTests();
});
it("sends stream preview message with message_thread_id when provided", async () => {
const api = createMockDraftApi();
const stream = createForumDraftStream(api);
stream.update("Hello");
await expectInitialForumSend(api);
});
it("edits existing stream preview message on subsequent updates", async () => {
const api = createMockDraftApi();
const stream = createForumDraftStream(api);
stream.update("Hello");
await expectInitialForumSend(api);
await (api.sendMessage.mock.results[0]?.value as Promise<unknown>);
stream.update("Hello again");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello again");
});
it("waits for in-flight updates before final flush edit", async () => {
let resolveSend: ((value: { message_id: number }) => void) | undefined;
const firstSend = new Promise<{ message_id: number }>((resolve) => {
resolveSend = resolve;
});
const api = createMockDraftApi(() => firstSend);
const stream = createForumDraftStream(api);
stream.update("Hello");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
stream.update("Hello final");
const flushPromise = stream.flush();
expect(api.editMessageText).not.toHaveBeenCalled();
resolveSend?.({ message_id: 17 });
await flushPromise;
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello final");
});
it("omits message_thread_id for general topic id", async () => {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 1, scope: "forum" });
stream.update("Hello");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", undefined));
});
it("uses sendMessageDraft for dm threads and does not create a preview message", async () => {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
stream.update("Hello");
await vi.waitFor(() =>
expect(api.sendMessageDraft).toHaveBeenCalledWith(123, expect.any(Number), "Hello", {
message_thread_id: 42,
}),
);
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
await stream.clear();
expect(api.deleteMessage).not.toHaveBeenCalled();
});
it("supports forcing message transport in dm threads", async () => {
const { api } = await createDmDraftTransportStream({ previewTransport: "message" });
expectDmMessagePreviewViaSendMessage(api);
expect(api.sendMessageDraft).not.toHaveBeenCalled();
});
it("falls back to message transport when sendMessageDraft is unavailable", async () => {
const api = createMockDraftApi();
delete (api as { sendMessageDraft?: unknown }).sendMessageDraft;
const warn = vi.fn();
await createDmDraftTransportStream({ api, warn });
expectDmMessagePreviewViaSendMessage(api);
expect(warn).toHaveBeenCalledWith(
"telegram stream preview: sendMessageDraft unavailable; falling back to sendMessage/editMessageText",
);
});
it("falls back to message transport when sendMessageDraft is rejected at runtime", async () => {
const api = createMockDraftApi();
api.sendMessageDraft.mockRejectedValueOnce(
new Error(
"Call to 'sendMessageDraft' failed! (400: Bad Request: method sendMessageDraft can be used only in private chats)",
),
);
const warn = vi.fn();
const { stream } = await createDmDraftTransportStream({ api, warn });
expect(api.sendMessageDraft).toHaveBeenCalledTimes(1);
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
expect(stream.previewMode?.()).toBe("message");
expect(warn).toHaveBeenCalledWith(
"telegram stream preview: sendMessageDraft rejected by API; falling back to sendMessage/editMessageText",
);
stream.update("Hello again");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello again");
});
it("retries DM message preview send without thread when thread is not found", async () => {
const api = createMockDraftApi();
api.sendMessage
.mockRejectedValueOnce(new Error("400: Bad Request: message thread not found"))
.mockResolvedValueOnce({ message_id: 17 });
const warn = vi.fn();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "message",
warn,
});
stream.update("Hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Hello", { message_thread_id: 42 });
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Hello", undefined);
expect(warn).toHaveBeenCalledWith(
"telegram stream preview send failed with message_thread_id, retrying without thread",
);
});
it("materializes draft previews using rendered HTML text", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
renderText: (text) => ({
text: text.replace("**bold**", "<b>bold</b>"),
parseMode: "HTML",
}),
});
stream.update("**bold**");
await stream.flush();
await stream.materialize?.();
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>bold</b>", {
message_thread_id: 42,
parse_mode: "HTML",
});
});
it("clears draft after materializing to avoid duplicate display in DM", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
});
stream.update("Hello");
await stream.flush();
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(17);
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
// Draft should be cleared with empty string after real message is sent.
const draftCalls = api.sendMessageDraft.mock.calls;
const clearCall = draftCalls.find((call) => call[2] === "");
expect(clearCall).toBeDefined();
expect(clearCall?.[0]).toBe(123);
expect(clearCall?.[3]).toEqual({ message_thread_id: 42 });
});
it("retries materialize send without thread when dm thread lookup fails", async () => {
const api = createMockDraftApi();
api.sendMessage
.mockRejectedValueOnce(new Error("400: Bad Request: message thread not found"))
.mockResolvedValueOnce({ message_id: 55 });
const warn = vi.fn();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
warn,
});
stream.update("Hello");
await stream.flush();
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(55);
expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Hello", { message_thread_id: 42 });
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Hello", undefined);
const draftCalls = api.sendMessageDraft.mock.calls;
const clearCall = draftCalls.find((call) => call[2] === "");
expect(clearCall).toBeDefined();
expect(clearCall?.[3]).toBeUndefined();
expect(warn).toHaveBeenCalledWith(
"telegram stream preview materialize send failed with message_thread_id, retrying without thread",
);
});
it("returns existing preview id when materializing message transport", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "message",
});
stream.update("Hello");
await stream.flush();
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(17);
expect(api.sendMessage).toHaveBeenCalledTimes(1);
});
it("does not edit or delete messages after DM draft stream finalization", async () => {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
stream.update("Hello");
await stream.flush();
stream.update("Hello again");
await stream.stop();
await stream.clear();
expect(api.sendMessageDraft).toHaveBeenCalled();
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
expect(api.deleteMessage).not.toHaveBeenCalled();
});
it("rotates draft_id when forceNewMessage races an in-flight DM draft send", async () => {
let resolveFirstDraft: ((value: boolean) => void) | undefined;
const firstDraftSend = new Promise<boolean>((resolve) => {
resolveFirstDraft = resolve;
});
const api = {
sendMessageDraft: vi.fn().mockReturnValueOnce(firstDraftSend).mockResolvedValueOnce(true),
sendMessage: vi.fn().mockResolvedValue({ message_id: 17 }),
editMessageText: vi.fn().mockResolvedValue(true),
deleteMessage: vi.fn().mockResolvedValue(true),
};
const stream = createThreadedDraftStream(
api as unknown as ReturnType<typeof createMockDraftApi>,
{ id: 42, scope: "dm" },
);
stream.update("Message A");
await vi.waitFor(() => expect(api.sendMessageDraft).toHaveBeenCalledTimes(1));
stream.forceNewMessage();
stream.update("Message B");
resolveFirstDraft?.(true);
await stream.flush();
expect(api.sendMessageDraft).toHaveBeenCalledTimes(2);
const firstDraftId = api.sendMessageDraft.mock.calls[0]?.[1];
const secondDraftId = api.sendMessageDraft.mock.calls[1]?.[1];
expect(typeof firstDraftId).toBe("number");
expect(typeof secondDraftId).toBe("number");
expect(firstDraftId).not.toBe(secondDraftId);
expect(api.sendMessageDraft.mock.calls[1]?.[2]).toBe("Message B");
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
});
it("shares draft-id allocation across distinct module instances", async () => {
const draftA = await importFreshModule<typeof import("./draft-stream.js")>(
import.meta.url,
"./draft-stream.js?scope=shared-a",
);
const draftB = await importFreshModule<typeof import("./draft-stream.js")>(
import.meta.url,
"./draft-stream.js?scope=shared-b",
);
const apiA = createMockDraftApi();
const apiB = createMockDraftApi();
draftA.__testing.resetTelegramDraftStreamForTests();
try {
const streamA = draftA.createTelegramDraftStream({
api: apiA as unknown as Bot["api"],
chatId: 123,
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
});
const streamB = draftB.createTelegramDraftStream({
api: apiB as unknown as Bot["api"],
chatId: 123,
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
});
streamA.update("Message A");
await streamA.flush();
streamB.update("Message B");
await streamB.flush();
expect(apiA.sendMessageDraft.mock.calls[0]?.[1]).toBe(1);
expect(apiB.sendMessageDraft.mock.calls[0]?.[1]).toBe(2);
} finally {
draftA.__testing.resetTelegramDraftStreamForTests();
}
});
it("creates new message after forceNewMessage is called", async () => {
const { api, stream } = createForceNewMessageHarness();
// First message
stream.update("Hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
// Normal edit (same message)
stream.update("Hello edited");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello edited");
// Force new message (e.g. after thinking block ends)
stream.forceNewMessage();
stream.update("After thinking");
await stream.flush();
// Should have sent a second new message, not edited the first
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expect(api.sendMessage).toHaveBeenLastCalledWith(123, "After thinking", undefined);
});
it("sends first update immediately after forceNewMessage within throttle window", async () => {
vi.useFakeTimers();
try {
const { api, stream } = createForceNewMessageHarness({ throttleMs: 1000 });
stream.update("Hello");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
stream.update("Hello edited");
expect(api.editMessageText).not.toHaveBeenCalled();
stream.forceNewMessage();
stream.update("Second message");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(2));
expect(api.sendMessage).toHaveBeenLastCalledWith(123, "Second message", undefined);
} finally {
vi.useRealTimers();
}
});
it("does not rebind to an old message when forceNewMessage races an in-flight send", async () => {
let resolveFirstSend: ((value: { message_id: number }) => void) | undefined;
const firstSend = new Promise<{ message_id: number }>((resolve) => {
resolveFirstSend = resolve;
});
const api = {
sendMessage: vi.fn().mockReturnValueOnce(firstSend).mockResolvedValueOnce({ message_id: 42 }),
editMessageText: vi.fn().mockResolvedValue(true),
deleteMessage: vi.fn().mockResolvedValue(true),
};
const onSupersededPreview = vi.fn();
const stream = createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
onSupersededPreview,
});
stream.update("Message A partial");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
// Rotate to message B before message A send resolves.
stream.forceNewMessage();
stream.update("Message B partial");
resolveFirstSend?.({ message_id: 17 });
await stream.flush();
expect(onSupersededPreview).toHaveBeenCalledWith({
messageId: 17,
textSnapshot: "Message A partial",
parseMode: undefined,
});
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Message B partial", undefined);
expect(api.editMessageText).not.toHaveBeenCalledWith(123, 17, "Message B partial");
});
it("marks sendMayHaveLanded after an ambiguous first preview send failure", async () => {
const api = createMockDraftApi();
api.sendMessage.mockRejectedValueOnce(new Error("timeout after Telegram accepted send"));
const stream = createDraftStream(api);
stream.update("Hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expect(stream.sendMayHaveLanded?.()).toBe(true);
});
async function expectSendMayHaveLandedStateAfterFirstFailure(error: Error, expected: boolean) {
const api = createMockDraftApi();
api.sendMessage.mockRejectedValueOnce(error);
const stream = createDraftStream(api);
stream.update("Hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expect(stream.sendMayHaveLanded?.()).toBe(expected);
}
it("clears sendMayHaveLanded on pre-connect first preview send failures", async () => {
await expectSendMayHaveLandedStateAfterFirstFailure(
Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
false,
);
});
it("clears sendMayHaveLanded on Telegram 4xx client rejections", async () => {
await expectSendMayHaveLandedStateAfterFirstFailure(
Object.assign(new Error("403: Forbidden"), { error_code: 403 }),
false,
);
});
it("supports rendered previews with parse_mode", async () => {
const api = createMockDraftApi();
const stream = createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
renderText: (text) => ({ text: `<i>${text}</i>`, parseMode: "HTML" }),
});
stream.update("hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledWith(123, "<i>hello</i>", { parse_mode: "HTML" });
stream.update("hello again");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "<i>hello again</i>", {
parse_mode: "HTML",
});
});
it("enforces maxChars after renderText expansion", async () => {
const api = createMockDraftApi();
const warn = vi.fn();
const stream = createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
maxChars: 100,
renderText: () => ({ text: `<b>${"<".repeat(120)}</b>`, parseMode: "HTML" }),
warn,
});
stream.update("short raw text");
await stream.flush();
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("telegram stream preview stopped (text length 127 > 100)"),
);
});
});
describe("draft stream initial message debounce", () => {
const createMockApi = () => ({
sendMessage: vi.fn().mockResolvedValue({ message_id: 42 }),
editMessageText: vi.fn().mockResolvedValue(true),
deleteMessage: vi.fn().mockResolvedValue(true),
});
function createDebouncedStream(api: ReturnType<typeof createMockApi>, minInitialChars = 30) {
return createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
minInitialChars,
});
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("isFinal has highest priority", () => {
it("sends immediately on stop() even with 1 character", async () => {
const api = createMockApi();
const stream = createDebouncedStream(api);
stream.update("Y");
await stream.stop();
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledWith(123, "Y", undefined);
});
it("sends immediately on stop() with short sentence", async () => {
const api = createMockApi();
const stream = createDebouncedStream(api);
stream.update("Ok.");
await stream.stop();
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledWith(123, "Ok.", undefined);
});
});
describe("minInitialChars threshold", () => {
it("does not send first message below threshold", async () => {
const api = createMockApi();
const stream = createDebouncedStream(api);
stream.update("Processing"); // 10 chars, below 30
await stream.flush();
expect(api.sendMessage).not.toHaveBeenCalled();
});
it("sends first message when reaching threshold", async () => {
const api = createMockApi();
const stream = createDebouncedStream(api);
// Exactly 30 chars
stream.update("I am processing your request..");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalled();
});
it("works with longer text above threshold", async () => {
const api = createMockApi();
const stream = createDebouncedStream(api);
stream.update("I am processing your request, please wait a moment"); // 50 chars
await stream.flush();
expect(api.sendMessage).toHaveBeenCalled();
});
});
describe("subsequent updates after first message", () => {
it("edits normally after first message is sent", async () => {
const api = createMockApi();
const stream = createDebouncedStream(api);
// First message at threshold (30 chars)
stream.update("I am processing your request..");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
// Subsequent updates should edit, not wait for threshold
stream.update("I am processing your request.. and summarizing");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalled();
expect(api.sendMessage).toHaveBeenCalledTimes(1); // still only 1 send
});
});
describe("default behavior without debounce params", () => {
it("sends immediately without minInitialChars set (backward compatible)", async () => {
const api = createMockApi();
const stream = createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
// no minInitialChars (backward-compatible behavior)
});
stream.update("Hi");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hi", undefined);
});
});
});

View File

@@ -1,459 +1 @@
import type { Bot } from "grammy";
import { createFinalizableDraftLifecycle } from "../channels/draft-stream-controls.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js";
const TELEGRAM_STREAM_MAX_CHARS = 4096;
const DEFAULT_THROTTLE_MS = 1000;
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i;
const DRAFT_METHOD_UNAVAILABLE_RE =
/(unknown method|method .*not (found|available|supported)|unsupported)/i;
const DRAFT_CHAT_UNSUPPORTED_RE = /(can't be used|can be used only)/i;
type TelegramSendMessageDraft = (
chatId: number,
draftId: number,
text: string,
params?: {
message_thread_id?: number;
parse_mode?: "HTML";
},
) => Promise<unknown>;
/**
* Keep draft-id allocation shared across bundled chunks so concurrent preview
* lanes do not accidentally reuse draft ids when code-split entries coexist.
*/
const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState");
const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({
nextDraftId: 0,
}));
function allocateTelegramDraftId(): number {
draftStreamState.nextDraftId =
draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1;
return draftStreamState.nextDraftId;
}
function resolveSendMessageDraftApi(api: Bot["api"]): TelegramSendMessageDraft | undefined {
const sendMessageDraft = (api as Bot["api"] & { sendMessageDraft?: TelegramSendMessageDraft })
.sendMessageDraft;
if (typeof sendMessageDraft !== "function") {
return undefined;
}
return sendMessageDraft.bind(api as object);
}
function shouldFallbackFromDraftTransport(err: unknown): boolean {
const text =
typeof err === "string"
? err
: err instanceof Error
? err.message
: typeof err === "object" && err && "description" in err
? typeof err.description === "string"
? err.description
: ""
: "";
if (!/sendMessageDraft/i.test(text)) {
return false;
}
return DRAFT_METHOD_UNAVAILABLE_RE.test(text) || DRAFT_CHAT_UNSUPPORTED_RE.test(text);
}
export type TelegramDraftStream = {
update: (text: string) => void;
flush: () => Promise<void>;
messageId: () => number | undefined;
previewMode?: () => "message" | "draft";
previewRevision?: () => number;
lastDeliveredText?: () => string;
clear: () => Promise<void>;
stop: () => Promise<void>;
/** Convert the current draft preview into a permanent message (sendMessage). */
materialize?: () => Promise<number | undefined>;
/** Reset internal state so the next update creates a new message instead of editing. */
forceNewMessage: () => void;
/** True when a preview sendMessage was attempted but the response was lost. */
sendMayHaveLanded?: () => boolean;
};
type TelegramDraftPreview = {
text: string;
parseMode?: "HTML";
};
type SupersededTelegramPreview = {
messageId: number;
textSnapshot: string;
parseMode?: "HTML";
};
export function createTelegramDraftStream(params: {
api: Bot["api"];
chatId: number;
maxChars?: number;
thread?: TelegramThreadSpec | null;
previewTransport?: "auto" | "message" | "draft";
replyToMessageId?: number;
throttleMs?: number;
/** Minimum chars before sending first message (debounce for push notifications) */
minInitialChars?: number;
/** Optional preview renderer (e.g. markdown -> HTML + parse mode). */
renderText?: (text: string) => TelegramDraftPreview;
/** Called when a late send resolves after forceNewMessage() switched generations. */
onSupersededPreview?: (preview: SupersededTelegramPreview) => void;
log?: (message: string) => void;
warn?: (message: string) => void;
}): TelegramDraftStream {
const maxChars = Math.min(
params.maxChars ?? TELEGRAM_STREAM_MAX_CHARS,
TELEGRAM_STREAM_MAX_CHARS,
);
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const minInitialChars = params.minInitialChars;
const chatId = params.chatId;
const requestedPreviewTransport = params.previewTransport ?? "auto";
const prefersDraftTransport =
requestedPreviewTransport === "draft"
? true
: requestedPreviewTransport === "message"
? false
: params.thread?.scope === "dm";
const threadParams = buildTelegramThreadParams(params.thread);
const replyParams =
params.replyToMessageId != null
? { ...threadParams, reply_to_message_id: params.replyToMessageId }
: threadParams;
const resolvedDraftApi = prefersDraftTransport
? resolveSendMessageDraftApi(params.api)
: undefined;
const usesDraftTransport = Boolean(prefersDraftTransport && resolvedDraftApi);
if (prefersDraftTransport && !usesDraftTransport) {
params.warn?.(
"telegram stream preview: sendMessageDraft unavailable; falling back to sendMessage/editMessageText",
);
}
const streamState = { stopped: false, final: false };
let messageSendAttempted = false;
let streamMessageId: number | undefined;
let streamDraftId = usesDraftTransport ? allocateTelegramDraftId() : undefined;
let previewTransport: "message" | "draft" = usesDraftTransport ? "draft" : "message";
let lastSentText = "";
let lastDeliveredText = "";
let lastSentParseMode: "HTML" | undefined;
let previewRevision = 0;
let generation = 0;
type PreviewSendParams = {
renderedText: string;
renderedParseMode: "HTML" | undefined;
sendGeneration: number;
};
const sendRenderedMessageWithThreadFallback = async (sendArgs: {
renderedText: string;
renderedParseMode: "HTML" | undefined;
fallbackWarnMessage: string;
}) => {
const sendParams = sendArgs.renderedParseMode
? {
...replyParams,
parse_mode: sendArgs.renderedParseMode,
}
: replyParams;
const usedThreadParams =
"message_thread_id" in (sendParams ?? {}) &&
typeof (sendParams as { message_thread_id?: unknown }).message_thread_id === "number";
try {
return {
sent: await params.api.sendMessage(chatId, sendArgs.renderedText, sendParams),
usedThreadParams,
};
} catch (err) {
if (!usedThreadParams || !THREAD_NOT_FOUND_RE.test(String(err))) {
throw err;
}
const threadlessParams = {
...(sendParams as Record<string, unknown>),
};
delete threadlessParams.message_thread_id;
params.warn?.(sendArgs.fallbackWarnMessage);
return {
sent: await params.api.sendMessage(
chatId,
sendArgs.renderedText,
Object.keys(threadlessParams).length > 0 ? threadlessParams : undefined,
),
usedThreadParams: false,
};
}
};
const sendMessageTransportPreview = async ({
renderedText,
renderedParseMode,
sendGeneration,
}: PreviewSendParams): Promise<boolean> => {
if (typeof streamMessageId === "number") {
if (renderedParseMode) {
await params.api.editMessageText(chatId, streamMessageId, renderedText, {
parse_mode: renderedParseMode,
});
} else {
await params.api.editMessageText(chatId, streamMessageId, renderedText);
}
return true;
}
messageSendAttempted = true;
let sent: Awaited<ReturnType<typeof sendRenderedMessageWithThreadFallback>>["sent"];
try {
({ sent } = await sendRenderedMessageWithThreadFallback({
renderedText,
renderedParseMode,
fallbackWarnMessage:
"telegram stream preview send failed with message_thread_id, retrying without thread",
}));
} catch (err) {
// Pre-connect failures (DNS, refused) and explicit Telegram rejections (4xx)
// guarantee the message was never delivered — clear the flag so
// sendMayHaveLanded() doesn't suppress fallback.
if (isSafeToRetrySendError(err) || isTelegramClientRejection(err)) {
messageSendAttempted = false;
}
throw err;
}
const sentMessageId = sent?.message_id;
if (typeof sentMessageId !== "number" || !Number.isFinite(sentMessageId)) {
streamState.stopped = true;
params.warn?.("telegram stream preview stopped (missing message id from sendMessage)");
return false;
}
const normalizedMessageId = Math.trunc(sentMessageId);
if (sendGeneration !== generation) {
params.onSupersededPreview?.({
messageId: normalizedMessageId,
textSnapshot: renderedText,
parseMode: renderedParseMode,
});
return true;
}
streamMessageId = normalizedMessageId;
return true;
};
const sendDraftTransportPreview = async ({
renderedText,
renderedParseMode,
}: PreviewSendParams): Promise<boolean> => {
const draftId = streamDraftId ?? allocateTelegramDraftId();
streamDraftId = draftId;
const draftParams = {
...(threadParams?.message_thread_id != null
? { message_thread_id: threadParams.message_thread_id }
: {}),
...(renderedParseMode ? { parse_mode: renderedParseMode } : {}),
};
await resolvedDraftApi!(
chatId,
draftId,
renderedText,
Object.keys(draftParams).length > 0 ? draftParams : undefined,
);
return true;
};
const sendOrEditStreamMessage = async (text: string): Promise<boolean> => {
// Allow final flush even if stopped (e.g., after clear()).
if (streamState.stopped && !streamState.final) {
return false;
}
const trimmed = text.trimEnd();
if (!trimmed) {
return false;
}
const rendered = params.renderText?.(trimmed) ?? { text: trimmed };
const renderedText = rendered.text.trimEnd();
const renderedParseMode = rendered.parseMode;
if (!renderedText) {
return false;
}
if (renderedText.length > maxChars) {
// Telegram text messages/edits cap at 4096 chars.
// Stop streaming once we exceed the cap to avoid repeated API failures.
streamState.stopped = true;
params.warn?.(
`telegram stream preview stopped (text length ${renderedText.length} > ${maxChars})`,
);
return false;
}
if (renderedText === lastSentText && renderedParseMode === lastSentParseMode) {
return true;
}
const sendGeneration = generation;
// Debounce first preview send for better push notification quality.
if (typeof streamMessageId !== "number" && minInitialChars != null && !streamState.final) {
if (renderedText.length < minInitialChars) {
return false;
}
}
lastSentText = renderedText;
lastSentParseMode = renderedParseMode;
try {
let sent = false;
if (previewTransport === "draft") {
try {
sent = await sendDraftTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
} catch (err) {
if (!shouldFallbackFromDraftTransport(err)) {
throw err;
}
previewTransport = "message";
streamDraftId = undefined;
params.warn?.(
"telegram stream preview: sendMessageDraft rejected by API; falling back to sendMessage/editMessageText",
);
sent = await sendMessageTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
}
} else {
sent = await sendMessageTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
}
if (sent) {
previewRevision += 1;
lastDeliveredText = trimmed;
}
return sent;
} catch (err) {
streamState.stopped = true;
params.warn?.(
`telegram stream preview failed: ${err instanceof Error ? err.message : String(err)}`,
);
return false;
}
};
const { loop, update, stop, clear } = createFinalizableDraftLifecycle({
throttleMs,
state: streamState,
sendOrEditStreamMessage,
readMessageId: () => streamMessageId,
clearMessageId: () => {
streamMessageId = undefined;
},
isValidMessageId: (value): value is number =>
typeof value === "number" && Number.isFinite(value),
deleteMessage: async (messageId) => {
await params.api.deleteMessage(chatId, messageId);
},
onDeleteSuccess: (messageId) => {
params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`);
},
warn: params.warn,
warnPrefix: "telegram stream preview cleanup failed",
});
const forceNewMessage = () => {
// Boundary rotation may call stop() to finalize the previous draft.
// Re-open the stream lifecycle for the next assistant segment.
streamState.final = false;
generation += 1;
messageSendAttempted = false;
streamMessageId = undefined;
if (previewTransport === "draft") {
streamDraftId = allocateTelegramDraftId();
}
lastSentText = "";
lastSentParseMode = undefined;
loop.resetPending();
loop.resetThrottleWindow();
};
/**
* Materialize the current draft into a permanent message.
* For draft transport: sends the accumulated text as a real sendMessage.
* For message transport: the message is already permanent (noop).
* Returns the permanent message id, or undefined if nothing to materialize.
*/
const materialize = async (): Promise<number | undefined> => {
await stop();
// If using message transport, the streamMessageId is already a real message.
if (previewTransport === "message" && typeof streamMessageId === "number") {
return streamMessageId;
}
// For draft transport, use the rendered snapshot first so parse_mode stays
// aligned with the text being materialized.
const renderedText = lastSentText || lastDeliveredText;
if (!renderedText) {
return undefined;
}
const renderedParseMode = lastSentText ? lastSentParseMode : undefined;
try {
const { sent, usedThreadParams } = await sendRenderedMessageWithThreadFallback({
renderedText,
renderedParseMode,
fallbackWarnMessage:
"telegram stream preview materialize send failed with message_thread_id, retrying without thread",
});
const sentId = sent?.message_id;
if (typeof sentId === "number" && Number.isFinite(sentId)) {
streamMessageId = Math.trunc(sentId);
// Clear the draft so Telegram's input area doesn't briefly show a
// stale copy alongside the newly materialized real message.
if (resolvedDraftApi != null && streamDraftId != null) {
const clearDraftId = streamDraftId;
const clearThreadParams =
usedThreadParams && threadParams?.message_thread_id != null
? { message_thread_id: threadParams.message_thread_id }
: undefined;
try {
await resolvedDraftApi(chatId, clearDraftId, "", clearThreadParams);
} catch {
// Best-effort cleanup; draft clear failure is cosmetic.
}
}
return streamMessageId;
}
} catch (err) {
params.warn?.(
`telegram stream preview materialize failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
return undefined;
};
params.log?.(`telegram stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
return {
update,
flush: loop.flush,
messageId: () => streamMessageId,
previewMode: () => previewTransport,
previewRevision: () => previewRevision,
lastDeliveredText: () => lastDeliveredText,
clear,
stop,
materialize,
forceNewMessage,
sendMayHaveLanded: () => messageSendAttempted && typeof streamMessageId !== "number",
};
}
export const __testing = {
resetTelegramDraftStreamForTests() {
draftStreamState.nextDraftId = 0;
},
};
export * from "../../extensions/telegram/src/draft-stream.js";

View File

@@ -1,156 +1,2 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
const baseRequest = {
id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
command: "npm view diver name version description",
agentId: "main",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
turnSourceChannel: "telegram",
turnSourceTo: "-1003841603622",
turnSourceThreadId: "928",
turnSourceAccountId: "default",
},
createdAtMs: 1000,
expiresAtMs: 61_000,
};
function createHandler(cfg: OpenClawConfig) {
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ messageId: "m1", chatId: "-1003841603622" })
.mockResolvedValue({ messageId: "m2", chatId: "8460800771" });
const editReplyMarkup = vi.fn().mockResolvedValue({ ok: true });
const handler = new TelegramExecApprovalHandler(
{
token: "tg-token",
accountId: "default",
cfg,
},
{
nowMs: () => 1000,
sendTyping,
sendMessage,
editReplyMarkup,
},
);
return { handler, sendTyping, sendMessage, editReplyMarkup };
}
describe("TelegramExecApprovalHandler", () => {
it("sends approval prompts to the originating telegram topic when target=channel", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendTyping, sendMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
expect(sendTyping).toHaveBeenCalledWith(
"-1003841603622",
expect.objectContaining({
accountId: "default",
messageThreadId: 928,
}),
);
expect(sendMessage).toHaveBeenCalledWith(
"-1003841603622",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
messageThreadId: 928,
buttons: [
[
{
text: "Allow Once",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
},
{
text: "Allow Always",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-always",
},
],
[
{
text: "Deny",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
},
],
],
}),
);
});
it("falls back to approver DMs when channel routing is unavailable", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["111", "222"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "slack",
turnSourceTo: "U1",
turnSourceAccountId: null,
turnSourceThreadId: null,
},
});
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage.mock.calls.map((call) => call[0])).toEqual(["111", "222"]);
});
it("clears buttons from tracked approval messages when resolved", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "both",
},
},
},
} as OpenClawConfig;
const { handler, editReplyMarkup } = createHandler(cfg);
await handler.handleRequested(baseRequest);
await handler.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "telegram:8460800771",
ts: 2000,
});
expect(editReplyMarkup).toHaveBeenCalled();
expect(editReplyMarkup).toHaveBeenCalledWith(
"-1003841603622",
"m1",
[],
expect.objectContaining({
accountId: "default",
}),
);
});
});
// Shim: re-exports from extensions/telegram/src/exec-approvals-handler.test.ts
export * from "../../extensions/telegram/src/exec-approvals-handler.test.js";

View File

@@ -1,369 +1,2 @@
import type { OpenClawConfig } from "../config/config.js";
import { GatewayClient } from "../gateway/client.js";
import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import { resolveExecApprovalCommandDisplay } from "../infra/exec-approval-command-display.js";
import {
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
} from "../infra/exec-approval-reply.js";
import { resolveExecApprovalSessionTarget } from "../infra/exec-approval-session-target.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
import {
getTelegramExecApprovalApprovers,
resolveTelegramExecApprovalConfig,
resolveTelegramExecApprovalTarget,
} from "./exec-approvals.js";
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
const log = createSubsystemLogger("telegram/exec-approvals");
type PendingMessage = {
chatId: string;
messageId: string;
};
type PendingApproval = {
timeoutId: NodeJS.Timeout;
messages: PendingMessage[];
};
type TelegramApprovalTarget = {
to: string;
threadId?: number;
};
export type TelegramExecApprovalHandlerOpts = {
token: string;
accountId: string;
cfg: OpenClawConfig;
gatewayUrl?: string;
runtime?: RuntimeEnv;
};
export type TelegramExecApprovalHandlerDeps = {
nowMs?: () => number;
sendTyping?: typeof sendTypingTelegram;
sendMessage?: typeof sendMessageTelegram;
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
};
function matchesFilters(params: {
cfg: OpenClawConfig;
accountId: string;
request: ExecApprovalRequest;
}): boolean {
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
accountId: params.accountId,
});
if (!config?.enabled) {
return false;
}
const approvers = getTelegramExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
});
if (approvers.length === 0) {
return false;
}
if (config.agentFilter?.length) {
const agentId =
params.request.request.agentId ??
parseAgentSessionKey(params.request.request.sessionKey)?.agentId;
if (!agentId || !config.agentFilter.includes(agentId)) {
return false;
}
}
if (config.sessionFilter?.length) {
const sessionKey = params.request.request.sessionKey;
if (!sessionKey) {
return false;
}
const matches = config.sessionFilter.some((pattern) => {
if (sessionKey.includes(pattern)) {
return true;
}
const regex = compileSafeRegex(pattern);
return regex ? testRegexWithBoundedInput(regex, sessionKey) : false;
});
if (!matches) {
return false;
}
}
return true;
}
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
accountId: params.accountId,
});
if (!config?.enabled) {
return false;
}
return (
getTelegramExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
}).length > 0
);
}
function resolveRequestSessionTarget(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}): { to: string; accountId?: string; threadId?: number; channel?: string } | null {
return resolveExecApprovalSessionTarget({
cfg: params.cfg,
request: params.request,
turnSourceChannel: params.request.request.turnSourceChannel ?? undefined,
turnSourceTo: params.request.request.turnSourceTo ?? undefined,
turnSourceAccountId: params.request.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined,
});
}
function resolveTelegramSourceTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ExecApprovalRequest;
}): TelegramApprovalTarget | null {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = params.request.request.turnSourceTo?.trim() || "";
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
if (turnSourceChannel === "telegram" && turnSourceTo) {
if (
turnSourceAccountId &&
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
const threadId =
typeof params.request.request.turnSourceThreadId === "number"
? params.request.request.turnSourceThreadId
: typeof params.request.request.turnSourceThreadId === "string"
? Number.parseInt(params.request.request.turnSourceThreadId, 10)
: undefined;
return { to: turnSourceTo, threadId: Number.isFinite(threadId) ? threadId : undefined };
}
const sessionTarget = resolveRequestSessionTarget(params);
if (!sessionTarget || sessionTarget.channel !== "telegram") {
return null;
}
if (
sessionTarget.accountId &&
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
return {
to: sessionTarget.to,
threadId: sessionTarget.threadId,
};
}
function dedupeTargets(targets: TelegramApprovalTarget[]): TelegramApprovalTarget[] {
const seen = new Set<string>();
const deduped: TelegramApprovalTarget[] = [];
for (const target of targets) {
const key = `${target.to}:${target.threadId ?? ""}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(target);
}
return deduped;
}
export class TelegramExecApprovalHandler {
private gatewayClient: GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private started = false;
private readonly nowMs: () => number;
private readonly sendTyping: typeof sendTypingTelegram;
private readonly sendMessage: typeof sendMessageTelegram;
private readonly editReplyMarkup: typeof editMessageReplyMarkupTelegram;
constructor(
private readonly opts: TelegramExecApprovalHandlerOpts,
deps: TelegramExecApprovalHandlerDeps = {},
) {
this.nowMs = deps.nowMs ?? Date.now;
this.sendTyping = deps.sendTyping ?? sendTypingTelegram;
this.sendMessage = deps.sendMessage ?? sendMessageTelegram;
this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram;
}
shouldHandle(request: ExecApprovalRequest): boolean {
return matchesFilters({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
});
}
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
if (!isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId })) {
return;
}
this.gatewayClient = await createOperatorApprovalsGatewayClient({
config: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
onEvent: (evt) => this.handleGatewayEvent(evt),
onConnectError: (err) => {
log.error(`telegram exec approvals: connect error: ${err.message}`);
},
});
this.gatewayClient.start();
}
async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
}
this.pending.clear();
this.gatewayClient?.stop();
this.gatewayClient = null;
}
async handleRequested(request: ExecApprovalRequest): Promise<void> {
if (!this.shouldHandle(request)) {
return;
}
const targetMode = resolveTelegramExecApprovalTarget({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
const targets: TelegramApprovalTarget[] = [];
const sourceTarget = resolveTelegramSourceTarget({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
});
let fallbackToDm = false;
if (targetMode === "channel" || targetMode === "both") {
if (sourceTarget) {
targets.push(sourceTarget);
} else {
fallbackToDm = true;
}
}
if (targetMode === "dm" || targetMode === "both" || fallbackToDm) {
for (const approver of getTelegramExecApprovalApprovers({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})) {
targets.push({ to: approver });
}
}
const resolvedTargets = dedupeTargets(targets);
if (resolvedTargets.length === 0) {
return;
}
const payloadParams: ExecApprovalPendingReplyParams = {
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: resolveExecApprovalCommandDisplay(request.request).commandText,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs: this.nowMs(),
};
const payload = buildExecApprovalPendingReplyPayload(payloadParams);
const buttons = buildTelegramExecApprovalButtons(request.id);
const sentMessages: PendingMessage[] = [];
for (const target of resolvedTargets) {
try {
await this.sendTyping(target.to, {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
}).catch(() => {});
const result = await this.sendMessage(target.to, payload.text ?? "", {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
buttons,
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
});
sentMessages.push({
chatId: result.chatId,
messageId: result.messageId,
});
} catch (err) {
log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`);
}
}
if (sentMessages.length === 0) {
return;
}
const timeoutMs = Math.max(0, request.expiresAtMs - this.nowMs());
const timeoutId = setTimeout(() => {
void this.handleResolved({ id: request.id, decision: "deny", ts: Date.now() });
}, timeoutMs);
timeoutId.unref?.();
this.pending.set(request.id, {
timeoutId,
messages: sentMessages,
});
}
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
const pending = this.pending.get(resolved.id);
if (!pending) {
return;
}
clearTimeout(pending.timeoutId);
this.pending.delete(resolved.id);
await Promise.allSettled(
pending.messages.map(async (message) => {
await this.editReplyMarkup(message.chatId, message.messageId, [], {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
});
}),
);
}
private handleGatewayEvent(evt: EventFrame): void {
if (evt.event === "exec.approval.requested") {
void this.handleRequested(evt.payload as ExecApprovalRequest);
return;
}
if (evt.event === "exec.approval.resolved") {
void this.handleResolved(evt.payload as ExecApprovalResolved);
}
}
}
// Shim: re-exports from extensions/telegram/src/exec-approvals-handler.ts
export * from "../../extensions/telegram/src/exec-approvals-handler.js";

View File

@@ -1,92 +1,2 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalClientEnabled,
resolveTelegramExecApprovalTarget,
shouldEnableTelegramExecApprovalButtons,
shouldInjectTelegramExecApprovalButtons,
} from "./exec-approvals.js";
function buildConfig(
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>["execApprovals"],
): OpenClawConfig {
return {
channels: {
telegram: {
botToken: "tok",
execApprovals,
},
},
} as OpenClawConfig;
}
describe("telegram exec approvals", () => {
it("requires enablement and at least one approver", () => {
expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }),
}),
).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["123"] }),
}),
).toBe(true);
});
it("matches approvers by normalized sender id", () => {
const cfg = buildConfig({ enabled: true, approvers: [123, "456"] });
expect(isTelegramExecApprovalApprover({ cfg, senderId: "123" })).toBe(true);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "456" })).toBe(true);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false);
});
it("defaults target to dm", () => {
expect(
resolveTelegramExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["1"] }) }),
).toBe("dm");
});
it("only injects approval buttons on eligible telegram targets", () => {
const dmCfg = buildConfig({ enabled: true, approvers: ["123"], target: "dm" });
const channelCfg = buildConfig({ enabled: true, approvers: ["123"], target: "channel" });
const bothCfg = buildConfig({ enabled: true, approvers: ["123"], target: "both" });
expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "123" })).toBe(true);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "-100123" })).toBe(false);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "-100123" })).toBe(true);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "123" })).toBe(false);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "123" })).toBe(true);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "-100123" })).toBe(true);
});
it("does not require generic inlineButtons capability to enable exec approval buttons", () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
capabilities: ["vision"],
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(true);
});
it("still respects explicit inlineButtons off for exec approval buttons", () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
capabilities: { inlineButtons: "off" },
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(false);
});
});
// Shim: re-exports from extensions/telegram/src/exec-approvals.test.ts
export * from "../../extensions/telegram/src/exec-approvals.test.js";

View File

@@ -1,106 +1,2 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramExecApprovalConfig } from "../config/types.telegram.js";
import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramTargetChatType } from "./targets.js";
function normalizeApproverId(value: string | number): string {
return String(value).trim();
}
export function resolveTelegramExecApprovalConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): TelegramExecApprovalConfig | undefined {
return resolveTelegramAccount(params).config.execApprovals;
}
export function getTelegramExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
return (resolveTelegramExecApprovalConfig(params)?.approvers ?? [])
.map(normalizeApproverId)
.filter(Boolean);
}
export function isTelegramExecApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const config = resolveTelegramExecApprovalConfig(params);
return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0);
}
export function isTelegramExecApprovalApprover(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const senderId = params.senderId?.trim();
if (!senderId) {
return false;
}
const approvers = getTelegramExecApprovalApprovers(params);
return approvers.includes(senderId);
}
export function resolveTelegramExecApprovalTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): "dm" | "channel" | "both" {
return resolveTelegramExecApprovalConfig(params)?.target ?? "dm";
}
export function shouldInjectTelegramExecApprovalButtons(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}): boolean {
if (!isTelegramExecApprovalClientEnabled(params)) {
return false;
}
const target = resolveTelegramExecApprovalTarget(params);
const chatType = resolveTelegramTargetChatType(params.to);
if (chatType === "direct") {
return target === "dm" || target === "both";
}
if (chatType === "group") {
return target === "channel" || target === "both";
}
return target === "both";
}
function resolveExecApprovalButtonsExplicitlyDisabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const capabilities = resolveTelegramAccount(params).config.capabilities;
if (!capabilities || Array.isArray(capabilities) || typeof capabilities !== "object") {
return false;
}
const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons;
return typeof inlineButtons === "string" && inlineButtons.trim().toLowerCase() === "off";
}
export function shouldEnableTelegramExecApprovalButtons(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}): boolean {
if (!shouldInjectTelegramExecApprovalButtons(params)) {
return false;
}
return !resolveExecApprovalButtonsExplicitlyDisabled(params);
}
export function shouldSuppressLocalTelegramExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
void params.cfg;
void params.accountId;
return getExecApprovalReplyMetadata(params.payload) !== null;
}
// Shim: re-exports from extensions/telegram/src/exec-approvals.ts
export * from "../../extensions/telegram/src/exec-approvals.js";

View File

@@ -1,58 +1,2 @@
import { createRequire } from "node:module";
import { afterEach, describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const EnvHttpProxyAgent = require("undici/lib/dispatcher/env-http-proxy-agent.js") as {
new (opts?: Record<string, unknown>): Record<PropertyKey, unknown>;
};
const { kHttpsProxyAgent, kNoProxyAgent } = require("undici/lib/core/symbols.js") as {
kHttpsProxyAgent: symbol;
kNoProxyAgent: symbol;
};
function getOwnSymbolValue(
target: Record<PropertyKey, unknown>,
description: string,
): Record<string, unknown> | undefined {
const symbol = Object.getOwnPropertySymbols(target).find(
(entry) => entry.description === description,
);
const value = symbol ? target[symbol] : undefined;
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
afterEach(() => {
vi.unstubAllEnvs();
});
describe("undici env proxy semantics", () => {
it("uses proxyTls rather than connect for proxied HTTPS transport settings", () => {
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
const connect = {
family: 4,
autoSelectFamily: false,
};
const withoutProxyTls = new EnvHttpProxyAgent({ connect });
const noProxyAgent = withoutProxyTls[kNoProxyAgent] as Record<PropertyKey, unknown>;
const httpsProxyAgent = withoutProxyTls[kHttpsProxyAgent] as Record<PropertyKey, unknown>;
expect(getOwnSymbolValue(noProxyAgent, "options")?.connect).toEqual(
expect.objectContaining(connect),
);
expect(getOwnSymbolValue(httpsProxyAgent, "proxy tls settings")).toBeUndefined();
const withProxyTls = new EnvHttpProxyAgent({
connect,
proxyTls: connect,
});
const httpsProxyAgentWithProxyTls = withProxyTls[kHttpsProxyAgent] as Record<
PropertyKey,
unknown
>;
expect(getOwnSymbolValue(httpsProxyAgentWithProxyTls, "proxy tls settings")).toEqual(
expect.objectContaining(connect),
);
});
});
// Shim: re-exports from extensions/telegram/src/fetch.env-proxy-runtime.test.ts
export * from "../../extensions/telegram/src/fetch.env-proxy-runtime.test.js";

View File

@@ -1,639 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveFetch } from "../infra/fetch.js";
import { resolveTelegramFetch, resolveTelegramTransport } from "./fetch.js";
const setDefaultResultOrder = vi.hoisted(() => vi.fn());
const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn());
const undiciFetch = vi.hoisted(() => vi.fn());
const setGlobalDispatcher = vi.hoisted(() => vi.fn());
const AgentCtor = vi.hoisted(() =>
vi.fn(function MockAgent(
this: { options?: Record<string, unknown> },
options?: Record<string, unknown>,
) {
this.options = options;
}),
);
const EnvHttpProxyAgentCtor = vi.hoisted(() =>
vi.fn(function MockEnvHttpProxyAgent(
this: { options?: Record<string, unknown> },
options?: Record<string, unknown>,
) {
this.options = options;
}),
);
const ProxyAgentCtor = vi.hoisted(() =>
vi.fn(function MockProxyAgent(
this: { options?: Record<string, unknown> | string },
options?: Record<string, unknown> | string,
) {
this.options = options;
}),
);
vi.mock("node:dns", async () => {
const actual = await vi.importActual<typeof import("node:dns")>("node:dns");
return {
...actual,
setDefaultResultOrder,
};
});
vi.mock("node:net", async () => {
const actual = await vi.importActual<typeof import("node:net")>("node:net");
return {
...actual,
setDefaultAutoSelectFamily,
};
});
vi.mock("undici", () => ({
Agent: AgentCtor,
EnvHttpProxyAgent: EnvHttpProxyAgentCtor,
ProxyAgent: ProxyAgentCtor,
fetch: undiciFetch,
setGlobalDispatcher,
}));
function resolveTelegramFetchOrThrow(
proxyFetch?: typeof fetch,
options?: { network?: { autoSelectFamily?: boolean; dnsResultOrder?: "ipv4first" | "verbatim" } },
) {
return resolveTelegramFetch(proxyFetch, options);
}
function getDispatcherFromUndiciCall(nth: number) {
const call = undiciFetch.mock.calls[nth - 1] as [RequestInfo | URL, RequestInit?] | undefined;
if (!call) {
throw new Error(`missing undici fetch call #${nth}`);
}
const init = call[1] as (RequestInit & { dispatcher?: unknown }) | undefined;
return init?.dispatcher as
| {
options?: {
connect?: Record<string, unknown>;
proxyTls?: Record<string, unknown>;
};
}
| undefined;
}
function buildFetchFallbackError(code: string) {
const connectErr = Object.assign(new Error(`connect ${code} api.telegram.org:443`), {
code,
});
return Object.assign(new TypeError("fetch failed"), {
cause: connectErr,
});
}
const STICKY_IPV4_FALLBACK_NETWORK = {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first" as const,
},
};
async function runDefaultStickyIpv4FallbackProbe(code = "EHOSTUNREACH"): Promise<void> {
undiciFetch
.mockRejectedValueOnce(buildFetchFallbackError(code))
.mockResolvedValueOnce({ ok: true } as Response)
.mockResolvedValueOnce({ ok: true } as Response);
const resolved = resolveTelegramFetchOrThrow(undefined, STICKY_IPV4_FALLBACK_NETWORK);
await resolved("https://api.telegram.org/botx/sendMessage");
await resolved("https://api.telegram.org/botx/sendChatAction");
}
function primeStickyFallbackRetry(code = "EHOSTUNREACH", successCount = 2): void {
undiciFetch.mockRejectedValueOnce(buildFetchFallbackError(code));
for (let i = 0; i < successCount; i += 1) {
undiciFetch.mockResolvedValueOnce({ ok: true } as Response);
}
}
function expectStickyAutoSelectDispatcher(
dispatcher:
| {
options?: {
connect?: Record<string, unknown>;
proxyTls?: Record<string, unknown>;
};
}
| undefined,
field: "connect" | "proxyTls" = "connect",
): void {
expect(dispatcher?.options?.[field]).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
}),
);
}
function expectPinnedIpv4ConnectDispatcher(args: {
pinnedCall: number;
firstCall?: number;
followupCall?: number;
}): void {
const pinnedDispatcher = getDispatcherFromUndiciCall(args.pinnedCall);
expect(pinnedDispatcher?.options?.connect).toEqual(
expect.objectContaining({
family: 4,
autoSelectFamily: false,
}),
);
if (args.firstCall) {
expect(getDispatcherFromUndiciCall(args.firstCall)).not.toBe(pinnedDispatcher);
}
if (args.followupCall) {
expect(getDispatcherFromUndiciCall(args.followupCall)).toBe(pinnedDispatcher);
}
}
function expectCallerDispatcherPreserved(callIndexes: number[], dispatcher: unknown) {
for (const callIndex of callIndexes) {
const callInit = undiciFetch.mock.calls[callIndex - 1]?.[1] as
| (RequestInit & { dispatcher?: unknown })
| undefined;
expect(callInit?.dispatcher).toBe(dispatcher);
}
}
async function expectNoStickyRetryWithSameDispatcher(params: {
resolved: ReturnType<typeof resolveTelegramFetchOrThrow>;
expectedAgentCtor: typeof ProxyAgentCtor | typeof EnvHttpProxyAgentCtor;
field: "connect" | "proxyTls";
}) {
await expect(params.resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow(
"fetch failed",
);
await params.resolved("https://api.telegram.org/botx/sendChatAction");
expect(undiciFetch).toHaveBeenCalledTimes(2);
expect(params.expectedAgentCtor).toHaveBeenCalledTimes(1);
const firstDispatcher = getDispatcherFromUndiciCall(1);
const secondDispatcher = getDispatcherFromUndiciCall(2);
expect(firstDispatcher).toBe(secondDispatcher);
expectStickyAutoSelectDispatcher(firstDispatcher, params.field);
expect(firstDispatcher?.options?.[params.field]?.family).not.toBe(4);
}
afterEach(() => {
undiciFetch.mockReset();
setGlobalDispatcher.mockReset();
AgentCtor.mockClear();
EnvHttpProxyAgentCtor.mockClear();
ProxyAgentCtor.mockClear();
setDefaultResultOrder.mockReset();
setDefaultAutoSelectFamily.mockReset();
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("resolveTelegramFetch", () => {
it("wraps proxy fetches and leaves retry policy to caller-provided fetch", async () => {
const proxyFetch = vi.fn(async () => ({ ok: true }) as Response) as unknown as typeof fetch;
const resolved = resolveTelegramFetchOrThrow(proxyFetch);
await resolved("https://api.telegram.org/botx/getMe");
expect(proxyFetch).toHaveBeenCalledTimes(1);
expect(undiciFetch).not.toHaveBeenCalled();
});
it("does not double-wrap an already wrapped proxy fetch", async () => {
const proxyFetch = vi.fn(async () => ({ ok: true }) as Response) as unknown as typeof fetch;
const wrapped = resolveFetch(proxyFetch);
const resolved = resolveTelegramFetch(wrapped);
expect(resolved).toBe(wrapped);
});
it("uses resolver-scoped Agent dispatcher with configured transport policy", async () => {
undiciFetch.mockResolvedValue({ ok: true } as Response);
const resolved = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "verbatim",
},
});
await resolved("https://api.telegram.org/botx/getMe");
expect(AgentCtor).toHaveBeenCalledTimes(1);
expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled();
const dispatcher = getDispatcherFromUndiciCall(1);
expect(dispatcher).toBeDefined();
expect(dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
}),
);
expect(typeof dispatcher?.options?.connect?.lookup).toBe("function");
});
it("uses EnvHttpProxyAgent dispatcher when proxy env is configured", async () => {
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
undiciFetch.mockResolvedValue({ ok: true } as Response);
const resolved = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
});
await resolved("https://api.telegram.org/botx/getMe");
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1);
expect(AgentCtor).not.toHaveBeenCalled();
const dispatcher = getDispatcherFromUndiciCall(1);
expect(dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: false,
autoSelectFamilyAttemptTimeout: 300,
}),
);
expect(dispatcher?.options?.proxyTls).toEqual(
expect.objectContaining({
autoSelectFamily: false,
autoSelectFamilyAttemptTimeout: 300,
}),
);
});
it("pins env-proxy transport policy onto proxyTls for proxied HTTPS requests", async () => {
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
undiciFetch.mockResolvedValue({ ok: true } as Response);
const resolved = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
await resolved("https://api.telegram.org/botx/getMe");
const dispatcher = getDispatcherFromUndiciCall(1);
expect(dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
}),
);
expect(dispatcher?.options?.proxyTls).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
}),
);
});
it("keeps resolver-scoped transport policy for OpenClaw proxy fetches", async () => {
const { makeProxyFetch } = await import("./proxy.js");
const proxyFetch = makeProxyFetch("http://127.0.0.1:7890");
ProxyAgentCtor.mockClear();
undiciFetch.mockResolvedValue({ ok: true } as Response);
const resolved = resolveTelegramFetchOrThrow(proxyFetch, {
network: {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
});
await resolved("https://api.telegram.org/botx/getMe");
expect(ProxyAgentCtor).toHaveBeenCalledTimes(1);
expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled();
expect(AgentCtor).not.toHaveBeenCalled();
const dispatcher = getDispatcherFromUndiciCall(1);
expect(dispatcher?.options).toEqual(
expect.objectContaining({
uri: "http://127.0.0.1:7890",
}),
);
expect(dispatcher?.options?.proxyTls).toEqual(
expect.objectContaining({
autoSelectFamily: false,
}),
);
});
it("does not blind-retry when sticky IPv4 fallback is disallowed for explicit proxy paths", async () => {
const { makeProxyFetch } = await import("./proxy.js");
const proxyFetch = makeProxyFetch("http://127.0.0.1:7890");
ProxyAgentCtor.mockClear();
primeStickyFallbackRetry("EHOSTUNREACH", 1);
const resolved = resolveTelegramFetchOrThrow(proxyFetch, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
await expectNoStickyRetryWithSameDispatcher({
resolved,
expectedAgentCtor: ProxyAgentCtor,
field: "proxyTls",
});
});
it("does not blind-retry when sticky IPv4 fallback is disallowed for env proxy paths", async () => {
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
primeStickyFallbackRetry("EHOSTUNREACH", 1);
const resolved = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
await expectNoStickyRetryWithSameDispatcher({
resolved,
expectedAgentCtor: EnvHttpProxyAgentCtor,
field: "connect",
});
});
it("treats ALL_PROXY-only env as direct transport and arms sticky IPv4 fallback", async () => {
vi.stubEnv("ALL_PROXY", "socks5://127.0.0.1:1080");
undiciFetch
.mockRejectedValueOnce(buildFetchFallbackError("EHOSTUNREACH"))
.mockResolvedValueOnce({ ok: true } as Response)
.mockResolvedValueOnce({ ok: true } as Response);
const transport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
const resolved = transport.fetch;
await resolved("https://api.telegram.org/botx/sendMessage");
await resolved("https://api.telegram.org/botx/sendChatAction");
expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled();
expect(AgentCtor).toHaveBeenCalledTimes(2);
expectPinnedIpv4ConnectDispatcher({
firstCall: 1,
pinnedCall: 2,
followupCall: 3,
});
expect(transport.pinnedDispatcherPolicy).toEqual(
expect.objectContaining({
mode: "direct",
}),
);
});
it("arms sticky IPv4 fallback when env proxy init falls back to direct Agent", async () => {
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
EnvHttpProxyAgentCtor.mockImplementationOnce(function ThrowingEnvProxyAgent() {
throw new Error("invalid proxy config");
});
await runDefaultStickyIpv4FallbackProbe();
expect(undiciFetch).toHaveBeenCalledTimes(3);
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1);
expect(AgentCtor).toHaveBeenCalledTimes(2);
expectPinnedIpv4ConnectDispatcher({
firstCall: 1,
pinnedCall: 2,
followupCall: 3,
});
});
it("arms sticky IPv4 fallback when NO_PROXY bypasses telegram under env proxy", async () => {
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
vi.stubEnv("NO_PROXY", "api.telegram.org");
await runDefaultStickyIpv4FallbackProbe();
expect(undiciFetch).toHaveBeenCalledTimes(3);
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(2);
expect(AgentCtor).not.toHaveBeenCalled();
expectPinnedIpv4ConnectDispatcher({
firstCall: 1,
pinnedCall: 2,
followupCall: 3,
});
});
it("uses no_proxy over NO_PROXY when deciding env-proxy bypass", async () => {
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
vi.stubEnv("NO_PROXY", "");
vi.stubEnv("no_proxy", "api.telegram.org");
await runDefaultStickyIpv4FallbackProbe();
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(2);
expectPinnedIpv4ConnectDispatcher({ pinnedCall: 2 });
});
it("matches whitespace and wildcard no_proxy entries like EnvHttpProxyAgent", async () => {
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
vi.stubEnv("no_proxy", "localhost *.telegram.org");
await runDefaultStickyIpv4FallbackProbe();
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(2);
expectPinnedIpv4ConnectDispatcher({ pinnedCall: 2 });
});
it("fails closed when explicit proxy dispatcher initialization fails", async () => {
const { makeProxyFetch } = await import("./proxy.js");
const proxyFetch = makeProxyFetch("http://127.0.0.1:7890");
ProxyAgentCtor.mockClear();
ProxyAgentCtor.mockImplementationOnce(function ThrowingProxyAgent() {
throw new Error("invalid proxy config");
});
expect(() =>
resolveTelegramFetchOrThrow(proxyFetch, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
}),
).toThrow("explicit proxy dispatcher init failed: invalid proxy config");
});
it("falls back to Agent when env proxy dispatcher initialization fails", async () => {
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
EnvHttpProxyAgentCtor.mockImplementationOnce(function ThrowingEnvProxyAgent() {
throw new Error("invalid proxy config");
});
undiciFetch.mockResolvedValue({ ok: true } as Response);
const resolved = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: false,
},
});
await resolved("https://api.telegram.org/botx/getMe");
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1);
expect(AgentCtor).toHaveBeenCalledTimes(1);
const dispatcher = getDispatcherFromUndiciCall(1);
expect(dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: false,
}),
);
});
it("retries once and then keeps sticky IPv4 dispatcher for subsequent requests", async () => {
primeStickyFallbackRetry("ETIMEDOUT");
const resolved = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: true,
},
});
await resolved("https://api.telegram.org/botx/sendMessage");
await resolved("https://api.telegram.org/botx/sendChatAction");
expect(undiciFetch).toHaveBeenCalledTimes(3);
const firstDispatcher = getDispatcherFromUndiciCall(1);
const secondDispatcher = getDispatcherFromUndiciCall(2);
const thirdDispatcher = getDispatcherFromUndiciCall(3);
expect(firstDispatcher).toBeDefined();
expect(secondDispatcher).toBeDefined();
expect(thirdDispatcher).toBeDefined();
expect(firstDispatcher).not.toBe(secondDispatcher);
expect(secondDispatcher).toBe(thirdDispatcher);
expectStickyAutoSelectDispatcher(firstDispatcher);
expect(secondDispatcher?.options?.connect).toEqual(
expect.objectContaining({
family: 4,
autoSelectFamily: false,
}),
);
});
it("preserves caller-provided dispatcher across fallback retry", async () => {
const fetchError = buildFetchFallbackError("EHOSTUNREACH");
undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response);
const resolved = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: true,
},
});
const callerDispatcher = { name: "caller" };
await resolved("https://api.telegram.org/botx/sendMessage", {
dispatcher: callerDispatcher,
} as RequestInit);
expect(undiciFetch).toHaveBeenCalledTimes(2);
expectCallerDispatcherPreserved([1, 2], callerDispatcher);
});
it("does not arm sticky fallback from caller-provided dispatcher failures", async () => {
primeStickyFallbackRetry();
const resolved = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: true,
},
});
const callerDispatcher = { name: "caller" };
await resolved("https://api.telegram.org/botx/sendMessage", {
dispatcher: callerDispatcher,
} as RequestInit);
await resolved("https://api.telegram.org/botx/sendChatAction");
expect(undiciFetch).toHaveBeenCalledTimes(3);
expectCallerDispatcherPreserved([1, 2], callerDispatcher);
const thirdDispatcher = getDispatcherFromUndiciCall(3);
expectStickyAutoSelectDispatcher(thirdDispatcher);
expect(thirdDispatcher?.options?.connect?.family).not.toBe(4);
});
it("does not retry when error codes do not match fallback rules", async () => {
const fetchError = buildFetchFallbackError("ECONNRESET");
undiciFetch.mockRejectedValue(fetchError);
const resolved = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: true,
},
});
await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow(
"fetch failed",
);
expect(undiciFetch).toHaveBeenCalledTimes(1);
});
it("keeps per-resolver transport policy isolated across multiple accounts", async () => {
undiciFetch.mockResolvedValue({ ok: true } as Response);
const resolverA = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
});
const resolverB = resolveTelegramFetchOrThrow(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "verbatim",
},
});
await resolverA("https://api.telegram.org/botA/getMe");
await resolverB("https://api.telegram.org/botB/getMe");
const dispatcherA = getDispatcherFromUndiciCall(1);
const dispatcherB = getDispatcherFromUndiciCall(2);
expect(dispatcherA).toBeDefined();
expect(dispatcherB).toBeDefined();
expect(dispatcherA).not.toBe(dispatcherB);
expect(dispatcherA?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: false,
}),
);
expect(dispatcherB?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
}),
);
// Core guarantee: Telegram transport no longer mutates process-global defaults.
expect(setGlobalDispatcher).not.toHaveBeenCalled();
expect(setDefaultResultOrder).not.toHaveBeenCalled();
expect(setDefaultAutoSelectFamily).not.toHaveBeenCalled();
});
});

View File

@@ -1,514 +1 @@
import * as dns from "node:dns";
import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici";
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
import { resolveFetch } from "../infra/fetch.js";
import { hasEnvHttpProxyConfigured } from "../infra/net/proxy-env.js";
import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
resolveTelegramAutoSelectFamilyDecision,
resolveTelegramDnsResultOrderDecision,
} from "./network-config.js";
import { getProxyUrlFromFetch } from "./proxy.js";
const log = createSubsystemLogger("telegram/network");
const TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300;
const TELEGRAM_API_HOSTNAME = "api.telegram.org";
type RequestInitWithDispatcher = RequestInit & {
dispatcher?: unknown;
};
type TelegramDispatcher = Agent | EnvHttpProxyAgent | ProxyAgent;
type TelegramDispatcherMode = "direct" | "env-proxy" | "explicit-proxy";
type TelegramDnsResultOrder = "ipv4first" | "verbatim";
type LookupCallback =
| ((err: NodeJS.ErrnoException | null, address: string, family: number) => void)
| ((err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void);
type LookupOptions = (dns.LookupOneOptions | dns.LookupAllOptions) & {
order?: TelegramDnsResultOrder;
verbatim?: boolean;
};
type LookupFunction = (
hostname: string,
options: number | dns.LookupOneOptions | dns.LookupAllOptions | undefined,
callback: LookupCallback,
) => void;
const FALLBACK_RETRY_ERROR_CODES = [
"ETIMEDOUT",
"ENETUNREACH",
"EHOSTUNREACH",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_SOCKET",
] as const;
type Ipv4FallbackContext = {
message: string;
codes: Set<string>;
};
type Ipv4FallbackRule = {
name: string;
matches: (ctx: Ipv4FallbackContext) => boolean;
};
const IPV4_FALLBACK_RULES: readonly Ipv4FallbackRule[] = [
{
name: "fetch-failed-envelope",
matches: ({ message }) => message.includes("fetch failed"),
},
{
name: "known-network-code",
matches: ({ codes }) => FALLBACK_RETRY_ERROR_CODES.some((code) => codes.has(code)),
},
];
function normalizeDnsResultOrder(value: string | null): TelegramDnsResultOrder | null {
if (value === "ipv4first" || value === "verbatim") {
return value;
}
return null;
}
function createDnsResultOrderLookup(
order: TelegramDnsResultOrder | null,
): LookupFunction | undefined {
if (!order) {
return undefined;
}
const lookup = dns.lookup as unknown as (
hostname: string,
options: LookupOptions,
callback: LookupCallback,
) => void;
return (hostname, options, callback) => {
const baseOptions: LookupOptions =
typeof options === "number"
? { family: options }
: options
? { ...(options as LookupOptions) }
: {};
const lookupOptions: LookupOptions = {
...baseOptions,
order,
// Keep `verbatim` for compatibility with Node runtimes that ignore `order`.
verbatim: order === "verbatim",
};
lookup(hostname, lookupOptions, callback);
};
}
function buildTelegramConnectOptions(params: {
autoSelectFamily: boolean | null;
dnsResultOrder: TelegramDnsResultOrder | null;
forceIpv4: boolean;
}): {
autoSelectFamily?: boolean;
autoSelectFamilyAttemptTimeout?: number;
family?: number;
lookup?: LookupFunction;
} | null {
const connect: {
autoSelectFamily?: boolean;
autoSelectFamilyAttemptTimeout?: number;
family?: number;
lookup?: LookupFunction;
} = {};
if (params.forceIpv4) {
connect.family = 4;
connect.autoSelectFamily = false;
} else if (typeof params.autoSelectFamily === "boolean") {
connect.autoSelectFamily = params.autoSelectFamily;
connect.autoSelectFamilyAttemptTimeout = TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS;
}
const lookup = createDnsResultOrderLookup(params.dnsResultOrder);
if (lookup) {
connect.lookup = lookup;
}
return Object.keys(connect).length > 0 ? connect : null;
}
function shouldBypassEnvProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean {
// We need this classification before dispatch to decide whether sticky IPv4 fallback
// can safely arm. EnvHttpProxyAgent does not expose route decisions (proxy vs direct
// NO_PROXY bypass), so we mirror undici's parsing/matching behavior for this host.
// Match EnvHttpProxyAgent behavior (undici):
// - lower-case no_proxy takes precedence over NO_PROXY
// - entries split by comma or whitespace
// - wildcard handling is exact-string "*" only
// - leading "." and "*." are normalized the same way
const noProxyValue = env.no_proxy ?? env.NO_PROXY ?? "";
if (!noProxyValue) {
return false;
}
if (noProxyValue === "*") {
return true;
}
const targetHostname = TELEGRAM_API_HOSTNAME.toLowerCase();
const targetPort = 443;
const noProxyEntries = noProxyValue.split(/[,\s]/);
for (let i = 0; i < noProxyEntries.length; i++) {
const entry = noProxyEntries[i];
if (!entry) {
continue;
}
const parsed = entry.match(/^(.+):(\d+)$/);
const entryHostname = (parsed ? parsed[1] : entry).replace(/^\*?\./, "").toLowerCase();
const entryPort = parsed ? Number.parseInt(parsed[2], 10) : 0;
if (entryPort && entryPort !== targetPort) {
continue;
}
if (
targetHostname === entryHostname ||
targetHostname.slice(-(entryHostname.length + 1)) === `.${entryHostname}`
) {
return true;
}
}
return false;
}
function hasEnvHttpProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean {
return hasEnvHttpProxyConfigured("https", env);
}
function resolveTelegramDispatcherPolicy(params: {
autoSelectFamily: boolean | null;
dnsResultOrder: TelegramDnsResultOrder | null;
useEnvProxy: boolean;
forceIpv4: boolean;
proxyUrl?: string;
}): { policy: PinnedDispatcherPolicy; mode: TelegramDispatcherMode } {
const connect = buildTelegramConnectOptions({
autoSelectFamily: params.autoSelectFamily,
dnsResultOrder: params.dnsResultOrder,
forceIpv4: params.forceIpv4,
});
const explicitProxyUrl = params.proxyUrl?.trim();
if (explicitProxyUrl) {
return {
policy: connect
? {
mode: "explicit-proxy",
proxyUrl: explicitProxyUrl,
proxyTls: { ...connect },
}
: {
mode: "explicit-proxy",
proxyUrl: explicitProxyUrl,
},
mode: "explicit-proxy",
};
}
if (params.useEnvProxy) {
return {
policy: {
mode: "env-proxy",
...(connect ? { connect: { ...connect }, proxyTls: { ...connect } } : {}),
},
mode: "env-proxy",
};
}
return {
policy: {
mode: "direct",
...(connect ? { connect: { ...connect } } : {}),
},
mode: "direct",
};
}
function createTelegramDispatcher(policy: PinnedDispatcherPolicy): {
dispatcher: TelegramDispatcher;
mode: TelegramDispatcherMode;
effectivePolicy: PinnedDispatcherPolicy;
} {
if (policy.mode === "explicit-proxy") {
const proxyOptions = policy.proxyTls
? ({
uri: policy.proxyUrl,
proxyTls: { ...policy.proxyTls },
} satisfies ConstructorParameters<typeof ProxyAgent>[0])
: policy.proxyUrl;
try {
return {
dispatcher: new ProxyAgent(proxyOptions),
mode: "explicit-proxy",
effectivePolicy: policy,
};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`explicit proxy dispatcher init failed: ${reason}`, { cause: err });
}
}
if (policy.mode === "env-proxy") {
const proxyOptions =
policy.connect || policy.proxyTls
? ({
...(policy.connect ? { connect: { ...policy.connect } } : {}),
// undici's EnvHttpProxyAgent passes `connect` only to the no-proxy Agent.
// Real proxied HTTPS traffic reads transport settings from ProxyAgent.proxyTls.
...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}),
} satisfies ConstructorParameters<typeof EnvHttpProxyAgent>[0])
: undefined;
try {
return {
dispatcher: new EnvHttpProxyAgent(proxyOptions),
mode: "env-proxy",
effectivePolicy: policy,
};
} catch (err) {
log.warn(
`env proxy dispatcher init failed; falling back to direct dispatcher: ${
err instanceof Error ? err.message : String(err)
}`,
);
const directPolicy: PinnedDispatcherPolicy = {
mode: "direct",
...(policy.connect ? { connect: { ...policy.connect } } : {}),
};
return {
dispatcher: new Agent(
directPolicy.connect
? ({
connect: { ...directPolicy.connect },
} satisfies ConstructorParameters<typeof Agent>[0])
: undefined,
),
mode: "direct",
effectivePolicy: directPolicy,
};
}
}
return {
dispatcher: new Agent(
policy.connect
? ({
connect: { ...policy.connect },
} satisfies ConstructorParameters<typeof Agent>[0])
: undefined,
),
mode: "direct",
effectivePolicy: policy,
};
}
function withDispatcherIfMissing(
init: RequestInit | undefined,
dispatcher: TelegramDispatcher,
): RequestInitWithDispatcher {
const withDispatcher = init as RequestInitWithDispatcher | undefined;
if (withDispatcher?.dispatcher) {
return init ?? {};
}
return init ? { ...init, dispatcher } : { dispatcher };
}
function resolveWrappedFetch(fetchImpl: typeof fetch): typeof fetch {
return resolveFetch(fetchImpl) ?? fetchImpl;
}
function logResolverNetworkDecisions(params: {
autoSelectDecision: ReturnType<typeof resolveTelegramAutoSelectFamilyDecision>;
dnsDecision: ReturnType<typeof resolveTelegramDnsResultOrderDecision>;
}): void {
if (params.autoSelectDecision.value !== null) {
const sourceLabel = params.autoSelectDecision.source
? ` (${params.autoSelectDecision.source})`
: "";
log.info(`autoSelectFamily=${params.autoSelectDecision.value}${sourceLabel}`);
}
if (params.dnsDecision.value !== null) {
const sourceLabel = params.dnsDecision.source ? ` (${params.dnsDecision.source})` : "";
log.info(`dnsResultOrder=${params.dnsDecision.value}${sourceLabel}`);
}
}
function collectErrorCodes(err: unknown): Set<string> {
const codes = new Set<string>();
const queue: unknown[] = [err];
const seen = new Set<unknown>();
while (queue.length > 0) {
const current = queue.shift();
if (!current || seen.has(current)) {
continue;
}
seen.add(current);
if (typeof current === "object") {
const code = (current as { code?: unknown }).code;
if (typeof code === "string" && code.trim()) {
codes.add(code.trim().toUpperCase());
}
const cause = (current as { cause?: unknown }).cause;
if (cause && !seen.has(cause)) {
queue.push(cause);
}
const errors = (current as { errors?: unknown }).errors;
if (Array.isArray(errors)) {
for (const nested of errors) {
if (nested && !seen.has(nested)) {
queue.push(nested);
}
}
}
}
}
return codes;
}
function formatErrorCodes(err: unknown): string {
const codes = [...collectErrorCodes(err)];
return codes.length > 0 ? codes.join(",") : "none";
}
function shouldRetryWithIpv4Fallback(err: unknown): boolean {
const ctx: Ipv4FallbackContext = {
message:
err && typeof err === "object" && "message" in err ? String(err.message).toLowerCase() : "",
codes: collectErrorCodes(err),
};
for (const rule of IPV4_FALLBACK_RULES) {
if (!rule.matches(ctx)) {
return false;
}
}
return true;
}
export function shouldRetryTelegramIpv4Fallback(err: unknown): boolean {
return shouldRetryWithIpv4Fallback(err);
}
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
export type TelegramTransport = {
fetch: typeof fetch;
sourceFetch: typeof fetch;
pinnedDispatcherPolicy?: PinnedDispatcherPolicy;
fallbackPinnedDispatcherPolicy?: PinnedDispatcherPolicy;
};
export function resolveTelegramTransport(
proxyFetch?: typeof fetch,
options?: { network?: TelegramNetworkConfig },
): TelegramTransport {
const autoSelectDecision = resolveTelegramAutoSelectFamilyDecision({
network: options?.network,
});
const dnsDecision = resolveTelegramDnsResultOrderDecision({
network: options?.network,
});
logResolverNetworkDecisions({
autoSelectDecision,
dnsDecision,
});
const explicitProxyUrl = proxyFetch ? getProxyUrlFromFetch(proxyFetch) : undefined;
const undiciSourceFetch = resolveWrappedFetch(undiciFetch as unknown as typeof fetch);
const sourceFetch = explicitProxyUrl
? undiciSourceFetch
: proxyFetch
? resolveWrappedFetch(proxyFetch)
: undiciSourceFetch;
const dnsResultOrder = normalizeDnsResultOrder(dnsDecision.value);
// Preserve fully caller-owned custom fetch implementations.
if (proxyFetch && !explicitProxyUrl) {
return { fetch: sourceFetch, sourceFetch };
}
const useEnvProxy = !explicitProxyUrl && hasEnvHttpProxyForTelegramApi();
const defaultDispatcherResolution = resolveTelegramDispatcherPolicy({
autoSelectFamily: autoSelectDecision.value,
dnsResultOrder,
useEnvProxy,
forceIpv4: false,
proxyUrl: explicitProxyUrl,
});
const defaultDispatcher = createTelegramDispatcher(defaultDispatcherResolution.policy);
const shouldBypassEnvProxy = shouldBypassEnvProxyForTelegramApi();
const allowStickyIpv4Fallback =
defaultDispatcher.mode === "direct" ||
(defaultDispatcher.mode === "env-proxy" && shouldBypassEnvProxy);
const stickyShouldUseEnvProxy = defaultDispatcher.mode === "env-proxy";
const fallbackPinnedDispatcherPolicy = allowStickyIpv4Fallback
? resolveTelegramDispatcherPolicy({
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
useEnvProxy: stickyShouldUseEnvProxy,
forceIpv4: true,
proxyUrl: explicitProxyUrl,
}).policy
: undefined;
let stickyIpv4FallbackEnabled = false;
let stickyIpv4Dispatcher: TelegramDispatcher | null = null;
const resolveStickyIpv4Dispatcher = () => {
if (!stickyIpv4Dispatcher) {
if (!fallbackPinnedDispatcherPolicy) {
return defaultDispatcher.dispatcher;
}
stickyIpv4Dispatcher = createTelegramDispatcher(fallbackPinnedDispatcherPolicy).dispatcher;
}
return stickyIpv4Dispatcher;
};
const resolvedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const callerProvidedDispatcher = Boolean(
(init as RequestInitWithDispatcher | undefined)?.dispatcher,
);
const initialInit = withDispatcherIfMissing(
init,
stickyIpv4FallbackEnabled ? resolveStickyIpv4Dispatcher() : defaultDispatcher.dispatcher,
);
try {
return await sourceFetch(input, initialInit);
} catch (err) {
if (shouldRetryWithIpv4Fallback(err)) {
// Preserve caller-owned dispatchers on retry.
if (callerProvidedDispatcher) {
return sourceFetch(input, init ?? {});
}
// Proxy routes should not arm sticky IPv4 mode; `family=4` would constrain
// proxy-connect behavior instead of Telegram endpoint selection.
if (!allowStickyIpv4Fallback) {
throw err;
}
if (!stickyIpv4FallbackEnabled) {
stickyIpv4FallbackEnabled = true;
log.warn(
`fetch fallback: enabling sticky IPv4-only dispatcher (codes=${formatErrorCodes(err)})`,
);
}
return sourceFetch(input, withDispatcherIfMissing(init, resolveStickyIpv4Dispatcher()));
}
throw err;
}
}) as typeof fetch;
return {
fetch: resolvedFetch,
sourceFetch,
pinnedDispatcherPolicy: defaultDispatcher.effectivePolicy,
fallbackPinnedDispatcherPolicy,
};
}
export function resolveTelegramFetch(
proxyFetch?: typeof fetch,
options?: { network?: TelegramNetworkConfig },
): typeof fetch {
return resolveTelegramTransport(proxyFetch, options).fetch;
}
export * from "../../extensions/telegram/src/fetch.js";

View File

@@ -1,137 +0,0 @@
import { describe, expect, it } from "vitest";
import { markdownToTelegramHtml, splitTelegramHtmlChunks } from "./format.js";
describe("markdownToTelegramHtml", () => {
it("handles core markdown-to-telegram conversions", () => {
const cases = [
[
"renders basic inline formatting",
"hi _there_ **boss** `code`",
"hi <i>there</i> <b>boss</b> <code>code</code>",
],
[
"renders links as Telegram-safe HTML",
"see [docs](https://example.com)",
'see <a href="https://example.com">docs</a>',
],
["escapes raw HTML", "<b>nope</b>", "&lt;b&gt;nope&lt;/b&gt;"],
["escapes unsafe characters", "a & b < c", "a &amp; b &lt; c"],
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
["renders lists without block HTML", "- one\n- two", "• one\n• two"],
["renders ordered lists with numbering", "2. two\n3. three", "2. two\n3. three"],
["flattens headings", "# Title", "Title"],
] as const;
for (const [name, input, expected] of cases) {
expect(markdownToTelegramHtml(input), name).toBe(expected);
}
});
it("renders blockquotes as native Telegram blockquote tags", () => {
const res = markdownToTelegramHtml("> Quote");
expect(res).toContain("<blockquote>");
expect(res).toContain("Quote");
expect(res).toContain("</blockquote>");
});
it("renders blockquotes with inline formatting", () => {
const res = markdownToTelegramHtml("> **bold** quote");
expect(res).toContain("<blockquote>");
expect(res).toContain("<b>bold</b>");
expect(res).toContain("</blockquote>");
});
it("renders multiline blockquotes as a single Telegram blockquote", () => {
const res = markdownToTelegramHtml("> first\n> second");
expect(res).toBe("<blockquote>first\nsecond</blockquote>");
});
it("renders separated quoted paragraphs as distinct blockquotes", () => {
const res = markdownToTelegramHtml("> first\n\n> second");
expect(res).toContain("<blockquote>first");
expect(res).toContain("<blockquote>second</blockquote>");
expect(res.match(/<blockquote>/g)).toHaveLength(2);
});
it("renders fenced code blocks", () => {
const res = markdownToTelegramHtml("```js\nconst x = 1;\n```");
expect(res).toBe("<pre><code>const x = 1;\n</code></pre>");
});
it("properly nests overlapping bold and autolink (#4071)", () => {
const res = markdownToTelegramHtml("**start https://example.com** end");
expect(res).toMatch(
/<b>start <a href="https:\/\/example\.com">https:\/\/example\.com<\/a><\/b> end/,
);
});
it("properly nests link inside bold", () => {
const res = markdownToTelegramHtml("**bold [link](https://example.com) text**");
expect(res).toBe('<b>bold <a href="https://example.com">link</a> text</b>');
});
it("properly nests bold wrapping a link with trailing text", () => {
const res = markdownToTelegramHtml("**[link](https://example.com) rest**");
expect(res).toBe('<b><a href="https://example.com">link</a> rest</b>');
});
it("properly nests bold inside a link", () => {
const res = markdownToTelegramHtml("[**bold**](https://example.com)");
expect(res).toBe('<a href="https://example.com"><b>bold</b></a>');
});
it("wraps punctuated file references in code tags", () => {
const res = markdownToTelegramHtml("See README.md. Also (backup.sh).");
expect(res).toContain("<code>README.md</code>.");
expect(res).toContain("(<code>backup.sh</code>).");
});
it("renders spoiler tags", () => {
const res = markdownToTelegramHtml("the answer is ||42||");
expect(res).toBe("the answer is <tg-spoiler>42</tg-spoiler>");
});
it("renders spoiler with nested formatting", () => {
const res = markdownToTelegramHtml("||**secret** text||");
expect(res).toBe("<tg-spoiler><b>secret</b> text</tg-spoiler>");
});
it("does not treat single pipe as spoiler", () => {
const res = markdownToTelegramHtml("( ̄_ ̄|) face");
expect(res).not.toContain("tg-spoiler");
expect(res).toContain("|");
});
it("does not treat unpaired || as spoiler", () => {
const res = markdownToTelegramHtml("before || after");
expect(res).not.toContain("tg-spoiler");
expect(res).toContain("||");
});
it("keeps valid spoiler pairs when a trailing || is unmatched", () => {
const res = markdownToTelegramHtml("||secret|| trailing ||");
expect(res).toContain("<tg-spoiler>secret</tg-spoiler>");
expect(res).toContain("trailing ||");
});
it("splits long multiline html text without breaking balanced tags", () => {
const chunks = splitTelegramHtmlChunks(`<b>${"A\n".repeat(2500)}</b>`, 4000);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
expect(chunks[0]).toMatch(/^<b>[\s\S]*<\/b>$/);
expect(chunks[1]).toMatch(/^<b>[\s\S]*<\/b>$/);
});
it("fails loudly when a leading entity cannot fit inside a chunk", () => {
expect(() => splitTelegramHtmlChunks(`A&amp;${"B".repeat(20)}`, 4)).toThrow(/leading entity/i);
});
it("treats malformed leading ampersands as plain text when chunking html", () => {
const chunks = splitTelegramHtmlChunks(`&${"A".repeat(5000)}`, 4000);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
});
it("fails loudly when tag overhead leaves no room for text", () => {
expect(() => splitTelegramHtmlChunks("<b><i><u>x</u></i></b>", 10)).toThrow(/tag overhead/i);
});
});

View File

@@ -1,582 +1 @@
import type { MarkdownTableMode } from "../config/types.base.js";
import {
chunkMarkdownIR,
markdownToIR,
type MarkdownLinkSpan,
type MarkdownIR,
} from "../markdown/ir.js";
import { renderMarkdownWithMarkers } from "../markdown/render.js";
export type TelegramFormattedChunk = {
html: string;
text: string;
};
function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeHtmlAttr(text: string): string {
return escapeHtml(text).replace(/"/g, "&quot;");
}
/**
* File extensions that share TLDs and commonly appear in code/documentation.
* These are wrapped in <code> tags to prevent Telegram from generating
* spurious domain registrar previews.
*
* Only includes extensions that are:
* 1. Commonly used as file extensions in code/docs
* 2. Rarely used as intentional domain references
*
* Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io)
*/
const FILE_EXTENSIONS_WITH_TLD = new Set([
"md", // Markdown (Moldova) - very common in repos
"go", // Go language - common in Go projects
"py", // Python (Paraguay) - common in Python projects
"pl", // Perl (Poland) - common in Perl projects
"sh", // Shell (Saint Helena) - common for scripts
"am", // Automake files (Armenia)
"at", // Assembly (Austria)
"be", // Backend files (Belgium)
"cc", // C++ source (Cocos Islands)
]);
/** Detects when markdown-it linkify auto-generated a link from a bare filename (e.g. README.md → http://README.md) */
function isAutoLinkedFileRef(href: string, label: string): boolean {
const stripped = href.replace(/^https?:\/\//i, "");
if (stripped !== label) {
return false;
}
const dotIndex = label.lastIndexOf(".");
if (dotIndex < 1) {
return false;
}
const ext = label.slice(dotIndex + 1).toLowerCase();
if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) {
return false;
}
// Reject if any path segment before the filename contains a dot (looks like a domain)
const segments = label.split("/");
if (segments.length > 1) {
for (let i = 0; i < segments.length - 1; i++) {
if (segments[i].includes(".")) {
return false;
}
}
}
return true;
}
function buildTelegramLink(link: MarkdownLinkSpan, text: string) {
const href = link.href.trim();
if (!href) {
return null;
}
if (link.start === link.end) {
return null;
}
// Suppress auto-linkified file references (e.g. README.md → http://README.md)
const label = text.slice(link.start, link.end);
if (isAutoLinkedFileRef(href, label)) {
return null;
}
const safeHref = escapeHtmlAttr(href);
return {
start: link.start,
end: link.end,
open: `<a href="${safeHref}">`,
close: "</a>",
};
}
function renderTelegramHtml(ir: MarkdownIR): string {
return renderMarkdownWithMarkers(ir, {
styleMarkers: {
bold: { open: "<b>", close: "</b>" },
italic: { open: "<i>", close: "</i>" },
strikethrough: { open: "<s>", close: "</s>" },
code: { open: "<code>", close: "</code>" },
code_block: { open: "<pre><code>", close: "</code></pre>" },
spoiler: { open: "<tg-spoiler>", close: "</tg-spoiler>" },
blockquote: { open: "<blockquote>", close: "</blockquote>" },
},
escapeText: escapeHtml,
buildLink: buildTelegramLink,
});
}
export function markdownToTelegramHtml(
markdown: string,
options: { tableMode?: MarkdownTableMode; wrapFileRefs?: boolean } = {},
): string {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode,
});
const html = renderTelegramHtml(ir);
// Apply file reference wrapping if requested (for chunked rendering)
if (options.wrapFileRefs !== false) {
return wrapFileReferencesInHtml(html);
}
return html;
}
/**
* Wraps standalone file references (with TLD extensions) in <code> tags.
* This prevents Telegram from treating them as URLs and generating
* irrelevant domain registrar previews.
*
* Runs AFTER markdown→HTML conversion to avoid modifying HTML attributes.
* Skips content inside <code>, <pre>, and <a> tags to avoid nesting issues.
*/
/** Escape regex metacharacters in a string */
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
const FILE_REFERENCE_PATTERN = new RegExp(
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
"gi",
);
const ORPHANED_TLD_PATTERN = new RegExp(
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`,
"g",
);
const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string {
if (filename.startsWith("//")) {
return match;
}
if (/https?:\/\/$/i.test(prefix)) {
return match;
}
return `${prefix}<code>${escapeHtml(filename)}</code>`;
}
function wrapSegmentFileRefs(
text: string,
codeDepth: number,
preDepth: number,
anchorDepth: number,
): string {
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
return text;
}
const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef);
return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) =>
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
);
}
export function wrapFileReferencesInHtml(html: string): string {
// Safety-net: de-linkify auto-generated anchors where href="http://<label>" (defense in depth for textMode: "html")
AUTO_LINKED_ANCHOR_PATTERN.lastIndex = 0;
const deLinkified = html.replace(AUTO_LINKED_ANCHOR_PATTERN, (_match, label: string) => {
if (!isAutoLinkedFileRef(`http://${label}`, label)) {
return _match;
}
return `<code>${escapeHtml(label)}</code>`;
});
// Track nesting depth for tags that should not be modified
let codeDepth = 0;
let preDepth = 0;
let anchorDepth = 0;
let result = "";
let lastIndex = 0;
// Process tags token-by-token so we can skip protected regions while wrapping plain text.
HTML_TAG_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = HTML_TAG_PATTERN.exec(deLinkified)) !== null) {
const tagStart = match.index;
const tagEnd = HTML_TAG_PATTERN.lastIndex;
const isClosing = match[1] === "</";
const tagName = match[2].toLowerCase();
// Process text before this tag
const textBefore = deLinkified.slice(lastIndex, tagStart);
result += wrapSegmentFileRefs(textBefore, codeDepth, preDepth, anchorDepth);
// Update tag depth (clamp at 0 for malformed HTML with stray closing tags)
if (tagName === "code") {
codeDepth = isClosing ? Math.max(0, codeDepth - 1) : codeDepth + 1;
} else if (tagName === "pre") {
preDepth = isClosing ? Math.max(0, preDepth - 1) : preDepth + 1;
} else if (tagName === "a") {
anchorDepth = isClosing ? Math.max(0, anchorDepth - 1) : anchorDepth + 1;
}
// Add the tag itself
result += deLinkified.slice(tagStart, tagEnd);
lastIndex = tagEnd;
}
// Process remaining text
const remainingText = deLinkified.slice(lastIndex);
result += wrapSegmentFileRefs(remainingText, codeDepth, preDepth, anchorDepth);
return result;
}
export function renderTelegramHtmlText(
text: string,
options: { textMode?: "markdown" | "html"; tableMode?: MarkdownTableMode } = {},
): string {
const textMode = options.textMode ?? "markdown";
if (textMode === "html") {
// For HTML mode, trust caller markup - don't modify
return text;
}
// markdownToTelegramHtml already wraps file references by default
return markdownToTelegramHtml(text, { tableMode: options.tableMode });
}
type TelegramHtmlTag = {
name: string;
openTag: string;
closeTag: string;
};
const TELEGRAM_SELF_CLOSING_HTML_TAGS = new Set(["br"]);
function buildTelegramHtmlOpenPrefix(tags: TelegramHtmlTag[]): string {
return tags.map((tag) => tag.openTag).join("");
}
function buildTelegramHtmlCloseSuffix(tags: TelegramHtmlTag[]): string {
return tags
.slice()
.toReversed()
.map((tag) => tag.closeTag)
.join("");
}
function buildTelegramHtmlCloseSuffixLength(tags: TelegramHtmlTag[]): number {
return tags.reduce((total, tag) => total + tag.closeTag.length, 0);
}
function findTelegramHtmlEntityEnd(text: string, start: number): number {
if (text[start] !== "&") {
return -1;
}
let index = start + 1;
if (index >= text.length) {
return -1;
}
if (text[index] === "#") {
index += 1;
if (index >= text.length) {
return -1;
}
const isHex = text[index] === "x" || text[index] === "X";
if (isHex) {
index += 1;
const hexStart = index;
while (/[0-9A-Fa-f]/.test(text[index] ?? "")) {
index += 1;
}
if (index === hexStart) {
return -1;
}
} else {
const digitStart = index;
while (/[0-9]/.test(text[index] ?? "")) {
index += 1;
}
if (index === digitStart) {
return -1;
}
}
} else {
const nameStart = index;
while (/[A-Za-z0-9]/.test(text[index] ?? "")) {
index += 1;
}
if (index === nameStart) {
return -1;
}
}
return text[index] === ";" ? index : -1;
}
function findTelegramHtmlSafeSplitIndex(text: string, maxLength: number): number {
if (text.length <= maxLength) {
return text.length;
}
const normalizedMaxLength = Math.max(1, Math.floor(maxLength));
const lastAmpersand = text.lastIndexOf("&", normalizedMaxLength - 1);
if (lastAmpersand === -1) {
return normalizedMaxLength;
}
const lastSemicolon = text.lastIndexOf(";", normalizedMaxLength - 1);
if (lastAmpersand < lastSemicolon) {
return normalizedMaxLength;
}
const entityEnd = findTelegramHtmlEntityEnd(text, lastAmpersand);
if (entityEnd === -1 || entityEnd < normalizedMaxLength) {
return normalizedMaxLength;
}
return lastAmpersand;
}
function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void {
for (let index = tags.length - 1; index >= 0; index -= 1) {
if (tags[index]?.name === name) {
tags.splice(index, 1);
return;
}
}
}
export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
if (!html) {
return [];
}
const normalizedLimit = Math.max(1, Math.floor(limit));
if (html.length <= normalizedLimit) {
return [html];
}
const chunks: string[] = [];
const openTags: TelegramHtmlTag[] = [];
let current = "";
let chunkHasPayload = false;
const resetCurrent = () => {
current = buildTelegramHtmlOpenPrefix(openTags);
chunkHasPayload = false;
};
const flushCurrent = () => {
if (!chunkHasPayload) {
return;
}
chunks.push(`${current}${buildTelegramHtmlCloseSuffix(openTags)}`);
resetCurrent();
};
const appendText = (segment: string) => {
let remaining = segment;
while (remaining.length > 0) {
const available =
normalizedLimit - current.length - buildTelegramHtmlCloseSuffixLength(openTags);
if (available <= 0) {
if (!chunkHasPayload) {
throw new Error(
`Telegram HTML chunk limit exceeded by tag overhead (limit=${normalizedLimit})`,
);
}
flushCurrent();
continue;
}
if (remaining.length <= available) {
current += remaining;
chunkHasPayload = true;
break;
}
const splitAt = findTelegramHtmlSafeSplitIndex(remaining, available);
if (splitAt <= 0) {
if (!chunkHasPayload) {
throw new Error(
`Telegram HTML chunk limit exceeded by leading entity (limit=${normalizedLimit})`,
);
}
flushCurrent();
continue;
}
current += remaining.slice(0, splitAt);
chunkHasPayload = true;
remaining = remaining.slice(splitAt);
flushCurrent();
}
};
resetCurrent();
HTML_TAG_PATTERN.lastIndex = 0;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = HTML_TAG_PATTERN.exec(html)) !== null) {
const tagStart = match.index;
const tagEnd = HTML_TAG_PATTERN.lastIndex;
appendText(html.slice(lastIndex, tagStart));
const rawTag = match[0];
const isClosing = match[1] === "</";
const tagName = match[2].toLowerCase();
const isSelfClosing =
!isClosing &&
(TELEGRAM_SELF_CLOSING_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>"));
if (!isClosing) {
const nextCloseLength = isSelfClosing ? 0 : `</${tagName}>`.length;
if (
chunkHasPayload &&
current.length +
rawTag.length +
buildTelegramHtmlCloseSuffixLength(openTags) +
nextCloseLength >
normalizedLimit
) {
flushCurrent();
}
}
current += rawTag;
if (isSelfClosing) {
chunkHasPayload = true;
}
if (isClosing) {
popTelegramHtmlTag(openTags, tagName);
} else if (!isSelfClosing) {
openTags.push({
name: tagName,
openTag: rawTag,
closeTag: `</${tagName}>`,
});
}
lastIndex = tagEnd;
}
appendText(html.slice(lastIndex));
flushCurrent();
return chunks.length > 0 ? chunks : [html];
}
function splitTelegramChunkByHtmlLimit(
chunk: MarkdownIR,
htmlLimit: number,
renderedHtmlLength: number,
): MarkdownIR[] {
const currentTextLength = chunk.text.length;
if (currentTextLength <= 1) {
return [chunk];
}
const proportionalLimit = Math.floor(
(currentTextLength * htmlLimit) / Math.max(renderedHtmlLength, 1),
);
const candidateLimit = Math.min(currentTextLength - 1, proportionalLimit);
const splitLimit =
Number.isFinite(candidateLimit) && candidateLimit > 0
? candidateLimit
: Math.max(1, Math.floor(currentTextLength / 2));
const split = splitMarkdownIRPreserveWhitespace(chunk, splitLimit);
if (split.length > 1) {
return split;
}
return splitMarkdownIRPreserveWhitespace(chunk, Math.max(1, Math.floor(currentTextLength / 2)));
}
function sliceStyleSpans(
styles: MarkdownIR["styles"],
start: number,
end: number,
): MarkdownIR["styles"] {
return styles.flatMap((span) => {
if (span.end <= start || span.start >= end) {
return [];
}
const nextStart = Math.max(span.start, start) - start;
const nextEnd = Math.min(span.end, end) - start;
if (nextEnd <= nextStart) {
return [];
}
return [{ ...span, start: nextStart, end: nextEnd }];
});
}
function sliceLinkSpans(
links: MarkdownIR["links"],
start: number,
end: number,
): MarkdownIR["links"] {
return links.flatMap((link) => {
if (link.end <= start || link.start >= end) {
return [];
}
const nextStart = Math.max(link.start, start) - start;
const nextEnd = Math.min(link.end, end) - start;
if (nextEnd <= nextStart) {
return [];
}
return [{ ...link, start: nextStart, end: nextEnd }];
});
}
function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) {
return [];
}
const normalizedLimit = Math.max(1, Math.floor(limit));
if (normalizedLimit <= 0 || ir.text.length <= normalizedLimit) {
return [ir];
}
const chunks: MarkdownIR[] = [];
let cursor = 0;
while (cursor < ir.text.length) {
const end = Math.min(ir.text.length, cursor + normalizedLimit);
chunks.push({
text: ir.text.slice(cursor, end),
styles: sliceStyleSpans(ir.styles, cursor, end),
links: sliceLinkSpans(ir.links, cursor, end),
});
cursor = end;
}
return chunks;
}
function renderTelegramChunksWithinHtmlLimit(
ir: MarkdownIR,
limit: number,
): TelegramFormattedChunk[] {
const normalizedLimit = Math.max(1, Math.floor(limit));
const pending = chunkMarkdownIR(ir, normalizedLimit);
const rendered: TelegramFormattedChunk[] = [];
while (pending.length > 0) {
const chunk = pending.shift();
if (!chunk) {
continue;
}
const html = wrapFileReferencesInHtml(renderTelegramHtml(chunk));
if (html.length <= normalizedLimit || chunk.text.length <= 1) {
rendered.push({ html, text: chunk.text });
continue;
}
const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length);
if (split.length <= 1) {
// Worst-case safety: avoid retry loops, deliver the chunk as-is.
rendered.push({ html, text: chunk.text });
continue;
}
pending.unshift(...split);
}
return rendered;
}
export function markdownToTelegramChunks(
markdown: string,
limit: number,
options: { tableMode?: MarkdownTableMode } = {},
): TelegramFormattedChunk[] {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode,
});
return renderTelegramChunksWithinHtmlLimit(ir, limit);
}
export function markdownToTelegramHtmlChunks(markdown: string, limit: number): string[] {
return markdownToTelegramChunks(markdown, limit).map((chunk) => chunk.html);
}
export * from "../../extensions/telegram/src/format.js";

View File

@@ -1,422 +0,0 @@
import { describe, expect, it } from "vitest";
import {
markdownToTelegramChunks,
markdownToTelegramHtml,
renderTelegramHtmlText,
wrapFileReferencesInHtml,
} from "./format.js";
describe("wrapFileReferencesInHtml", () => {
it("wraps supported file references and paths", () => {
const cases = [
["Check README.md", "Check <code>README.md</code>"],
["See HEARTBEAT.md for status", "See <code>HEARTBEAT.md</code> for status"],
["Check main.go", "Check <code>main.go</code>"],
["Run script.py", "Run <code>script.py</code>"],
["Check backup.pl", "Check <code>backup.pl</code>"],
["Run backup.sh", "Run <code>backup.sh</code>"],
["Look at squad/friday/HEARTBEAT.md", "Look at <code>squad/friday/HEARTBEAT.md</code>"],
] as const;
for (const [input, expected] of cases) {
expect(wrapFileReferencesInHtml(input), input).toContain(expected);
}
});
it("does not wrap inside protected html contexts", () => {
const cases = [
"Already <code>wrapped.md</code> here",
"<pre><code>README.md</code></pre>",
'<a href="README.md">Link</a>',
'Visit <a href="https://example.com/README.md">example.com/README.md</a>',
] as const;
for (const input of cases) {
const result = wrapFileReferencesInHtml(input);
expect(result, input).toBe(input);
}
expect(wrapFileReferencesInHtml(cases[0])).not.toContain("<code><code>");
});
it("handles mixed content correctly", () => {
const result = wrapFileReferencesInHtml("Check README.md and CONTRIBUTING.md");
expect(result).toContain("<code>README.md</code>");
expect(result).toContain("<code>CONTRIBUTING.md</code>");
});
it("handles boundary and punctuation wrapping cases", () => {
const cases = [
{ input: "No markdown files here", contains: undefined },
{ input: "File.md at start", contains: "<code>File.md</code>" },
{ input: "Ends with file.md", contains: "<code>file.md</code>" },
{ input: "See README.md.", contains: "<code>README.md</code>." },
{ input: "See README.md,", contains: "<code>README.md</code>," },
{ input: "(README.md)", contains: "(<code>README.md</code>)" },
{ input: "README.md:", contains: "<code>README.md</code>:" },
] as const;
for (const testCase of cases) {
const result = wrapFileReferencesInHtml(testCase.input);
if (!testCase.contains) {
expect(result).not.toContain("<code>");
continue;
}
expect(result).toContain(testCase.contains);
}
});
it("de-linkifies auto-linkified anchors for plain files and paths", () => {
const cases = [
{
input: '<a href="http://README.md">README.md</a>',
expected: "<code>README.md</code>",
},
{
input: '<a href="http://squad/friday/HEARTBEAT.md">squad/friday/HEARTBEAT.md</a>',
expected: "<code>squad/friday/HEARTBEAT.md</code>",
},
] as const;
for (const testCase of cases) {
expect(wrapFileReferencesInHtml(testCase.input)).toBe(testCase.expected);
}
});
it("preserves explicit links where label differs from href", () => {
const cases = [
'<a href="http://README.md">click here</a>',
'<a href="http://other.md">README.md</a>',
] as const;
for (const input of cases) {
expect(wrapFileReferencesInHtml(input)).toBe(input);
}
});
it("wraps file ref after closing anchor tag", () => {
const input = '<a href="https://example.com">link</a> then README.md';
const result = wrapFileReferencesInHtml(input);
expect(result).toContain("</a> then <code>README.md</code>");
});
});
describe("renderTelegramHtmlText - file reference wrapping", () => {
it("wraps file references in markdown mode", () => {
const result = renderTelegramHtmlText("Check README.md");
expect(result).toContain("<code>README.md</code>");
});
it("does not wrap in HTML mode (trusts caller markup)", () => {
// textMode: "html" should pass through unchanged - caller owns the markup
const result = renderTelegramHtmlText("Check README.md", { textMode: "html" });
expect(result).toBe("Check README.md");
expect(result).not.toContain("<code>");
});
it("does not double-wrap already code-formatted content", () => {
const result = renderTelegramHtmlText("Already `wrapped.md` here");
// Should have code tags but not nested
expect(result).toContain("<code>");
expect(result).not.toContain("<code><code>");
});
});
describe("markdownToTelegramHtml - file reference wrapping", () => {
it("wraps file references by default", () => {
const result = markdownToTelegramHtml("Check README.md");
expect(result).toContain("<code>README.md</code>");
});
it("can skip wrapping when requested", () => {
const result = markdownToTelegramHtml("Check README.md", { wrapFileRefs: false });
expect(result).not.toContain("<code>README.md</code>");
});
it("wraps multiple file types in a single message", () => {
const result = markdownToTelegramHtml("Edit main.go and script.py");
expect(result).toContain("<code>main.go</code>");
expect(result).toContain("<code>script.py</code>");
});
it("preserves real URLs as anchor tags", () => {
const result = markdownToTelegramHtml("Visit https://example.com");
expect(result).toContain('<a href="https://example.com">');
});
it("preserves explicit markdown links even when href looks like a file ref", () => {
const result = markdownToTelegramHtml("[docs](http://README.md)");
expect(result).toContain('<a href="http://README.md">docs</a>');
});
it("wraps file ref after real URL in same message", () => {
const result = markdownToTelegramHtml("Visit https://example.com and README.md");
expect(result).toContain('<a href="https://example.com">');
expect(result).toContain("<code>README.md</code>");
});
});
describe("markdownToTelegramChunks - file reference wrapping", () => {
it("wraps file references in chunked output", () => {
const chunks = markdownToTelegramChunks("Check README.md and backup.sh", 4096);
expect(chunks.length).toBeGreaterThan(0);
expect(chunks[0].html).toContain("<code>README.md</code>");
expect(chunks[0].html).toContain("<code>backup.sh</code>");
});
it("keeps rendered html chunks within the provided limit", () => {
const input = "<".repeat(1500);
const chunks = markdownToTelegramChunks(input, 512);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 512)).toBe(true);
});
it("preserves whitespace when html-limit retry splitting runs", () => {
const input = "a < b";
const chunks = markdownToTelegramChunks(input, 5);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 5)).toBe(true);
});
});
describe("edge cases", () => {
it("wraps file refs inside emphasis tags", () => {
const cases = [
["**README.md**", "<b><code>README.md</code></b>"],
["*script.py*", "<i><code>script.py</code></i>"],
] as const;
for (const [input, expected] of cases) {
expect(markdownToTelegramHtml(input), input).toBe(expected);
}
});
it("does not wrap inside fenced code blocks", () => {
const result = markdownToTelegramHtml("```\nREADME.md\n```");
expect(result).toBe("<pre><code>README.md\n</code></pre>");
expect(result).not.toContain("<code><code>");
});
it("preserves real URL/domain paths as anchors", () => {
const cases = [
{
input: "example.com/README.md",
href: 'href="http://example.com/README.md"',
},
{
input: "https://github.com/foo/README.md",
href: 'href="https://github.com/foo/README.md"',
},
] as const;
for (const testCase of cases) {
const result = markdownToTelegramHtml(testCase.input);
expect(result).toContain(`<a ${testCase.href}>`);
expect(result).not.toContain("<code>");
}
});
it("handles wrapFileRefs: false (plain text output)", () => {
const result = markdownToTelegramHtml("README.md", { wrapFileRefs: false });
// buildTelegramLink returns null, so no <a> tag; wrapFileRefs: false skips <code>
expect(result).toBe("README.md");
});
it("classifies extension-like tokens as file refs or domains", () => {
const cases = [
{
name: "supported file-style extensions",
input: "Makefile.am and code.at and app.be and main.cc",
contains: [
"<code>Makefile.am</code>",
"<code>code.at</code>",
"<code>app.be</code>",
"<code>main.cc</code>",
],
},
{
name: "popular domain TLDs stay links",
input: "Check x.ai and vercel.io and app.tv and radio.fm",
contains: [
'<a href="http://x.ai">',
'<a href="http://vercel.io">',
'<a href="http://app.tv">',
'<a href="http://radio.fm">',
],
},
{
name: ".co stays links",
input: "Visit t.co and openclaw.co",
contains: ['<a href="http://t.co">', '<a href="http://openclaw.co">'],
notContains: ["<code>t.co</code>", "<code>openclaw.co</code>"],
},
{
name: "non-target extensions stay plain text",
input: "image.png and style.css and script.js",
notContains: ["<code>image.png</code>", "<code>style.css</code>", "<code>script.js</code>"],
},
] as const;
for (const testCase of cases) {
const result = markdownToTelegramHtml(testCase.input);
if ("contains" in testCase && testCase.contains) {
for (const expected of testCase.contains) {
expect(result, testCase.name).toContain(expected);
}
}
if ("notContains" in testCase && testCase.notContains) {
for (const unexpected of testCase.notContains) {
expect(result, testCase.name).not.toContain(unexpected);
}
}
}
});
it("wraps file refs across boundaries, sequences, and path variants", () => {
const cases = [
{
name: "message start boundary",
input: "README.md is important",
expectedExact: "<code>README.md</code> is important",
},
{
name: "message end boundary",
input: "Check the README.md",
expectedExact: "Check the <code>README.md</code>",
},
{
name: "multiple file refs",
input: "README.md CHANGELOG.md LICENSE.md",
contains: [
"<code>README.md</code>",
"<code>CHANGELOG.md</code>",
"<code>LICENSE.md</code>",
],
},
{
name: "nested path",
input: "src/utils/helpers/format.go",
contains: ["<code>src/utils/helpers/format.go</code>"],
},
{
name: "version-like non-domain path",
input: "v1.0/README.md",
contains: ["<code>v1.0/README.md</code>"],
},
{
name: "domain with version path",
input: "example.com/v1.0/README.md",
contains: ['<a href="http://example.com/v1.0/README.md">'],
},
{
name: "hyphen underscore and uppercase extensions",
input: "my-file_name.md README.MD and SCRIPT.PY",
contains: [
"<code>my-file_name.md</code>",
"<code>README.MD</code>",
"<code>SCRIPT.PY</code>",
],
},
] as const;
for (const testCase of cases) {
const result = markdownToTelegramHtml(testCase.input);
if ("expectedExact" in testCase) {
expect(result, testCase.name).toBe(testCase.expectedExact);
}
if ("contains" in testCase && testCase.contains) {
for (const expected of testCase.contains) {
expect(result, testCase.name).toContain(expected);
}
}
}
});
it("handles nested code tags (depth tracking)", () => {
// Nested <code> inside <pre> - should not wrap inner content
const input = "<pre><code>README.md</code></pre> then script.py";
const result = wrapFileReferencesInHtml(input);
expect(result).toBe("<pre><code>README.md</code></pre> then <code>script.py</code>");
});
it("handles multiple anchor tags in sequence", () => {
const input =
'<a href="https://a.com">link1</a> README.md <a href="https://b.com">link2</a> script.py';
const result = wrapFileReferencesInHtml(input);
expect(result).toContain("</a> <code>README.md</code> <a");
expect(result).toContain("</a> <code>script.py</code>");
});
it("wraps orphaned TLD pattern after special character", () => {
// R&D.md - the & breaks the main pattern, but D.md could be auto-linked
// So we wrap the orphaned D.md part to prevent Telegram linking it
const input = "R&D.md";
const result = wrapFileReferencesInHtml(input);
expect(result).toBe("R&<code>D.md</code>");
});
it("wraps orphaned single-letter TLD patterns", () => {
// Use extensions still in the set (md, sh, py, go)
const result1 = wrapFileReferencesInHtml("X.md is cool");
expect(result1).toContain("<code>X.md</code>");
const result2 = wrapFileReferencesInHtml("Check R.sh");
expect(result2).toContain("<code>R.sh</code>");
});
it("does not match filenames containing angle brackets", () => {
// The regex character class [a-zA-Z0-9_.\\-./] doesn't include < >
// so these won't be matched and wrapped (which is correct/safe)
const input = "file<script>.md";
const result = wrapFileReferencesInHtml(input);
// Not wrapped because < breaks the filename pattern
expect(result).toBe(input);
});
it("wraps file ref before unrelated HTML tags", () => {
// x.md followed by unrelated closing tag and bold - wrap the file ref only
const input = "x.md <b>bold</b>";
const result = wrapFileReferencesInHtml(input);
expect(result).toBe("<code>x.md</code> <b>bold</b>");
});
it("handles malformed HTML with stray closing tags (negative depth)", () => {
// Stray </code> before content shouldn't break protection logic
// (depth should clamp at 0, not go negative)
const input = "</code>README.md<code>inside</code> after.md";
const result = wrapFileReferencesInHtml(input);
// README.md should be wrapped (codeDepth = 0 after clamping stray close)
expect(result).toContain("<code>README.md</code>");
// after.md should be wrapped (codeDepth = 0 after proper close)
expect(result).toContain("<code>after.md</code>");
// Should not have nested code tags
expect(result).not.toContain("<code><code>");
});
it("does not wrap orphaned TLD fragments inside protected HTML contexts", () => {
const cases = [
"<code>R&D.md</code>",
'<a href="https://example.com">R&D.md</a>',
'<a href="http://example.com/R&D.md">link</a>',
'<img src="logo/R&D.md" alt="R&D.md">',
] as const;
for (const input of cases) {
const result = wrapFileReferencesInHtml(input);
expect(result, input).toBe(input);
expect(result, input).not.toContain("<code>D.md</code>");
expect(result, input).not.toContain("<code><code>");
expect(result, input).not.toContain("</code></code>");
}
});
it("handles multiple orphaned TLDs with HTML tags (offset stability)", () => {
// This tests the bug where offset is relative to pre-replacement string
// but we were checking against the mutating result string
const input = '<a href="http://A.md">link</a> B.md <span title="C.sh">text</span> D.py';
const result = wrapFileReferencesInHtml(input);
// A.md in href should NOT be wrapped (inside attribute)
// B.md outside tags SHOULD be wrapped
// C.sh in title attribute should NOT be wrapped
// D.py outside tags SHOULD be wrapped
expect(result).toContain("<code>B.md</code>");
expect(result).toContain("<code>D.py</code>");
expect(result).not.toContain("<code>A.md</code>");
expect(result).not.toContain("<code>C.sh</code>");
// Attributes should be unchanged
expect(result).toContain('href="http://A.md"');
expect(result).toContain('title="C.sh"');
});
});

View File

@@ -1,23 +1 @@
/** Telegram forum-topic service-message fields (Bot API). */
export const TELEGRAM_FORUM_SERVICE_FIELDS = [
"forum_topic_created",
"forum_topic_edited",
"forum_topic_closed",
"forum_topic_reopened",
"general_forum_topic_hidden",
"general_forum_topic_unhidden",
] as const;
/**
* Returns `true` when the message is a Telegram forum service message (e.g.
* "Topic created"). These auto-generated messages carry one of the
* `forum_topic_*` / `general_forum_topic_*` fields and should not count as
* regular bot replies for implicit-mention purposes.
*/
export function isTelegramForumServiceMessage(msg: unknown): boolean {
if (!msg || typeof msg !== "object") {
return false;
}
const record = msg as Record<string, unknown>;
return TELEGRAM_FORUM_SERVICE_FIELDS.some((field) => record[field] != null);
}
export * from "../../extensions/telegram/src/forum-service-message.js";

View File

@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import type { NormalizedAllowFrom } from "./bot-access.js";
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
function allow(entries: string[], hasWildcard = false): NormalizedAllowFrom {
return {
entries,
hasWildcard,
hasEntries: entries.length > 0 || hasWildcard,
invalidEntries: [],
};
}
describe("evaluateTelegramGroupBaseAccess", () => {
it("fails closed when explicit group allowFrom override is empty", () => {
const result = evaluateTelegramGroupBaseAccess({
isGroup: true,
hasGroupAllowOverride: true,
effectiveGroupAllow: allow([]),
senderId: "12345",
senderUsername: "tester",
enforceAllowOverride: true,
requireSenderForAllowOverride: true,
});
expect(result).toEqual({ allowed: false, reason: "group-override-unauthorized" });
});
it("allows group message when override is not configured", () => {
const result = evaluateTelegramGroupBaseAccess({
isGroup: true,
hasGroupAllowOverride: false,
effectiveGroupAllow: allow([]),
senderId: "12345",
senderUsername: "tester",
enforceAllowOverride: true,
requireSenderForAllowOverride: true,
});
expect(result).toEqual({ allowed: true });
});
it("allows sender explicitly listed in override", () => {
const result = evaluateTelegramGroupBaseAccess({
isGroup: true,
hasGroupAllowOverride: true,
effectiveGroupAllow: allow(["12345"]),
senderId: "12345",
senderUsername: "tester",
enforceAllowOverride: true,
requireSenderForAllowOverride: true,
});
expect(result).toEqual({ allowed: true });
});
});

View File

@@ -1,13 +0,0 @@
import { describe } from "vitest";
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../test-utils/runtime-group-policy-contract.js";
import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js";
describe("resolveTelegramRuntimeGroupPolicy", () => {
installProviderRuntimeGroupPolicyFallbackSuite({
resolve: resolveTelegramRuntimeGroupPolicy,
configuredLabel: "keeps open fallback when channels.telegram is configured",
defaultGroupPolicyUnderTest: "disabled",
missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set",
missingDefaultLabel: "ignores explicit defaults when provider config is missing",
});
});

View File

@@ -1,215 +0,0 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramAccountConfig } from "../config/types.js";
import { evaluateTelegramGroupPolicyAccess } from "./group-access.js";
/**
* Minimal stubs shared across tests.
*/
const baseCfg = {
channels: { telegram: {} },
} as unknown as OpenClawConfig;
const baseTelegramCfg: TelegramAccountConfig = {
groupPolicy: "allowlist",
} as unknown as TelegramAccountConfig;
const emptyAllow = { entries: [], hasWildcard: false, hasEntries: false, invalidEntries: [] };
const senderAllow = {
entries: ["111"],
hasWildcard: false,
hasEntries: true,
invalidEntries: [],
};
type GroupAccessParams = Parameters<typeof evaluateTelegramGroupPolicyAccess>[0];
const DEFAULT_GROUP_ACCESS_PARAMS: GroupAccessParams = {
isGroup: true,
chatId: "-100123456",
cfg: baseCfg,
telegramCfg: baseTelegramCfg,
effectiveGroupAllow: emptyAllow,
senderId: "999",
senderUsername: "user",
resolveGroupPolicy: () => ({
allowlistEnabled: true,
allowed: true,
groupConfig: { requireMention: false },
}),
enforcePolicy: true,
useTopicAndGroupOverrides: false,
enforceAllowlistAuthorization: true,
allowEmptyAllowlistEntries: false,
requireSenderForAllowlistAuthorization: true,
checkChatAllowlist: true,
};
function runAccess(overrides: Partial<GroupAccessParams>) {
return evaluateTelegramGroupPolicyAccess({
...DEFAULT_GROUP_ACCESS_PARAMS,
...overrides,
resolveGroupPolicy:
overrides.resolveGroupPolicy ?? DEFAULT_GROUP_ACCESS_PARAMS.resolveGroupPolicy,
});
}
describe("evaluateTelegramGroupPolicyAccess chat allowlist vs sender allowlist ordering", () => {
it("allows a group explicitly listed in groups config even when no allowFrom entries exist", () => {
// Issue #30613: a group configured with a dedicated entry (groupConfig set)
// should be allowed even without any allowFrom / groupAllowFrom entries.
const result = runAccess({
resolveGroupPolicy: () => ({
allowlistEnabled: true,
allowed: true,
groupConfig: { requireMention: false }, // dedicated entry — not just wildcard
}),
});
expect(result).toEqual({ allowed: true, groupPolicy: "allowlist" });
});
it("still blocks when only wildcard match and no allowFrom entries", () => {
// groups: { "*": ... } with no allowFrom → wildcard does NOT bypass sender checks.
const result = runAccess({
resolveGroupPolicy: () => ({
allowlistEnabled: true,
allowed: true,
groupConfig: undefined, // wildcard match only — no dedicated entry
}),
});
expect(result).toEqual({
allowed: false,
reason: "group-policy-allowlist-empty",
groupPolicy: "allowlist",
});
});
it("rejects a group NOT in groups config", () => {
const result = runAccess({
chatId: "-100999999",
resolveGroupPolicy: () => ({
allowlistEnabled: true,
allowed: false,
}),
});
expect(result).toEqual({
allowed: false,
reason: "group-chat-not-allowed",
groupPolicy: "allowlist",
});
});
it("still enforces sender allowlist when checkChatAllowlist is disabled", () => {
const result = runAccess({
resolveGroupPolicy: () => ({
allowlistEnabled: true,
allowed: true,
groupConfig: { requireMention: false },
}),
checkChatAllowlist: false,
});
expect(result).toEqual({
allowed: false,
reason: "group-policy-allowlist-empty",
groupPolicy: "allowlist",
});
});
it("blocks unauthorized sender even when chat is explicitly allowed and sender entries exist", () => {
const result = runAccess({
effectiveGroupAllow: senderAllow, // entries: ["111"]
senderId: "222", // not in senderAllow.entries
senderUsername: "other",
resolveGroupPolicy: () => ({
allowlistEnabled: true,
allowed: true,
groupConfig: { requireMention: false },
}),
});
// Chat is explicitly allowed, but sender entries exist and sender is not in them.
expect(result).toEqual({
allowed: false,
reason: "group-policy-allowlist-unauthorized",
groupPolicy: "allowlist",
});
});
it("allows when groupPolicy is open regardless of allowlist state", () => {
const result = runAccess({
telegramCfg: { groupPolicy: "open" } as unknown as TelegramAccountConfig,
resolveGroupPolicy: () => ({
allowlistEnabled: false,
allowed: false,
}),
});
expect(result).toEqual({ allowed: true, groupPolicy: "open" });
});
it("rejects when groupPolicy is disabled", () => {
const result = runAccess({
telegramCfg: { groupPolicy: "disabled" } as unknown as TelegramAccountConfig,
resolveGroupPolicy: () => ({
allowlistEnabled: false,
allowed: false,
}),
});
expect(result).toEqual({
allowed: false,
reason: "group-policy-disabled",
groupPolicy: "disabled",
});
});
it("allows non-group messages without any checks", () => {
const result = runAccess({
isGroup: false,
chatId: "12345",
resolveGroupPolicy: () => ({
allowlistEnabled: true,
allowed: false,
}),
});
expect(result).toEqual({ allowed: true, groupPolicy: "allowlist" });
});
it("blocks allowlist groups without sender identity before sender matching", () => {
const result = runAccess({
senderId: undefined,
senderUsername: undefined,
effectiveGroupAllow: senderAllow,
resolveGroupPolicy: () => ({
allowlistEnabled: true,
allowed: true,
groupConfig: { requireMention: false },
}),
});
expect(result).toEqual({
allowed: false,
reason: "group-policy-allowlist-no-sender",
groupPolicy: "allowlist",
});
});
it("allows authorized sender in wildcard-matched group with sender entries", () => {
const result = runAccess({
effectiveGroupAllow: senderAllow, // entries: ["111"]
senderId: "111", // IS in senderAllow.entries
resolveGroupPolicy: () => ({
allowlistEnabled: true,
allowed: true,
groupConfig: undefined, // wildcard only
}),
});
expect(result).toEqual({ allowed: true, groupPolicy: "allowlist" });
});
});

View File

@@ -1,205 +1 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js";
import type {
TelegramAccountConfig,
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../config/types.js";
import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js";
import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js";
import { firstDefined } from "./bot-access.js";
export type TelegramGroupBaseBlockReason =
| "group-disabled"
| "topic-disabled"
| "group-override-unauthorized";
export type TelegramGroupBaseAccessResult =
| { allowed: true }
| { allowed: false; reason: TelegramGroupBaseBlockReason };
function isGroupAllowOverrideAuthorized(params: {
effectiveGroupAllow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
requireSenderForAllowOverride: boolean;
}): boolean {
if (!params.effectiveGroupAllow.hasEntries) {
return false;
}
const senderId = params.senderId ?? "";
if (params.requireSenderForAllowOverride && !senderId) {
return false;
}
return isSenderAllowed({
allow: params.effectiveGroupAllow,
senderId,
senderUsername: params.senderUsername ?? "",
});
}
export const evaluateTelegramGroupBaseAccess = (params: {
isGroup: boolean;
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
hasGroupAllowOverride: boolean;
effectiveGroupAllow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
enforceAllowOverride: boolean;
requireSenderForAllowOverride: boolean;
}): TelegramGroupBaseAccessResult => {
// Check enabled flags for both groups and DMs
if (params.groupConfig?.enabled === false) {
return { allowed: false, reason: "group-disabled" };
}
if (params.topicConfig?.enabled === false) {
return { allowed: false, reason: "topic-disabled" };
}
if (!params.isGroup) {
// For DMs, check allowFrom override if present
if (params.enforceAllowOverride && params.hasGroupAllowOverride) {
if (
!isGroupAllowOverrideAuthorized({
effectiveGroupAllow: params.effectiveGroupAllow,
senderId: params.senderId,
senderUsername: params.senderUsername,
requireSenderForAllowOverride: params.requireSenderForAllowOverride,
})
) {
return { allowed: false, reason: "group-override-unauthorized" };
}
}
return { allowed: true };
}
if (!params.enforceAllowOverride || !params.hasGroupAllowOverride) {
return { allowed: true };
}
if (
!isGroupAllowOverrideAuthorized({
effectiveGroupAllow: params.effectiveGroupAllow,
senderId: params.senderId,
senderUsername: params.senderUsername,
requireSenderForAllowOverride: params.requireSenderForAllowOverride,
})
) {
return { allowed: false, reason: "group-override-unauthorized" };
}
return { allowed: true };
};
export type TelegramGroupPolicyBlockReason =
| "group-policy-disabled"
| "group-policy-allowlist-no-sender"
| "group-policy-allowlist-empty"
| "group-policy-allowlist-unauthorized"
| "group-chat-not-allowed";
export type TelegramGroupPolicyAccessResult =
| { allowed: true; groupPolicy: "open" | "disabled" | "allowlist" }
| {
allowed: false;
reason: TelegramGroupPolicyBlockReason;
groupPolicy: "open" | "disabled" | "allowlist";
};
export const resolveTelegramRuntimeGroupPolicy = (params: {
providerConfigPresent: boolean;
groupPolicy?: TelegramAccountConfig["groupPolicy"];
defaultGroupPolicy?: TelegramAccountConfig["groupPolicy"];
}) =>
resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.providerConfigPresent,
groupPolicy: params.groupPolicy,
defaultGroupPolicy: params.defaultGroupPolicy,
});
export const evaluateTelegramGroupPolicyAccess = (params: {
isGroup: boolean;
chatId: string | number;
cfg: OpenClawConfig;
telegramCfg: TelegramAccountConfig;
topicConfig?: TelegramTopicConfig;
groupConfig?: TelegramGroupConfig;
effectiveGroupAllow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
enforcePolicy: boolean;
useTopicAndGroupOverrides: boolean;
enforceAllowlistAuthorization: boolean;
allowEmptyAllowlistEntries: boolean;
requireSenderForAllowlistAuthorization: boolean;
checkChatAllowlist: boolean;
}): TelegramGroupPolicyAccessResult => {
const { groupPolicy: runtimeFallbackPolicy } = resolveTelegramRuntimeGroupPolicy({
providerConfigPresent: params.cfg.channels?.telegram !== undefined,
groupPolicy: params.telegramCfg.groupPolicy,
defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy,
});
const fallbackPolicy =
firstDefined(params.telegramCfg.groupPolicy, params.cfg.channels?.defaults?.groupPolicy) ??
runtimeFallbackPolicy;
const groupPolicy = params.useTopicAndGroupOverrides
? (firstDefined(
params.topicConfig?.groupPolicy,
params.groupConfig?.groupPolicy,
params.telegramCfg.groupPolicy,
params.cfg.channels?.defaults?.groupPolicy,
) ?? runtimeFallbackPolicy)
: fallbackPolicy;
if (!params.isGroup || !params.enforcePolicy) {
return { allowed: true, groupPolicy };
}
if (groupPolicy === "disabled") {
return { allowed: false, reason: "group-policy-disabled", groupPolicy };
}
// Check chat-level allowlist first so that groups explicitly listed in the
// `groups` config are not blocked by the sender-level "empty allowlist" guard.
let chatExplicitlyAllowed = false;
if (params.checkChatAllowlist) {
const groupAllowlist = params.resolveGroupPolicy(params.chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
return { allowed: false, reason: "group-chat-not-allowed", groupPolicy };
}
// The chat is explicitly allowed when it has a dedicated entry in the groups
// config (groupConfig is set). A wildcard ("*") match alone does not count
// because it only enables the group — sender-level filtering still applies.
if (groupAllowlist.allowlistEnabled && groupAllowlist.allowed && groupAllowlist.groupConfig) {
chatExplicitlyAllowed = true;
}
}
if (groupPolicy === "allowlist" && params.enforceAllowlistAuthorization) {
const senderId = params.senderId ?? "";
const senderAuthorization = evaluateMatchedGroupAccessForPolicy({
groupPolicy,
requireMatchInput: params.requireSenderForAllowlistAuthorization,
hasMatchInput: Boolean(senderId),
allowlistConfigured:
chatExplicitlyAllowed ||
params.allowEmptyAllowlistEntries ||
params.effectiveGroupAllow.hasEntries,
allowlistMatched:
(chatExplicitlyAllowed && !params.effectiveGroupAllow.hasEntries) ||
isSenderAllowed({
allow: params.effectiveGroupAllow,
senderId,
senderUsername: params.senderUsername ?? "",
}),
});
if (!senderAuthorization.allowed && senderAuthorization.reason === "missing_match_input") {
return { allowed: false, reason: "group-policy-allowlist-no-sender", groupPolicy };
}
if (!senderAuthorization.allowed && senderAuthorization.reason === "empty_allowlist") {
return { allowed: false, reason: "group-policy-allowlist-empty", groupPolicy };
}
if (!senderAuthorization.allowed && senderAuthorization.reason === "not_allowlisted") {
return { allowed: false, reason: "group-policy-allowlist-unauthorized", groupPolicy };
}
}
return { allowed: true, groupPolicy };
};
export * from "../../extensions/telegram/src/group-access.js";

View File

@@ -1,23 +1 @@
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../config/types.js";
import { firstDefined } from "./bot-access.js";
export function resolveTelegramGroupPromptSettings(params: {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
}): {
skillFilter: string[] | undefined;
groupSystemPrompt: string | undefined;
} {
const skillFilter = firstDefined(params.topicConfig?.skills, params.groupConfig?.skills);
const systemPromptParts = [
params.groupConfig?.systemPrompt?.trim() || null,
params.topicConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
return { skillFilter, groupSystemPrompt };
}
export * from "../../extensions/telegram/src/group-config-helpers.js";

View File

@@ -1,118 +0,0 @@
import { describe, expect, it } from "vitest";
import { migrateTelegramGroupConfig, migrateTelegramGroupsInPlace } from "./group-migration.js";
function createTelegramGlobalGroupConfig(groups: Record<string, Record<string, unknown>>) {
return {
channels: {
telegram: {
groups,
},
},
};
}
function createTelegramAccountGroupConfig(
accountId: string,
groups: Record<string, Record<string, unknown>>,
) {
return {
channels: {
telegram: {
accounts: {
[accountId]: {
groups,
},
},
},
},
};
}
describe("migrateTelegramGroupConfig", () => {
it("migrates global group ids", () => {
const cfg = createTelegramGlobalGroupConfig({
"-123": { requireMention: false },
});
const result = migrateTelegramGroupConfig({
cfg,
accountId: "default",
oldChatId: "-123",
newChatId: "-100123",
});
expect(result.migrated).toBe(true);
expect(cfg.channels.telegram.groups).toEqual({
"-100123": { requireMention: false },
});
});
it("migrates account-scoped groups", () => {
const cfg = createTelegramAccountGroupConfig("primary", {
"-123": { requireMention: true },
});
const result = migrateTelegramGroupConfig({
cfg,
accountId: "primary",
oldChatId: "-123",
newChatId: "-100123",
});
expect(result.migrated).toBe(true);
expect(result.scopes).toEqual(["account"]);
expect(cfg.channels.telegram.accounts.primary.groups).toEqual({
"-100123": { requireMention: true },
});
});
it("matches account ids case-insensitively", () => {
const cfg = createTelegramAccountGroupConfig("Primary", {
"-123": {},
});
const result = migrateTelegramGroupConfig({
cfg,
accountId: "primary",
oldChatId: "-123",
newChatId: "-100123",
});
expect(result.migrated).toBe(true);
expect(cfg.channels.telegram.accounts.Primary.groups).toEqual({
"-100123": {},
});
});
it("skips migration when new id already exists", () => {
const cfg = createTelegramGlobalGroupConfig({
"-123": { requireMention: true },
"-100123": { requireMention: false },
});
const result = migrateTelegramGroupConfig({
cfg,
accountId: "default",
oldChatId: "-123",
newChatId: "-100123",
});
expect(result.migrated).toBe(false);
expect(result.skippedExisting).toBe(true);
expect(cfg.channels.telegram.groups).toEqual({
"-123": { requireMention: true },
"-100123": { requireMention: false },
});
});
it("no-ops when old and new group ids are the same", () => {
const groups = {
"-123": { requireMention: true },
};
const result = migrateTelegramGroupsInPlace(groups, "-123", "-123");
expect(result).toEqual({ migrated: false, skippedExisting: false });
expect(groups).toEqual({
"-123": { requireMention: true },
});
});
});

View File

@@ -1,89 +1 @@
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramGroupConfig } from "../config/types.telegram.js";
import { normalizeAccountId } from "../routing/session-key.js";
type TelegramGroups = Record<string, TelegramGroupConfig>;
type MigrationScope = "account" | "global";
export type TelegramGroupMigrationResult = {
migrated: boolean;
skippedExisting: boolean;
scopes: MigrationScope[];
};
function resolveAccountGroups(
cfg: OpenClawConfig,
accountId?: string | null,
): { groups?: TelegramGroups } {
if (!accountId) {
return {};
}
const normalized = normalizeAccountId(accountId);
const accounts = cfg.channels?.telegram?.accounts;
if (!accounts || typeof accounts !== "object") {
return {};
}
const exact = accounts[normalized];
if (exact?.groups) {
return { groups: exact.groups };
}
const matchKey = Object.keys(accounts).find(
(key) => key.toLowerCase() === normalized.toLowerCase(),
);
return { groups: matchKey ? accounts[matchKey]?.groups : undefined };
}
export function migrateTelegramGroupsInPlace(
groups: TelegramGroups | undefined,
oldChatId: string,
newChatId: string,
): { migrated: boolean; skippedExisting: boolean } {
if (!groups) {
return { migrated: false, skippedExisting: false };
}
if (oldChatId === newChatId) {
return { migrated: false, skippedExisting: false };
}
if (!Object.hasOwn(groups, oldChatId)) {
return { migrated: false, skippedExisting: false };
}
if (Object.hasOwn(groups, newChatId)) {
return { migrated: false, skippedExisting: true };
}
groups[newChatId] = groups[oldChatId];
delete groups[oldChatId];
return { migrated: true, skippedExisting: false };
}
export function migrateTelegramGroupConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
oldChatId: string;
newChatId: string;
}): TelegramGroupMigrationResult {
const scopes: MigrationScope[] = [];
let migrated = false;
let skippedExisting = false;
const migrationTargets: Array<{
scope: MigrationScope;
groups: TelegramGroups | undefined;
}> = [
{ scope: "account", groups: resolveAccountGroups(params.cfg, params.accountId).groups },
{ scope: "global", groups: params.cfg.channels?.telegram?.groups },
];
for (const target of migrationTargets) {
const result = migrateTelegramGroupsInPlace(target.groups, params.oldChatId, params.newChatId);
if (result.migrated) {
migrated = true;
scopes.push(target.scope);
}
if (result.skippedExisting) {
skippedExisting = true;
}
}
return { migrated, skippedExisting, scopes };
}
export * from "../../extensions/telegram/src/group-migration.js";

View File

@@ -1,37 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveTelegramTargetChatType } from "./inline-buttons.js";
describe("resolveTelegramTargetChatType", () => {
it("returns 'direct' for positive numeric IDs", () => {
expect(resolveTelegramTargetChatType("5232990709")).toBe("direct");
expect(resolveTelegramTargetChatType("123456789")).toBe("direct");
});
it("returns 'group' for negative numeric IDs", () => {
expect(resolveTelegramTargetChatType("-123456789")).toBe("group");
expect(resolveTelegramTargetChatType("-1001234567890")).toBe("group");
});
it("handles telegram: prefix from normalizeTelegramMessagingTarget", () => {
expect(resolveTelegramTargetChatType("telegram:5232990709")).toBe("direct");
expect(resolveTelegramTargetChatType("telegram:-123456789")).toBe("group");
expect(resolveTelegramTargetChatType("TELEGRAM:5232990709")).toBe("direct");
});
it("handles tg/group prefixes and topic suffixes", () => {
expect(resolveTelegramTargetChatType("tg:5232990709")).toBe("direct");
expect(resolveTelegramTargetChatType("telegram:group:-1001234567890")).toBe("group");
expect(resolveTelegramTargetChatType("telegram:group:-1001234567890:topic:456")).toBe("group");
expect(resolveTelegramTargetChatType("-1001234567890:456")).toBe("group");
});
it("returns 'unknown' for usernames", () => {
expect(resolveTelegramTargetChatType("@username")).toBe("unknown");
expect(resolveTelegramTargetChatType("telegram:@username")).toBe("unknown");
});
it("returns 'unknown' for empty strings", () => {
expect(resolveTelegramTargetChatType("")).toBe("unknown");
expect(resolveTelegramTargetChatType(" ")).toBe("unknown");
});
});

View File

@@ -1,67 +1 @@
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramInlineButtonsScope } from "../config/types.telegram.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist";
function normalizeInlineButtonsScope(value: unknown): TelegramInlineButtonsScope | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim().toLowerCase();
if (
trimmed === "off" ||
trimmed === "dm" ||
trimmed === "group" ||
trimmed === "all" ||
trimmed === "allowlist"
) {
return trimmed as TelegramInlineButtonsScope;
}
return undefined;
}
function resolveInlineButtonsScopeFromCapabilities(
capabilities: unknown,
): TelegramInlineButtonsScope {
if (!capabilities) {
return DEFAULT_INLINE_BUTTONS_SCOPE;
}
if (Array.isArray(capabilities)) {
const enabled = capabilities.some(
(entry) => String(entry).trim().toLowerCase() === "inlinebuttons",
);
return enabled ? "all" : "off";
}
if (typeof capabilities === "object") {
const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons;
return normalizeInlineButtonsScope(inlineButtons) ?? DEFAULT_INLINE_BUTTONS_SCOPE;
}
return DEFAULT_INLINE_BUTTONS_SCOPE;
}
export function resolveTelegramInlineButtonsScope(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): TelegramInlineButtonsScope {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
return resolveInlineButtonsScopeFromCapabilities(account.config.capabilities);
}
export function isTelegramInlineButtonsEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
if (params.accountId) {
return resolveTelegramInlineButtonsScope(params) !== "off";
}
const accountIds = listTelegramAccountIds(params.cfg);
if (accountIds.length === 0) {
return resolveTelegramInlineButtonsScope(params) !== "off";
}
return accountIds.some(
(accountId) => resolveTelegramInlineButtonsScope({ cfg: params.cfg, accountId }) !== "off",
);
}
export { resolveTelegramTargetChatType } from "./targets.js";
export * from "../../extensions/telegram/src/inline-buttons.js";

Some files were not shown because too many files have changed in this diff Show More