From 182d0fcee2bbd2db460745dfa9f5be10a066b1f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 06:16:59 +0100 Subject: [PATCH] fix(telegram): isolate sent-message cache stores --- extensions/telegram/src/send.test.ts | 176 +++++++++++++++--- extensions/telegram/src/sent-message-cache.ts | 101 ++++++---- 2 files changed, 212 insertions(+), 65 deletions(-) diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 2abb1d2bee6..30a0dc0da91 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import type { Bot } from "grammy"; import { afterEach, describe, expect, it, vi } from "vitest"; import { importFreshModule } from "../../../test/helpers/import-fresh.js"; @@ -22,7 +23,6 @@ const { loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget, - resolveStorePath, } = getTelegramSendTestMocks(); const { buildInlineKeyboard, @@ -39,6 +39,8 @@ const { unpinMessageTelegram, } = await importTelegramSendModule(); +const TELEGRAM_TEST_CFG = {}; + async function expectChatNotFoundWithChatId( action: Promise, expectedChatId: string, @@ -100,6 +102,7 @@ function mockLoadedMedia({ describe("sent-message-cache", () => { afterEach(() => { + vi.useRealTimers(); clearSentMessageCache(); }); @@ -131,10 +134,10 @@ describe("sent-message-cache", () => { it("keeps sent-message ownership across restart", async () => { const persistedStorePath = `/tmp/openclaw-telegram-send-tests-${process.pid}-restart.json`; - resolveStorePath.mockReturnValue(persistedStorePath); + const sentMessageCfg = { session: { store: persistedStorePath } }; - recordSentMessage(123, 1); - expect(wasSentByBot(123, 1)).toBe(true); + recordSentMessage(123, 1, sentMessageCfg); + expect(wasSentByBot(123, 1, sentMessageCfg)).toBe(true); resetSentMessageCacheForTest(); @@ -144,12 +147,51 @@ describe("sent-message-cache", () => { ); try { - expect(restartedCache.wasSentByBot(123, 1)).toBe(true); + expect(restartedCache.wasSentByBot(123, 1, sentMessageCfg)).toBe(true); } finally { restartedCache.clearSentMessageCache(); } }); + it("keeps expired custom-store cleanup away from the default store", () => { + const customStorePath = `/tmp/openclaw-telegram-send-tests-${process.pid}-custom-cleanup.json`; + const customCfg = { session: { store: customStorePath } }; + const startedAt = new Date("2026-01-01T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(startedAt); + + try { + recordSentMessage(123, 2, customCfg); + + vi.setSystemTime(startedAt.getTime() + 24 * 60 * 60 * 1000 + 1); + recordSentMessage(123, 1); + + expect(wasSentByBot(123, 2, customCfg)).toBe(false); + expect(wasSentByBot(123, 1)).toBe(true); + } finally { + fs.rmSync(customStorePath, { force: true }); + fs.rmSync(`${customStorePath}.telegram-sent-messages.json`, { force: true }); + } + }); + + it("keeps default and custom stores isolated while both are loaded", () => { + const customStorePath = `/tmp/openclaw-telegram-send-tests-${process.pid}-custom-isolated.json`; + const customCfg = { session: { store: customStorePath } }; + + try { + recordSentMessage(123, 1); + recordSentMessage(123, 2, customCfg); + + expect(wasSentByBot(123, 1)).toBe(true); + expect(wasSentByBot(123, 2)).toBe(false); + expect(wasSentByBot(123, 1, customCfg)).toBe(false); + expect(wasSentByBot(123, 2, customCfg)).toBe(true); + } finally { + fs.rmSync(customStorePath, { force: true }); + fs.rmSync(`${customStorePath}.telegram-sent-messages.json`, { force: true }); + } + }); + it("shares sent-message state across distinct module instances", async () => { const cacheA = await importFreshModule( import.meta.url, @@ -267,6 +309,8 @@ describe("sendMessageTelegram", () => { botApi.sendChatAction.mockResolvedValue(true); await sendTypingTelegram("telegram:group:-1001234567890:topic:271", { + cfg: TELEGRAM_TEST_CFG, + token: "tok", accountId: "default", }); @@ -286,8 +330,16 @@ describe("sendMessageTelegram", () => { botApi.pinChatMessage.mockResolvedValue(true); botApi.unpinChatMessage.mockResolvedValue(true); - await pinMessageTelegram("-1001234567890", 101, { accountId: "default" }); - await unpinMessageTelegram("-1001234567890", 101, { accountId: "default" }); + await pinMessageTelegram("-1001234567890", 101, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + accountId: "default", + }); + await unpinMessageTelegram("-1001234567890", 101, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + accountId: "default", + }); expect(botApi.pinChatMessage).toHaveBeenCalledWith("-1001234567890", 101, { disable_notification: true, @@ -306,6 +358,8 @@ describe("sendMessageTelegram", () => { botApi.pinChatMessage.mockResolvedValue(true); await pinMessageTelegram("-1001234567890", 101, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", accountId: "default", notify: true, }); @@ -326,6 +380,8 @@ describe("sendMessageTelegram", () => { botApi.editForumTopic.mockResolvedValue(true); await renameForumTopicTelegram("-1001234567890", 271, "Codex Thread", { + cfg: TELEGRAM_TEST_CFG, + token: "tok", accountId: "default", }); @@ -345,6 +401,8 @@ describe("sendMessageTelegram", () => { botApi.editForumTopic.mockResolvedValue(true); await editForumTopicTelegram("-1001234567890", 271, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", accountId: "default", name: "Codex Thread", iconCustomEmojiId: "emoji-123", @@ -367,6 +425,8 @@ describe("sendMessageTelegram", () => { botApi.editForumTopic.mockResolvedValue(true); await editForumTopicTelegram("telegram:group:-1001234567890:topic:271", 271, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", accountId: "default", name: "Codex Thread", }); @@ -379,11 +439,13 @@ describe("sendMessageTelegram", () => { it("rejects empty topic edits", async () => { await expect( editForumTopicTelegram("-1001234567890", 271, { + cfg: TELEGRAM_TEST_CFG, accountId: "default", }), ).rejects.toThrow("Telegram forum topic update requires a name or iconCustomEmojiId"); await expect( editForumTopicTelegram("-1001234567890", 271, { + cfg: TELEGRAM_TEST_CFG, accountId: "default", iconCustomEmojiId: " ", }), @@ -395,7 +457,7 @@ describe("sendMessageTelegram", () => { { name: "global telegram timeout", cfg: { channels: { telegram: { timeoutSeconds: 60 } } }, - opts: { token: "tok" }, + opts: { cfg: TELEGRAM_TEST_CFG, token: "tok" }, expectedTimeout: 60, }, { @@ -408,7 +470,7 @@ describe("sendMessageTelegram", () => { }, }, }, - opts: { token: "tok", accountId: "foo" }, + opts: { cfg: TELEGRAM_TEST_CFG, token: "tok", accountId: "foo" }, expectedTimeout: 61, }, ] as const; @@ -419,7 +481,7 @@ describe("sendMessageTelegram", () => { message_id: 1, chat: { id: "123" }, }); - await sendMessageTelegram("123", "hi", testCase.opts); + await sendMessageTelegram("123", "hi", { ...testCase.opts, cfg: testCase.cfg }); expect(botCtorSpy, testCase.name).toHaveBeenCalledWith( "tok", expect.objectContaining({ @@ -478,6 +540,7 @@ describe("sendMessageTelegram", () => { }; const res = await sendMessageTelegram(testCase.chatId, testCase.text, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, ...testCase.options, @@ -539,13 +602,18 @@ describe("sendMessageTelegram", () => { }, ] as const; for (const testCase of cases) { - loadConfig.mockReturnValue({ + const cfg = { channels: { telegram: { linkPreview: false } }, - }); + }; + loadConfig.mockReturnValue(cfg); const api = { sendMessage: testCase.sendMessage } as unknown as { sendMessage: typeof testCase.sendMessage; }; - await sendMessageTelegram("123", testCase.text, { token: "tok", api }); + await sendMessageTelegram("123", testCase.text, { + cfg, + token: "tok", + api, + }); expect(testCase.sendMessage.mock.calls, testCase.name).toEqual(testCase.expectedCalls); } }); @@ -560,6 +628,7 @@ describe("sendMessageTelegram", () => { await expect( sendMessageTelegram("123", "hi", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, }), @@ -577,6 +646,7 @@ describe("sendMessageTelegram", () => { await expect( sendMessageTelegram("123", "caption", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/photo.png", @@ -595,7 +665,7 @@ describe("sendMessageTelegram", () => { chat: { id: "123" }, }); try { - await sendMessageTelegram("123", "hi", { token: "tok" }); + await sendMessageTelegram("123", "hi", { cfg: TELEGRAM_TEST_CFG, token: "tok" }); const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) ?.client?.fetch; expect(clientFetch).toBeTypeOf("function"); @@ -620,6 +690,7 @@ describe("sendMessageTelegram", () => { }; await sendMessageTelegram("telegram:123", "hi", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, }); @@ -641,6 +712,7 @@ describe("sendMessageTelegram", () => { }; await sendMessageTelegram("https://t.me/mychannel", "hi", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, gatewayClientScopes: ["operator.write"], @@ -667,6 +739,7 @@ describe("sendMessageTelegram", () => { await expect( sendMessageTelegram("@missingchannel", "hi", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, }), @@ -690,6 +763,7 @@ describe("sendMessageTelegram", () => { }); await sendMessageTelegram(chatId, "photo in topic", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/photo.jpg", @@ -727,6 +801,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, longText, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/photo.jpg", @@ -762,6 +837,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, shortText, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/photo.jpg", @@ -794,6 +870,7 @@ describe("sendMessageTelegram", () => { }); await sendMessageTelegram(chatId, caption, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/photo.jpg", @@ -830,6 +907,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, text, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/video.mp4", @@ -860,6 +938,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, text, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/video.mp4", @@ -930,6 +1009,7 @@ describe("sendMessageTelegram", () => { }); const sendOptions: NonNullable[2]> = { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/video.mp4", @@ -975,6 +1055,7 @@ describe("sendMessageTelegram", () => { const setTimeoutSpy = vi.spyOn(global, "setTimeout"); const promise = sendMessageTelegram(chatId, "hi", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 }, @@ -1010,6 +1091,7 @@ describe("sendMessageTelegram", () => { }; const promise = sendMessageTelegram(chatId, "hi", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 }, @@ -1030,6 +1112,7 @@ describe("sendMessageTelegram", () => { await expect( sendMessageTelegram(chatId, "hi", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, retry: { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, @@ -1051,6 +1134,7 @@ describe("sendMessageTelegram", () => { await expect( sendMessageTelegram(chatId, "hi", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, @@ -1075,6 +1159,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, "caption", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/fun", @@ -1124,6 +1209,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, "caption", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: testCase.mediaUrl, @@ -1164,6 +1250,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, "caption", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/photo.png", @@ -1198,6 +1285,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, "caption", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/photo.png", @@ -1228,6 +1316,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, "caption", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/report.pdf", @@ -1338,6 +1427,7 @@ describe("sendMessageTelegram", () => { }); await sendMessageTelegram(testCase.chatId, testCase.text, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: testCase.mediaUrl, @@ -1394,6 +1484,7 @@ describe("sendMessageTelegram", () => { }; await sendMessageTelegram(testCase.chatId, testCase.text, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, messageThreadId: 271, @@ -1426,6 +1517,7 @@ describe("sendMessageTelegram", () => { }; const res = await sendMessageTelegram(testCase.chatId, testCase.text, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, messageThreadId: 271, @@ -1490,6 +1582,7 @@ describe("sendMessageTelegram", () => { await expect( sendMessageTelegram(testCase.chatId, testCase.text, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, ...testCase.opts, @@ -1512,6 +1605,7 @@ describe("sendMessageTelegram", () => { }; await sendMessageTelegram(chatId, "hi", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, silent: true, @@ -1537,6 +1631,7 @@ describe("sendMessageTelegram", () => { }; await sendMessageTelegram(chatId, "_oops_", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, silent: true, @@ -1559,6 +1654,7 @@ describe("sendMessageTelegram", () => { }; await sendMessageTelegram(`telegram:group:${chatId}:topic:271`, "hello forum", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, }); @@ -1590,6 +1686,7 @@ describe("sendMessageTelegram", () => { }); const res = await sendMessageTelegram(chatId, "photo", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/photo.jpg", @@ -1625,6 +1722,7 @@ describe("sendMessageTelegram", () => { }); await sendMessageTelegram(chatId, "photo", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, mediaUrl: "https://example.com/photo.jpg", @@ -1645,13 +1743,14 @@ describe("sendMessageTelegram", () => { const api = { sendPhoto } as unknown as { sendPhoto: typeof sendPhoto; }; - loadConfig.mockReturnValue({ + const cfg = { channels: { telegram: { mediaMaxMb: 42, }, }, - }); + }; + loadConfig.mockReturnValue(cfg); mockLoadedMedia({ buffer: Buffer.from("fake-image"), @@ -1660,6 +1759,7 @@ describe("sendMessageTelegram", () => { }); await sendMessageTelegram(chatId, "photo", { + cfg, token: "tok", api, mediaUrl: "https://example.com/photo.jpg", @@ -1682,6 +1782,7 @@ describe("sendMessageTelegram", () => { const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; const res = await sendMessageTelegram(chatId, htmlText, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, textMode: "html", @@ -1718,6 +1819,7 @@ describe("sendMessageTelegram", () => { const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; const res = await sendMessageTelegram(chatId, htmlText, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, textMode: "html", @@ -1747,6 +1849,7 @@ describe("sendMessageTelegram", () => { const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; const res = await sendMessageTelegram(chatId, htmlText, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, textMode: "html", @@ -1773,6 +1876,7 @@ describe("sendMessageTelegram", () => { const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; const res = await sendMessageTelegram(chatId, htmlText, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, textMode: "html", @@ -1819,6 +1923,7 @@ describe("reactMessageTelegram", () => { }; await reactMessageTelegram(testCase.target, testCase.messageId, testCase.emoji, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, ...(testCase.remove ? { remove: true } : {}), @@ -1836,6 +1941,7 @@ describe("reactMessageTelegram", () => { }; await reactMessageTelegram("@mychannel", 456, "✅", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, }); @@ -1881,6 +1987,7 @@ describe("sendStickerTelegram", () => { }; const res = await sendStickerTelegram(chatId, testCase.fileId, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, }); @@ -1893,9 +2000,9 @@ describe("sendStickerTelegram", () => { it("throws error when fileId is blank", async () => { for (const fileId of ["", " "]) { - await expect(sendStickerTelegram("123", fileId, { token: "tok" })).rejects.toThrow( - /file_id is required/i, - ); + await expect( + sendStickerTelegram("123", fileId, { cfg: TELEGRAM_TEST_CFG, token: "tok" }), + ).rejects.toThrow(/file_id is required/i); } }); @@ -1914,6 +2021,7 @@ describe("sendStickerTelegram", () => { }; const res = await sendStickerTelegram(chatId, "fileId123", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, messageThreadId: 271, @@ -1937,6 +2045,7 @@ describe("sendStickerTelegram", () => { await expect( sendStickerTelegram(chatId, "fileId123", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, }), @@ -1954,6 +2063,7 @@ describe("sendStickerTelegram", () => { await expect( sendStickerTelegram(chatId, "fileId123", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, @@ -1981,6 +2091,7 @@ describe("sendStickerTelegram", () => { const setTimeoutSpy = vi.spyOn(global, "setTimeout"); const promise = sendStickerTelegram(chatId, "fileId123", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 }, @@ -2010,6 +2121,7 @@ describe("shared send behaviors", () => { sendMessage: typeof sendMessage; }; await sendMessageTelegram(chatId, "reply text", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, replyToMessageId: 100, @@ -2034,6 +2146,7 @@ describe("shared send behaviors", () => { sendSticker: typeof sendSticker; }; await sendStickerTelegram(chatId, fileId, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, replyToMessageId: 500, @@ -2070,11 +2183,13 @@ describe("shared send behaviors", () => { }; await sendMessageTelegram(chatId, "reply text", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, replyToMessageId: invalidReplyToMessageId as unknown as number, }); await sendStickerTelegram(chatId, "CAACAgIAAxkBAAI...sticker_file_id", { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, replyToMessageId: invalidReplyToMessageId as unknown as number, @@ -2107,7 +2222,7 @@ describe("shared send behaviors", () => { sendMessage: typeof sendMessage; }; await expectChatNotFoundWithChatId( - sendMessageTelegram(chatId, "hi", { token: "tok", api }), + sendMessageTelegram(chatId, "hi", { cfg: TELEGRAM_TEST_CFG, token: "tok", api }), chatId, ); }, @@ -2122,7 +2237,7 @@ describe("shared send behaviors", () => { sendSticker: typeof sendSticker; }; await expectChatNotFoundWithChatId( - sendStickerTelegram(chatId, "fileId123", { token: "tok", api }), + sendStickerTelegram(chatId, "fileId123", { cfg: TELEGRAM_TEST_CFG, token: "tok", api }), chatId, ); }, @@ -2145,7 +2260,7 @@ describe("shared send behaviors", () => { sendMessage: typeof sendMessage; }; await expectTelegramMembershipErrorWithChatId( - sendMessageTelegram(chatId, "hi", { token: "tok", api }), + sendMessageTelegram(chatId, "hi", { cfg: TELEGRAM_TEST_CFG, token: "tok", api }), chatId, /bot is not a member of the channel chat/i, ); @@ -2160,7 +2275,7 @@ describe("shared send behaviors", () => { sendSticker: typeof sendSticker; }; await expectTelegramMembershipErrorWithChatId( - sendStickerTelegram(chatId, "fileId123", { token: "tok", api }), + sendStickerTelegram(chatId, "fileId123", { cfg: TELEGRAM_TEST_CFG, token: "tok", api }), chatId, /bot was kicked from the group chat/i, ); @@ -2305,6 +2420,7 @@ describe("sendPollTelegram", () => { "https://t.me/mychannel", { question: " Q ", options: [" A ", "B "] }, { + cfg: TELEGRAM_TEST_CFG, token: "t", api: api as unknown as Bot["api"], gatewayClientScopes: ["operator.admin"], @@ -2329,7 +2445,7 @@ describe("sendPollTelegram", () => { const res = await sendPollTelegram( "123", { question: " Q ", options: [" A ", "B "], durationSeconds: 60 }, - { token: "t", api: api as unknown as Bot["api"] }, + { cfg: TELEGRAM_TEST_CFG, token: "t", api: api as unknown as Bot["api"] }, ); expect(res).toEqual({ messageId: "123", chatId: "555", pollId: "p1" }); @@ -2357,7 +2473,12 @@ describe("sendPollTelegram", () => { const res = await sendPollTelegram( "-100123", { question: "Q", options: ["A", "B"] }, - { token: "t", api: api as unknown as Bot["api"], messageThreadId: 99 }, + { + cfg: TELEGRAM_TEST_CFG, + token: "t", + api: api as unknown as Bot["api"], + messageThreadId: 99, + }, ); expect(res).toEqual({ messageId: "1", chatId: "2", pollId: "p2" }); @@ -2376,7 +2497,7 @@ describe("sendPollTelegram", () => { sendPollTelegram( "123", { question: "Q", options: ["A", "B"], durationHours: 1 }, - { token: "t", api: api as unknown as Bot["api"] }, + { cfg: TELEGRAM_TEST_CFG, token: "t", api: api as unknown as Bot["api"] }, ), ).rejects.toThrow(/durationHours is not supported/i); @@ -2392,7 +2513,7 @@ describe("sendPollTelegram", () => { sendPollTelegram( "123", { question: "Q", options: ["A", "B"] }, - { token: "t", api: api as unknown as Bot["api"] }, + { cfg: TELEGRAM_TEST_CFG, token: "t", api: api as unknown as Bot["api"] }, ), ).rejects.toThrow(/returned no message_id/i); }); @@ -2440,6 +2561,7 @@ describe("createForumTopicTelegram", () => { const api = { createForumTopic } as unknown as Bot["api"]; const result = await createForumTopicTelegram(testCase.target, testCase.title, { + cfg: TELEGRAM_TEST_CFG, token: "tok", api, ...("options" in testCase ? testCase.options : {}), diff --git a/extensions/telegram/src/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts index f670c450d76..eeff10d0611 100644 --- a/extensions/telegram/src/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { resolveStorePath, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; const TTL_MS = 24 * 60 * 60 * 1000; @@ -8,9 +8,13 @@ const TELEGRAM_SENT_MESSAGES_STATE_KEY = Symbol.for("openclaw.telegramSentMessag type SentMessageStore = Map>; +type SentMessageBucket = { + persistedPath: string; + store: SentMessageStore; +}; + type SentMessageState = { - persistedPath?: string; - store?: SentMessageStore; + bucketsByPath: Map; }; function getSentMessageState(): SentMessageState { @@ -19,7 +23,9 @@ function getSentMessageState(): SentMessageState { if (existing) { return existing; } - const state: SentMessageState = {}; + const state: SentMessageState = { + bucketsByPath: new Map(), + }; globalStore[TELEGRAM_SENT_MESSAGES_STATE_KEY] = state; return state; } @@ -28,19 +34,23 @@ function createSentMessageStore(): SentMessageStore { return new Map>(); } -function resolveSentMessageStorePath(): string { - const cfg = loadConfig(); - return `${resolveStorePath(cfg.session?.store)}.telegram-sent-messages.json`; +function resolveSentMessageStorePath(cfg?: Pick): string { + return `${resolveStorePath(cfg?.session?.store)}.telegram-sent-messages.json`; } -function cleanupExpired(scopeKey: string, entry: Map, now: number): void { +function cleanupExpired( + store: SentMessageStore, + scopeKey: string, + entry: Map, + now: number, +): void { for (const [id, timestamp] of entry) { if (now - timestamp > TTL_MS) { entry.delete(id); } } if (entry.size === 0) { - getSentMessages().delete(scopeKey); + store.delete(scopeKey); } } @@ -75,46 +85,55 @@ function readPersistedSentMessages(filePath: string): SentMessageStore { } } -function getSentMessages(): SentMessageStore { +function getSentMessageBucket(cfg?: Pick): SentMessageBucket { const state = getSentMessageState(); - const persistedPath = resolveSentMessageStorePath(); - if (!state.store || state.persistedPath !== persistedPath) { - state.store = readPersistedSentMessages(persistedPath); - state.persistedPath = persistedPath; + const persistedPath = resolveSentMessageStorePath(cfg); + const existing = state.bucketsByPath.get(persistedPath); + if (existing) { + return existing; } - return state.store; + const bucket = { + persistedPath, + store: readPersistedSentMessages(persistedPath), + }; + state.bucketsByPath.set(persistedPath, bucket); + return bucket; } -function persistSentMessages(): void { - const state = getSentMessageState(); - const store = state.store; - const filePath = state.persistedPath; - if (!store || !filePath) { - return; - } +function getSentMessages(cfg?: Pick): SentMessageStore { + return getSentMessageBucket(cfg).store; +} + +function persistSentMessages(bucket: SentMessageBucket): void { + const { store, persistedPath } = bucket; const now = Date.now(); const serialized: Record> = {}; for (const [chatId, entry] of store) { - cleanupExpired(chatId, entry, now); + cleanupExpired(store, chatId, entry, now); if (entry.size > 0) { serialized[chatId] = Object.fromEntries(entry); } } if (Object.keys(serialized).length === 0) { - fs.rmSync(filePath, { force: true }); + fs.rmSync(persistedPath, { force: true }); return; } - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const tempPath = `${filePath}.${process.pid}.tmp`; + fs.mkdirSync(path.dirname(persistedPath), { recursive: true }); + const tempPath = `${persistedPath}.${process.pid}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(serialized), "utf-8"); - fs.renameSync(tempPath, filePath); + fs.renameSync(tempPath, persistedPath); } -export function recordSentMessage(chatId: number | string, messageId: number): void { +export function recordSentMessage( + chatId: number | string, + messageId: number, + cfg?: Pick, +): void { const scopeKey = String(chatId); const idKey = String(messageId); const now = Date.now(); - const store = getSentMessages(); + const bucket = getSentMessageBucket(cfg); + const { store } = bucket; let entry = store.get(scopeKey); if (!entry) { entry = new Map(); @@ -122,34 +141,40 @@ export function recordSentMessage(chatId: number | string, messageId: number): v } entry.set(idKey, now); if (entry.size > 100) { - cleanupExpired(scopeKey, entry, now); + cleanupExpired(store, scopeKey, entry, now); } try { - persistSentMessages(); + persistSentMessages(bucket); } catch (error) { logVerbose(`telegram: failed to persist sent-message cache: ${String(error)}`); } } -export function wasSentByBot(chatId: number | string, messageId: number): boolean { +export function wasSentByBot( + chatId: number | string, + messageId: number, + cfg?: Pick, +): boolean { const scopeKey = String(chatId); const idKey = String(messageId); - const entry = getSentMessages().get(scopeKey); + const store = getSentMessages(cfg); + const entry = store.get(scopeKey); if (!entry) { return false; } - cleanupExpired(scopeKey, entry, Date.now()); + cleanupExpired(store, scopeKey, entry, Date.now()); return entry.has(idKey); } export function clearSentMessageCache(): void { const state = getSentMessageState(); - getSentMessages().clear(); - if (state.persistedPath) { - fs.rmSync(state.persistedPath, { force: true }); + for (const bucket of state.bucketsByPath.values()) { + bucket.store.clear(); + fs.rmSync(bucket.persistedPath, { force: true }); } + state.bucketsByPath.clear(); } export function resetSentMessageCacheForTest(): void { - getSentMessageState().store = undefined; + getSentMessageState().bucketsByPath.clear(); }