Files
openclaw/extensions/telegram/src/bot.test.ts
alexph-dev aa117ec4de fix(telegram): derive DM topics from bot capability
Remove the Telegram DM thread reply policy config and use Telegram bot capability as the single source of truth for DM topic session splitting.

DM messages with message_thread_id now split into thread-scoped sessions only when Telegram getMe reports has_topics_enabled for the bot. Doctor removes retired dm.threadReplies and direct.*.threadReplies keys, docs explain the upgrade behavior, and startup keeps cached bot info as a non-auth fallback when a fresh probe fails.

Refs #86513.
Thanks @alexph-dev.

Verification:
- pnpm docs:list
- pnpm exec oxfmt --check --threads=1 extensions/telegram/src/channel.ts extensions/telegram/src/channel.gateway.test.ts extensions/telegram/src/doctor-contract.ts extensions/telegram/src/doctor.test.ts
- git diff --check
- node scripts/run-vitest.mjs extensions/telegram/src/channel.gateway.test.ts extensions/telegram/src/doctor.test.ts extensions/telegram/src/bot/helpers.test.ts extensions/telegram/src/bot-message-context.dm-threads.test.ts extensions/telegram/src/config-schema.test.ts
- pnpm config:channels:check
- pnpm config:docs:check
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub Actions: CI 26468039803, Workflow Sanity 26468040057, OpenGrep 26468039472, Real behavior proof 26468036483, CodeQL 26468039466, CodeQL Critical Quality 26468039473

Known CI caveat: checks-windows-node-test failed before tests because Windows runner setup left Node 22.19.0 active while the job requested Node 24.x; the same setup failure is present on current main CI run 26468063947.
2026-05-26 19:52:17 +01:00

3782 lines
118 KiB
TypeScript

