diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a63f827..d088a404d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.clawd.bot - Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. - Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff. - Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x. +- Slack: reduce WebClient retries to avoid duplicate sends. (#1481) - Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz. - macOS: prefer linked channels in gateway summary to avoid false “not linked” status. - macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483) diff --git a/src/slack/actions.ts b/src/slack/actions.ts index d045bda3b..4ce6b37ca 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -1,8 +1,9 @@ -import { WebClient } from "@slack/web-api"; +import type { WebClient } from "@slack/web-api"; import { loadConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { resolveSlackAccount } from "./accounts.js"; +import { createSlackWebClient } from "./client.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackBotToken } from "./token.js"; @@ -56,7 +57,7 @@ function normalizeEmoji(raw: string) { async function getClient(opts: SlackActionClientOpts = {}) { const token = resolveToken(opts.token, opts.accountId); - return opts.client ?? new WebClient(token); + return opts.client ?? createSlackWebClient(token); } async function resolveBotUserId(client: WebClient) { diff --git a/src/slack/client.test.ts b/src/slack/client.test.ts new file mode 100644 index 000000000..370e2d250 --- /dev/null +++ b/src/slack/client.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@slack/web-api", () => { + const WebClient = vi.fn(function WebClientMock( + this: Record, + token: string, + options?: Record, + ) { + this.token = token; + this.options = options; + }); + return { WebClient }; +}); + +const slackWebApi = await import("@slack/web-api"); +const { createSlackWebClient, resolveSlackWebClientOptions, SLACK_DEFAULT_RETRY_OPTIONS } = + await import("./client.js"); + +const WebClient = slackWebApi.WebClient as unknown as ReturnType; + +describe("slack web client config", () => { + it("applies the default retry config when none is provided", () => { + const options = resolveSlackWebClientOptions(); + + expect(options.retryConfig).toEqual(SLACK_DEFAULT_RETRY_OPTIONS); + }); + + it("respects explicit retry config overrides", () => { + const customRetry = { retries: 0 }; + const options = resolveSlackWebClientOptions({ retryConfig: customRetry }); + + expect(options.retryConfig).toBe(customRetry); + }); + + it("passes merged options into WebClient", () => { + createSlackWebClient("xoxb-test", { timeout: 1234 }); + + expect(WebClient).toHaveBeenCalledWith( + "xoxb-test", + expect.objectContaining({ + timeout: 1234, + retryConfig: SLACK_DEFAULT_RETRY_OPTIONS, + }), + ); + }); +}); diff --git a/src/slack/client.ts b/src/slack/client.ts new file mode 100644 index 000000000..f792bd22a --- /dev/null +++ b/src/slack/client.ts @@ -0,0 +1,20 @@ +import { type RetryOptions, type WebClientOptions, WebClient } from "@slack/web-api"; + +export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = { + retries: 2, + factor: 2, + minTimeout: 500, + maxTimeout: 3000, + randomize: true, +}; + +export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions { + return { + ...options, + retryConfig: options.retryConfig ?? SLACK_DEFAULT_RETRY_OPTIONS, + }; +} + +export function createSlackWebClient(token: string, options: WebClientOptions = {}) { + return new WebClient(token, resolveSlackWebClientOptions(options)); +} diff --git a/src/slack/directory-live.ts b/src/slack/directory-live.ts index dd92ba848..57da909e0 100644 --- a/src/slack/directory-live.ts +++ b/src/slack/directory-live.ts @@ -1,4 +1,4 @@ -import { WebClient } from "@slack/web-api"; +import { createSlackWebClient } from "./client.js"; import type { ChannelDirectoryEntry } from "../channels/plugins/types.js"; import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; @@ -61,7 +61,7 @@ export async function listSlackDirectoryPeersLive( ): Promise { const token = resolveReadToken(params); if (!token) return []; - const client = new WebClient(token); + const client = createSlackWebClient(token); const query = normalizeQuery(params.query); const members: SlackUser[] = []; let cursor: string | undefined; @@ -119,7 +119,7 @@ export async function listSlackDirectoryGroupsLive( ): Promise { const token = resolveReadToken(params); if (!token) return []; - const client = new WebClient(token); + const client = createSlackWebClient(token); const query = normalizeQuery(params.query); const channels: SlackChannel[] = []; let cursor: string | undefined; diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 2583eb7ba..366a32a34 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -17,6 +17,7 @@ import { resolveSlackChannelAllowlist } from "../resolve-channels.js"; import { resolveSlackUserAllowlist } from "../resolve-users.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js"; import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js"; +import { resolveSlackWebClientOptions } from "../client.js"; import { resolveSlackSlashCommandConfig } from "./commands.js"; import { createSlackMonitorContext } from "./context.js"; import { registerSlackMonitorEvents } from "./events.js"; @@ -130,16 +131,19 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { endpoints: slackWebhookPath, }) : null; + const clientOptions = resolveSlackWebClientOptions(); const app = new App( slackMode === "socket" ? { token: botToken, appToken, socketMode: true, + clientOptions, } : { token: botToken, receiver: receiver ?? undefined, + clientOptions, }, ); const slackHttpHandler = diff --git a/src/slack/probe.ts b/src/slack/probe.ts index d06bb066a..a4e880838 100644 --- a/src/slack/probe.ts +++ b/src/slack/probe.ts @@ -1,4 +1,4 @@ -import { WebClient } from "@slack/web-api"; +import { createSlackWebClient } from "./client.js"; export type SlackProbe = { ok: boolean; @@ -21,7 +21,7 @@ function withTimeout(promise: Promise, timeoutMs: number): Promise { } export async function probeSlack(token: string, timeoutMs = 2500): Promise { - const client = new WebClient(token); + const client = createSlackWebClient(token); const start = Date.now(); try { const result = await withTimeout(client.auth.test(), timeoutMs); diff --git a/src/slack/resolve-channels.ts b/src/slack/resolve-channels.ts index 2b70e6d98..b9f14bcf0 100644 --- a/src/slack/resolve-channels.ts +++ b/src/slack/resolve-channels.ts @@ -1,4 +1,6 @@ -import { WebClient } from "@slack/web-api"; +import type { WebClient } from "@slack/web-api"; + +import { createSlackWebClient } from "./client.js"; export type SlackChannelLookup = { id: string; @@ -84,7 +86,7 @@ export async function resolveSlackChannelAllowlist(params: { entries: string[]; client?: WebClient; }): Promise { - const client = params.client ?? new WebClient(params.token); + const client = params.client ?? createSlackWebClient(params.token); const channels = await listSlackChannels(client); const results: SlackChannelResolution[] = []; diff --git a/src/slack/resolve-users.ts b/src/slack/resolve-users.ts index 034923399..a87057b5c 100644 --- a/src/slack/resolve-users.ts +++ b/src/slack/resolve-users.ts @@ -1,4 +1,6 @@ -import { WebClient } from "@slack/web-api"; +import type { WebClient } from "@slack/web-api"; + +import { createSlackWebClient } from "./client.js"; export type SlackUserLookup = { id: string; @@ -101,7 +103,7 @@ export async function resolveSlackUserAllowlist(params: { entries: string[]; client?: WebClient; }): Promise { - const client = params.client ?? new WebClient(params.token); + const client = params.client ?? createSlackWebClient(params.token); const users = await listSlackUsers(client); const results: SlackUserResolution[] = []; diff --git a/src/slack/scopes.ts b/src/slack/scopes.ts index 7cb7addb1..911f0f5b9 100644 --- a/src/slack/scopes.ts +++ b/src/slack/scopes.ts @@ -1,4 +1,6 @@ -import { WebClient } from "@slack/web-api"; +import type { WebClient } from "@slack/web-api"; + +import { createSlackWebClient } from "./client.js"; export type SlackScopesResult = { ok: boolean; @@ -81,7 +83,7 @@ export async function fetchSlackScopes( token: string, timeoutMs: number, ): Promise { - const client = new WebClient(token, { timeout: timeoutMs }); + const client = createSlackWebClient(token, { timeout: timeoutMs }); const attempts: SlackScopesSource[] = ["auth.scopes", "apps.permissions.info"]; const errors: string[] = []; diff --git a/src/slack/send.ts b/src/slack/send.ts index a3d61cdef..06de9770d 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -1,4 +1,4 @@ -import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api"; +import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api"; import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; @@ -6,6 +6,7 @@ import { logVerbose } from "../globals.js"; import { loadWebMedia } from "../web/media.js"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; +import { createSlackWebClient } from "./client.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; import { parseSlackTarget } from "./targets.js"; import { resolveSlackBotToken } from "./token.js"; @@ -137,7 +138,7 @@ export async function sendMessageSlack( fallbackToken: account.botToken, fallbackSource: account.botTokenSource, }); - const client = opts.client ?? new WebClient(token); + const client = opts.client ?? createSlackWebClient(token); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(client, recipient); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);