diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b53c8d616e..a9807740511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai - Slack/delivery: preserve Slack Web API missing-scope details in outbound delivery errors, so queued retry state identifies the OAuth scope to add. Fixes #62391. Thanks @alexey-pelykh. - Slack/DMs: send text/block-only proactive DMs directly with `chat.postMessage(channel=)` while keeping conversation resolution for uploads and threaded sends. Fixes #62042. Thanks @MarkMolina. - Slack/routing: match route bindings written with Slack target syntax such as `channel:C...`, `user:U...`, or `<@U...>`, so bound Slack peers route to the configured agent instead of `main`. Fixes #41608. Thanks @Winnsolutionsadmin. +- Slack/delivery: retry Slack Web API writes only when the SDK wraps a DNS request failure such as `EAI_AGAIN`, so transient resolver hiccups can recover without retrying platform errors that may duplicate messages. Fixes #68789. Thanks @sonnyb9. - Slack/mentions: resolve `` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack. - Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97. diff --git a/extensions/slack/src/send.blocks.test.ts b/extensions/slack/src/send.blocks.test.ts index 6af1ae07517..5f085a60029 100644 --- a/extensions/slack/src/send.blocks.test.ts +++ b/extensions/slack/src/send.blocks.test.ts @@ -6,6 +6,17 @@ const { sendMessageSlack } = await import("./send.js"); const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } }; const SLACK_TEXT_LIMIT = 8000; +function slackDnsRequestError(): Error { + return Object.assign(new Error("A request error occurred: getaddrinfo EAI_AGAIN slack.com"), { + code: "slack_webapi_request_error", + original: Object.assign(new Error("getaddrinfo EAI_AGAIN slack.com"), { + code: "EAI_AGAIN", + syscall: "getaddrinfo", + hostname: "slack.com", + }), + }); +} + describe("sendMessageSlack NO_REPLY guard", () => { it("suppresses NO_REPLY text before any Slack API call", async () => { const client = createSlackSendTestClient(); @@ -136,6 +147,63 @@ describe("sendMessageSlack blocks", () => { expect(result).toEqual({ messageId: "171234.567", channelId: "U123" }); }); + it("retries Slack postMessage DNS request errors without enabling broad write retries", async () => { + const client = createSlackSendTestClient(); + client.chat.postMessage + .mockRejectedValueOnce(slackDnsRequestError()) + .mockResolvedValueOnce({ ts: "171234.999" }); + + const result = await sendMessageSlack("channel:C123", "hello", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + }); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(2); + expect(result).toEqual({ messageId: "171234.999", channelId: "C123" }); + }); + + it("retries Slack conversations.open DNS request errors for threaded DMs", async () => { + const client = createSlackSendTestClient(); + client.conversations.open + .mockRejectedValueOnce(slackDnsRequestError()) + .mockResolvedValueOnce({ channel: { id: "D123" } }); + + const result = await sendMessageSlack("user:U123", "hello", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + threadTs: "171234.100", + }); + + expect(client.conversations.open).toHaveBeenCalledTimes(2); + expect(client.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ channel: "D123", thread_ts: "171234.100" }), + ); + expect(result).toEqual({ messageId: "171234.567", channelId: "D123" }); + }); + + it("does not retry Slack platform errors", async () => { + const client = createSlackSendTestClient(); + const platformError = Object.assign( + new Error("An API error occurred: message_limit_exceeded"), + { + data: { ok: false, error: "message_limit_exceeded" }, + }, + ); + client.chat.postMessage.mockRejectedValue(platformError); + + await expect( + sendMessageSlack("channel:C123", "hello", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + }), + ).rejects.toThrow("message_limit_exceeded"); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(1); + }); + it("derives fallback text from image blocks", async () => { const client = createSlackSendTestClient(); await sendMessageSlack("channel:C123", "", { diff --git a/extensions/slack/src/send.identity-fallback.test.ts b/extensions/slack/src/send.identity-fallback.test.ts index 1f6d1be5007..0b897d2ee21 100644 --- a/extensions/slack/src/send.identity-fallback.test.ts +++ b/extensions/slack/src/send.identity-fallback.test.ts @@ -189,6 +189,7 @@ describe("sendMessageSlack customize-scope fallback", () => { token: "xoxb-test", cfg: SLACK_TEST_CFG, client, + threadTs: "171234.100", }), ).rejects.toThrow( "An API error occurred: missing_scope (needed: im:write; granted: chat:write)", diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index aa7a58ddcfb..3839cc91209 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -32,6 +32,9 @@ const SLACK_UPLOAD_SSRF_POLICY = { allowRfc2544BenchmarkRange: true, }; const SLACK_DM_CHANNEL_CACHE_MAX = 1024; +const SLACK_DNS_RETRY_CODES = new Set(["EAI_AGAIN", "ENOTFOUND", "UND_ERR_DNS_RESOLVE_FAILED"]); +const SLACK_DNS_RETRY_ATTEMPTS = 2; +const SLACK_DNS_RETRY_BASE_DELAY_MS = 250; const slackDmChannelCache = new Map(); const slackSendQueues = new Map>(); @@ -147,6 +150,66 @@ function enrichSlackWebApiError(err: unknown): unknown { return new Error(message); } +function readSlackRequestErrorCode(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const code = (value as { code?: unknown }).code; + return typeof code === "string" ? code.toUpperCase() : undefined; +} + +function readSlackRequestErrorMessage(value: unknown): string { + if (value instanceof Error) { + return value.message; + } + return typeof value === "string" ? value : ""; +} + +function hasSlackDnsRequestSignal(err: unknown): boolean { + let current: unknown = err; + const seen = new Set(); + for (let depth = 0; current && typeof current === "object" && depth < 6; depth += 1) { + if (seen.has(current)) { + return false; + } + seen.add(current); + const code = readSlackRequestErrorCode(current); + if (code && SLACK_DNS_RETRY_CODES.has(code)) { + return true; + } + const message = readSlackRequestErrorMessage(current); + if (/\b(EAI_AGAIN|ENOTFOUND|UND_ERR_DNS_RESOLVE_FAILED)\b/i.test(message)) { + return true; + } + current = + (current as { original?: unknown; cause?: unknown }).original ?? + (current as { cause?: unknown }).cause; + } + return false; +} + +function delaySlackDnsRetry(attempt: number): Promise { + return new Promise((resolve) => + setTimeout(resolve, SLACK_DNS_RETRY_BASE_DELAY_MS * Math.max(1, attempt)), + ); +} + +async function withSlackDnsRequestRetry(operation: string, fn: () => Promise): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await fn(); + } catch (err) { + if (attempt >= SLACK_DNS_RETRY_ATTEMPTS || !hasSlackDnsRequestSignal(err)) { + throw err; + } + logVerbose( + `slack send: retrying ${operation} after transient DNS request error (${attempt + 1}/${SLACK_DNS_RETRY_ATTEMPTS})`, + ); + await delaySlackDnsRetry(attempt + 1); + } + } +} + function isSlackCustomizeScopeError(err: unknown): boolean { const data = getSlackWebApiErrorData(err); const code = normalizeLowercaseStringOrEmpty(normalizeSlackApiString(data?.error)); @@ -182,30 +245,37 @@ async function postSlackMessageBestEffort(params: { try { // Slack Web API types model icon_url and icon_emoji as mutually exclusive. // Build payloads in explicit branches so TS and runtime stay aligned. - if (params.identity?.iconUrl) { - return await postChatMessage({ - ...basePayload, - ...(params.identity.username ? { username: params.identity.username } : {}), - icon_url: params.identity.iconUrl, - }); + const identity = params.identity; + if (identity?.iconUrl) { + return await withSlackDnsRequestRetry("chat.postMessage", () => + postChatMessage({ + ...basePayload, + ...(identity.username ? { username: identity.username } : {}), + icon_url: identity.iconUrl, + }), + ); } - if (params.identity?.iconEmoji) { - return await postChatMessage({ - ...basePayload, - ...(params.identity.username ? { username: params.identity.username } : {}), - icon_emoji: params.identity.iconEmoji, - }); + if (identity?.iconEmoji) { + return await withSlackDnsRequestRetry("chat.postMessage", () => + postChatMessage({ + ...basePayload, + ...(identity.username ? { username: identity.username } : {}), + icon_emoji: identity.iconEmoji, + }), + ); } - return await postChatMessage({ - ...basePayload, - ...(params.identity?.username ? { username: params.identity.username } : {}), - }); + return await withSlackDnsRequestRetry("chat.postMessage", () => + postChatMessage({ + ...basePayload, + ...(identity?.username ? { username: identity.username } : {}), + }), + ); } catch (err) { if (!hasCustomIdentity(params.identity) || !isSlackCustomizeScopeError(err)) { throw err; } logVerbose("slack send: missing chat:write.customize, retrying without custom identity"); - return postChatMessage(basePayload); + return withSlackDnsRequestRetry("chat.postMessage", () => postChatMessage(basePayload)); } } @@ -338,7 +408,9 @@ async function resolveChannelId( if (cachedChannelId) { return { channelId: cachedChannelId, isDm: true, cacheHit: true }; } - const response = await client.conversations.open({ users: recipient.id }); + const response = await withSlackDnsRequestRetry("conversations.open", () => + client.conversations.open({ users: recipient.id }), + ); const channelId = response.channel?.id; if (!channelId) { throw new Error("Failed to open Slack DM channel"); @@ -382,13 +454,16 @@ async function uploadSlackFile(params: { // Use the 3-step upload flow (getUploadURLExternal -> POST -> completeUploadExternal) // instead of files.uploadV2 which relies on the deprecated files.upload endpoint // and can fail with missing_scope even when files:write is granted. - const uploadUrlResp = await params.client.files.getUploadURLExternal({ - filename: uploadFileName, - length: buffer.length, - }); + const uploadUrlResp = await withSlackDnsRequestRetry("files.getUploadURLExternal", () => + params.client.files.getUploadURLExternal({ + filename: uploadFileName, + length: buffer.length, + }), + ); if (!uploadUrlResp.ok || !uploadUrlResp.upload_url || !uploadUrlResp.file_id) { throw new Error(`Failed to get upload URL: ${uploadUrlResp.error ?? "unknown error"}`); } + const uploadFileId = uploadUrlResp.file_id; // Upload the file content to the presigned URL const uploadBody = new Uint8Array(buffer) as BodyInit; @@ -413,17 +488,19 @@ async function uploadSlackFile(params: { } // Complete the upload and share to channel/thread - const completeResp = await params.client.files.completeUploadExternal({ - files: [{ id: uploadUrlResp.file_id, title: uploadTitle }], - channel_id: params.channelId, - ...(params.caption ? { initial_comment: params.caption } : {}), - ...(params.threadTs ? { thread_ts: params.threadTs } : {}), - }); + const completeResp = await withSlackDnsRequestRetry("files.completeUploadExternal", () => + params.client.files.completeUploadExternal({ + files: [{ id: uploadFileId, title: uploadTitle }], + channel_id: params.channelId, + ...(params.caption ? { initial_comment: params.caption } : {}), + ...(params.threadTs ? { thread_ts: params.threadTs } : {}), + }), + ); if (!completeResp.ok) { throw new Error(`Failed to complete upload: ${completeResp.error ?? "unknown error"}`); } - return uploadUrlResp.file_id; + return uploadFileId; } export async function sendMessageSlack(