import { rm } from "node:fs/promises";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
clearPluginInteractiveHandlers,
registerPluginInteractiveHandler,
} from "openclaw/plugin-sdk/plugin-runtime";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { loadSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramInteractiveHandlerContext } from "./interactive-dispatch.js";
const {
answerCallbackQuerySpy,
commandSpy,
editMessageReplyMarkupSpy,
editMessageTextSpy,
enqueueSystemEventSpy,
getFileSpy,
getChatSpy,
getLoadConfigMock,
getLoadWebMediaMock,
getReadChannelAllowFromStoreMock,
getOnHandler,
listSkillCommandsForAgents,
onSpy,
replySpy,
resolveExecApprovalSpy,
sendMessageSpy,
setSessionStoreEntriesForTest,
setMyCommandsSpy,
telegramBotDepsForTest,
telegramBotRuntimeForTest,
wasSentByBot,
} = await import("./bot.create-telegram-bot.test-harness.js");
let createTelegramBotBase: typeof import("./bot-core.js").createTelegramBotCore;
let setTelegramBotRuntimeForTest: typeof import("./bot-core.js").setTelegramBotRuntimeForTest;
let createTelegramBot: (
opts: import("./bot.types.js").TelegramBotOptions,
) => ReturnType<typeof import("./bot-core.js").createTelegramBotCore>;
const loadConfig = getLoadConfigMock();
const loadWebMedia = getLoadWebMediaMock();
const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();
const PUZZLE_EMOJI = "\u{1F9E9}";
const INFO_EMOJI = "\u{2139}\u{FE0F}";
const CHECK_MARK_EMOJI = "\u{2705}";
const THUMBS_UP_EMOJI = "\u{1F44D}";
const FIRE_EMOJI = "\u{1F525}";
const PARTY_EMOJI = "\u{1F389}";
const EYES_EMOJI = "\u{1F440}";
const HEART_EMOJI = "\u{2764}\u{FE0F}";
function createSignal() {
let resolve: (() => void) | undefined;
const promise = new Promise<void>((res) => {
resolve = res;
});
if (!resolve) {
throw new Error("Expected Telegram bot signal resolver to be initialized");
}
return { promise, resolve };
}
function waitForReplyCalls(count: number) {
const done = createSignal();
let seen = 0;
replySpy.mockImplementation(async (_ctx, opts) => {
await opts?.onReplyStart?.();
seen += 1;
if (seen >= count) {
done.resolve();
}
return undefined;
});
return done.promise;
}
async function loadEnvelopeTimestampHelpers() {
return await import("openclaw/plugin-sdk/channel-test-helpers");
}
async function loadInboundContextContract() {
return await import("./test-support/inbound-context-contract.js");
}
type MockCallSource = {
mock: {
calls: ReadonlyArray<ReadonlyArray<unknown>>;
};
};
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`expected ${label}`);
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): unknown[] {
expect(Array.isArray(value), label).toBe(true);
return value as unknown[];
}
function mockArg(source: MockCallSource, callIndex: number, argIndex: number, label: string) {
const call = source.mock.calls[callIndex];
if (!call) {
throw new Error(`expected mock call: ${label}`);
}
return call[argIndex];
}
function mockCall(source: MockCallSource, callIndex: number, label: string) {
const call = source.mock.calls[callIndex];
if (!call) {
throw new Error(`expected mock call: ${label}`);
}
return call;
}
function firstEditMessageTextArg(argIndex: number) {
return mockArg(editMessageTextSpy as unknown as MockCallSource, 0, argIndex, "edit message text");
}
function firstSystemEventArg(argIndex: number) {
return mockArg(enqueueSystemEventSpy as unknown as MockCallSource, 0, argIndex, "system event");
}
function mockMsgContextArg(
source: MockCallSource,
callIndex: number,
argIndex: number,
label: string,
): MsgContext {
return mockArg(source, callIndex, argIndex, label) as MsgContext;
}
function execApprovalCall(index = 0) {
return requireRecord(
mockArg(resolveExecApprovalSpy as unknown as MockCallSource, index, 0, "exec approval call"),
"exec approval call",
);
}
function execApprovalTelegramConfig(call = execApprovalCall()) {
return requireRecord(
requireRecord(requireRecord(call.cfg, "approval cfg").channels, "approval channels").telegram,
"telegram config",
);
}
function execApprovalTargetConfig(call = execApprovalCall()) {
return requireRecord(
requireRecord(requireRecord(call.cfg, "approval cfg").approvals, "approvals").exec,
"exec approvals target config",
);
}
function systemEventOptions(index = 0) {
return requireRecord(
mockArg(enqueueSystemEventSpy as unknown as MockCallSource, index, 1, "system event options"),
"system event options",
);
}
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
beforeAll(async () => {
({ createTelegramBotCore: createTelegramBotBase, setTelegramBotRuntimeForTest } =
await import("./bot-core.js"));
});
beforeAll(() => {
process.env.TZ = "UTC";
});
afterAll(() => {
if (ORIGINAL_TZ === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = ORIGINAL_TZ;
}
});
beforeEach(() => {
setMyCommandsSpy.mockClear();
clearPluginInteractiveHandlers();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
});
setTelegramBotRuntimeForTest(
telegramBotRuntimeForTest as unknown as Parameters<typeof setTelegramBotRuntimeForTest>[0],
);
createTelegramBot = (opts) =>
createTelegramBotBase({
...opts,
telegramDeps: telegramBotDepsForTest,
});
});
it("blocks callback_query when inline buttons are allowlist-only and sender not authorized", async () => {
onSpy.mockClear();
replySpy.mockClear();
sendMessageSpy.mockClear();
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "pairing",
capabilities: { inlineButtons: "allowlist" },
allowFrom: [],
},
},
},
});
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-2",
data: "cmd:option_b",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 11,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-2");
});
it("blocks DM model-selection callbacks for unpaired users when inline buttons are DM-scoped", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-callback-authz-${process.pid}-${Date.now()}.json`;
await rm(storePath, { force: true });
try {
const config = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-5.4": {},
},
},
},
channels: {
telegram: {
dmPolicy: "pairing",
capabilities: { inlineButtons: "dm" },
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
readChannelAllowFromStore.mockResolvedValueOnce([]);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-model-authz-bypass-1",
data: "mdl_sel_openai/gpt-5.4",
from: { id: 999, first_name: "Mallory", username: "mallory" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 19,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(loadSessionStore(storePath, { skipCache: true })).toStrictEqual({});
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-authz-bypass-1");
} finally {
await rm(storePath, { force: true });
}
});
it("blocks group model-selection callbacks for senders who are not authorized for /models", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-group-model-authz-${process.pid}-${Date.now()}.json`;
await rm(storePath, { force: true });
try {
const config = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-5.4": {},
},
},
},
commands: {
allowFrom: {
telegram: ["9"],
},
},
channels: {
telegram: {
dmPolicy: "open",
capabilities: { inlineButtons: "group" },
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-group-model-authz-1",
data: "mdl_sel_openai/gpt-5.4",
from: { id: 999, first_name: "Mallory", username: "mallory" },
message: {
chat: { id: -100999, type: "supergroup", title: "Test Group" },
date: 1736380800,
message_id: 21,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(loadSessionStore(storePath, { skipCache: true })).toStrictEqual({});
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-model-authz-1");
} finally {
await rm(storePath, { force: true });
}
});
it("recomputes group model-selection callback auth from runtime command config", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-group-model-authz-runtime-${process.pid}-${Date.now()}.json`;
await rm(storePath, { force: true });
try {
let currentConfig = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-5.4": {},
},
},
},
commands: {
allowFrom: {
telegram: ["999"],
},
},
channels: {
telegram: {
dmPolicy: "open",
capabilities: { inlineButtons: "group" },
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockImplementation(() => currentConfig);
createTelegramBot({
token: "tok",
config: currentConfig,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
currentConfig = {
...currentConfig,
commands: {
allowFrom: {
telegram: ["9"],
},
},
};
await callbackHandler({
callbackQuery: {
id: "cbq-group-model-authz-runtime-1",
data: "mdl_sel_openai/gpt-5.4",
from: { id: 999, first_name: "Mallory", username: "mallory" },
message: {
chat: { id: -100999, type: "supergroup", title: "Test Group" },
date: 1736380800,
message_id: 22,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(loadSessionStore(storePath, { skipCache: true })).toStrictEqual({});
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-model-authz-runtime-1");
} finally {
loadConfig.mockReset();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
});
await rm(storePath, { force: true });
}
});
it("allows callback_query in groups when group policy authorizes the sender", async () => {
onSpy.mockClear();
editMessageTextSpy.mockClear();
listSkillCommandsForAgents.mockClear();
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
capabilities: { inlineButtons: "allowlist" },
allowFrom: [],
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
},
});
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-group-1",
data: "commands_page_2",
from: { id: 42, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: -100999, type: "supergroup", title: "Test Group" },
date: 1736380800,
message_id: 20,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
// The callback should be processed (not silently blocked)
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1");
});
it("clears approval buttons without re-editing callback message text", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-approve-style",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 21,
text: [
`${PUZZLE_EMOJI} Yep-needs approval again.`,
"",
"Run:",
"/approve 138e9b8c allow-once",
"",
"Pending command:",
"```shell",
"npm view diver name version description",
"```",
].join("\n"),
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
const [chatId, messageId, replyMarkup] = mockCall(
editMessageReplyMarkupSpy as unknown as MockCallSource,
0,
"edit reply markup",
);
expect(chatId).toBe(1234);
expect(messageId).toBe(21);
expect(replyMarkup).toEqual({ reply_markup: { inline_keyboard: [] } });
const approvalCall = execApprovalCall();
const execApprovals = requireRecord(
execApprovalTelegramConfig(approvalCall).execApprovals,
"telegram exec approvals",
);
expect(execApprovals.enabled).toBe(true);
expect(execApprovals.approvers).toEqual(["9"]);
expect(execApprovals.target).toBe("dm");
expect(approvalCall.approvalId).toBe("138e9b8c");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.allowPluginFallback).toBe(true);
expect(approvalCall.senderId).toBe("9");
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-style");
});
it("allows approval callbacks when exec approvals are enabled even without generic inlineButtons capability", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
botToken: "tok",
dmPolicy: "open",
allowFrom: ["*"],
capabilities: ["vision"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-approve-capability-free",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 23,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free");
});
it("resolves plugin approval callbacks through the shared approval resolver", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-approve",
data: "/approve plugin:138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 24,
text: "Plugin approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
const approvalCall = execApprovalCall();
const execApprovals = requireRecord(
execApprovalTelegramConfig(approvalCall).execApprovals,
"telegram exec approvals",
);
expect(execApprovals.enabled).toBe(true);
expect(execApprovals.approvers).toEqual(["9"]);
expect(execApprovals.target).toBe("dm");
expect(approvalCall.approvalId).toBe("plugin:138e9b8c");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.allowPluginFallback).toBe(true);
expect(approvalCall.senderId).toBe("9");
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-plugin-approve");
});
it("blocks approval callbacks from telegram users who are not exec approvers", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["999"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-approve-blocked",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 22,
text: "Run: /approve 138e9b8c allow-once",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(resolveExecApprovalSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-blocked");
});
it("keeps approval callback resolution failures out of Telegram chat before retry", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
resolveExecApprovalSpy.mockClear();
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("gateway secret detail"));
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await expect(
callbackHandler({
callbackQuery: {
id: "cbq-approve-error",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 25,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
}),
).rejects.toThrow("gateway secret detail");
expect(sendMessageSpy).not.toHaveBeenCalled();
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-error");
});
it("allows exec approval callbacks from target-only Telegram recipients", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "9" }],
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-approve-target",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 23,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
const approvalCall = execApprovalCall();
const execApprovals = execApprovalTargetConfig(approvalCall);
expect(execApprovals.enabled).toBe(true);
expect(execApprovals.mode).toBe("targets");
expect(approvalCall.approvalId).toBe("138e9b8c");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.allowPluginFallback).toBe(false);
expect(approvalCall.senderId).toBe("9");
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-target");
});
it("drops target-only approval not-found misses without clearing legacy fallback buttons", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
replySpy.mockClear();
sendMessageSpy.mockClear();
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("unknown or expired approval id"));
loadConfig.mockReturnValue({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "9" }],
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-legacy-plugin-fallback-blocked",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 25,
text: "Legacy plugin approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
const approvalCall = execApprovalCall();
const execApprovals = execApprovalTargetConfig(approvalCall);
expect(execApprovals.enabled).toBe(true);
expect(execApprovals.mode).toBe("targets");
expect(approvalCall.approvalId).toBe("138e9b8c");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.allowPluginFallback).toBe(false);
expect(approvalCall.senderId).toBe("9");
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-legacy-plugin-fallback-blocked");
});
it("drops expired approval callbacks for configured approvers after clearing buttons", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
replySpy.mockClear();
sendMessageSpy.mockClear();
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("unknown or expired approval id"));
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-expired-approval",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 26,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
const approvalCall = execApprovalCall();
expect(approvalCall.approvalId).toBe("138e9b8c");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.allowPluginFallback).toBe(true);
expect(approvalCall.senderId).toBe("9");
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-expired-approval");
});
it("keeps plugin approval callback buttons for target-only recipients", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
loadConfig.mockReturnValue({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "9" }],
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
capabilities: ["vision"],
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-approve-blocked",
data: "/approve plugin:138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 24,
text: "Plugin approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-plugin-approve-blocked");
});
it("edits commands list for pagination callbacks", async () => {
onSpy.mockClear();
listSkillCommandsForAgents.mockClear();
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-3",
data: "commands_page_2:main",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 12,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
const listCall = requireRecord(
mockArg(
listSkillCommandsForAgents as unknown as MockCallSource,
0,
0,
"list skill commands call",
),
"list skill commands call",
);
expect(listCall.cfg).toBeTypeOf("object");
expect(listCall.agentIds).toEqual(["main"]);
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
const [chatId, messageId, text, params] = mockCall(
editMessageTextSpy as unknown as MockCallSource,
0,
"edit message text",
);
expect(chatId).toBe(1234);
expect(messageId).toBe(12);
expect(String(text)).toContain(`${INFO_EMOJI} Commands (2/`);
expect(params).toEqual({
reply_markup: {
inline_keyboard: [
[
{ text: "◀ Prev", callback_data: "commands_page_1:main" },
{ text: "2/6", callback_data: "commands_page_noop:main" },
{ text: "Next ▶", callback_data: "commands_page_3:main" },
],
],
},
});
});
it("falls back to default agent for pagination callbacks without agent suffix", async () => {
onSpy.mockClear();
listSkillCommandsForAgents.mockClear();
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-no-suffix",
data: "commands_page_2",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 14,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
const listCall = requireRecord(
mockArg(
listSkillCommandsForAgents as unknown as MockCallSource,
0,
0,
"list skill commands call",
),
"list skill commands call",
);
expect(listCall.cfg).toBeTypeOf("object");
expect(listCall.agentIds).toEqual(["main"]);
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
});
it("blocks pagination callbacks when allowlist rejects sender", async () => {
onSpy.mockClear();
editMessageTextSpy.mockClear();
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "pairing",
capabilities: { inlineButtons: "allowlist" },
allowFrom: [],
},
},
},
});
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-4",
data: "commands_page_2",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 13,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-4");
});
it("routes compact model callbacks by inferring provider", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const modelId = "us.anthropic.claude-3-5-sonnet-20240620-v1:0";
const storePath = `/tmp/openclaw-telegram-model-compact-${process.pid}-${Date.now()}.json`;
const config: OpenClawConfig = {
agents: {
defaults: {
model: `bedrock/${modelId}`,
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
await rm(storePath, { force: true });
try {
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-model-compact-1",
data: `mdl_sel/${modelId}`,
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 14,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
expect(String(firstEditMessageTextArg(2))).toContain(
`${CHECK_MARK_EMOJI} Model reset to default`,
);
expect(String(firstEditMessageTextArg(2))).toContain(
"Session selection cleared. Runtime unchanged. New replies use the agent's configured default.",
);
const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0];
expect(entry?.providerOverride).toBeUndefined();
expect(entry?.modelOverride).toBeUndefined();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-compact-1");
} finally {
await rm(storePath, { force: true });
}
});
it("renders model callback lists with configured display names", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const buildModelsProviderDataMock =
telegramBotDepsForTest.buildModelsProviderData as unknown as ReturnType<typeof vi.fn>;
buildModelsProviderDataMock.mockResolvedValueOnce({
byProvider: new Map<string, Set<string>>([["openai", new Set(["gpt-5", "gpt-4.1"])]]),
providers: ["openai"],
resolvedDefault: { provider: "openai", model: "gpt-5" },
modelNames: new Map<string, string>([
["openai/gpt-4.1", "GPT 4.1 Bridge"],
["openai/gpt-5", "GPT Five Bridge"],
]),
});
const config = {
agents: {
defaults: {
model: "openai/gpt-5",
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-model-display-names-1",
data: "mdl_list_openai_1",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 23,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
const params = firstEditMessageTextArg(3);
const inlineKeyboard = (
params as {
reply_markup?: {
inline_keyboard?: Array<Array<{ text?: string; callback_data?: string }>>;
};
}
).reply_markup?.inline_keyboard;
expect(inlineKeyboard).toStrictEqual([
[{ text: "GPT 4.1 Bridge", callback_data: "mdl_sel_openai/gpt-4.1" }],
[{ text: "GPT Five Bridge ✓", callback_data: "mdl_sel_openai/gpt-5" }],
[{ text: "<< Back", callback_data: "mdl_back" }],
]);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-display-names-1");
});
it("resets overrides when selecting the configured default model", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-model-default-${process.pid}-${Date.now()}.json`;
const config: OpenClawConfig = {
agents: {
defaults: {
model: "claude-opus-4-6",
models: {
"anthropic/claude-opus-4-6": {},
},
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
session: {
store: storePath,
},
};
await rm(storePath, { force: true });
try {
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-model-default-1",
data: "mdl_sel_anthropic/claude-opus-4-6",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 16,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
expect(String(firstEditMessageTextArg(2))).toContain(
`${CHECK_MARK_EMOJI} Model reset to default`,
);
expect(String(firstEditMessageTextArg(2))).toContain(
"Session selection cleared. Runtime unchanged. New replies use the agent's configured default.",
);
const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0];
expect(entry?.providerOverride).toBeUndefined();
expect(entry?.modelOverride).toBeUndefined();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-default-1");
} finally {
await rm(storePath, { force: true });
}
});
it("formats non-default model selection confirmations with Telegram HTML parse mode", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-model-html-${process.pid}-${Date.now()}.json`;
await rm(storePath, { force: true });
try {
const config = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-5.4": {},
},
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-model-html-1",
data: "mdl_sel_openai/gpt-5.4",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 17,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
const editCall = mockCall(
editMessageTextSpy as unknown as MockCallSource,
0,
"edit message text",
);
expect(editCall[0]).toBe(1234);
expect(editCall[1]).toBe(17);
expect(editCall[2]).toBe(
`${CHECK_MARK_EMOJI} Model changed to <b>openai/gpt-5.4</b>\n\nSession-only model selection. Runtime unchanged. Use /model openai/gpt-5.4 --runtime &lt;runtime&gt; to switch harnesses. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`,
);
expect(requireRecord(editCall[3], "edit params").parse_mode).toBe("HTML");
const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0];
expect(entry?.providerOverride).toBe("openai");
expect(entry?.modelOverride).toBe("gpt-5.4");
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-html-1");
} finally {
await rm(storePath, { force: true });
}
});
it("persists non-default model override using fresh config, not stale startup snapshot", async () => {
// Regression: the callback handler used the startup `cfg` snapshot for
// store path and default-model resolution. If the config was reloaded
// (e.g. default model changed) the override could be written to the wrong
// store or incorrectly cleared because `isDefaultSelection` was wrong.
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-model-fresh-cfg-${process.pid}-${Date.now()}.json`;
await rm(storePath, { force: true });
try {
// Startup config: default is openai/gpt-5.4
const startupConfig = {
agents: {
defaults: {
model: "openai/gpt-5.4",
models: {
"openai/gpt-5.4": {},
"anthropic/claude-opus-4-6": {},
},
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
// Fresh config: default changed to anthropic/claude-opus-4-6
const freshConfig = {
...startupConfig,
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"openai/gpt-5.4": {},
"anthropic/claude-opus-4-6": {},
},
},
},
};
// Bot created with startup config; loadConfig now returns fresh config
loadConfig.mockReturnValue(freshConfig);
createTelegramBot({
token: "tok",
config: startupConfig,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
// User selects openai/gpt-5.4 — was default at startup but NOT default
// in fresh config. The override must be persisted.
await callbackHandler({
callbackQuery: {
id: "cbq-model-fresh-cfg-1",
data: "mdl_sel_openai/gpt-5.4",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 20,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
// Override must be persisted (not cleared) because openai/gpt-5.4 is
// NOT the default in the fresh config.
const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0];
expect(entry?.providerOverride).toBe("openai");
expect(entry?.modelOverride).toBe("gpt-5.4");
} finally {
await rm(storePath, { force: true });
}
});
it("rejects ambiguous compact model callbacks and returns provider list", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
createTelegramBot({
token: "tok",
config: {
agents: {
defaults: {
model: "anthropic/shared-model",
models: {
"anthropic/shared-model": {},
"openai/shared-model": {},
},
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
});
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-model-compact-2",
data: "mdl_sel/shared-model",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 15,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
expect(String(firstEditMessageTextArg(2))).toContain('Could not resolve model "shared-model".');
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-compact-2");
});
it("includes sender identity in group envelope headers", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
const { expectChannelInboundContextContract: expectInboundContextContract } =
await loadInboundContextContract();
const { escapeRegExp, formatEnvelopeTimestamp } = await loadEnvelopeTimestampHelpers();
expectInboundContextContract(payload);
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
});
it("adds live chat and reply-target windows for stale group replies", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const baseCtx = {
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
};
await handler({
...baseCtx,
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "Earlier deployment answer",
date: 1736380200,
message_id: 100,
from: { id: 777, is_bot: true, first_name: "Assistant" },
},
});
await handler({
...baseCtx,
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "Lunch after standup?",
date: 1736380800,
message_id: 200,
from: { id: 201, is_bot: false, first_name: "Sam" },
},
});
await handler({
...baseCtx,
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "After the incident review.",
date: 1736380860,
message_id: 201,
from: { id: 202, is_bot: false, first_name: "Riley" },
},
});
replySpy.mockClear();
await handler({
...baseCtx,
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "@openclaw_bot thoughts?",
date: 1736380920,
message_id: 202,
from: { id: 203, is_bot: false, first_name: "Avery" },
reply_to_message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "Earlier deployment answer",
date: 1736380200,
message_id: 100,
from: { id: 777, is_bot: true, first_name: "Assistant" },
},
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
const [conversationContext] = requireArray(
payload.UntrustedStructuredContext,
"structured context",
);
const contextRecord = requireRecord(conversationContext, "conversation context");
expect(contextRecord.label).toBe("Conversation context");
const contextPayload = requireRecord(contextRecord.payload, "conversation context payload");
expect(contextPayload.relation).toBe("selected_for_current_message");
const messages = requireArray(contextPayload.messages, "conversation context messages").map(
(message, index) => requireRecord(message, `conversation context message ${index + 1}`),
);
const messagesById = new Map(messages.map((message) => [message.message_id, message]));
expect(messagesById.get("100")?.sender).toBe("Assistant");
expect(messagesById.get("100")?.body).toBe("Earlier deployment answer");
expect(messagesById.get("100")?.is_reply_target).toBe(true);
expect(messagesById.get("200")?.sender).toBe("Sam");
expect(messagesById.get("200")?.body).toBe("Lunch after standup?");
expect(messagesById.get("201")?.sender).toBe("Riley");
expect(messagesById.get("201")?.body).toBe("After the incident review.");
});
it("updates cached bot messages from Telegram edit updates", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const editedHandler = getOnHandler("edited_message") as (
ctx: Record<string, unknown>,
) => Promise<void>;
const baseCtx = {
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
};
const chat = { id: 42, type: "group", title: "Ops" };
const question = {
chat,
text: "/ask which bikes can reach 383kmph",
date: 1778474813,
message_id: 35014,
from: { id: 201, is_bot: false, first_name: "Kesava" },
};
const fullAnswer =
"Kawasaki Ninja H2R (claimed 400 km/h) and MTT 420RR turbine (claimed up to 439 km/h) exceed 383 km/h. Dodge Tomahawk reaches higher but is a 4-wheeled concept, not a standard bike.";
await handler({
...baseCtx,
message: question,
});
await handler({
...baseCtx,
message: {
chat,
text: "K",
date: 1778474823,
message_id: 35016,
from: { id: 777, is_bot: true, first_name: "Super Serious Bot" },
reply_to_message: question,
},
});
replySpy.mockClear();
await editedHandler({
...baseCtx,
editedMessage: {
chat,
text: fullAnswer,
date: 1778474823,
edit_date: 1778474824,
message_id: 35016,
from: { id: 777, is_bot: true, first_name: "Super Serious Bot" },
reply_to_message: question,
},
});
expect(replySpy).not.toHaveBeenCalled();
await handler({
...baseCtx,
message: {
chat,
text: "wtf",
date: 1778474850,
message_id: 35018,
from: { id: 202, is_bot: false, first_name: "Kesava" },
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
const [conversationContext] = requireArray(
payload.UntrustedStructuredContext,
"structured context",
);
const contextRecord = requireRecord(conversationContext, "conversation context");
const contextPayload = requireRecord(contextRecord.payload, "conversation context payload");
const messages = requireArray(contextPayload.messages, "conversation context messages").map(
(message, index) => requireRecord(message, `conversation context message ${index + 1}`),
);
const messagesById = new Map(messages.map((message) => [message.message_id, message]));
expect(messagesById.get("35016")?.sender).toBe("Super Serious Bot");
expect(messagesById.get("35016")?.body).toBe(fullAnswer);
expect(messagesById.get("35016")?.body).not.toBe("K");
});
it("uses quote text when a Telegram partial reply is received", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Sure, see below",
date: 1736380800,
reply_to_message: {
message_id: 9001,
text: "Can you summarize this?",
from: { first_name: "Ada" },
},
quote: {
text: " summarize this\n",
position: 8,
entities: [{ type: "bold", offset: 1, length: 9 }],
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.Body).toContain("[Reply chain - nearest first]");
expect(payload.Body).toContain("[1. Ada id:9001]");
expect(payload.Body).toContain('"summarize this"');
expect(payload.ReplyToId).toBe("9001");
expect(payload.ReplyToBody).toBe("summarize this");
expect(payload.ReplyToSender).toBe("Ada");
const telegramPayload = payload as Record<string, unknown>;
expect(telegramPayload.ReplyToQuoteText).toBe(" summarize this\n");
expect(telegramPayload.ReplyToQuotePosition).toBe(8);
expect(telegramPayload.ReplyToQuoteEntities).toEqual([{ type: "bold", offset: 1, length: 9 }]);
expect(telegramPayload.ReplyToQuoteSourceText).toBe("Can you summarize this?");
});
it("keeps reply linkage while omitting filtered binary reply captions", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Sure, see below",
date: 1736380800,
reply_to_message: {
message_id: 9001,
caption: "PK\x00\x03\x04binary",
from: { first_name: "Ada" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.Body).toContain("[Reply chain - nearest first]");
expect(payload.Body).toContain("[1. Ada id:9001]");
expect(payload.Body).not.toContain("PK");
expect(payload.Body).not.toContain("unsafe reply text omitted");
expect(payload.ReplyToBody).toBeUndefined();
expect(payload.ReplyToId).toBe("9001");
expect(payload.ReplyToSender).toBe("Ada");
});
it("includes replied image media in inbound context for text replies", async () => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
loadWebMedia.mockResolvedValueOnce({ path: "/tmp/reply-photo.png", contentType: "image/png" });
const mediaFetch = vi.fn(
async () =>
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const ssrfMock = mockPinnedHostnameResolution();
try {
createTelegramBot({
token: "tok",
telegramTransport: {
fetch: mediaFetch as typeof fetch,
sourceFetch: mediaFetch as typeof fetch,
close: async () => {},
},
});
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "what is in this image?",
date: 1736380800,
reply_to_message: {
message_id: 9001,
photo: [{ file_id: "reply-photo-1" }],
from: { first_name: "Ada" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
} finally {
ssrfMock.mockRestore();
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(
replySpy as unknown as MockCallSource,
0,
0,
"replySpy call",
) as {
MediaPath?: string;
MediaPaths?: string[];
ReplyToBody?: string;
};
expect(payload.ReplyToBody).toBe("<media:image>");
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1");
expect(loadWebMedia).not.toHaveBeenCalled();
expect(mediaFetch).toHaveBeenCalledTimes(1);
});
it("hydrates reply chains from cached Telegram messages", async () => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
const mediaFetch = vi.fn(
async () =>
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const ssrfMock = mockPinnedHostnameResolution();
try {
createTelegramBot({
token: "tok",
telegramTransport: {
fetch: mediaFetch as typeof fetch,
sourceFetch: mediaFetch as typeof fetch,
close: async () => {},
},
});
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
message_id: 9000,
date: 1736380700,
from: { id: 1, first_name: "Kesava" },
photo: [{ file_id: "root-photo-1" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "media/root.jpg" }),
});
await handler({
message: {
chat: { id: 7, type: "private" },
message_id: 9001,
text: "r u back from hermes",
date: 1736380750,
from: { id: 2, first_name: "Ada" },
reply_to_message: {
message_id: 9000,
photo: [{ file_id: "root-photo-1" }],
from: { id: 1, first_name: "Kesava" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
replySpy.mockClear();
getFileSpy.mockClear();
mediaFetch.mockClear();
await handler({
message: {
chat: { id: 7, type: "private" },
message_id: 9002,
text: "why did you reply?",
date: 1736380800,
from: { id: 3, first_name: "Grace" },
reply_to_message: {
message_id: 9001,
text: "r u back from hermes",
from: { id: 2, first_name: "Ada" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
} finally {
ssrfMock.mockRestore();
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(
replySpy as unknown as MockCallSource,
0,
0,
"replySpy call",
) as {
ReplyChain?: Array<{
messageId?: string;
body?: string;
mediaPath?: string;
mediaRef?: string;
replyToId?: string;
}>;
};
expect(payload.ReplyChain).toHaveLength(2);
expect(payload.ReplyChain?.[0]?.messageId).toBe("9001");
expect(payload.ReplyChain?.[0]?.body).toBe("r u back from hermes");
expect(payload.ReplyChain?.[0]?.replyToId).toBe("9000");
expect(payload.ReplyChain?.[1]?.messageId).toBe("9000");
expect(payload.ReplyChain?.[1]?.mediaRef).toBe("telegram:file/root-photo-1");
expect(payload.ReplyChain?.[1]?.mediaPath).toBeTypeOf("string");
expect(payload.ReplyChain?.[1]?.mediaPath).not.toBe("");
expect(getFileSpy).toHaveBeenCalledWith("root-photo-1");
expect(mediaFetch).toHaveBeenCalledTimes(1);
});
it("hydrates bot reply targets cached from earlier Telegram replies", async () => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
const mediaFetch = vi.fn(
async () =>
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const ssrfMock = mockPinnedHostnameResolution();
try {
createTelegramBot({
token: "tok",
telegramTransport: {
fetch: mediaFetch as typeof fetch,
sourceFetch: mediaFetch as typeof fetch,
close: async () => {},
},
});
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const baseCtx = {
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
};
const chat = { id: 7, type: "group", title: "Ops" };
await handler({
...baseCtx,
message: {
chat,
message_id: 102,
text: "Why is there a 4th person?",
date: 1736380750,
from: { id: 2, is_bot: false, first_name: "UserB" },
reply_to_message: {
chat,
message_id: 101,
text: "Done, here is the image",
date: 1736380700,
from: { id: 999, is_bot: true, first_name: "OpenClaw" },
photo: [{ file_id: "generated-photo-1" }],
},
},
});
replySpy.mockClear();
getFileSpy.mockClear();
mediaFetch.mockClear();
await handler({
...baseCtx,
message: {
chat,
message_id: 103,
text: "@openclaw_bot explain what went wrong",
date: 1736380800,
from: { id: 1, is_bot: false, first_name: "UserA" },
reply_to_message: {
chat,
message_id: 102,
text: "Why is there a 4th person?",
date: 1736380750,
from: { id: 2, is_bot: false, first_name: "UserB" },
},
},
});
} finally {
ssrfMock.mockRestore();
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(
replySpy as unknown as MockCallSource,
0,
0,
"replySpy call",
) as {
ReplyChain?: Array<{
messageId?: string;
sender?: string;
body?: string;
mediaRef?: string;
mediaPath?: string;
}>;
UntrustedStructuredContext?: unknown[];
};
expect(payload.ReplyChain?.map((entry) => entry.messageId)).toEqual(["102", "101"]);
expect(payload.ReplyChain?.[1]).toMatchObject({
sender: "OpenClaw",
body: "Done, here is the image",
mediaRef: "telegram:file/generated-photo-1",
});
expect(payload.ReplyChain?.[1]?.mediaPath).toBeTypeOf("string");
const [conversationContext] = requireArray(
payload.UntrustedStructuredContext,
"structured context",
);
const contextRecord = requireRecord(conversationContext, "conversation context");
const contextPayload = requireRecord(contextRecord.payload, "conversation context payload");
const messages = requireArray(contextPayload.messages, "conversation context messages").map(
(message, index) => requireRecord(message, `conversation context message ${index + 1}`),
);
const messagesById = new Map(messages.map((message) => [message.message_id, message]));
expect(messagesById.get("101")).toMatchObject({
sender: "OpenClaw",
body: "Done, here is the image",
media_ref: "telegram:file/generated-photo-1",
is_reply_target: true,
});
expect(messagesById.get("102")).toMatchObject({
sender: "UserB",
body: "Why is there a 4th person?",
reply_to_id: "101",
is_reply_target: true,
});
expect(getFileSpy).toHaveBeenCalledWith("generated-photo-1");
expect(mediaFetch).toHaveBeenCalledTimes(1);
});
it("does not fetch reply media for unauthorized DM replies", async () => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
sendMessageSpy.mockClear();
readChannelAllowFromStore.mockResolvedValue([]);
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "pairing",
allowFrom: [],
},
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "hey",
date: 1736380800,
from: { id: 999, first_name: "Eve" },
reply_to_message: {
message_id: 9001,
photo: [{ file_id: "reply-photo-1" }],
from: { first_name: "Ada" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
expect(getFileSpy).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
});
it("defers reply media download until debounce flush", async () => {
const DEBOUNCE_MS = 4321;
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
messages: {
inbound: {
debounceMs: DEBOUNCE_MS,
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
const mediaFetch = vi.fn(
async () =>
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const ssrfMock = mockPinnedHostnameResolution();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const replyDelivered = waitForReplyCalls(1);
createTelegramBot({
token: "tok",
telegramTransport: {
fetch: mediaFetch as typeof fetch,
sourceFetch: mediaFetch as typeof fetch,
close: async () => {},
},
});
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "first",
date: 1736380800,
message_id: 101,
from: { id: 42, first_name: "Ada" },
reply_to_message: {
message_id: 9001,
photo: [{ file_id: "reply-photo-1" }],
from: { first_name: "Ada" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
await handler({
message: {
chat: { id: 7, type: "private" },
text: "second",
date: 1736380801,
message_id: 102,
from: { id: 42, first_name: "Ada" },
reply_to_message: {
message_id: 9001,
photo: [{ file_id: "reply-photo-1" }],
from: { first_name: "Ada" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
expect(replySpy).not.toHaveBeenCalled();
expect(getFileSpy).not.toHaveBeenCalled();
const flushTimerCallIndex = setTimeoutSpy.mock.calls.findLastIndex(
(call) => call[1] === DEBOUNCE_MS,
);
const flushTimer =
flushTimerCallIndex >= 0
? (setTimeoutSpy.mock.calls[flushTimerCallIndex]?.[0] as (() => unknown) | undefined)
: undefined;
if (flushTimerCallIndex >= 0) {
clearTimeout(
setTimeoutSpy.mock.results[flushTimerCallIndex]?.value as ReturnType<typeof setTimeout>,
);
}
expect(flushTimer).toBeTypeOf("function");
await flushTimer?.();
await replyDelivered;
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1");
expect(mediaFetch).toHaveBeenCalled();
} finally {
setTimeoutSpy.mockRestore();
ssrfMock.mockRestore();
}
});
it("handles quote-only replies without reply metadata", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Sure, see below",
date: 1736380800,
quote: {
text: "summarize this",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.Body).toContain("[Reply chain - nearest first]");
expect(payload.Body).toContain("[1. unknown sender");
expect(payload.Body).toContain('"summarize this"');
expect(payload.ReplyToId).toBeUndefined();
expect(payload.ReplyToBody).toBe("summarize this");
expect(payload.ReplyToSender).toBe("unknown sender");
});
it("uses top-level quote text for external partial replies", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Sure, see below",
date: 1736380800,
quote: {
text: "summarize this",
},
external_reply: {
message_id: 9002,
text: "Can you summarize this?",
from: { first_name: "Ada" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.Body).toContain("[Reply chain - nearest first]");
expect(payload.Body).toContain("[1. Ada id:9002");
expect(payload.Body).toContain('"summarize this"');
expect(payload.ReplyToId).toBe("9002");
expect(payload.ReplyToBody).toBe("summarize this");
expect(payload.ReplyToSender).toBe("Ada");
expect((payload as Record<string, unknown>).ReplyToIsExternal).toBe(true);
});
it("propagates forwarded origin from external_reply targets", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
replySpy.mockReset();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Thoughts?",
date: 1736380800,
external_reply: {
message_id: 9003,
text: "forwarded text",
from: { first_name: "Ada" },
quote: {
text: "forwarded snippet",
},
forward_origin: {
type: "user",
sender_user: {
id: 999,
first_name: "Bob",
last_name: "Smith",
username: "bobsmith",
is_bot: false,
},
date: 500,
},
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.ReplyToForwardedFrom).toBe("Bob Smith (@bobsmith)");
expect(payload.ReplyToForwardedFromType).toBe("user");
expect(payload.ReplyToForwardedFromId).toBe("999");
expect(payload.ReplyToForwardedFromUsername).toBe("bobsmith");
expect(payload.ReplyToForwardedFromTitle).toBe("Bob Smith");
expect(payload.ReplyToForwardedDate).toBe(500000);
expect(payload.Body).toContain(
"[Forwarded from Bob Smith (@bobsmith) at 1970-01-01T00:08:20.000Z]",
);
});
it("redacts forwarded origin inside reply targets when context visibility is allowlist", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
contextVisibility: "allowlist",
groups: {
"-1007": {
requireMention: false,
allowFrom: ["1"],
},
},
},
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
message_id: 9004,
chat: { id: -1007, type: "group", title: "Ops" },
text: "Thoughts?",
date: 1736380800,
from: { id: 1, first_name: "Ada", username: "ada", is_bot: false },
reply_to_message: {
message_id: 9003,
text: "forwarded text",
from: { id: 1, first_name: "Ada", username: "ada", is_bot: false },
forward_origin: {
type: "user",
sender_user: {
id: 999,
first_name: "Bob",
last_name: "Smith",
username: "bobsmith",
is_bot: false,
},
date: 500,
},
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.ReplyToId).toBe("9003");
expect(payload.ReplyToBody).toBe("forwarded text");
expect(payload.ReplyToSender).toBe("Ada");
expect(payload.ReplyToForwardedFrom).toBeUndefined();
expect(payload.ReplyToForwardedFromType).toBeUndefined();
expect(payload.ReplyToForwardedFromId).toBeUndefined();
expect(payload.ReplyToForwardedFromUsername).toBeUndefined();
expect(payload.ReplyToForwardedDate).toBeUndefined();
expect(payload.Body).not.toContain("[Forwarded from Bob Smith (@bobsmith)");
});
it("accepts group replies to the bot without explicit mention when requireMention is enabled", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: { groups: { "*": { requireMention: true } } },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 456, type: "group", title: "Ops Chat" },
text: "following up",
date: 1736380800,
reply_to_message: {
message_id: 42,
text: "original reply",
from: { id: 999, first_name: "OpenClaw" },
},
},
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.WasMentioned).toBe(true);
});
it("prefers topic allowFrom over group allowFrom", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-1001234567890": {
allowFrom: ["123456789"],
topics: {
"99": { allowFrom: ["999999999"] },
},
},
},
},
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
from: { id: 123456789, username: "testuser" },
text: "hello",
date: 1736380800,
message_thread_id: 99,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(0);
});
it("allows group messages for per-group groupPolicy open override (global groupPolicy allowlist)", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-100123456789": {
groupPolicy: "open",
requireMention: false,
},
},
},
},
});
readChannelAllowFromStore.mockResolvedValueOnce(["123456789"]);
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: -100123456789, type: "group", title: "Test Group" },
from: { id: 999999, username: "random" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("blocks control commands from unauthorized senders in per-group open groups", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-100123456789": {
groupPolicy: "open",
requireMention: false,
},
},
},
},
});
readChannelAllowFromStore.mockResolvedValueOnce(["123456789"]);
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: -100123456789, type: "group", title: "Test Group" },
from: { id: 999999, username: "random" },
text: "/status",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
});
it("routes plugin-owned callback namespaces before synthetic command fallback", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
sendMessageSpy.mockClear();
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: (async ({ respond, callback }: TelegramInteractiveHandlerContext) => {
await respond.editMessage({
text: `Handled ${callback.payload}`,
});
return { handled: true };
}) as never,
});
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
});
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await callbackHandler({
callbackQuery: {
id: "cbq-codex-1",
data: "codexapp:resume:thread-1",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 11,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageTextSpy).toHaveBeenCalledWith(1234, 11, "Handled resume:thread-1", undefined);
expect(replySpy).not.toHaveBeenCalled();
});
it("passes false command auth to Telegram plugin callbacks for non-allowlisted group senders", async () => {
onSpy.mockClear();
let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined;
const handler = vi.fn(async ({ auth }: TelegramInteractiveHandlerContext) => {
observedAuth = auth;
return { handled: true };
});
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: handler as never,
});
const config = {
commands: {
allowFrom: {
telegram: ["111111111"],
},
},
channels: {
telegram: {
dmPolicy: "open",
capabilities: { inlineButtons: "group" },
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-auth-false",
data: "codexapp:resume:thread-1",
from: { id: 999999999, first_name: "Mallory", username: "mallory" },
message: {
chat: { id: -100999, type: "supergroup", title: "Test Group" },
date: 1736380800,
message_id: 22,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(handler).toHaveBeenCalledOnce();
expect(observedAuth?.isAuthorizedSender).toBe(false);
});
it("passes true command auth to Telegram plugin callbacks for allowlisted group senders", async () => {
onSpy.mockClear();
let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined;
const handler = vi.fn(async ({ auth }: TelegramInteractiveHandlerContext) => {
observedAuth = auth;
return { handled: true };
});
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: handler as never,
});
const config = {
commands: {
allowFrom: {
telegram: ["111111111"],
},
},
channels: {
telegram: {
dmPolicy: "open",
capabilities: { inlineButtons: "group" },
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-auth-true",
data: "codexapp:resume:thread-1",
from: { id: 111111111, first_name: "Ada", username: "ada" },
message: {
chat: { id: -100999, type: "supergroup", title: "Test Group" },
date: 1736380800,
message_id: 23,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(handler).toHaveBeenCalledOnce();
expect(observedAuth?.isAuthorizedSender).toBe(true);
});
it("passes true command auth to Telegram plugin callbacks for access-group DM senders", async () => {
onSpy.mockClear();
let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined;
const handler = vi.fn(async ({ auth }: TelegramInteractiveHandlerContext) => {
observedAuth = auth;
return { handled: true };
});
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: handler as never,
});
const config = {
accessGroups: {
operators: {
type: "message.senders",
members: { telegram: ["123456789"] },
},
},
channels: {
telegram: {
dmPolicy: "allowlist",
allowFrom: ["accessGroup:operators"],
capabilities: { inlineButtons: "dm" },
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-access-group-auth",
data: "codexapp:resume:thread-1",
from: { id: 123456789, first_name: "Ada", username: "ada" },
message: {
chat: { id: 123456789, type: "private" },
date: 1736380800,
message_id: 24,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(handler).toHaveBeenCalledOnce();
expect(observedAuth?.isAuthorizedSender).toBe(true);
});
it("routes Telegram #General callback payloads as topic 1 when Telegram omits topic metadata", async () => {
onSpy.mockClear();
getChatSpy.mockResolvedValue({ id: -100123456789, type: "supergroup", is_forum: true });
const handler = vi.fn(
async ({ respond, conversationId, threadId }: TelegramInteractiveHandlerContext) => {
expect(conversationId).toBe("-100123456789:topic:1");
expect(threadId).toBe(1);
await respond.editMessage({
text: `Handled ${conversationId}`,
});
return { handled: true };
},
);
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: handler as never,
});
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
});
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await callbackHandler({
callbackQuery: {
id: "cbq-codex-general",
data: "codexapp:resume:thread-1",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: -100123456789, type: "supergroup", title: "Forum Group" },
date: 1736380800,
message_id: 11,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(getChatSpy).toHaveBeenCalledWith(-100123456789);
expect(handler).toHaveBeenCalledOnce();
expect(editMessageTextSpy).toHaveBeenCalledWith(
-100123456789,
11,
"Handled -100123456789:topic:1",
undefined,
);
});
it("keeps unconfigured dm topic commands on the flat dm session", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
commandSpy.mockClear();
replySpy.mockClear();
replySpy.mockResolvedValue({ text: "response" });
loadConfig.mockReturnValue({
commands: { native: true },
channels: {
telegram: {
dmPolicy: "pairing",
},
},
});
readChannelAllowFromStore.mockResolvedValueOnce(["12345"]);
createTelegramBot({ token: "tok" });
const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as
| ((ctx: Record<string, unknown>) => Promise<void>)
| undefined;
if (!handler) {
throw new Error("status command handler missing");
}
await handler({
message: {
chat: { id: 12345, type: "private" },
from: { id: 12345, username: "testuser" },
text: "/status",
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
match: "",
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.CommandTargetSessionKey).toBe("agent:main:main");
});
it("uses bot topic capability for native dm topic command target sessions", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
commandSpy.mockClear();
replySpy.mockClear();
replySpy.mockResolvedValue({ text: "response" });
loadConfig.mockReturnValue({
commands: { native: true },
channels: {
telegram: {
dmPolicy: "pairing",
},
},
});
readChannelAllowFromStore.mockResolvedValueOnce(["12345"]);
createTelegramBot({ token: "tok" });
const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as
| ((ctx: Record<string, unknown>) => Promise<void>)
| undefined;
if (!handler) {
throw new Error("status command handler missing");
}
await handler({
message: {
chat: { id: 12345, type: "private" },
from: { id: 12345, username: "testuser" },
text: "/status",
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
me: { has_topics_enabled: true },
match: "",
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.CommandTargetSessionKey).toBe("agent:main:main:thread:12345:99");
});
it("allows native DM commands for paired users", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
commandSpy.mockClear();
replySpy.mockClear();
replySpy.mockResolvedValue({ text: "response" });
loadConfig.mockReturnValue({
commands: { native: true },
channels: {
telegram: {
dmPolicy: "pairing",
},
},
});
readChannelAllowFromStore.mockResolvedValueOnce(["12345"]);
createTelegramBot({ token: "tok" });
const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as
| ((ctx: Record<string, unknown>) => Promise<void>)
| undefined;
if (!handler) {
throw new Error("status command handler missing");
}
await handler({
message: {
chat: { id: 12345, type: "private" },
from: { id: 12345, username: "testuser" },
text: "/status",
date: 1736380800,
message_id: 42,
},
match: "",
});
expect(replySpy).toHaveBeenCalledTimes(1);
expect(
sendMessageSpy.mock.calls.some(
(call) => call[1] === "You are not authorized to use this command.",
),
).toBe(false);
});
it("keeps native DM commands on the startup-resolved config when fresh reads contain SecretRefs", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
commandSpy.mockClear();
replySpy.mockClear();
replySpy.mockResolvedValue({ text: "response" });
const startupConfig = {
commands: { native: true },
channels: {
telegram: {
dmPolicy: "pairing" as const,
botToken: "resolved-token",
},
},
};
createTelegramBot({
token: "tok",
config: startupConfig,
});
loadConfig.mockReturnValue({
commands: { native: true },
channels: {
telegram: {
dmPolicy: "pairing",
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
},
},
});
readChannelAllowFromStore.mockResolvedValueOnce(["12345"]);
const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as
| ((ctx: Record<string, unknown>) => Promise<void>)
| undefined;
if (!handler) {
throw new Error("status command handler missing");
}
await handler({
message: {
chat: { id: 12345, type: "private" },
from: { id: 12345, username: "testuser" },
text: "/status",
date: 1736380800,
message_id: 42,
},
match: "",
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("blocks native DM commands for unpaired users", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
commandSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
commands: { native: true },
channels: {
telegram: {
dmPolicy: "pairing",
},
},
});
readChannelAllowFromStore.mockResolvedValueOnce([]);
createTelegramBot({ token: "tok" });
const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as
| ((ctx: Record<string, unknown>) => Promise<void>)
| undefined;
if (!handler) {
throw new Error("status command handler missing");
}
await handler({
message: {
chat: { id: 12345, type: "private" },
from: { id: 12345, username: "testuser" },
text: "/status",
date: 1736380800,
message_id: 42,
},
match: "",
});
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith(
12345,
"You are not authorized to use this command.",
{},
);
});
it("registers message_reaction handler", () => {
onSpy.mockClear();
createTelegramBot({ token: "tok" });
const reactionHandler = onSpy.mock.calls.find((call) => call[0] === "message_reaction");
expect(reactionHandler?.[0]).toBe("message_reaction");
if (typeof reactionHandler?.[1] !== "function") {
throw new Error("expected message_reaction handler");
}
});
it("enqueues system event for reaction", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 500 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada", username: "ada_bot" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
expect(firstSystemEventArg(0)).toBe(
`Telegram reaction added: ${THUMBS_UP_EMOJI} by Ada (@ada_bot) on msg 42`,
);
expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:1234:42:9");
});
it.each([
{
name: "blocks reaction when dmPolicy is disabled",
updateId: 510,
channelConfig: { dmPolicy: "disabled", reactionNotifications: "all" },
reaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
expectedEnqueueCalls: 0,
},
{
name: "blocks reaction in pairing mode for non-paired sender (default dmPolicy)",
updateId: 514,
channelConfig: { dmPolicy: "pairing", reactionNotifications: "all" },
reaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
expectedEnqueueCalls: 0,
},
{
name: "blocks reaction in allowlist mode for unauthorized direct sender",
updateId: 511,
channelConfig: {
dmPolicy: "allowlist",
allowFrom: ["12345"],
reactionNotifications: "all",
},
reaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
expectedEnqueueCalls: 0,
},
{
name: "allows reaction in allowlist mode for authorized direct sender",
updateId: 512,
channelConfig: { dmPolicy: "allowlist", allowFrom: ["9"], reactionNotifications: "all" },
reaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
expectedEnqueueCalls: 1,
},
{
name: "blocks reaction in open mode when wildcard access was constrained",
updateId: 515,
channelConfig: { dmPolicy: "open", allowFrom: ["12345"], reactionNotifications: "all" },
reaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
expectedEnqueueCalls: 0,
},
{
name: "allows reaction in open mode with explicit wildcard access",
updateId: 516,
channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
reaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
expectedEnqueueCalls: 1,
},
{
name: "blocks reaction in group allowlist mode for unauthorized sender",
updateId: 513,
channelConfig: {
dmPolicy: "open",
groupPolicy: "allowlist",
groupAllowFrom: ["12345"],
reactionNotifications: "all",
},
reaction: {
chat: { id: 9999, type: "supergroup" },
message_id: 77,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: FIRE_EMOJI }],
},
expectedEnqueueCalls: 0,
},
])("$name", async ({ updateId, channelConfig, reaction, expectedEnqueueCalls }) => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: channelConfig,
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: updateId },
messageReaction: reaction,
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(expectedEnqueueCalls);
});
it("skips reaction when reactionNotifications is off", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(true);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", reactionNotifications: "off" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 501 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
});
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("defaults reactionNotifications to own", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(true);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 502 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 43,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
});
it("allows reaction in all mode regardless of message sender", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(false);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 503 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 99,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
expect(firstSystemEventArg(0)).toBe(`Telegram reaction added: ${PARTY_EMOJI} by Ada on msg 99`);
expect(firstSystemEventArg(1)).toBeTypeOf("object");
});
it("skips reaction in own mode when message is not sent by bot", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(false);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 503 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 99,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }],
},
});
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("allows reaction in own mode when message is sent by bot", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(true);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 503 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 99,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
});
it("skips reaction from bot users", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(true);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 503 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 99,
user: { id: 9, first_name: "Bot", is_bot: true },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }],
},
});
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("skips reaction removal (only processes added reactions)", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 504 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
new_reaction: [],
},
});
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("enqueues one event per added emoji reaction", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 505 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
new_reaction: [
{ type: "emoji", emoji: THUMBS_UP_EMOJI },
{ type: "emoji", emoji: FIRE_EMOJI },
{ type: "emoji", emoji: PARTY_EMOJI },
],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(2);
expect(enqueueSystemEventSpy.mock.calls.map((call) => call[0])).toEqual([
`Telegram reaction added: ${FIRE_EMOJI} by Ada on msg 42`,
`Telegram reaction added: ${PARTY_EMOJI} by Ada on msg 42`,
]);
});
it("routes forum group reactions to the general topic (thread id not available on reactions)", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
// MessageReactionUpdated does not include message_thread_id in the Bot API,
// so forum reactions always route to the general topic (1).
await handler({
update: { update_id: 505 },
messageReaction: {
chat: { id: 5678, type: "supergroup", is_forum: true },
message_id: 100,
user: { id: 10, first_name: "Bob", username: "bob_user" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: FIRE_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
expect(firstSystemEventArg(0)).toBe(
`Telegram reaction added: ${FIRE_EMOJI} by Bob (@bob_user) on msg 100`,
);
expect(String(systemEventOptions().sessionKey)).toContain("telegram:group:5678:topic:1");
expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:5678:100:10");
});
it("uses correct session key for forum group reactions in general topic", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 506 },
messageReaction: {
chat: { id: 5678, type: "supergroup", is_forum: true },
message_id: 101,
// No message_thread_id - should default to general topic (1)
user: { id: 10, first_name: "Bob" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: EYES_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
expect(firstSystemEventArg(0)).toBe(`Telegram reaction added: ${EYES_EMOJI} by Bob on msg 101`);
expect(String(systemEventOptions().sessionKey)).toContain("telegram:group:5678:topic:1");
expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:5678:101:10");
});
it("uses correct session key for regular group reactions without topic", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 507 },
messageReaction: {
chat: { id: 9999, type: "group" },
message_id: 200,
user: { id: 11, first_name: "Charlie" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: HEART_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
expect(firstSystemEventArg(0)).toBe(
`Telegram reaction added: ${HEART_EMOJI} by Charlie on msg 200`,
);
expect(String(systemEventOptions().sessionKey)).toContain("telegram:group:9999");
expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:9999:200:11");
// Verify session key does NOT contain :topic:
const eventOptions = firstSystemEventArg(1) as {
sessionKey?: string;
};
const sessionKey = eventOptions.sessionKey ?? "";
expect(sessionKey).not.toContain(":topic:");
});
it("blocks reaction in own mode when cache is warm and message not sent by bot", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(false);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", reactionNotifications: "own" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 601 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 99,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
});
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
});