import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; import { useFrozenTime, useRealTime } from "../../../test/helpers/extensions/frozen-time.js"; const harness = await import("./bot.create-telegram-bot.test-harness.js"); const { answerCallbackQuerySpy, botCtorSpy, commandSpy, dispatchReplyWithBufferedBlockDispatcher, getLoadWebMediaMock, getChatSpy, getLoadConfigMock, getOnHandler, getReadChannelAllowFromStoreMock, getUpsertChannelPairingRequestMock, makeForumGroupMessageCtx, middlewareUseSpy, onSpy, replySpy, sendAnimationSpy, sendChatActionSpy, sendMessageSpy, sendPhotoSpy, sequentializeSpy, setMessageReactionSpy, setMyCommandsSpy, telegramBotDepsForTest, telegramBotRuntimeForTest, throttlerSpy, useSpy, } = harness; const { resolveTelegramFetch } = await import("./fetch.js"); const { createTelegramBot: createTelegramBotBase, getTelegramSequentialKey, setTelegramBotRuntimeForTest, } = await import("./bot.js"); let createTelegramBot: ( opts: Parameters[0], ) => ReturnType; const loadConfig = getLoadConfigMock(); const loadWebMedia = getLoadWebMediaMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); const upsertChannelPairingRequest = getUpsertChannelPairingRequestMock(); const ORIGINAL_TZ = process.env.TZ; const TELEGRAM_TEST_TIMINGS = { mediaGroupFlushMs: 20, textFragmentGapMs: 30, } as const; async function withIsolatedStateDirAsync(fn: () => Promise): Promise { 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(cfg: unknown, fn: () => Promise): Promise { 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"; }); afterAll(() => { process.env.TZ = ORIGINAL_TZ; }); beforeEach(() => { setTelegramBotRuntimeForTest( telegramBotRuntimeForTest as unknown as Parameters[0], ); createTelegramBot = (opts) => createTelegramBotBase({ ...opts, telegramDeps: telegramBotDepsForTest, }); }); // groupPolicy tests it("installs grammY throttler", () => { createTelegramBot({ token: "tok" }); expect(throttlerSpy).toHaveBeenCalledTimes(1); expect(useSpy).toHaveBeenCalledWith("throttler"); }); it("uses wrapped fetch when global fetch is available", () => { const originalFetch = globalThis.fetch; const fetchSpy = vi.fn() as unknown as typeof fetch; globalThis.fetch = fetchSpy; try { createTelegramBot({ token: "tok" }); const fetchImpl = resolveTelegramFetch(); expect(fetchImpl).toBeTypeOf("function"); expect(fetchImpl).not.toBe(fetchSpy); const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) ?.client?.fetch; expect(clientFetch).toBeTypeOf("function"); expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; } }); it("applies global and per-account timeoutSeconds", () => { loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], timeoutSeconds: 60 }, }, }); createTelegramBot({ token: "tok" }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ client: expect.objectContaining({ timeoutSeconds: 60 }), }), ); botCtorSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], timeoutSeconds: 60, accounts: { foo: { timeoutSeconds: 61 }, }, }, }, }); createTelegramBot({ token: "tok", accountId: "foo" }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ client: expect.objectContaining({ timeoutSeconds: 61 }), }), ); }); it("sequentializes updates by chat and thread", () => { createTelegramBot({ token: "tok" }); expect(sequentializeSpy).toHaveBeenCalledTimes(1); expect(middlewareUseSpy).toHaveBeenCalledWith(sequentializeSpy.mock.results[0]?.value); expect(harness.sequentializeKey).toBe(getTelegramSequentialKey); }); it("preserves same-chat reply order when a debounced run is still active", async () => { const DEBOUNCE_MS = 4321; loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, messages: { inbound: { debounceMs: DEBOUNCE_MS, }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, }); sequentializeSpy.mockImplementationOnce(() => { const lanes = new Map>(); return async (ctx: Record, next: () => Promise) => { const key = harness.sequentializeKey?.(ctx) ?? "default"; const previous = lanes.get(key) ?? Promise.resolve(); const current = previous.then(async () => { await next(); }); lanes.set( key, current.catch(() => undefined), ); try { await current; } finally { if (lanes.get(key) === current) { lanes.delete(key); } } }; }); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const startedBodies: string[] = []; let releaseFirstRun!: () => void; const firstRunGate = new Promise((resolve) => { releaseFirstRun = resolve; }); replySpy.mockImplementation(async (ctx: MsgContext, opts?: GetReplyOptions) => { await opts?.onReplyStart?.(); const body = String(ctx.Body ?? ""); startedBodies.push(body); if (body.includes("first")) { await firstRunGate; } return { text: `reply:${body}` }; }); const runMiddlewareChain = async (ctx: Record) => { const middlewares = middlewareUseSpy.mock.calls .map((call) => call[0]) .filter( (fn): fn is (ctx: Record, next: () => Promise) => Promise => typeof fn === "function", ); const handler = getOnHandler("message") as (ctx: Record) => Promise; let idx = -1; const dispatch = async (i: number): Promise => { if (i <= idx) { throw new Error("middleware dispatch called multiple times"); } idx = i; const fn = middlewares[i]; if (!fn) { await handler(ctx); return; } await fn(ctx, async () => dispatch(i + 1)); }; await dispatch(0); }; const extractLatestDebounceFlush = () => { const debounceCallIndex = setTimeoutSpy.mock.calls.findLastIndex( (call) => call[1] === DEBOUNCE_MS, ); expect(debounceCallIndex).toBeGreaterThanOrEqual(0); clearTimeout( setTimeoutSpy.mock.results[debounceCallIndex]?.value as ReturnType, ); return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as (() => Promise) | undefined; }; try { createTelegramBot({ token: "tok" }); await runMiddlewareChain({ update: { update_id: 101 }, message: { chat: { id: 7, type: "private" }, text: "first", date: 1736380800, message_id: 101, from: { id: 42, first_name: "Ada" }, }, me: { username: "openclaw_bot" }, getFile: async () => ({}), }); const flushFirst = extractLatestDebounceFlush(); const firstFlush = flushFirst?.(); await vi.waitFor(() => { expect(startedBodies).toHaveLength(1); expect(startedBodies[0]).toContain("first"); }); await runMiddlewareChain({ update: { update_id: 102 }, message: { chat: { id: 7, type: "private" }, text: "second", date: 1736380801, message_id: 102, from: { id: 42, first_name: "Ada" }, }, me: { username: "openclaw_bot" }, getFile: async () => ({}), }); const flushSecond = extractLatestDebounceFlush(); const secondFlush = flushSecond?.(); await Promise.resolve(); expect(startedBodies).toHaveLength(1); expect(sendMessageSpy).not.toHaveBeenCalled(); releaseFirstRun(); await Promise.all([firstFlush, secondFlush]); await vi.waitFor(() => { expect(startedBodies).toHaveLength(2); expect(sendMessageSpy).toHaveBeenCalledTimes(2); }); expect(startedBodies[0]).toContain("first"); expect(startedBodies[1]).toContain("second"); expect(sendMessageSpy.mock.calls.map((call) => call[1])).toEqual([ expect.stringContaining("first"), expect.stringContaining("second"), ]); } finally { setTimeoutSpy.mockRestore(); } }); it("routes callback_query payloads as messages and answers callbacks", async () => { createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); await callbackHandler({ callbackQuery: { id: "cbq-1", data: "cmd:option_a", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 10, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toContain("cmd:option_a"); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); }); it("reloads callback model routing bindings without recreating the bot", async () => { const buildModelsProviderDataMock = telegramBotDepsForTest.buildModelsProviderData as unknown as ReturnType; let boundAgentId = "agent-a"; loadConfig.mockImplementation(() => ({ agents: { defaults: { model: "openai/gpt-4.1", }, list: [{ id: "agent-a" }, { id: "agent-b" }], }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, bindings: [ { agentId: boundAgentId, match: { channel: "telegram", accountId: "default" }, }, ], })); createTelegramBot({ token: "tok" }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; const sendModelCallback = async (id: number) => { await callbackHandler({ callbackQuery: { id: `cbq-model-${id}`, data: "mdl_prov", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800 + id, message_id: id, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); }; buildModelsProviderDataMock.mockClear(); await sendModelCallback(1); expect(buildModelsProviderDataMock).toHaveBeenCalled(); expect(buildModelsProviderDataMock.mock.calls.at(-1)?.[1]).toBe("agent-a"); boundAgentId = "agent-b"; await sendModelCallback(2); expect(buildModelsProviderDataMock.mock.calls.at(-1)?.[1]).toBe("agent-b"); }); it("wraps inbound message with Telegram envelope", async () => { await withEnvAsync({ TZ: "Europe/Vienna" }, async () => { createTelegramBot({ token: "tok" }); expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); const handler = getOnHandler("message") as (ctx: Record) => Promise; const message = { chat: { id: 1234, type: "private" }, text: "hello world", date: 1736380800, // 2025-01-09T00:00:00Z from: { first_name: "Ada", last_name: "Lovelace", username: "ada_bot", }, }; await handler({ message, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); const timestampPattern = escapeRegExp(expectedTimestamp); expect(payload.Body).toMatch( new RegExp( `^\\[Telegram Ada Lovelace \\(@ada_bot\\) id:1234 (\\+\\d+[smhd] )?${timestampPattern}\\]`, ), ); expect(payload.Body).toContain("hello world"); }); }); it("handles pairing DM flows for new and already-pending requests", async () => { const cases = [ { name: "new unknown sender", messages: ["hello"], expectedSendCount: 1, pairingUpsertResults: [{ code: "PAIRCODE", created: true }], }, { name: "already pending request", messages: ["hello", "hello again"], expectedSendCount: 1, pairingUpsertResults: [ { code: "PAIRCODE", created: true }, { code: "PAIRCODE", created: false }, ], }, ] as const; 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(); let pairingUpsertCall = 0; upsertChannelPairingRequest.mockImplementation(async () => { const result = testCase.pairingUpsertResults[ Math.min(pairingUpsertCall, testCase.pairingUpsertResults.length - 1) ]; pairingUpsertCall += 1; return result; }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; 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:"); expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram"); expect(sendMessageSpy.mock.calls[0]?.[2], testCase.name).toEqual( expect.objectContaining({ parse_mode: "HTML" }), ); } }); }); it("ignores private self-authored message updates instead of issuing a pairing challenge", async () => { await withIsolatedStateDirAsync(async () => { loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "pairing" } }, }); readChannelAllowFromStore.mockResolvedValue([]); upsertChannelPairingRequest.mockClear(); sendMessageSpy.mockClear(); replySpy.mockClear(); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 1234, type: "private", first_name: "Harold" }, message_id: 1884, date: 1736380800, from: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" }, pinned_message: { message_id: 1883, date: 1736380799, chat: { id: 1234, type: "private", first_name: "Harold" }, from: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" }, text: "Binding: Review pull request 54118 (openclaw)", }, }, me: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(upsertChannelPairingRequest).not.toHaveBeenCalled(); expect(sendMessageSpy).not.toHaveBeenCalled(); expect(replySpy).not.toHaveBeenCalled(); }); }); it("blocks unauthorized DM media before download and sends pairing reply", async () => { 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()}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) => Promise; await handler({ message: { chat: { id: 1234, type: "private" }, message_id: 410, 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); const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); expect(pairingText).toContain("Pairing code:"); expect(pairingText).toContain("
");
        expect(sendMessageSpy.mock.calls[0]?.[2]).toEqual(
          expect.objectContaining({ parse_mode: "HTML" }),
        );
        expect(replySpy).not.toHaveBeenCalled();
      } finally {
        fetchSpy.mockRestore();
      }
    });
  });
  it("blocks DM media downloads completely when dmPolicy is disabled", async () => {
    loadConfig.mockReturnValue({
      channels: { telegram: { dmPolicy: "disabled" } },
    });
    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) => Promise;

      await handler({
        message: {
          chat: { id: 1234, type: "private" },
          message_id: 411,
          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).not.toHaveBeenCalled();
      expect(replySpy).not.toHaveBeenCalled();
    } finally {
      fetchSpy.mockRestore();
    }
  });
  it("blocks unauthorized DM media groups before any photo download", async () => {
    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));

      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) => Promise;

        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);
        const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
        expect(pairingText).toContain("Pairing code:");
        expect(pairingText).toContain("
");
        expect(sendMessageSpy.mock.calls[0]?.[2]).toEqual(
          expect.objectContaining({ parse_mode: "HTML" }),
        );
        expect(replySpy).not.toHaveBeenCalled();
      } finally {
        fetchSpy.mockRestore();
      }
    });
  });
  it("triggers typing cue via onReplyStart", async () => {
    dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
      async ({ dispatcherOptions }) => {
        await dispatcherOptions.typingCallbacks?.onReplyStart?.();
        return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
      },
    );
    createTelegramBot({ token: "tok" });
    const handler = getOnHandler("message") as (ctx: Record) => Promise;
    await handler({
      message: {
        chat: { id: 42, type: "private" },
        from: { id: 999, username: "random" },
        text: "hi",
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({ download: async () => new Uint8Array() }),
    });
    expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined);
  });

  it("dedupes duplicate updates for callback_query, message, and channel_post", async () => {
    loadConfig.mockReturnValue({
      channels: {
        telegram: {
          dmPolicy: "open",
          allowFrom: ["*"],
          groupPolicy: "open",
          groups: {
            "-100777111222": {
              enabled: true,
              requireMention: false,
            },
          },
        },
      },
    });

    createTelegramBot({ token: "tok" });
    const callbackHandler = getOnHandler("callback_query") as (
      ctx: Record,
    ) => Promise;
    const messageHandler = getOnHandler("message") as (
      ctx: Record,
    ) => Promise;
    const channelPostHandler = getOnHandler("channel_post") as (
      ctx: Record,
    ) => Promise;

    await callbackHandler({
      update: { update_id: 222 },
      callbackQuery: {
        id: "cb-1",
        data: "ping",
        from: { id: 789, username: "testuser" },
        message: {
          chat: { id: 123, type: "private" },
          date: 1736380800,
          message_id: 9001,
        },
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({}),
    });
    await callbackHandler({
      update: { update_id: 222 },
      callbackQuery: {
        id: "cb-1",
        data: "ping",
        from: { id: 789, username: "testuser" },
        message: {
          chat: { id: 123, type: "private" },
          date: 1736380800,
          message_id: 9001,
        },
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({}),
    });
    expect(replySpy).toHaveBeenCalledTimes(1);

    replySpy.mockClear();

    await messageHandler({
      update: { update_id: 111 },
      message: {
        chat: { id: 123, type: "private" },
        from: { id: 456, username: "testuser" },
        text: "hello",
        date: 1736380800,
        message_id: 42,
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({ download: async () => new Uint8Array() }),
    });
    await messageHandler({
      update: { update_id: 111 },
      message: {
        chat: { id: 123, type: "private" },
        from: { id: 456, username: "testuser" },
        text: "hello",
        date: 1736380800,
        message_id: 42,
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({ download: async () => new Uint8Array() }),
    });
    expect(replySpy).toHaveBeenCalledTimes(1);

    replySpy.mockClear();

    await channelPostHandler({
      channelPost: {
        chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
        from: { id: 98765, is_bot: true, first_name: "wakebot", username: "wake_bot" },
        message_id: 777,
        text: "wake check",
        date: 1736380800,
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({}),
    });
    await channelPostHandler({
      channelPost: {
        chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
        from: { id: 98765, is_bot: true, first_name: "wakebot", username: "wake_bot" },
        message_id: 777,
        text: "wake check",
        date: 1736380800,
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({}),
    });
    expect(replySpy).toHaveBeenCalledTimes(1);
  });

  it("does not persist update offset past pending updates", async () => {
    // For this test we need sequentialize(...) to behave like a normal middleware and call next().
    sequentializeSpy.mockImplementationOnce(
      () => async (_ctx: unknown, next: () => Promise) => {
        await next();
      },
    );

    const onUpdateId = vi.fn();
    loadConfig.mockReturnValue({
      channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
    });

    createTelegramBot({
      token: "tok",
      updateOffset: {
        lastUpdateId: 100,
        onUpdateId,
      },
    });

    type Middleware = (
      ctx: Record,
      next: () => Promise,
    ) => Promise | void;

    const middlewares = middlewareUseSpy.mock.calls
      .map((call) => call[0])
      .filter((fn): fn is Middleware => typeof fn === "function");

    const runMiddlewareChain = async (
      ctx: Record,
      finalNext: () => Promise,
    ) => {
      let idx = -1;
      const dispatch = async (i: number): Promise => {
        if (i <= idx) {
          throw new Error("middleware dispatch called multiple times");
        }
        idx = i;
        const fn = middlewares[i];
        if (!fn) {
          await finalNext();
          return;
        }
        await fn(ctx, async () => dispatch(i + 1));
      };
      await dispatch(0);
    };

    let releaseUpdate101: (() => void) | undefined;
    const update101Gate = new Promise((resolve) => {
      releaseUpdate101 = resolve;
    });

    // Start processing update 101 but keep it pending (simulates an update queued behind sequentialize()).
    const p101 = runMiddlewareChain({ update: { update_id: 101 } }, async () => update101Gate);
    // Let update 101 enter the chain and mark itself pending before 102 completes.
    await Promise.resolve();

    // Complete update 102 while 101 is still pending. The persisted watermark must not jump to 102.
    await runMiddlewareChain({ update: { update_id: 102 } }, async () => {});

    const persistedValues = onUpdateId.mock.calls.map((call) => Number(call[0]));
    const maxPersisted = persistedValues.length > 0 ? Math.max(...persistedValues) : -Infinity;
    expect(maxPersisted).toBeLessThan(101);

    releaseUpdate101?.();
    await p101;

    // Once the pending update finishes, the watermark can safely catch up.
    const persistedAfterDrain = onUpdateId.mock.calls.map((call) => Number(call[0]));
    const maxPersistedAfterDrain =
      persistedAfterDrain.length > 0 ? Math.max(...persistedAfterDrain) : -Infinity;
    expect(maxPersistedAfterDrain).toBe(102);
  });
  it("allows distinct callback_query ids without update_id", async () => {
    loadConfig.mockReturnValue({
      channels: {
        telegram: { dmPolicy: "open", allowFrom: ["*"] },
      },
    });

    createTelegramBot({ token: "tok" });
    const handler = getOnHandler("callback_query") as (
      ctx: Record,
    ) => Promise;

    await handler({
      callbackQuery: {
        id: "cb-1",
        data: "ping",
        from: { id: 789, username: "testuser" },
        message: {
          chat: { id: 123, type: "private" },
          date: 1736380800,
          message_id: 9001,
        },
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({}),
    });

    await handler({
      callbackQuery: {
        id: "cb-2",
        data: "ping",
        from: { id: 789, username: "testuser" },
        message: {
          chat: { id: 123, type: "private" },
          date: 1736380800,
          message_id: 9001,
        },
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({}),
    });

    expect(replySpy).toHaveBeenCalledTimes(2);
  });

  const groupPolicyCases: Array<{
    name: string;
    config: Record;
    message: Record;
    expectedReplyCount: number;
  }> = [
    {
      name: "blocks all group messages when groupPolicy is 'disabled'",
      config: {
        channels: {
          telegram: {
            groupPolicy: "disabled",
            allowFrom: ["123456789"],
          },
        },
      },
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 123456789, username: "testuser" },
        text: "@openclaw_bot hello",
        date: 1736380800,
      },
      expectedReplyCount: 0,
    },
    {
      name: "blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'",
      config: {
        channels: {
          telegram: {
            groupPolicy: "allowlist",
            allowFrom: ["123456789"],
          },
        },
      },
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 999999, username: "notallowed" },
        text: "@openclaw_bot hello",
        date: 1736380800,
      },
      expectedReplyCount: 0,
    },
    {
      name: "allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'",
      config: {
        channels: {
          telegram: {
            groupPolicy: "allowlist",
            allowFrom: ["123456789"],
            groups: { "*": { requireMention: false } },
          },
        },
      },
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 123456789, username: "testuser" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 1,
    },
    {
      name: "blocks group messages when allowFrom is configured with @username entries (numeric IDs required)",
      config: {
        channels: {
          telegram: {
            groupPolicy: "allowlist",
            allowFrom: ["@testuser"],
            groups: { "*": { requireMention: false } },
          },
        },
      },
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 12345, username: "testuser" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 0,
    },
    {
      name: "allows group messages from tg:-prefixed allowFrom entries case-insensitively",
      config: {
        channels: {
          telegram: {
            groupPolicy: "allowlist",
            allowFrom: ["TG:77112533"],
            groups: { "*": { requireMention: false } },
          },
        },
      },
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 77112533, username: "mneves" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 1,
    },
    {
      name: "blocks group messages when per-group allowFrom override is explicitly empty",
      config: {
        channels: {
          telegram: {
            groupPolicy: "open",
            groups: {
              "-100123456789": {
                allowFrom: [],
                requireMention: false,
              },
            },
          },
        },
      },
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 999999, username: "random" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 0,
    },
    {
      name: "allows all group messages when groupPolicy is 'open'",
      config: {
        channels: {
          telegram: {
            groupPolicy: "open",
            groups: { "*": { requireMention: false } },
          },
        },
      },
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 999999, username: "random" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 1,
    },
  ];

  it("applies groupPolicy cases", async () => {
    for (const [index, testCase] of groupPolicyCases.entries()) {
      resetHarnessSpies();
      loadConfig.mockReturnValue(testCase.config);
      await dispatchMessage({
        message: {
          ...testCase.message,
          message_id: 1_000 + index,
          date: 1_736_380_800 + index,
        },
      });
      expect(replySpy.mock.calls.length, testCase.name).toBe(testCase.expectedReplyCount);
    }
  });

  it("routes DMs by telegram accountId binding", async () => {
    const config = {
      channels: {
        telegram: {
          allowFrom: ["*"],
          accounts: {
            opie: {
              botToken: "tok-opie",
              dmPolicy: "open",
              allowFrom: ["*"],
            },
          },
        },
      },
      bindings: [
        {
          agentId: "opie",
          match: { channel: "telegram", accountId: "opie" },
        },
      ],
    };
    loadConfig.mockReturnValue(config);

    await withConfigPathAsync(config, async () => {
      createTelegramBot({ token: "tok", accountId: "opie" });
      const handler = getOnHandler("message") as (ctx: Record) => Promise;

      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("reloads DM routing bindings between messages without recreating the bot", async () => {
    let boundAgentId = "agent-a";
    const configForAgent = (agentId: string) => ({
      channels: {
        telegram: {
          accounts: {
            opie: {
              botToken: "tok-opie",
              dmPolicy: "open",
            },
          },
        },
      },
      agents: {
        list: [{ id: "agent-a" }, { id: "agent-b" }],
      },
      bindings: [
        {
          agentId,
          match: { channel: "telegram", accountId: "opie" },
        },
      ],
    });
    loadConfig.mockImplementation(() => configForAgent(boundAgentId));

    createTelegramBot({ token: "tok", accountId: "opie" });
    const handler = getOnHandler("message") as (ctx: Record) => Promise;

    const sendDm = async (messageId: number, text: string) => {
      await handler({
        message: {
          chat: { id: 123, type: "private" },
          from: { id: 999, username: "testuser" },
          text,
          date: 1736380800 + messageId,
          message_id: messageId,
        },
        me: { username: "openclaw_bot" },
        getFile: async () => ({ download: async () => new Uint8Array() }),
      });
    };

    await sendDm(42, "hello one");
    expect(replySpy).toHaveBeenCalledTimes(1);
    expect(replySpy.mock.calls[0]?.[0].AccountId).toBe("opie");
    expect(replySpy.mock.calls[0]?.[0].SessionKey).toContain("agent:agent-a:");

    boundAgentId = "agent-b";
    await sendDm(43, "hello two");
    expect(replySpy).toHaveBeenCalledTimes(2);
    expect(replySpy.mock.calls[1]?.[0].AccountId).toBe("opie");
    expect(replySpy.mock.calls[1]?.[0].SessionKey).toContain("agent:agent-b:");
  });

  it("reloads topic agent overrides between messages without recreating the bot", async () => {
    let topicAgentId = "topic-a";
    loadConfig.mockImplementation(() => ({
      channels: {
        telegram: {
          groupPolicy: "open",
          groups: {
            "-1001234567890": {
              requireMention: false,
              topics: {
                "99": {
                  agentId: topicAgentId,
                },
              },
            },
          },
        },
      },
      agents: {
        list: [{ id: "topic-a" }, { id: "topic-b" }],
      },
    }));

    createTelegramBot({ token: "tok" });
    const handler = getOnHandler("message") as (ctx: Record) => Promise;

    const sendTopicMessage = async (messageId: number) => {
      await handler({
        message: {
          chat: { id: -1001234567890, type: "supergroup", title: "Forum Group", is_forum: true },
          from: { id: 12345, username: "testuser" },
          text: "hello",
          date: 1736380800 + messageId,
          message_id: messageId,
          message_thread_id: 99,
        },
        me: { username: "openclaw_bot" },
        getFile: async () => ({ download: async () => new Uint8Array() }),
      });
    };

    await sendTopicMessage(301);
    expect(replySpy).toHaveBeenCalledTimes(1);
    expect(replySpy.mock.calls[0]?.[0].SessionKey).toContain("agent:topic-a:");

    topicAgentId = "topic-b";
    await sendTopicMessage(302);
    expect(replySpy).toHaveBeenCalledTimes(2);
    expect(replySpy.mock.calls[1]?.[0].SessionKey).toContain("agent:topic-b:");
  });

  it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => {
    loadConfig.mockReturnValue({
      channels: {
        telegram: {
          accounts: {
            opie: {
              botToken: "tok-opie",
              dmPolicy: "open",
            },
          },
        },
      },
    });

    createTelegramBot({ token: "tok", accountId: "opie" });
    const handler = getOnHandler("message") as (ctx: Record) => Promise;

    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).toContain("agent:main:telegram:opie:");
  });

  it("applies group mention overrides and fallback behavior", async () => {
    const cases: Array<{
      config: Record;
      message: Record;
      me?: Record;
    }> = [
      {
        config: {
          channels: {
            telegram: {
              groupPolicy: "open",
              groups: {
                "*": { requireMention: true },
                "123": { requireMention: false },
              },
            },
          },
        },
        message: {
          chat: { id: 123, type: "group", title: "Dev Chat" },
          text: "hello",
          date: 1736380800,
        },
      },
      {
        config: {
          channels: {
            telegram: {
              groupPolicy: "open",
              groups: {
                "*": { requireMention: true },
                "-1001234567890": {
                  requireMention: true,
                  topics: {
                    "99": { requireMention: false },
                  },
                },
              },
            },
          },
        },
        message: {
          chat: {
            id: -1001234567890,
            type: "supergroup",
            title: "Forum Group",
            is_forum: true,
          },
          text: "hello",
          date: 1736380800,
          message_thread_id: 99,
        },
      },
      {
        config: {
          channels: {
            telegram: {
              groupPolicy: "open",
              groups: { "*": { requireMention: false } },
            },
          },
        },
        message: {
          chat: { id: 456, type: "group", title: "Ops" },
          text: "hello",
          date: 1736380800,
        },
      },
      {
        config: {
          channels: {
            telegram: {
              groupPolicy: "open",
              groups: { "*": { requireMention: true } },
            },
          },
        },
        message: {
          chat: { id: 789, type: "group", title: "No Me" },
          text: "hello",
          date: 1736380800,
        },
        me: {},
      },
    ];

    for (const testCase of cases) {
      resetHarnessSpies();
      loadConfig.mockReturnValue(testCase.config);
      await dispatchMessage({
        message: testCase.message,
        me: testCase.me,
      });
      expect(replySpy).toHaveBeenCalledTimes(1);
    }
  });

  it("routes forum topics to parent or topic-specific bindings", async () => {
    const cases: Array<{
      config: Record;
      expectedSessionKeyFragment: string;
      text: string;
    }> = [
      {
        config: {
          channels: {
            telegram: {
              groupPolicy: "open",
              groups: { "*": { requireMention: false } },
            },
          },
          agents: {
            list: [{ id: "forum-agent" }],
          },
          bindings: [
            {
              agentId: "forum-agent",
              match: {
                channel: "telegram",
                peer: { kind: "group", id: "-1001234567890" },
              },
            },
          ],
        },
        expectedSessionKeyFragment: "agent:forum-agent:",
        text: "hello from topic",
      },
      {
        config: {
          channels: {
            telegram: {
              groupPolicy: "open",
              groups: { "*": { requireMention: false } },
            },
          },
          agents: {
            list: [{ id: "topic-agent" }, { id: "group-agent" }],
          },
          bindings: [
            {
              agentId: "topic-agent",
              match: {
                channel: "telegram",
                peer: { kind: "group", id: "-1001234567890:topic:99" },
              },
            },
            {
              agentId: "group-agent",
              match: {
                channel: "telegram",
                peer: { kind: "group", id: "-1001234567890" },
              },
            },
          ],
        },
        expectedSessionKeyFragment: "agent:topic-agent:",
        text: "hello from topic 99",
      },
    ];

    for (const testCase of cases) {
      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,
          },
        });
        expect(replySpy).toHaveBeenCalledTimes(1);
        const payload = replySpy.mock.calls[0][0];
        expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
      });
    }
  });

  it("sends GIF replies as animations", async () => {
    replySpy.mockResolvedValueOnce({
      text: "caption",
      mediaUrl: "https://example.com/fun",
    });
    loadWebMedia.mockResolvedValueOnce({
      buffer: Buffer.from("GIF89a"),
      contentType: "image/gif",
      fileName: "fun.gif",
    });
    createTelegramBot({ token: "tok" });
    const handler = getOnHandler("message") as (ctx: Record) => Promise;

    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(loadWebMedia).toHaveBeenCalledTimes(1);
    expect(loadWebMedia.mock.calls[0]?.[0]).toBe("https://example.com/fun");
  });

  function resetHarnessSpies() {
    onSpy.mockClear();
    replySpy.mockClear();
    sendMessageSpy.mockClear();
    setMessageReactionSpy.mockClear();
    setMyCommandsSpy.mockClear();
  }
  function getMessageHandler() {
    createTelegramBot({ token: "tok" });
    return getOnHandler("message") as (ctx: Record) => Promise;
  }
  async function dispatchMessage(params: {
    message: Record;
    me?: Record;
  }) {
    const handler = getMessageHandler();
    await handler({
      message: params.message,
      me: params.me ?? { username: "openclaw_bot" },
      getFile: async () => ({ download: async () => new Uint8Array() }),
    });
  }

  it("accepts mentionPatterns matches with and without unrelated mentions", async () => {
    const cases = [
      {
        name: "plain mention pattern text",
        message: {
          chat: { id: 7, type: "group", title: "Test Group" },
          text: "bert: introduce yourself",
          date: 1736380800,
          message_id: 1,
          from: { id: 9, first_name: "Ada" },
        },
        assertEnvelope: true,
      },
      {
        name: "mention pattern plus another @mention",
        message: {
          chat: { id: 7, type: "group", title: "Test Group" },
          text: "bert: hello @alice",
          entities: [{ type: "mention", offset: 12, length: 6 }],
          date: 1736380801,
          message_id: 3,
          from: { id: 9, first_name: "Ada" },
        },
        assertEnvelope: false,
      },
    ] as const;

    for (const testCase of cases) {
      resetHarnessSpies();
      loadConfig.mockReturnValue({
        agents: {
          defaults: {
            envelopeTimezone: "utc",
          },
        },
        identity: { name: "Bert" },
        messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
        channels: {
          telegram: {
            groupPolicy: "open",
            groups: { "*": { requireMention: true } },
          },
        },
      });

      await dispatchMessage({
        message: testCase.message,
      });

      expect(replySpy.mock.calls.length, testCase.name).toBe(1);
      const payload = replySpy.mock.calls[0][0];
      expect(payload.WasMentioned, testCase.name).toBe(true);
      if (testCase.assertEnvelope) {
        expect(payload.SenderName).toBe("Ada");
        expect(payload.SenderId).toBe("9");
        const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
        const timestampPattern = escapeRegExp(expectedTimestamp);
        expect(payload.Body).toMatch(
          new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
        );
      }
    }
  });
  it("keeps group envelope headers stable (sender identity is separate)", async () => {
    resetHarnessSpies();

    loadConfig.mockReturnValue({
      agents: {
        defaults: {
          envelopeTimezone: "utc",
        },
      },
      channels: {
        telegram: {
          groupPolicy: "open",
          groups: { "*": { requireMention: false } },
        },
      },
    });

    await dispatchMessage({
      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",
        },
      },
    });

    expect(replySpy).toHaveBeenCalledTimes(1);
    const payload = replySpy.mock.calls[0][0];
    expect(payload.SenderName).toBe("Ada Lovelace");
    expect(payload.SenderId).toBe("99");
    expect(payload.SenderUsername).toBe("ada");
    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}\\]`),
    );
  });
  it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
    resetHarnessSpies();

    loadConfig.mockReturnValue({
      messages: {
        ackReaction: "👀",
        ackReactionScope: "group-mentions",
        groupChat: { mentionPatterns: ["\\bbert\\b"] },
      },
      channels: {
        telegram: {
          groupPolicy: "open",
          groups: { "*": { requireMention: true } },
        },
      },
    });

    await dispatchMessage({
      message: {
        chat: { id: 7, type: "group", title: "Test Group" },
        text: "bert hello",
        date: 1736380800,
        message_id: 123,
        from: { id: 9, first_name: "Ada" },
      },
    });

    expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [{ type: "emoji", emoji: "👀" }]);
  });
  it("clears native commands when disabled", () => {
    resetHarnessSpies();
    loadConfig.mockReturnValue({
      commands: { native: false },
    });

    createTelegramBot({ token: "tok" });

    expect(setMyCommandsSpy).toHaveBeenCalledWith([]);
  });
  it("handles requireMention when mentions do and do not resolve", async () => {
    const cases = [
      {
        name: "mention pattern configured but no match",
        config: { messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } } },
        me: { username: "openclaw_bot" },
        expectedReplyCount: 0,
        expectedWasMentioned: undefined,
      },
      {
        name: "mention detection unavailable",
        config: { messages: { groupChat: { mentionPatterns: [] } } },
        me: {},
        expectedReplyCount: 1,
        expectedWasMentioned: false,
      },
    ] as const;

    for (const [index, testCase] of cases.entries()) {
      resetHarnessSpies();
      loadConfig.mockReturnValue({
        ...testCase.config,
        channels: {
          telegram: {
            groupPolicy: "open",
            groups: { "*": { requireMention: true } },
          },
        },
      });

      await dispatchMessage({
        message: {
          chat: { id: 7, type: "group", title: "Test Group" },
          text: "hello everyone",
          date: 1_736_380_800 + index,
          message_id: 2 + index,
          from: { id: 9, first_name: "Ada" },
        },
        me: testCase.me,
      });

      expect(replySpy.mock.calls.length, testCase.name).toBe(testCase.expectedReplyCount);
      if (testCase.expectedWasMentioned != null) {
        const payload = replySpy.mock.calls[0][0];
        expect(payload.WasMentioned, testCase.name).toBe(testCase.expectedWasMentioned);
      }
    }
  });
  it("includes reply-to context when a Telegram reply is received", async () => {
    resetHarnessSpies();

    await dispatchMessage({
      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" },
        },
      },
    });

    expect(replySpy).toHaveBeenCalledTimes(1);
    const payload = replySpy.mock.calls[0][0];
    expect(payload.Body).toContain("[Replying to Ada id:9001]");
    expect(payload.Body).toContain("Can you summarize this?");
    expect(payload.ReplyToId).toBe("9001");
    expect(payload.ReplyToBody).toBe("Can you summarize this?");
    expect(payload.ReplyToSender).toBe("Ada");
  });

  it("blocks group messages for restrictive group config edge cases", async () => {
    const blockedCases = [
      {
        name: "allowlist policy with no groupAllowFrom",
        config: {
          channels: {
            telegram: {
              groupPolicy: "allowlist",
              groups: { "*": { requireMention: false } },
            },
          },
        },
        message: {
          chat: { id: -100123456789, type: "group", title: "Test Group" },
          from: { id: 123456789, username: "testuser" },
          text: "hello",
          date: 1736380800,
        },
      },
      {
        name: "groups map without wildcard",
        config: {
          channels: {
            telegram: {
              groups: {
                "123": { requireMention: false },
              },
            },
          },
        },
        message: {
          chat: { id: 456, type: "group", title: "Ops" },
          text: "@openclaw_bot hello",
          date: 1736380800,
        },
      },
    ] as const;

    for (const testCase of blockedCases) {
      resetHarnessSpies();
      loadConfig.mockReturnValue(testCase.config);
      await dispatchMessage({ message: testCase.message });
      expect(replySpy.mock.calls.length, testCase.name).toBe(0);
    }
  });
  it("blocks group sender not in groupAllowFrom even when sender is paired in DM store", async () => {
    resetHarnessSpies();
    loadConfig.mockReturnValue({
      channels: {
        telegram: {
          groupPolicy: "allowlist",
          groupAllowFrom: ["222222222"],
          groups: { "*": { requireMention: false } },
        },
      },
    });
    readChannelAllowFromStore.mockResolvedValueOnce(["123456789"]);

    await dispatchMessage({
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 123456789, username: "testuser" },
        text: "hello",
        date: 1736380800,
      },
    });

    expect(replySpy).not.toHaveBeenCalled();
  });
  it("allows control commands with TG-prefixed groupAllowFrom entries", async () => {
    loadConfig.mockReturnValue({
      channels: {
        telegram: {
          groupPolicy: "allowlist",
          groupAllowFrom: ["  TG:123456789  "],
          groups: { "*": { requireMention: true } },
        },
      },
    });

    createTelegramBot({ token: "tok" });
    const handler = getOnHandler("message") as (ctx: Record) => Promise;

    await handler({
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 123456789, username: "testuser" },
        text: "/status",
        date: 1736380800,
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({ download: async () => new Uint8Array() }),
    });

    expect(replySpy).toHaveBeenCalledTimes(1);
  });
  it("handles forum topic metadata and typing thread fallbacks", async () => {
    const forumCases = [
      {
        name: "topic-scoped forum message",
        threadId: 99,
        expectedTypingThreadId: 99,
        assertTopicMetadata: true,
      },
      {
        name: "General topic forum message",
        threadId: undefined,
        expectedTypingThreadId: 1,
        assertTopicMetadata: false,
      },
    ] as const;

    for (const testCase of forumCases) {
      resetHarnessSpies();
      sendChatActionSpy.mockClear();
      let dispatchCall:
        | {
            ctx: {
              SessionKey?: unknown;
              From?: unknown;
              MessageThreadId?: unknown;
              IsForum?: unknown;
            };
          }
        | undefined;
      dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
        dispatchCall = params as typeof dispatchCall;
        await params.dispatcherOptions.typingCallbacks?.onReplyStart?.();
        return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
      });
      loadConfig.mockReturnValue({
        channels: {
          telegram: {
            groupPolicy: "open",
            groups: { "*": { requireMention: false } },
          },
        },
      });

      const handler = getMessageHandler();
      await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId }));

      const payload = dispatchCall?.ctx;
      expect(payload).toBeDefined();
      if (!payload) {
        continue;
      }
      if (testCase.assertTopicMetadata) {
        expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99");
        expect(payload.From).toBe("telegram:group:-1001234567890:topic:99");
        expect(payload.MessageThreadId).toBe(99);
        expect(payload.IsForum).toBe(true);
      }
      expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", {
        message_thread_id: testCase.expectedTypingThreadId,
      });
    }
  });

  it("routes General-topic forum messages via getChat when Telegram omits forum metadata", async () => {
    resetHarnessSpies();
    sendChatActionSpy.mockClear();
    getChatSpy.mockResolvedValue({
      id: -1001234567890,
      type: "supergroup",
      is_forum: true,
      title: "Forum Group",
    });
    let dispatchCall:
      | {
          ctx: {
            SessionKey?: unknown;
            From?: unknown;
            MessageThreadId?: unknown;
            IsForum?: unknown;
          };
        }
      | undefined;
    dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
      dispatchCall = params as typeof dispatchCall;
      await params.dispatcherOptions.typingCallbacks?.onReplyStart?.();
      return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
    });
    loadConfig.mockReturnValue({
      channels: {
        telegram: {
          groupPolicy: "open",
          groups: { "*": { requireMention: false } },
        },
      },
    });

    const handler = getMessageHandler();
    await handler({
      message: {
        chat: { id: -1001234567890, type: "supergroup", title: "Forum Group" },
        from: { id: 12345, username: "testuser" },
        text: "hello",
        date: 1736380800,
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({ download: async () => new Uint8Array() }),
    });

    expect(getChatSpy).toHaveBeenCalledOnce();
    expect(getChatSpy).toHaveBeenCalledWith(-1001234567890);
    expect(dispatchCall?.ctx).toEqual(
      expect.objectContaining({
        SessionKey: expect.stringContaining("telegram:group:-1001234567890:topic:1"),
        From: "telegram:group:-1001234567890:topic:1",
        MessageThreadId: 1,
        IsForum: true,
      }),
    );
    expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", {
      message_thread_id: 1,
    });
  });
  it("threads forum replies only when a topic id exists", async () => {
    const threadCases = [
      { name: "General topic reply", threadId: undefined, expectedMessageThreadId: undefined },
      { name: "topic reply", threadId: 99, expectedMessageThreadId: 99 },
    ] as const;

    for (const testCase of threadCases) {
      resetHarnessSpies();
      replySpy.mockResolvedValue({ text: "response" });
      loadConfig.mockReturnValue({
        channels: {
          telegram: {
            groupPolicy: "open",
            groups: { "*": { requireMention: false } },
          },
        },
      });

      const handler = getMessageHandler();
      await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId }));

      expect(sendMessageSpy.mock.calls.length, testCase.name).toBe(1);
      const sendParams = sendMessageSpy.mock.calls[0]?.[2] as { message_thread_id?: number };
      if (testCase.expectedMessageThreadId == null) {
        expect(sendParams?.message_thread_id, testCase.name).toBeUndefined();
      } else {
        expect(sendParams?.message_thread_id, testCase.name).toBe(testCase.expectedMessageThreadId);
      }
    }
  });

  const allowFromEdgeCases: Array<{
    name: string;
    config: Record;
    message: Record;
    expectedReplyCount: number;
  }> = [
    {
      name: "allows direct messages regardless of groupPolicy",
      config: {
        channels: {
          telegram: {
            groupPolicy: "disabled",
            allowFrom: ["123456789"],
          },
        },
      },
      message: {
        chat: { id: 123456789, type: "private" },
        from: { id: 123456789, username: "testuser" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 1,
    },
    {
      name: "allows direct messages with tg/Telegram-prefixed allowFrom entries",
      config: {
        channels: {
          telegram: {
            allowFrom: ["  TG:123456789  "],
          },
        },
      },
      message: {
        chat: { id: 123456789, type: "private" },
        from: { id: 123456789, username: "testuser" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 1,
    },
    {
      name: "matches direct message allowFrom against sender user id when chat id differs",
      config: {
        channels: {
          telegram: {
            allowFrom: ["123456789"],
          },
        },
      },
      message: {
        chat: { id: 777777777, type: "private" },
        from: { id: 123456789, username: "testuser" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 1,
    },
    {
      name: "falls back to direct message chat id when sender user id is missing",
      config: {
        channels: {
          telegram: {
            allowFrom: ["123456789"],
          },
        },
      },
      message: {
        chat: { id: 123456789, type: "private" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 1,
    },
    {
      name: "allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'",
      config: {
        channels: {
          telegram: {
            groupPolicy: "allowlist",
            allowFrom: ["*"],
            groups: { "*": { requireMention: false } },
          },
        },
      },
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        from: { id: 999999, username: "random" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 1,
    },
    {
      name: "blocks group messages with no sender ID when groupPolicy is 'allowlist'",
      config: {
        channels: {
          telegram: {
            groupPolicy: "allowlist",
            allowFrom: ["123456789"],
          },
        },
      },
      message: {
        chat: { id: -100123456789, type: "group", title: "Test Group" },
        text: "hello",
        date: 1736380800,
      },
      expectedReplyCount: 0,
    },
  ];

  it("applies allowFrom edge cases", async () => {
    for (const [index, testCase] of allowFromEdgeCases.entries()) {
      resetHarnessSpies();
      loadConfig.mockReturnValue(testCase.config);
      await dispatchMessage({
        message: {
          ...testCase.message,
          message_id: 2_000 + index,
          date: 1_736_380_900 + index,
        },
      });
      expect(replySpy.mock.calls.length, testCase.name).toBe(testCase.expectedReplyCount);
    }
  });
  it("sends replies without native reply threading", async () => {
    replySpy.mockResolvedValue({ text: "a".repeat(4500) });

    createTelegramBot({ token: "tok" });
    const handler = getOnHandler("message") as (ctx: Record) => Promise;
    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] as { reply_to_message_id?: number } | undefined)?.reply_to_message_id,
      ).toBeUndefined();
    }
  });
  it("prefixes final replies with responsePrefix", async () => {
    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) => Promise;
    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 threaded replies for replyToMode=first/all", async () => {
    for (const [mode, messageId] of [
      ["first", 101],
      ["all", 102],
    ] as const) {
      onSpy.mockClear();
      sendMessageSpy.mockClear();
      replySpy.mockClear();
      replySpy.mockResolvedValue({
        text: "a".repeat(4500),
        replyToId: String(messageId),
      });

      createTelegramBot({ token: "tok", replyToMode: mode });
      const handler = getOnHandler("message") as (ctx: Record) => Promise;
      await handler({
        message: {
          chat: { id: 5, type: "private" },
          text: "hi",
          date: 1736380800,
          message_id: messageId,
        },
        me: { username: "openclaw_bot" },
        getFile: async () => ({ download: async () => new Uint8Array() }),
      });

      expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1);
      for (const [index, call] of sendMessageSpy.mock.calls.entries()) {
        const actual = (call[2] as { reply_to_message_id?: number } | undefined)
          ?.reply_to_message_id;
        if (mode === "all" || index === 0) {
          expect(actual).toBe(messageId);
        } else {
          expect(actual).toBeUndefined();
        }
      }
    }
  });
  it("honors routed group activation from session store", async () => {
    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",
    );
    const config = {
      channels: {
        telegram: {
          groupPolicy: "open",
          groups: { "*": { requireMention: true } },
        },
      },
      bindings: [
        {
          agentId: "ops",
          match: {
            channel: "telegram",
            peer: { kind: "group", id: "123" },
          },
        },
      ],
      session: { store: storePath },
    };
    loadConfig.mockReturnValue(config);

    await withConfigPathAsync(config, async () => {
      createTelegramBot({ token: "tok" });
      const handler = getOnHandler("message") as (ctx: Record) => Promise;

      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 () => {
    let dispatchCall:
      | {
          ctx: {
            GroupSystemPrompt?: unknown;
          };
          replyOptions?: {
            skillFilter?: unknown;
          };
        }
      | undefined;
    dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
      dispatchCall = params as typeof dispatchCall;
      return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
    });
    loadConfig.mockReturnValue({
      channels: {
        telegram: {
          groupPolicy: "open",
          groups: {
            "-1001234567890": {
              requireMention: false,
              systemPrompt: "Group prompt",
              skills: ["group-skill"],
              topics: {
                "99": {
                  skills: [],
                  systemPrompt: "Topic prompt",
                },
              },
            },
          },
        },
      },
    });

    createTelegramBot({ token: "tok" });
    const handler = getOnHandler("message") as (ctx: Record) => Promise;

    await handler(makeForumGroupMessageCtx({ threadId: 99 }));

    const payload = dispatchCall?.ctx;
    expect(payload).toBeDefined();
    if (!payload) {
      return;
    }
    expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt");
    expect(dispatchCall?.replyOptions?.skillFilter).toEqual([]);
  });
  it("threads native command replies inside topics", async () => {
    commandSpy.mockClear();
    replySpy.mockResolvedValue({ text: "response" });

    loadConfig.mockReturnValue({
      commands: { native: true },
      channels: {
        telegram: {
          dmPolicy: "open",
          allowFrom: ["*"],
          groups: { "*": { requireMention: false } },
        },
      },
    });

    createTelegramBot({ token: "tok" });
    expect(commandSpy).toHaveBeenCalled();
    const handler = commandSpy.mock.calls[0][1] as (ctx: Record) => Promise;

    await handler({
      ...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }),
      match: "",
    });

    expect(sendMessageSpy).toHaveBeenCalledWith(
      "-1001234567890",
      expect.any(String),
      expect.objectContaining({ message_thread_id: 99 }),
    );
  });
  it("reloads native command routing bindings between invocations without recreating the bot", async () => {
    commandSpy.mockClear();
    replySpy.mockClear();

    let boundAgentId = "agent-a";
    loadConfig.mockImplementation(() => ({
      commands: { native: true },
      channels: {
        telegram: {
          dmPolicy: "open",
          allowFrom: ["*"],
        },
      },
      agents: {
        list: [{ id: "agent-a" }, { id: "agent-b" }],
      },
      bindings: [
        {
          agentId: boundAgentId,
          match: { channel: "telegram", accountId: "default" },
        },
      ],
    }));

    createTelegramBot({ token: "tok" });
    const statusHandler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as
      | ((ctx: Record) => Promise)
      | undefined;
    if (!statusHandler) {
      throw new Error("status command handler missing");
    }

    const invokeStatus = async (messageId: number) => {
      await statusHandler({
        message: {
          chat: { id: 1234, type: "private" },
          from: { id: 9, username: "ada_bot" },
          text: "/status",
          date: 1736380800 + messageId,
          message_id: messageId,
        },
        match: "",
      });
    };

    await invokeStatus(401);
    expect(replySpy).toHaveBeenCalledTimes(1);
    expect(replySpy.mock.calls[0]?.[0].SessionKey).toContain("agent:agent-a:");

    boundAgentId = "agent-b";
    await invokeStatus(402);
    expect(replySpy).toHaveBeenCalledTimes(2);
    expect(replySpy.mock.calls[1]?.[0].SessionKey).toContain("agent:agent-b:");
  });
  it("skips tool summaries for native slash commands", async () => {
    commandSpy.mockClear();
    replySpy.mockImplementation(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
      await opts?.onToolResult?.({ text: "tool update" });
      return { text: "final reply" };
    });

    loadConfig.mockReturnValue({
      commands: { native: true },
      channels: {
        telegram: {
          dmPolicy: "open",
          allowFrom: ["*"],
        },
      },
    });

    createTelegramBot({ token: "tok" });
    const verboseHandler = commandSpy.mock.calls.find((call) => call[0] === "verbose")?.[1] as
      | ((ctx: Record) => Promise)
      | undefined;
    if (!verboseHandler) {
      throw new Error("verbose command handler missing");
    }

    await verboseHandler({
      message: {
        chat: { id: 12345, type: "private" },
        from: { id: 12345, username: "testuser" },
        text: "/verbose on",
        date: 1736380800,
        message_id: 42,
      },
      match: "on",
    });

    expect(sendMessageSpy).toHaveBeenCalledTimes(1);
    expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("final reply");
  });
  it("dedupes duplicate message updates by update_id", async () => {
    onSpy.mockReset();
    replySpy.mockReset();

    loadConfig.mockReturnValue({
      channels: {
        telegram: { dmPolicy: "open", allowFrom: ["*"] },
      },
    });

    createTelegramBot({ token: "tok" });
    const handler = getOnHandler("message") as (ctx: Record) => Promise;

    const ctx = {
      update: { update_id: 111 },
      message: {
        chat: { id: 123, type: "private" },
        from: { id: 456, username: "testuser" },
        text: "hello",
        date: 1736380800,
        message_id: 42,
      },
      me: { username: "openclaw_bot" },
      getFile: async () => ({ download: async () => new Uint8Array() }),
    };

    await handler(ctx);
    await handler(ctx);

    expect(replySpy).toHaveBeenCalledTimes(1);
  });
});