Files
openclaw/extensions/telegram/src/bot.test.ts
Ayaan Zaidi 0bf66ab7bd fix(sessions): scope ambient transcript watermark to session id
Ambient transcript watermarks now carry the transcript session id, resolve only for the current session entry, and skip stale room-event hooks that no longer match the prepared transcript session.

This protects Telegram group prompt windows after reset by backfilling rows that are no longer present in the new session transcript, while preserving steady-state watermark filtering within one session.

Fixes #99373

Release-note: fixes Telegram group context loss after session reset when ambient transcript watermarks outlived the transcript they referenced.
2026-07-02 22:57:01 -07:00

4885 lines
153 KiB
TypeScript

import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
clearPluginInteractiveHandlers,
registerPluginInteractiveHandler,
} from "openclaw/plugin-sdk/plugin-runtime";
import {
createPluginStateKeyedStoreForTests,
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-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 {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
} from "./conversation-route.js";
import type { TelegramInteractiveHandlerContext } from "./interactive-dispatch.js";
import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
import { clearTelegramRuntime, setTelegramRuntime } from "./runtime.js";
import type { TelegramRuntime } from "./runtime.types.js";
const {
answerCallbackQuerySpy,
commandSpy,
deleteMessageSpy,
editMessageReplyMarkupSpy,
editMessageTextSpy,
enqueueSystemEventSpy,
getFileSpy,
getChatSpy,
getLoadConfigMock,
getLoadWebMediaMock,
getReadChannelAllowFromStoreMock,
getOnHandler,
listSkillCommandsForAgents,
onSpy,
replySpy,
resolveExecApprovalSpy,
sendMessageSpy,
setMyCommandsSpy,
telegramBotDepsForTest,
telegramBotRuntimeForTest,
wasSentByBot,
} = await import("./bot.create-telegram-bot.test-harness.js");
const { recordOutboundMessageForPromptContext } = await import("./outbound-message-context.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;
}
async function writeDirectTelegramTranscriptContext(params: {
cfg: OpenClawConfig;
storePath: string;
chatId: number;
senderId: number;
sessionId: string;
text: string;
timestamp: number;
}) {
const route = resolveTelegramConversationRoute({
cfg: params.cfg,
accountId: "default",
chatId: params.chatId,
isGroup: false,
senderId: params.senderId,
}).route;
const sessionKey = resolveTelegramConversationBaseSessionKey({
cfg: params.cfg,
route,
chatId: params.chatId,
isGroup: false,
senderId: params.senderId,
});
await writeFile(
params.storePath,
JSON.stringify({
[sessionKey]: {
sessionId: params.sessionId,
chatType: "direct",
channel: "telegram",
},
}),
"utf-8",
);
await writeFile(
path.join(path.dirname(params.storePath), `${params.sessionId}.jsonl`),
[
JSON.stringify({ type: "session", id: params.sessionId }),
JSON.stringify({
id: "transcript-user-1",
type: "message",
message: {
role: "user",
content: params.text,
timestamp: params.timestamp,
},
}),
"",
].join("\n"),
"utf-8",
);
}
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("starts with retired includeGroupHistoryContext still present in raw config", async () => {
loadConfig.mockReturnValue({
messages: { groupChat: { unmentionedInbound: "room_event" } },
channels: {
telegram: {
includeGroupHistoryContext: "mention-only",
},
},
} as never);
createTelegramBot({ token: "tok" });
expect(getOnHandler("message")).toEqual(expect.any(Function));
});
it("records outbound prompt-context sends into ambient group history", async () => {
onSpy.mockClear();
replySpy.mockClear();
const cfg = {
messages: { groupChat: { unmentionedInbound: "room_event", mentionPatterns: [] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
} satisfies OpenClawConfig;
loadConfig.mockReturnValue(cfg);
createTelegramBot({
token: "tok",
botInfo: {
id: 999,
is_bot: true,
first_name: "OpenClaw",
username: "openclaw_bot",
can_join_groups: true,
can_read_all_group_messages: false,
can_manage_bots: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: false,
allows_users_to_create_topics: false,
},
});
await recordOutboundMessageForPromptContext({
cfg,
account: { accountId: "default", name: "OpenClaw" },
chatId: -42,
message: {
chat: { id: -42, type: "group", title: "Ops" },
date: 1_736_380_700,
message_id: 700,
text: "Bot just replied",
},
messageId: 700,
text: "Bot just replied",
});
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: -42, type: "group", title: "Ops" },
text: "What now?",
date: 1_736_380_800,
message_id: 701,
from: { id: 201, is_bot: false, first_name: "Sam" },
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.InboundEventKind).toBe("room_event");
expect(payload.InboundHistory).toEqual(
expect.arrayContaining([
expect.objectContaining({ body: "Bot just replied", sender: "OpenClaw (you)" }),
]),
);
});
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: "tgcmd:/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 opaque 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("does not resolve opaque approval-shaped plugin callbacks", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
resolveExecApprovalSpy.mockClear();
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-opaque-plugin-approve",
data: buildTelegramOpaqueCallbackData("/approve plugin:138e9b8c allow-once"),
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 25,
text: "Plugin callback.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(resolveExecApprovalSpy).not.toHaveBeenCalled();
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-opaque-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("ignores unsafe command pagination pages", async () => {
onSpy.mockClear();
listSkillCommandsForAgents.mockClear();
editMessageTextSpy.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-unsafe-page",
data: "commands_page_9007199254740993:main",
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(listSkillCommandsForAgents).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
});
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 against the configured 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: `amazon-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 storePath = `/tmp/openclaw-telegram-model-display-names-${process.pid}-${Date.now()}.json`;
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: ["*"],
},
},
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-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");
} finally {
await rm(storePath, { force: true });
}
});
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("keeps skipped group messages in default recent group history context", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
groupPolicy: "allowlist",
groupAllowFrom: ["111", "222"],
groups: { "*": { requireMention: true } },
},
},
});
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: "Please run the maintenance step later.",
date: 1736380800,
message_id: 501,
from: { id: 111, is_bot: false, first_name: "Requester" },
},
});
expect(replySpy).not.toHaveBeenCalled();
await handler({
...baseCtx,
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "@openclaw_bot Hello",
date: 1736380860,
message_id: 502,
from: { id: 222, is_bot: false, first_name: "Operator" },
entities: [{ type: "mention", offset: 0, length: 13 }],
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.UntrustedStructuredContext).toEqual([
{
label: "Conversation context",
payload: {
messages: [
expect.objectContaining({
body: "Please run the maintenance step later.",
sender: "Requester",
}),
],
order: "chronological",
relation: "selected_for_current_message",
},
source: "telegram",
type: "chat_window",
},
]);
});
it("excludes ambient transcript rows from live group conversation context", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
groupPolicy: "allowlist",
groupAllowFrom: ["111", "222", "333", "444"],
groups: { "*": { requireMention: true } },
},
},
});
const previousReadAmbient = telegramBotDepsForTest.readAmbientTranscriptWatermark;
const previousResolveAmbientKey = telegramBotDepsForTest.resolveAmbientTranscriptWatermarkKey;
telegramBotDepsForTest.resolveAmbientTranscriptWatermarkKey = vi.fn(
() => "telegram:default:42",
);
telegramBotDepsForTest.readAmbientTranscriptWatermark = vi.fn(() => ({
sessionId: "session-current",
messageId: "502",
timestampMs: 1_736_380_860_000,
updatedAt: 1_736_380_900_000,
}));
try {
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() }),
};
for (const message of [
{
chat: { id: 42, type: "group", title: "Ops" },
text: "persisted ambient one",
date: 1_736_380_800,
message_id: 501,
from: { id: 111, is_bot: false, first_name: "Requester" },
},
{
chat: { id: 42, type: "group", title: "Ops" },
text: "persisted ambient two",
date: 1_736_380_860,
message_id: 502,
from: { id: 222, is_bot: false, first_name: "Operator" },
},
{
chat: { id: 42, type: "group", title: "Ops" },
text: "unpersisted gap",
date: 1_736_380_920,
message_id: 503,
from: { id: 333, is_bot: false, first_name: "Mira" },
},
]) {
await handler({ ...baseCtx, message });
}
expect(replySpy).not.toHaveBeenCalled();
await handler({
...baseCtx,
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "@openclaw_bot what changed?",
date: 1_736_380_980,
message_id: 504,
from: { id: 444, is_bot: false, first_name: "Pat" },
entities: [{ type: "mention", offset: 0, length: 13 }],
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(
replySpy as unknown as MockCallSource,
0,
0,
"replySpy call",
);
const [conversationContext] = requireArray(
payload.UntrustedStructuredContext,
"structured context",
);
const contextPayload = requireRecord(
requireRecord(conversationContext, "conversation context").payload,
"conversation context payload",
);
const messages = requireArray(contextPayload.messages, "conversation context messages").map(
(message, index) => requireRecord(message, `conversation context message ${index + 1}`),
);
expect(messages.map((message) => message.body)).toEqual(["unpersisted gap"]);
} finally {
telegramBotDepsForTest.readAmbientTranscriptWatermark = previousReadAmbient;
telegramBotDepsForTest.resolveAmbientTranscriptWatermarkKey = previousResolveAmbientKey;
}
});
it("honors historyLimit zero for group chat-window context", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
groupPolicy: "allowlist",
groupAllowFrom: ["111", "222"],
historyLimit: 0,
groups: { "*": { requireMention: true } },
},
},
});
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: "Do not include this cached group line.",
date: 1736380800,
message_id: 601,
from: { id: 111, is_bot: false, first_name: "Requester" },
},
});
expect(replySpy).not.toHaveBeenCalled();
await handler({
...baseCtx,
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "@openclaw_bot Hello",
date: 1736380860,
message_id: 602,
from: { id: 222, is_bot: false, first_name: "Operator" },
entities: [{ type: "mention", offset: 0, length: 13 }],
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.UntrustedStructuredContext).toBeUndefined();
expect(payload.Body).not.toContain("Do not include this cached group line.");
});
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("keeps direct Telegram media context when transcript context exists", async () => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-dm-media-context-${process.pid}-${Date.now()}.json`;
const config = {
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 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: 7771, type: "private" },
caption: "the reference image",
date: 1778474800,
message_id: 100,
from: { id: 202, is_bot: false, first_name: "Kesava" },
photo: [{ file_id: "reference-photo-1", width: 1, height: 1 }],
},
});
await writeDirectTelegramTranscriptContext({
cfg: config,
storePath,
chatId: 7771,
senderId: 202,
sessionId: "telegram-dm-media-context-session",
text: "remember the launch checklist",
timestamp: 1778474700000,
});
replySpy.mockClear();
await handler({
...baseCtx,
message: {
chat: { id: 7771, type: "private" },
text: "what about the image above?",
date: 1778474850,
message_id: 101,
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}`),
);
expect(messages.some((message) => message.body === "remember the launch checklist")).toBe(
true,
);
const photoMessage = messages.find((message) => message.message_id === "100");
expect(photoMessage?.body).toBe("the reference image");
expect(photoMessage?.media_ref).toBe("telegram:file/reference-photo-1");
} finally {
await rm(storePath, { force: true });
}
});
it("skips direct transcript context for hard reset messages", async () => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-dm-reset-context-${process.pid}-${Date.now()}.json`;
const config = {
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 });
await writeDirectTelegramTranscriptContext({
cfg: config,
storePath,
chatId: 7772,
senderId: 202,
sessionId: "telegram-dm-reset-context-session",
text: "old private transcript text",
timestamp: 1778474700000,
});
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: 7772, type: "private" },
text: "/reset summarize my workspace",
date: 1778474850,
message_id: 101,
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",
);
expect(JSON.stringify(payload.UntrustedStructuredContext ?? [])).not.toContain(
"old private transcript text",
);
} finally {
await rm(storePath, { force: true });
}
});
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;
}>;
UntrustedStructuredContext?: unknown[];
};
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]?.mediaPath).toBeTypeOf("string");
expect(payload.ReplyChain?.[1]?.mediaPath).toContain("/media/inbound/");
expect(payload.ReplyChain?.[1]?.mediaRef).toBeUndefined();
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("9000")).toMatchObject({
sender: "Kesava",
});
expect(messagesById.get("9000")?.media_path).toMatch(/^media:\/\/inbound\//);
expect(messagesById.get("9000")?.media_path).not.toBe(payload.ReplyChain?.[1]?.mediaPath);
expect(messagesById.get("9000")?.media_ref).toBeUndefined();
expect(getFileSpy).toHaveBeenCalledWith("root-photo-1");
expect(mediaFetch).toHaveBeenCalledTimes(1);
});
it.each([
{
name: "hydrates allowlisted group reply ancestors",
allowFrom: ["1", "999"],
expectHydrated: true,
chatId: 7,
},
{
name: "does not hydrate unallowlisted group reply ancestors through quote override",
allowFrom: ["1"],
expectHydrated: false,
chatId: 8,
},
])("$name", async ({ allowFrom, expectHydrated, chatId }) => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
contextVisibility: "allowlist_quote",
allowFrom,
},
},
});
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: chatId, 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",
});
if (expectHydrated) {
expect(payload.ReplyChain?.[1]?.mediaPath).toBeTypeOf("string");
expect(payload.ReplyChain?.[1]?.mediaPath).toContain("/media/inbound/");
expect(payload.ReplyChain?.[1]?.mediaRef).toBeUndefined();
} else {
expect(payload.ReplyChain?.[1]?.mediaPath).toBeUndefined();
expect(payload.ReplyChain?.[1]?.mediaRef).toBe("telegram:file/generated-photo-1");
}
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",
is_reply_target: true,
});
if (expectHydrated) {
expect(messagesById.get("101")?.media_path).toMatch(/^media:\/\/inbound\//);
expect(messagesById.get("101")?.media_ref).toBeUndefined();
} else {
expect(messagesById.get("101")?.media_path).toBeUndefined();
expect(messagesById.get("101")?.media_ref).toBe("telegram:file/generated-photo-1");
}
expect(messagesById.get("102")).toMatchObject({
sender: "UserB",
body: "Why is there a 4th person?",
reply_to_id: "101",
is_reply_target: true,
});
if (expectHydrated) {
expect(getFileSpy).toHaveBeenCalledWith("generated-photo-1");
expect(mediaFetch).toHaveBeenCalledTimes(1);
} else {
expect(getFileSpy).not.toHaveBeenCalled();
expect(mediaFetch).not.toHaveBeenCalled();
}
});
it("does not hydrate reply media denied by General forum topic visibility", async () => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
contextVisibility: "allowlist",
groups: {
"-1007": {
requireMention: false,
allowFrom: ["1", "2"],
topics: {
"1": { allowFrom: ["1"], requireMention: false },
},
},
},
},
},
});
const mediaFetch = vi.fn(
async () =>
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const ssrfMock = mockPinnedHostnameResolution();
setTelegramRuntime({
state: {
openKeyedStore: ((options) =>
createPluginStateKeyedStoreForTests(
"telegram",
options,
)) as TelegramRuntime["state"]["openKeyedStore"],
openSyncKeyedStore: ((options) =>
createPluginStateSyncKeyedStoreForTests(
"telegram",
options,
)) as TelegramRuntime["state"]["openSyncKeyedStore"],
},
channel: {},
} as TelegramRuntime);
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>;
const chat = { id: -1007, type: "supergroup", title: "Ops", is_forum: true };
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat,
message_id: 103,
text: "explain this",
date: 1736380800,
from: { id: 1, is_bot: false, first_name: "Allowed" },
reply_to_message: {
chat,
message_id: 102,
caption: "hidden image",
date: 1736380750,
from: { id: 2, is_bot: false, first_name: "Hidden" },
photo: [{ file_id: "hidden-photo-1" }],
},
},
});
await replyDelivered;
} finally {
ssrfMock.mockRestore();
clearTelegramRuntime();
resetPluginStateStoreForTests();
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(
replySpy as unknown as MockCallSource,
0,
0,
"replySpy call",
) as {
ReplyChain?: unknown[];
UntrustedStructuredContext?: unknown[];
};
expect(payload.ReplyChain).toBeUndefined();
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 hiddenMessage = messages.find((message) => message.message_id === "102");
expect(hiddenMessage?.media_ref).toBe("telegram:file/hidden-photo-1");
expect(hiddenMessage?.media_path).toBeUndefined();
expect(getFileSpy).not.toHaveBeenCalled();
expect(mediaFetch).not.toHaveBeenCalled();
});
it.each([
{
name: "hydrates group reply media allowed through an option-level access group",
chatId: -1008,
runtimeGroupAllowFrom: undefined,
startupGroupAllowFrom: undefined,
optionGroupAllowFrom: ["1", "accessGroup:operators"],
useAccessGroup: true,
expectHydrated: true,
},
{
name: "does not hydrate a sender removed from the refreshed runtime allowlist",
chatId: -1009,
runtimeGroupAllowFrom: ["1"],
startupGroupAllowFrom: ["1", "2"],
optionGroupAllowFrom: undefined,
useAccessGroup: false,
expectHydrated: false,
},
])(
"$name",
async ({
chatId,
runtimeGroupAllowFrom,
startupGroupAllowFrom,
optionGroupAllowFrom,
useAccessGroup,
expectHydrated,
}) => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
const runtimeConfig = {
...(useAccessGroup
? {
accessGroups: {
operators: {
type: "message.senders" as const,
members: { telegram: ["2"] },
},
},
}
: {}),
channels: {
telegram: {
groupPolicy: "open",
contextVisibility: "allowlist",
...(runtimeGroupAllowFrom ? { groupAllowFrom: runtimeGroupAllowFrom } : {}),
groups: {
[String(chatId)]: {
requireMention: false,
},
},
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
const startupConfig = {
channels: {
telegram: {
groupPolicy: "open",
...(startupGroupAllowFrom ? { groupAllowFrom: startupGroupAllowFrom } : {}),
groups: { [String(chatId)]: { requireMention: false } },
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(runtimeConfig);
const mediaFetch = vi.fn(
async () =>
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
const runtimeExit = vi.fn();
const ssrfMock = mockPinnedHostnameResolution();
try {
createTelegramBot({
token: "tok",
config: startupConfig,
...(optionGroupAllowFrom ? { groupAllowFrom: optionGroupAllowFrom } : {}),
runtime: { log: runtimeLog, error: runtimeError, exit: runtimeExit },
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 chat = { id: chatId, type: "group", title: "Ops" };
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat,
message_id: 103,
text: "@openclaw_bot explain this",
date: 1736380800,
from: { id: 1, is_bot: false, first_name: "Allowed" },
reply_to_message: {
chat,
message_id: 102,
caption: "allowed image",
date: 1736380750,
from: { id: 2, is_bot: false, first_name: "Also allowed" },
photo: [{ file_id: "allowed-photo-1" }],
},
},
});
} finally {
ssrfMock.mockRestore();
}
expect(runtimeError).not.toHaveBeenCalled();
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(
replySpy as unknown as MockCallSource,
0,
0,
"replySpy call",
) as {
UntrustedStructuredContext?: unknown[];
};
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 replyMessage = messages.find((message) => message.message_id === "102");
if (expectHydrated) {
expect(replyMessage?.media_path).toMatch(/^media:\/\/inbound\//);
expect(replyMessage?.media_ref).toBeUndefined();
expect(getFileSpy).toHaveBeenCalledWith("allowed-photo-1");
expect(mediaFetch).toHaveBeenCalledTimes(1);
} else {
expect(replyMessage?.media_path).toBeUndefined();
expect(replyMessage?.media_ref).toBe("telegram:file/allowed-photo-1");
expect(getFileSpy).not.toHaveBeenCalled();
expect(mediaFetch).not.toHaveBeenCalled();
}
},
);
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("omits Date-invalid forwarded origin timestamps without dropping forwarded context", 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: 9004,
text: "forwarded text",
from: { first_name: "Ada" },
forward_origin: {
type: "user",
sender_user: {
id: 999,
first_name: "Bob",
last_name: "Smith",
username: "bobsmith",
is_bot: false,
},
date: 8_700_000_000_000,
},
},
},
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.Body).toContain("[Forwarded from Bob Smith (@bobsmith)]");
expect(payload.Body).not.toContain("+275760");
});
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: {
business_connection_id: "biz-1",
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", {
business_connection_id: "biz-1",
});
expect(replySpy).not.toHaveBeenCalled();
});
it("deletes plugin-owned callback messages through the bot API", async () => {
onSpy.mockClear();
replySpy.mockClear();
deleteMessageSpy.mockClear();
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: (async ({ respond }: TelegramInteractiveHandlerContext) => {
await respond.deleteMessage();
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-delete",
data: "codexapp:delete: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(deleteMessageSpy).toHaveBeenCalledWith(1234, 11);
expect(replySpy).not.toHaveBeenCalled();
});
it("routes plugin-owned callback replies with Telegram topic params", async () => {
onSpy.mockClear();
replySpy.mockClear();
sendMessageSpy.mockClear();
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: (async ({ respond }: TelegramInteractiveHandlerContext) => {
await respond.reply({ text: "Handled in topic" });
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-topic-reply",
data: "codexapp:reply:thread-1",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
business_connection_id: "biz-topic-1",
chat: { id: -100987654321, type: "supergroup", title: "Forum Group" },
date: 1736380800,
is_topic_message: true,
message_id: 11,
message_thread_id: 99,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(sendMessageSpy).toHaveBeenCalledWith(-100987654321, "Handled in topic", {
business_connection_id: "biz-topic-1",
message_thread_id: 99,
});
sendMessageSpy.mockClear();
await callbackHandler({
callbackQuery: {
id: "cbq-codex-general-reply",
data: "codexapp:reply:thread-1",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: -100987654322, type: "supergroup", title: "Forum Group" },
date: 1736380800,
is_topic_message: true,
message_id: 12,
message_thread_id: 1,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(sendMessageSpy).toHaveBeenCalledWith(-100987654322, "Handled in topic", 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();
});
});