diff --git a/extensions/slack/src/actions.download-file.test.ts b/extensions/slack/src/actions.download-file.test.ts index c73bad4e397..5e8765d0f4d 100644 --- a/extensions/slack/src/actions.download-file.test.ts +++ b/extensions/slack/src/actions.download-file.test.ts @@ -12,6 +12,7 @@ vi.mock("./monitor/media.js", () => ({ vi.mock("./client.js", () => ({ createSlackWebClient: createSlackWebClientMock, createSlackWriteClient: createSlackWebClientMock, + getSlackWriteClient: createSlackWebClientMock, })); let downloadSlackFile: typeof import("./actions.js").downloadSlackFile; diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index 5034b01faec..88057e808ec 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -4,7 +4,7 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; -import { createSlackWebClient, createSlackWriteClient } from "./client.js"; +import { createSlackWebClient, getSlackWriteClient } from "./client.js"; import { resolveSlackMedia } from "./monitor/media.js"; import type { SlackMediaResult } from "./monitor/media.js"; import { sendMessageSlack } from "./send.js"; @@ -81,7 +81,7 @@ async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write return opts.client; } const token = resolveToken(opts.token, opts.accountId, opts.cfg); - return mode === "write" ? createSlackWriteClient(token) : createSlackWebClient(token); + return mode === "write" ? getSlackWriteClient(token) : createSlackWebClient(token); } async function resolveBotUserId(client: WebClient) { diff --git a/extensions/slack/src/client.test.ts b/extensions/slack/src/client.test.ts index e4368b45598..e4ada517b43 100644 --- a/extensions/slack/src/client.test.ts +++ b/extensions/slack/src/client.test.ts @@ -14,6 +14,8 @@ vi.mock("@slack/web-api", () => { let createSlackWebClient: typeof import("./client.js").createSlackWebClient; let createSlackWriteClient: typeof import("./client.js").createSlackWriteClient; +let getSlackWriteClient: typeof import("./client.js").getSlackWriteClient; +let clearSlackWriteClientCacheForTest: typeof import("./client.js").clearSlackWriteClientCacheForTest; let resolveSlackWebClientOptions: typeof import("./client.js").resolveSlackWebClientOptions; let resolveSlackWriteClientOptions: typeof import("./client.js").resolveSlackWriteClientOptions; let SLACK_DEFAULT_RETRY_OPTIONS: typeof import("./client.js").SLACK_DEFAULT_RETRY_OPTIONS; @@ -25,6 +27,8 @@ beforeAll(async () => { ({ createSlackWebClient, createSlackWriteClient, + getSlackWriteClient, + clearSlackWriteClientCacheForTest, resolveSlackWebClientOptions, resolveSlackWriteClientOptions, SLACK_DEFAULT_RETRY_OPTIONS, @@ -35,6 +39,7 @@ beforeAll(async () => { beforeEach(() => { WebClient.mockClear(); + clearSlackWriteClientCacheForTest(); }); describe("slack web client config", () => { @@ -93,6 +98,29 @@ describe("slack web client config", () => { }), ); }); + + it("reuses default write clients per token", () => { + const first = getSlackWriteClient("xoxb-test"); + const second = getSlackWriteClient("xoxb-test"); + + expect(second).toBe(first); + expect(WebClient).toHaveBeenCalledTimes(1); + expect(WebClient).toHaveBeenCalledWith( + "xoxb-test", + expect.objectContaining({ + maxRequestConcurrency: 1, + retryConfig: SLACK_WRITE_RETRY_OPTIONS, + }), + ); + }); + + it("keeps default write clients separated by token", () => { + const first = getSlackWriteClient("xoxb-one"); + const second = getSlackWriteClient("xoxb-two"); + + expect(second).not.toBe(first); + expect(WebClient).toHaveBeenCalledTimes(2); + }); }); describe("slack proxy agent", () => { diff --git a/extensions/slack/src/client.ts b/extensions/slack/src/client.ts index 58ddda289c2..006b9a3e65a 100644 --- a/extensions/slack/src/client.ts +++ b/extensions/slack/src/client.ts @@ -1,5 +1,9 @@ import { type WebClientOptions, WebClient } from "@slack/web-api"; import { resolveSlackWebClientOptions, resolveSlackWriteClientOptions } from "./client-options.js"; + +const SLACK_WRITE_CLIENT_CACHE_MAX = 32; +const slackWriteClientCache = new Map(); + export { resolveSlackWebClientOptions, resolveSlackWriteClientOptions, @@ -14,3 +18,25 @@ export function createSlackWebClient(token: string, options: WebClientOptions = export function createSlackWriteClient(token: string, options: WebClientOptions = {}) { return new WebClient(token, resolveSlackWriteClientOptions(options)); } + +export function getSlackWriteClient(token: string): WebClient { + const cached = slackWriteClientCache.get(token); + if (cached) { + slackWriteClientCache.delete(token); + slackWriteClientCache.set(token, cached); + return cached; + } + const client = createSlackWriteClient(token); + if (slackWriteClientCache.size >= SLACK_WRITE_CLIENT_CACHE_MAX) { + const oldestToken = slackWriteClientCache.keys().next().value; + if (oldestToken) { + slackWriteClientCache.delete(oldestToken); + } + } + slackWriteClientCache.set(token, client); + return client; +} + +export function clearSlackWriteClientCacheForTest(): void { + slackWriteClientCache.clear(); +} diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 9a4ec0db4e6..34cfc44a8e4 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -19,7 +19,7 @@ import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; -import { createSlackWriteClient } from "./client.js"; +import { getSlackWriteClient } from "./client.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; import { loadOutboundMediaFromUrl } from "./runtime-api.js"; @@ -397,7 +397,7 @@ async function sendMessageSlackQueued(params: { blocks?: (Block | KnownBlock)[]; }): Promise { const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params; - const client = opts.client ?? createSlackWriteClient(token); + const client = opts.client ?? getSlackWriteClient(token); const { channelId } = await resolveChannelId(client, recipient, { accountId: account.accountId, token,