diff --git a/extensions/slack/src/channel-type.test.ts b/extensions/slack/src/channel-type.test.ts index 120afa2dc0f..fef35eaca9a 100644 --- a/extensions/slack/src/channel-type.test.ts +++ b/extensions/slack/src/channel-type.test.ts @@ -178,6 +178,55 @@ describe("resolveSlackChannelType", () => { expect(conversationsInfoMock).not.toHaveBeenCalled(); }); + it("evicts least-recently-used conversation info entries after the cache limit", async () => { + const cacheMaxEntries = 1024; + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + }, + }, + } as never; + + conversationsInfoMock.mockImplementation(async ({ channel }) => ({ + channel: { + id: channel, + }, + })); + + for (let index = 0; index < cacheMaxEntries; index++) { + await resolveSlackConversationInfo({ + cfg, + channelId: `C${index.toString().padStart(8, "0")}`, + }); + } + expect(conversationsInfoMock).toHaveBeenCalledTimes(cacheMaxEntries); + + await resolveSlackConversationInfo({ + cfg, + channelId: "C00000000", + }); + expect(conversationsInfoMock).toHaveBeenCalledTimes(cacheMaxEntries); + + await resolveSlackConversationInfo({ + cfg, + channelId: `C${cacheMaxEntries.toString().padStart(8, "0")}`, + }); + expect(conversationsInfoMock).toHaveBeenCalledTimes(cacheMaxEntries + 1); + + await resolveSlackConversationInfo({ + cfg, + channelId: "C00000001", + }); + expect(conversationsInfoMock).toHaveBeenCalledTimes(cacheMaxEntries + 2); + + await resolveSlackConversationInfo({ + cfg, + channelId: "C00000000", + }); + expect(conversationsInfoMock).toHaveBeenCalledTimes(cacheMaxEntries + 2); + }); + it("preserves the channel-type wrapper contract", async () => { conversationsInfoMock.mockResolvedValueOnce({ channel: { diff --git a/extensions/slack/src/channel-type.ts b/extensions/slack/src/channel-type.ts index b766cb1a78d..660332919d3 100644 --- a/extensions/slack/src/channel-type.ts +++ b/extensions/slack/src/channel-type.ts @@ -1,4 +1,5 @@ // Slack plugin module implements channel type behavior. +import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -13,8 +14,27 @@ export type SlackConversationInfo = { user?: string; }; +const SLACK_CONVERSATION_INFO_CACHE_MAX_ENTRIES = 1024; const SLACK_CONVERSATION_INFO_CACHE = new Map(); +function getCachedSlackConversationInfo(cacheKey: string): SlackConversationInfo | undefined { + const cached = SLACK_CONVERSATION_INFO_CACHE.get(cacheKey); + if (cached) { + SLACK_CONVERSATION_INFO_CACHE.delete(cacheKey); + SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, cached); + } + return cached; +} + +function setCachedSlackConversationInfo( + cacheKey: string, + conversationInfo: SlackConversationInfo, +): void { + SLACK_CONVERSATION_INFO_CACHE.delete(cacheKey); + SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, conversationInfo); + pruneMapToMaxSize(SLACK_CONVERSATION_INFO_CACHE, SLACK_CONVERSATION_INFO_CACHE_MAX_ENTRIES); +} + export async function resolveSlackConversationInfo(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -26,7 +46,7 @@ export async function resolveSlackConversationInfo(params: { } const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); const cacheKey = `${account.accountId}:${channelId}`; - const cached = SLACK_CONVERSATION_INFO_CACHE.get(cacheKey); + const cached = getCachedSlackConversationInfo(cacheKey); if (cached) { return cached; } @@ -42,7 +62,7 @@ export async function resolveSlackConversationInfo(params: { groupChannels.includes(`mpim:${channelIdLower}`)) ) { const result = { type: "group" } as const; - SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result); + setCachedSlackConversationInfo(cacheKey, result); return result; } @@ -59,7 +79,7 @@ export async function resolveSlackConversationInfo(params: { }) ) { const result = { type: "channel" } as const; - SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result); + setCachedSlackConversationInfo(cacheKey, result); return result; } @@ -70,7 +90,7 @@ export async function resolveSlackConversationInfo(params: { if (!token) { const result = { type: isNativeImChannel ? "dm" : "unknown" } as const; if (!isNativeImChannel) { - SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result); + setCachedSlackConversationInfo(cacheKey, result); } return result; } @@ -89,7 +109,7 @@ export async function resolveSlackConversationInfo(params: { : undefined; const result: SlackConversationInfo = user ? { type: "dm", user } : { type: "dm" }; if (user) { - SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result); + setCachedSlackConversationInfo(cacheKey, result); } return result; } @@ -97,12 +117,12 @@ export async function resolveSlackConversationInfo(params: { const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined; const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; const result = { type } as const; - SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result); + setCachedSlackConversationInfo(cacheKey, result); return result; } catch { const result = { type: isNativeImChannel ? "dm" : "unknown" } as const; if (!isNativeImChannel) { - SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result); + setCachedSlackConversationInfo(cacheKey, result); } return result; }