Telegram tests: harden dispatch and pairing scenarios

This commit is contained in:
joshavant
2026-03-18 22:06:33 -05:00
parent be0329eda2
commit e8eb7f6dc1
2 changed files with 258 additions and 211 deletions

View File

@@ -10,7 +10,14 @@ import {
const createTelegramDraftStream = vi.hoisted(() => vi.fn());
const dispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => vi.fn());
const deliverReplies = vi.hoisted(() => vi.fn());
const createForumTopicTelegram = vi.hoisted(() => vi.fn());
const deleteMessageTelegram = vi.hoisted(() => vi.fn());
const editForumTopicTelegram = vi.hoisted(() => vi.fn());
const editMessageTelegram = vi.hoisted(() => vi.fn());
const reactMessageTelegram = vi.hoisted(() => vi.fn());
const sendMessageTelegram = vi.hoisted(() => vi.fn());
const sendPollTelegram = vi.hoisted(() => vi.fn());
const sendStickerTelegram = vi.hoisted(() => vi.fn());
const loadSessionStore = vi.hoisted(() => vi.fn());
const resolveStorePath = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json"));
@@ -18,23 +25,30 @@ vi.mock("./draft-stream.js", () => ({
createTelegramDraftStream,
}));
vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher,
}));
vi.mock("./bot-deps.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./bot-deps.js")>();
return {
...actual,
defaultTelegramBotDeps: {
...actual.defaultTelegramBotDeps,
dispatchReplyWithBufferedBlockDispatcher,
},
};
});
vi.mock("./bot/delivery.js", () => ({
deliverReplies,
}));
vi.mock("./send.js", () => ({
createForumTopicTelegram: vi.fn(),
deleteMessageTelegram: vi.fn(),
editForumTopicTelegram: vi.fn(),
createForumTopicTelegram,
deleteMessageTelegram,
editForumTopicTelegram,
editMessageTelegram,
reactMessageTelegram: vi.fn(),
sendMessageTelegram: vi.fn(),
sendPollTelegram: vi.fn(),
sendStickerTelegram: vi.fn(),
reactMessageTelegram,
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {

View File

@@ -13,7 +13,6 @@ const {
commandSpy,
dispatchReplyWithBufferedBlockDispatcher,
getLoadConfigMock,
getLoadWebMediaMock,
getOnHandler,
getReadChannelAllowFromStoreMock,
getUpsertChannelPairingRequestMock,
@@ -51,7 +50,6 @@ const createTelegramBot = (opts: Parameters<typeof createTelegramBotBase>[0]) =>
});
const loadConfig = getLoadConfigMock();
const loadWebMedia = getLoadWebMediaMock();
const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();
const upsertChannelPairingRequest = getUpsertChannelPairingRequestMock();
@@ -61,6 +59,30 @@ const TELEGRAM_TEST_TIMINGS = {
textFragmentGapMs: 30,
} as const;
async function withIsolatedStateDirAsync<T>(fn: () => Promise<T>): Promise<T> {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-state-"));
return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
try {
return await fn();
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
}
async function withConfigPathAsync<T>(cfg: unknown, fn: () => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-cfg-"));
const configPath = path.join(dir, "openclaw.json");
fs.writeFileSync(configPath, JSON.stringify(cfg), "utf-8");
return await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, async () => {
try {
return await fn();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
}
describe("createTelegramBot", () => {
beforeAll(() => {
process.env.TZ = "UTC";
@@ -199,107 +221,102 @@ describe("createTelegramBot", () => {
const cases = [
{
name: "new unknown sender",
upsertResults: [{ code: "PAIRME12", created: true }],
messages: ["hello"],
expectedSendCount: 1,
expectPairingText: true,
},
{
name: "already pending request",
upsertResults: [
{ code: "PAIRME12", created: true },
{ code: "PAIRME12", created: false },
],
messages: ["hello", "hello again"],
expectedSendCount: 1,
expectPairingText: false,
},
] as const;
for (const testCase of cases) {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
await withIsolatedStateDirAsync(async () => {
for (const [index, testCase] of cases.entries()) {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockClear();
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true });
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const senderId = Number(`${Date.now()}${index}`.slice(-9));
for (const text of testCase.messages) {
await handler({
message: {
chat: { id: 1234, type: "private" },
text,
date: 1736380800,
from: { id: senderId, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
}
expect(replySpy, testCase.name).not.toHaveBeenCalled();
expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount);
expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234);
const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
expect(pairingText, testCase.name).toContain(`Your Telegram user id: ${senderId}`);
expect(pairingText, testCase.name).toContain("Pairing code:");
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
expect(code, testCase.name).toBeDefined();
expect(pairingText, testCase.name).toContain(`openclaw pairing approve telegram ${code}`);
expect(pairingText, testCase.name).not.toContain("<code>");
}
});
});
it("blocks unauthorized DM media before download and sends pairing reply", async () => {
await withIsolatedStateDirAsync(async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockClear();
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true });
for (const result of testCase.upsertResults) {
upsertChannelPairingRequest.mockResolvedValueOnce(result);
}
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const senderId = Number(`${Date.now()}01`.slice(-9));
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
for (const text of testCase.messages) {
await handler({
message: {
chat: { id: 1234, type: "private" },
text,
message_id: 410,
date: 1736380800,
from: { id: 999, username: "random" },
photo: [{ file_id: "p1" }],
from: { id: senderId, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
getFile: getFileSpy,
});
}
expect(replySpy, testCase.name).not.toHaveBeenCalled();
expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount);
if (testCase.expectPairingText) {
expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234);
const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
expect(pairingText, testCase.name).toContain("Your Telegram user id: 999");
expect(pairingText, testCase.name).toContain("Pairing code:");
expect(pairingText, testCase.name).toContain("PAIRME12");
expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram PAIRME12");
expect(pairingText, testCase.name).not.toContain("<code>");
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
}
});
it("blocks unauthorized DM media before download and sends pairing reply", async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
message_id: 410,
date: 1736380800,
photo: [{ file_id: "p1" }],
from: { id: 999, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: getFileSpy,
});
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("blocks DM media downloads completely when dmPolicy is disabled", async () => {
loadConfig.mockReturnValue({
@@ -342,48 +359,51 @@ describe("createTelegramBot", () => {
}
});
it("blocks unauthorized DM media groups before any photo download", async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
message_id: 412,
media_group_id: "dm-album-1",
date: 1736380800,
photo: [{ file_id: "p1" }],
from: { id: 999, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: getFileSpy,
await withIsolatedStateDirAsync(async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const senderId = Number(`${Date.now()}02`.slice(-9));
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
message_id: 412,
media_group_id: "dm-album-1",
date: 1736380800,
photo: [{ file_id: "p1" }],
from: { id: senderId, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: getFileSpy,
});
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
});
it("triggers typing cue via onReplyStart", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
@@ -800,13 +820,15 @@ describe("createTelegramBot", () => {
});
it("routes DMs by telegram accountId binding", async () => {
loadConfig.mockReturnValue({
const config = {
channels: {
telegram: {
allowFrom: ["*"],
accounts: {
opie: {
botToken: "tok-opie",
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
@@ -817,27 +839,30 @@ describe("createTelegramBot", () => {
match: { channel: "telegram", accountId: "opie" },
},
],
};
loadConfig.mockReturnValue(config);
await withConfigPathAsync(config, async () => {
createTelegramBot({ token: "tok", accountId: "opie" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "private" },
from: { id: 999, username: "testuser" },
text: "hello",
date: 1736380800,
message_id: 42,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.AccountId).toBe("opie");
expect(payload.SessionKey).toBe("agent:opie:main");
});
createTelegramBot({ token: "tok", accountId: "opie" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "private" },
from: { id: 999, username: "testuser" },
text: "hello",
date: 1736380800,
message_id: 42,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.AccountId).toBe("opie");
expect(payload.SessionKey).toBe("agent:opie:main");
});
it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => {
@@ -1036,26 +1061,28 @@ describe("createTelegramBot", () => {
];
for (const testCase of cases) {
resetHarnessSpies();
loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
await withConfigPathAsync(testCase.config, async () => {
resetHarnessSpies();
loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
from: { id: 999, username: "testuser" },
text: testCase.text,
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
from: { id: 999, username: "testuser" },
text: testCase.text,
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
}
});
@@ -1064,35 +1091,38 @@ describe("createTelegramBot", () => {
text: "caption",
mediaUrl: "https://example.com/fun",
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(Buffer.from("GIF89a"), {
status: 200,
headers: { "content-type": "image/gif" },
}),
);
try {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("GIF89a"),
contentType: "image/gif",
fileName: "fun.gif",
});
await handler({
message: {
chat: { id: 1234, type: "private" },
text: "hello world",
date: 1736380800,
message_id: 5,
from: { first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
text: "hello world",
date: 1736380800,
message_id: 5,
from: { first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(sendAnimationSpy).toHaveBeenCalledTimes(1);
expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), {
caption: "caption",
parse_mode: "HTML",
reply_to_message_id: undefined,
});
expect(sendPhotoSpy).not.toHaveBeenCalled();
expect(sendAnimationSpy).toHaveBeenCalledTimes(1);
expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), {
caption: "caption",
parse_mode: "HTML",
reply_to_message_id: undefined,
});
expect(sendPhotoSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
function resetHarnessSpies() {
@@ -1746,7 +1776,7 @@ describe("createTelegramBot", () => {
}),
"utf-8",
);
loadConfig.mockReturnValue({
const config = {
channels: {
telegram: {
groupPolicy: "open",
@@ -1763,23 +1793,26 @@ describe("createTelegramBot", () => {
},
],
session: { store: storePath },
};
loadConfig.mockReturnValue(config);
await withConfigPathAsync(config, async () => {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "group", title: "Routing" },
from: { id: 999, username: "ops" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "group", title: "Routing" },
from: { id: 999, username: "ops" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("applies topic skill filters and system prompts", async () => {