diff --git a/extensions/telegram/src/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts index 11ba27bcf7a..10c9441ee19 100644 --- a/extensions/telegram/src/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -1,4 +1,8 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetTopicNameCacheForTest } from "./topic-name-cache.js"; const { recordInboundSessionMock } = vi.hoisted(() => ({ recordInboundSessionMock: vi.fn().mockResolvedValue(undefined), })); @@ -34,10 +38,12 @@ const { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } = beforeEach(() => { clearRuntimeConfigSnapshot(); + resetTopicNameCacheForTest(); }); afterEach(() => { clearRuntimeConfigSnapshot(); + resetTopicNameCacheForTest(); recordInboundSessionMock.mockClear(); }); @@ -161,6 +167,52 @@ describe("buildTelegramMessageContext group sessions without forum", () => { expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); }); + + it("reloads topic name from disk after cache reset", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-topic-name-")); + const sessionStorePath = path.join(tempDir, "sessions.json"); + const buildPersistedContext = async (message: Record) => + await buildTelegramMessageContextForTest({ + message, + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + sessionRuntime: { + resolveStorePath: () => sessionStorePath, + }, + }); + + try { + await buildPersistedContext({ + message_id: 4, + chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true }, + date: 1700000003, + text: "@bot hello", + message_thread_id: 99, + from: { id: 42, first_name: "Alice" }, + reply_to_message: { + message_id: 3, + forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 }, + }, + }); + + resetTopicNameCacheForTest(); + + const ctx = await buildPersistedContext({ + message_id: 5, + chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true }, + date: 1700000004, + text: "@bot again", + message_thread_id: 99, + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + resetTopicNameCacheForTest(); + } + }); }); describe("buildTelegramMessageContext direct peer routing", () => { diff --git a/extensions/telegram/src/topic-name-cache.test.ts b/extensions/telegram/src/topic-name-cache.test.ts index 35afd6cf175..605f8bf26a1 100644 --- a/extensions/telegram/src/topic-name-cache.test.ts +++ b/extensions/telegram/src/topic-name-cache.test.ts @@ -1,20 +1,21 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import syncFs from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearTopicNameCache, getTopicEntry, getTopicName, + resetTopicNameCacheForTest, topicNameCacheSize, updateTopicName, } from "./topic-name-cache.js"; describe("topic-name-cache", () => { beforeEach(() => { - vi.useRealTimers(); clearTopicNameCache(); - }); - - afterEach(() => { - vi.useRealTimers(); + resetTopicNameCacheForTest(); }); it("stores and retrieves a topic name", () => { @@ -63,11 +64,9 @@ describe("topic-name-cache", () => { }); it("updates timestamps on write", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-13T22:00:00.000Z")); updateTopicName(-100123, 42, { name: "A" }); const t1 = getTopicEntry(-100123, 42)?.updatedAt ?? 0; - await vi.advanceTimersByTimeAsync(10); + await new Promise((r) => setTimeout(r, 10)); updateTopicName(-100123, 42, { name: "B" }); const t2 = getTopicEntry(-100123, 42)?.updatedAt ?? 0; expect(t2).toBeGreaterThan(t1); @@ -88,10 +87,8 @@ describe("topic-name-cache", () => { }); it("refreshes recency on read so active topics survive eviction", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-13T22:00:00.000Z")); updateTopicName(-100000, 1, { name: "Active" }); - await vi.advanceTimersByTimeAsync(10); + await new Promise((r) => setTimeout(r, 10)); for (let i = 2; i <= 2048; i++) { updateTopicName(-100000, i, { name: `Topic ${i}` }); } @@ -100,4 +97,37 @@ describe("topic-name-cache", () => { expect(getTopicName(-100000, 1)).toBe("Active"); expect(topicNameCacheSize()).toBe(2048); }); + + it("reloads persisted entries from disk", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-topic-cache-")); + const persistedPath = path.join(tempDir, "topic-names.json"); + try { + updateTopicName(-100123, 42, { name: "Deployments" }, persistedPath); + resetTopicNameCacheForTest(); + expect(getTopicName(-100123, 42, persistedPath)).toBe("Deployments"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + resetTopicNameCacheForTest(); + } + }); + + it("keeps separate in-memory stores for separate persisted paths", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-topic-cache-")); + const firstPath = path.join(tempDir, "first-topic-names.json"); + const secondPath = path.join(tempDir, "second-topic-names.json"); + try { + updateTopicName(-100123, 42, { name: "Deployments" }, firstPath); + updateTopicName(-200456, 84, { name: "Incidents" }, secondPath); + + const readFileSpy = vi.spyOn(syncFs, "readFileSync"); + + expect(getTopicName(-100123, 42, firstPath)).toBe("Deployments"); + expect(getTopicName(-200456, 84, secondPath)).toBe("Incidents"); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + resetTopicNameCacheForTest(); + } + }); });