From d33d6bfafa22cd32f29be97e8262c92613b25121 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 10:34:45 -0400 Subject: [PATCH] fix(discord): bound channel info cache clocks --- .../src/monitor/message-channel-info.ts | 39 ++++++++++++------- .../discord/src/monitor/message-utils.test.ts | 39 ++++++++++++++++++- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/extensions/discord/src/monitor/message-channel-info.ts b/extensions/discord/src/monitor/message-channel-info.ts index 7dc6e29bd04..4d246a9ca07 100644 --- a/extensions/discord/src/monitor/message-channel-info.ts +++ b/extensions/discord/src/monitor/message-channel-info.ts @@ -1,3 +1,7 @@ +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "openclaw/plugin-sdk/number-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ChannelType, Message } from "../internal/discord.js"; @@ -30,6 +34,22 @@ export function resetDiscordChannelInfoCacheForTest() { DISCORD_CHANNEL_INFO_CACHE.clear(); } +function resolveDiscordChannelInfoCacheExpiresAt(ttlMs: number, nowMs: number): number | undefined { + return resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs }); +} + +function cacheDiscordChannelInfo( + channelId: string, + value: DiscordChannelInfo | null, + ttlMs: number, + nowMs: number, +): void { + const expiresAt = resolveDiscordChannelInfoCacheExpiresAt(ttlMs, nowMs); + if (expiresAt !== undefined) { + DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value, expiresAt }); + } +} + function normalizeDiscordChannelId(value: unknown): string { return normalizeOptionalStringifiedId(value) ?? ""; } @@ -51,9 +71,11 @@ export async function resolveDiscordChannelInfo( client: DiscordChannelInfoClient, channelId: string, ): Promise { + const rawNow = Date.now(); + const now = asDateTimestampMs(rawNow); const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId); if (cached) { - if (cached.expiresAt > Date.now()) { + if (now !== undefined && cached.expiresAt > now) { return cached.value; } DISCORD_CHANNEL_INFO_CACHE.delete(channelId); @@ -61,10 +83,7 @@ export async function resolveDiscordChannelInfo( try { const channel = await client.fetchChannel(channelId); if (!channel) { - DISCORD_CHANNEL_INFO_CACHE.set(channelId, { - value: null, - expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, - }); + cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow); return null; } const channelInfo = resolveDiscordChannelInfoSafe(channel); @@ -80,17 +99,11 @@ export async function resolveDiscordChannelInfo( parentId: channelInfo.parentId, ownerId: channelInfo.ownerId, }; - DISCORD_CHANNEL_INFO_CACHE.set(channelId, { - value: payload, - expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS, - }); + cacheDiscordChannelInfo(channelId, payload, DISCORD_CHANNEL_INFO_CACHE_TTL_MS, rawNow); return payload; } catch (err) { logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); - DISCORD_CHANNEL_INFO_CACHE.set(channelId, { - value: null, - expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, - }); + cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow); return null; } } diff --git a/extensions/discord/src/monitor/message-utils.test.ts b/extensions/discord/src/monitor/message-utils.test.ts index 0615e9904de..82cf7f7c45f 100644 --- a/extensions/discord/src/monitor/message-utils.test.ts +++ b/extensions/discord/src/monitor/message-utils.test.ts @@ -4,7 +4,7 @@ import { MessageReferenceType, StickerFormatType, } from "discord-api-types/v10"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { ChannelType, type Client, type Message } from "../internal/discord.js"; const readRemoteMediaBuffer = vi.fn(); @@ -65,6 +65,10 @@ beforeAll(async () => { } = await import("./message-utils.js")); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + function asMessage(payload: Record): Message { return payload as unknown as Message; } @@ -1231,4 +1235,37 @@ describe("resolveDiscordChannelInfo", () => { expect(second).toBeNull(); expect(fetchChannel).toHaveBeenCalledTimes(1); }); + + it("does not reuse cached channel info while the process clock is invalid", async () => { + const fetchChannel = vi + .fn() + .mockResolvedValueOnce({ type: ChannelType.GuildText, name: "old" }) + .mockResolvedValueOnce({ type: ChannelType.GuildText, name: "fresh" }); + const client = { fetchChannel } as unknown as Client; + + const first = await resolveDiscordChannelInfo(client, "invalid-clock-channel"); + expect(first?.name).toBe("old"); + + vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001); + const second = await resolveDiscordChannelInfo(client, "invalid-clock-channel"); + + expect(second?.name).toBe("fresh"); + expect(fetchChannel).toHaveBeenCalledTimes(2); + }); + + it("does not cache channel info when the cache expiry would exceed the Date range", async () => { + vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000); + const fetchChannel = vi + .fn() + .mockResolvedValueOnce({ type: ChannelType.GuildText, name: "first" }) + .mockResolvedValueOnce({ type: ChannelType.GuildText, name: "second" }); + const client = { fetchChannel } as unknown as Client; + + const first = await resolveDiscordChannelInfo(client, "overflow-cache-channel"); + const second = await resolveDiscordChannelInfo(client, "overflow-cache-channel"); + + expect(first?.name).toBe("first"); + expect(second?.name).toBe("second"); + expect(fetchChannel).toHaveBeenCalledTimes(2); + }); });