| import fs from "node:fs"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { beforeEach, describe, expect, it, vi } from "vitest"; |
|
|
| let createTelegramBot: typeof import("./bot.js").createTelegramBot; |
| let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; |
|
|
| const { sessionStorePath } = vi.hoisted(() => ({ |
| sessionStorePath: `/tmp/openclaw-telegram-reply-threading-${Math.random() |
| .toString(16) |
| .slice(2)}.json`, |
| })); |
|
|
| const { loadWebMedia } = vi.hoisted(() => ({ |
| loadWebMedia: vi.fn(), |
| })); |
|
|
| vi.mock("../web/media.js", () => ({ |
| loadWebMedia, |
| })); |
|
|
| const { loadConfig } = vi.hoisted(() => ({ |
| loadConfig: vi.fn(() => ({})), |
| })); |
| vi.mock("../config/config.js", async (importOriginal) => { |
| const actual = await importOriginal<typeof import("../config/config.js")>(); |
| return { |
| ...actual, |
| loadConfig, |
| }; |
| }); |
|
|
| vi.mock("../config/sessions.js", async (importOriginal) => { |
| const actual = await importOriginal<typeof import("../config/sessions.js")>(); |
| return { |
| ...actual, |
| resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), |
| }; |
| }); |
|
|
| const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ |
| readTelegramAllowFromStore: vi.fn(async () => [] as string[]), |
| upsertTelegramPairingRequest: vi.fn(async () => ({ |
| code: "PAIRCODE", |
| created: true, |
| })), |
| })); |
|
|
| vi.mock("./pairing-store.js", () => ({ |
| readTelegramAllowFromStore, |
| upsertTelegramPairingRequest, |
| })); |
|
|
| const useSpy = vi.fn(); |
| const middlewareUseSpy = vi.fn(); |
| const onSpy = vi.fn(); |
| const stopSpy = vi.fn(); |
| const commandSpy = vi.fn(); |
| const botCtorSpy = vi.fn(); |
| const answerCallbackQuerySpy = vi.fn(async () => undefined); |
| const sendChatActionSpy = vi.fn(); |
| const setMessageReactionSpy = vi.fn(async () => undefined); |
| const setMyCommandsSpy = vi.fn(async () => undefined); |
| const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); |
| const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); |
| const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); |
| type ApiStub = { |
| config: { use: (arg: unknown) => void }; |
| answerCallbackQuery: typeof answerCallbackQuerySpy; |
| sendChatAction: typeof sendChatActionSpy; |
| setMessageReaction: typeof setMessageReactionSpy; |
| setMyCommands: typeof setMyCommandsSpy; |
| sendMessage: typeof sendMessageSpy; |
| sendAnimation: typeof sendAnimationSpy; |
| sendPhoto: typeof sendPhotoSpy; |
| }; |
| const apiStub: ApiStub = { |
| config: { use: useSpy }, |
| answerCallbackQuery: answerCallbackQuerySpy, |
| sendChatAction: sendChatActionSpy, |
| setMessageReaction: setMessageReactionSpy, |
| setMyCommands: setMyCommandsSpy, |
| sendMessage: sendMessageSpy, |
| sendAnimation: sendAnimationSpy, |
| sendPhoto: sendPhotoSpy, |
| }; |
|
|
| vi.mock("grammy", () => ({ |
| Bot: class { |
| api = apiStub; |
| use = middlewareUseSpy; |
| on = onSpy; |
| stop = stopSpy; |
| command = commandSpy; |
| catch = vi.fn(); |
| constructor( |
| public token: string, |
| public options?: { client?: { fetch?: typeof fetch } }, |
| ) { |
| botCtorSpy(token, options); |
| } |
| }, |
| InputFile: class {}, |
| webhookCallback: vi.fn(), |
| })); |
|
|
| const sequentializeMiddleware = vi.fn(); |
| const sequentializeSpy = vi.fn(() => sequentializeMiddleware); |
| let _sequentializeKey: ((ctx: unknown) => string) | undefined; |
| vi.mock("@grammyjs/runner", () => ({ |
| sequentialize: (keyFn: (ctx: unknown) => string) => { |
| _sequentializeKey = keyFn; |
| return sequentializeSpy(); |
| }, |
| })); |
|
|
| const throttlerSpy = vi.fn(() => "throttler"); |
|
|
| vi.mock("@grammyjs/transformer-throttler", () => ({ |
| apiThrottler: () => throttlerSpy(), |
| })); |
|
|
| vi.mock("../auto-reply/reply.js", () => { |
| const replySpy = vi.fn(async (_ctx, opts) => { |
| await opts?.onReplyStart?.(); |
| return undefined; |
| }); |
| return { getReplyFromConfig: replySpy, __replySpy: replySpy }; |
| }); |
|
|
| let replyModule: typeof import("../auto-reply/reply.js"); |
|
|
| const getOnHandler = (event: string) => { |
| const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; |
| if (!handler) { |
| throw new Error(`Missing handler for event: ${event}`); |
| } |
| return handler as (ctx: Record<string, unknown>) => Promise<void>; |
| }; |
|
|
| describe("createTelegramBot", () => { |
| beforeEach(async () => { |
| vi.resetModules(); |
| ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); |
| ({ createTelegramBot } = await import("./bot.js")); |
| replyModule = await import("../auto-reply/reply.js"); |
| resetInboundDedupe(); |
| loadConfig.mockReturnValue({ |
| channels: { |
| telegram: { dmPolicy: "open", allowFrom: ["*"] }, |
| }, |
| }); |
| loadWebMedia.mockReset(); |
| sendAnimationSpy.mockReset(); |
| sendPhotoSpy.mockReset(); |
| setMessageReactionSpy.mockReset(); |
| answerCallbackQuerySpy.mockReset(); |
| setMyCommandsSpy.mockReset(); |
| middlewareUseSpy.mockReset(); |
| sequentializeSpy.mockReset(); |
| botCtorSpy.mockReset(); |
| _sequentializeKey = undefined; |
| }); |
|
|
| |
|
|
| it("sends replies without native reply threading", async () => { |
| onSpy.mockReset(); |
| sendMessageSpy.mockReset(); |
| const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; |
| replySpy.mockReset(); |
| replySpy.mockResolvedValue({ text: "a".repeat(4500) }); |
|
|
| createTelegramBot({ token: "tok" }); |
| const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; |
| await handler({ |
| message: { |
| chat: { id: 5, type: "private" }, |
| text: "hi", |
| date: 1736380800, |
| message_id: 101, |
| }, |
| me: { username: "openclaw_bot" }, |
| getFile: async () => ({ download: async () => new Uint8Array() }), |
| }); |
|
|
| expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); |
| for (const call of sendMessageSpy.mock.calls) { |
| expect(call[2]?.reply_to_message_id).toBeUndefined(); |
| } |
| }); |
| it("honors replyToMode=first for threaded replies", async () => { |
| onSpy.mockReset(); |
| sendMessageSpy.mockReset(); |
| const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; |
| replySpy.mockReset(); |
| replySpy.mockResolvedValue({ |
| text: "a".repeat(4500), |
| replyToId: "101", |
| }); |
|
|
| createTelegramBot({ token: "tok", replyToMode: "first" }); |
| const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; |
| await handler({ |
| message: { |
| chat: { id: 5, type: "private" }, |
| text: "hi", |
| date: 1736380800, |
| message_id: 101, |
| }, |
| me: { username: "openclaw_bot" }, |
| getFile: async () => ({ download: async () => new Uint8Array() }), |
| }); |
|
|
| expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); |
| const [first, ...rest] = sendMessageSpy.mock.calls; |
| expect(first?.[2]?.reply_to_message_id).toBe(101); |
| for (const call of rest) { |
| expect(call[2]?.reply_to_message_id).toBeUndefined(); |
| } |
| }); |
| it("prefixes final replies with responsePrefix", async () => { |
| onSpy.mockReset(); |
| sendMessageSpy.mockReset(); |
| const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; |
| replySpy.mockReset(); |
| replySpy.mockResolvedValue({ text: "final reply" }); |
| loadConfig.mockReturnValue({ |
| channels: { |
| telegram: { dmPolicy: "open", allowFrom: ["*"] }, |
| }, |
| messages: { responsePrefix: "PFX" }, |
| }); |
|
|
| createTelegramBot({ token: "tok" }); |
| const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; |
| await handler({ |
| message: { |
| chat: { id: 5, type: "private" }, |
| text: "hi", |
| date: 1736380800, |
| }, |
| me: { username: "openclaw_bot" }, |
| getFile: async () => ({ download: async () => new Uint8Array() }), |
| }); |
|
|
| expect(sendMessageSpy).toHaveBeenCalledTimes(1); |
| expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX final reply"); |
| }); |
| it("honors replyToMode=all for threaded replies", async () => { |
| onSpy.mockReset(); |
| sendMessageSpy.mockReset(); |
| const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; |
| replySpy.mockReset(); |
| replySpy.mockResolvedValue({ |
| text: "a".repeat(4500), |
| replyToId: "101", |
| }); |
|
|
| createTelegramBot({ token: "tok", replyToMode: "all" }); |
| const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; |
| await handler({ |
| message: { |
| chat: { id: 5, type: "private" }, |
| text: "hi", |
| date: 1736380800, |
| message_id: 101, |
| }, |
| me: { username: "openclaw_bot" }, |
| getFile: async () => ({ download: async () => new Uint8Array() }), |
| }); |
|
|
| expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); |
| for (const call of sendMessageSpy.mock.calls) { |
| expect(call[2]?.reply_to_message_id).toBe(101); |
| } |
| }); |
| it("blocks group messages when telegram.groups is set without a wildcard", async () => { |
| onSpy.mockReset(); |
| const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; |
| replySpy.mockReset(); |
| loadConfig.mockReturnValue({ |
| channels: { |
| telegram: { |
| groups: { |
| "123": { requireMention: false }, |
| }, |
| }, |
| }, |
| }); |
|
|
| createTelegramBot({ token: "tok" }); |
| const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; |
|
|
| await handler({ |
| message: { |
| chat: { id: 456, type: "group", title: "Ops" }, |
| text: "@openclaw_bot hello", |
| date: 1736380800, |
| }, |
| me: { username: "openclaw_bot" }, |
| getFile: async () => ({ download: async () => new Uint8Array() }), |
| }); |
|
|
| expect(replySpy).not.toHaveBeenCalled(); |
| }); |
| it("skips group messages without mention when requireMention is enabled", async () => { |
| onSpy.mockReset(); |
| const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; |
| replySpy.mockReset(); |
| 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: 123, type: "group", title: "Dev Chat" }, |
| text: "hello", |
| date: 1736380800, |
| }, |
| me: { username: "openclaw_bot" }, |
| getFile: async () => ({ download: async () => new Uint8Array() }), |
| }); |
|
|
| expect(replySpy).not.toHaveBeenCalled(); |
| }); |
| it("honors routed group activation from session store", async () => { |
| onSpy.mockReset(); |
| const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; |
| replySpy.mockReset(); |
| const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-")); |
| const storePath = path.join(storeDir, "sessions.json"); |
| fs.writeFileSync( |
| storePath, |
| JSON.stringify({ |
| "agent:ops:telegram:group:123": { groupActivation: "always" }, |
| }), |
| "utf-8", |
| ); |
| loadConfig.mockReturnValue({ |
| channels: { |
| telegram: { |
| groupPolicy: "open", |
| groups: { "*": { requireMention: true } }, |
| }, |
| }, |
| bindings: [ |
| { |
| agentId: "ops", |
| match: { |
| channel: "telegram", |
| peer: { kind: "group", id: "123" }, |
| }, |
| }, |
| ], |
| session: { store: storePath }, |
| }); |
|
|
| createTelegramBot({ token: "tok" }); |
| const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; |
|
|
| await handler({ |
| message: { |
| chat: { id: 123, type: "group", title: "Routing" }, |
| text: "hello", |
| date: 1736380800, |
| }, |
| me: { username: "openclaw_bot" }, |
| getFile: async () => ({ download: async () => new Uint8Array() }), |
| }); |
|
|
| expect(replySpy).toHaveBeenCalledTimes(1); |
| }); |
| }); |
|
|