From 0e2694ff478a3f99dffb51670b348ef1f23853fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 11:47:00 -0400 Subject: [PATCH] fix(msteams): bound team id cache expiry --- extensions/msteams/src/graph-thread.test.ts | 29 +++++++++++++++++++ extensions/msteams/src/graph-thread.ts | 31 +++++++++++++++++---- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/extensions/msteams/src/graph-thread.test.ts b/extensions/msteams/src/graph-thread.test.ts index 4a3cc2189e9e..a7bc97e5973d 100644 --- a/extensions/msteams/src/graph-thread.test.ts +++ b/extensions/msteams/src/graph-thread.test.ts @@ -77,6 +77,35 @@ describe("resolveTeamGroupId", () => { expect(fetchGraphJson).toHaveBeenCalledTimes(1); }); + it("does not cache team ids when the expiry would exceed a valid Date", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(8_640_000_000_000_000)); + try { + vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid-boundary" } as never); + + await resolveTeamGroupId("tok", "team-boundary"); + await resolveTeamGroupId("tok", "team-boundary"); + + expect(fetchGraphJson).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("evicts cached team ids when the current clock is invalid", async () => { + vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid-invalid-clock" } as never); + + await resolveTeamGroupId("tok", "team-invalid-clock"); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN); + try { + await resolveTeamGroupId("tok", "team-invalid-clock"); + } finally { + dateNow.mockRestore(); + } + + expect(fetchGraphJson).toHaveBeenCalledTimes(2); + }); + it("falls back to conversationTeamId when Graph returns no id", async () => { vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never); diff --git a/extensions/msteams/src/graph-thread.ts b/extensions/msteams/src/graph-thread.ts index 851dc11819a1..cd2a49687a32 100644 --- a/extensions/msteams/src/graph-thread.ts +++ b/extensions/msteams/src/graph-thread.ts @@ -1,3 +1,7 @@ +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "openclaw/plugin-sdk/number-runtime"; import { fetchGraphJson, type GraphResponse } from "./graph.js"; export type GraphThreadMessage = { @@ -14,6 +18,13 @@ export type GraphThreadMessage = { const teamGroupIdCache = new Map(); const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes +function resolveTeamGroupIdCacheExpiresAt(nowRaw = Date.now()): number | undefined { + const now = asDateTimestampMs(nowRaw); + return now === undefined + ? undefined + : resolveExpiresAtMsFromDurationMs(CACHE_TTL_MS, { nowMs: now }); +} + /** * Strip HTML tags from Teams message content, preserving @mention display names. * Teams wraps mentions in Name tags. @@ -44,8 +55,13 @@ export async function resolveTeamGroupId( conversationTeamId: string, ): Promise { const cached = teamGroupIdCache.get(conversationTeamId); - if (cached && cached.expiresAt > Date.now()) { - return cached.groupId; + if (cached) { + const now = asDateTimestampMs(Date.now()); + const expiresAt = asDateTimestampMs(cached.expiresAt); + if (now !== undefined && expiresAt !== undefined && expiresAt > now) { + return cached.groupId; + } + teamGroupIdCache.delete(conversationTeamId); } // The team ID in channelData is typically the group ID itself for standard teams. @@ -59,10 +75,13 @@ export async function resolveTeamGroupId( // Only cache when the Graph lookup succeeds — caching a fallback raw ID // can cause silent failures for the entire TTL if the ID is not a valid // Graph team GUID (e.g. Bot Framework conversation key). - teamGroupIdCache.set(conversationTeamId, { - groupId, - expiresAt: Date.now() + CACHE_TTL_MS, - }); + const expiresAt = resolveTeamGroupIdCacheExpiresAt(); + if (expiresAt !== undefined) { + teamGroupIdCache.set(conversationTeamId, { + groupId, + expiresAt, + }); + } return groupId; } catch {