fix(telegram): isolate sent-message cache stores

This commit is contained in:
Peter Steinberger
2026-04-22 06:16:59 +01:00
parent 95331e5cc5
commit 182d0fcee2
2 changed files with 212 additions and 65 deletions

View File

@@ -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<unknown>,
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<typeof import("./sent-message-cache.js")>(
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<Parameters<typeof sendMessageTelegram>[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 : {}),

View File

@@ -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<string, Map<string, number>>;
type SentMessageBucket = {
persistedPath: string;
store: SentMessageStore;
};
type SentMessageState = {
persistedPath?: string;
store?: SentMessageStore;
bucketsByPath: Map<string, SentMessageBucket>;
};
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<string, Map<string, number>>();
}
function resolveSentMessageStorePath(): string {
const cfg = loadConfig();
return `${resolveStorePath(cfg.session?.store)}.telegram-sent-messages.json`;
function resolveSentMessageStorePath(cfg?: Pick<OpenClawConfig, "session">): string {
return `${resolveStorePath(cfg?.session?.store)}.telegram-sent-messages.json`;
}
function cleanupExpired(scopeKey: string, entry: Map<string, number>, now: number): void {
function cleanupExpired(
store: SentMessageStore,
scopeKey: string,
entry: Map<string, number>,
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<OpenClawConfig, "session">): 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<OpenClawConfig, "session">): SentMessageStore {
return getSentMessageBucket(cfg).store;
}
function persistSentMessages(bucket: SentMessageBucket): void {
const { store, persistedPath } = bucket;
const now = Date.now();
const serialized: Record<string, Record<string, number>> = {};
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<OpenClawConfig, "session">,
): 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<string, number>();
@@ -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<OpenClawConfig, "session">,
): 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();
}