diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e1cdc9ee5..123388dc53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Slack/message actions: send media before the follow-up Block Kit message when Slack `send` includes a file plus presentation or interactive controls, so file attachments are no longer rejected. Fixes #51458. Thanks @HirokiKobayashi-R. - Slack/DMs: honor `dmHistoryLimit` for fresh 1:1 Slack DM sessions by backfilling recent conversation history before the current reply. Fixes #64427. Thanks @brantley-creator. - Slack/DMs: keep top-level direct messages on the stable DM session even when `replyToMode` targets Slack thread replies, preserving context across DM turns. Fixes #58832. Thanks @daye-jjeong. +- 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/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.identity-fallback.test.ts b/extensions/slack/src/send.identity-fallback.test.ts index 0b421419eaf..1f6d1be5007 100644 --- a/extensions/slack/src/send.identity-fallback.test.ts +++ b/extensions/slack/src/send.identity-fallback.test.ts @@ -25,7 +25,7 @@ function buildMissingScopeError(overrides?: { scopes?: string[]; acceptedScopes?: string[]; }): SlackMissingScopeError { - const err = new Error("missing_scope") as SlackMissingScopeError; + const err = new Error("An API error occurred: missing_scope") as SlackMissingScopeError; const response_metadata = overrides?.scopes || overrides?.acceptedScopes ? { @@ -131,7 +131,7 @@ describe("sendMessageSlack customize-scope fallback", () => { client, identity: { username: "Bot" }, }), - ).rejects.toBe(err); + ).rejects.toThrow("An API error occurred: missing_scope (needed: channels:history)"); expect(client.chat.postMessage).toHaveBeenCalledTimes(1); expect(vi.mocked(logVerbose)).not.toHaveBeenCalled(); @@ -148,9 +148,51 @@ describe("sendMessageSlack customize-scope fallback", () => { cfg: SLACK_TEST_CFG, client, }), - ).rejects.toBe(err); + ).rejects.toThrow("An API error occurred: missing_scope (needed: chat:write.customize)"); expect(client.chat.postMessage).toHaveBeenCalledTimes(1); expect(vi.mocked(logVerbose)).not.toHaveBeenCalled(); }); + + it("preserves Slack missing-scope details for delivery queue recovery", async () => { + const client = createSlackSendTestClient(); + vi.mocked(client.chat.postMessage).mockRejectedValueOnce( + buildMissingScopeError({ + needed: "im:write", + scopes: ["chat:write", "users:read"], + acceptedScopes: ["im:write", "mpim:write"], + }), + ); + + await expect( + sendMessageSlack("channel:C123", "hello", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + }), + ).rejects.toThrow( + "An API error occurred: missing_scope (needed: im:write; granted: chat:write, users:read; accepted: im:write, mpim:write)", + ); + }); + + it("preserves Slack missing-scope details while opening DMs", async () => { + const client = createSlackSendTestClient(); + vi.mocked(client.conversations.open).mockRejectedValueOnce( + buildMissingScopeError({ + needed: "im:write", + scopes: ["chat:write"], + }), + ); + + await expect( + sendMessageSlack("user:U123", "hello", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + }), + ).rejects.toThrow( + "An API error occurred: missing_scope (needed: im:write; granted: chat:write)", + ); + expect(client.chat.postMessage).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index f790989449f..bec06cb5e06 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -70,32 +70,96 @@ type SlackSendOpts = { blocks?: (Block | KnownBlock)[]; }; +type SlackWebApiErrorData = { + error?: unknown; + needed?: unknown; + response_metadata?: { + scopes?: unknown; + acceptedScopes?: unknown; + }; +}; + +type SlackWebApiError = Error & { + data?: SlackWebApiErrorData; +}; + function hasCustomIdentity(identity?: SlackSendIdentity): boolean { return Boolean(identity?.username || identity?.iconUrl || identity?.iconEmoji); } -function isSlackCustomizeScopeError(err: unknown): boolean { - if (!(err instanceof Error)) { - return false; +function normalizeSlackApiString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizeSlackScopeList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; } - const maybeData = err as Error & { - data?: { - error?: string; - needed?: string; - response_metadata?: { scopes?: string[]; acceptedScopes?: string[] }; - }; - }; - const code = normalizeLowercaseStringOrEmpty(maybeData.data?.error); + return value.flatMap((scope) => { + const normalized = normalizeSlackApiString(scope); + return normalized ? [normalized] : []; + }); +} + +function getSlackWebApiErrorData(err: unknown): SlackWebApiErrorData | undefined { + if (!(err instanceof Error)) { + return undefined; + } + const data = (err as SlackWebApiError).data; + if (!data || typeof data !== "object") { + return undefined; + } + return data; +} + +function formatSlackWebApiErrorMessage(err: unknown): string | undefined { + if (!(err instanceof Error)) { + return undefined; + } + const data = getSlackWebApiErrorData(err); + const code = normalizeSlackApiString(data?.error); + if (!code) { + return undefined; + } + const details: string[] = []; + const needed = normalizeSlackApiString(data?.needed); + if (needed) { + details.push(`needed: ${needed}`); + } + const scopes = normalizeSlackScopeList(data?.response_metadata?.scopes); + if (scopes.length) { + details.push(`granted: ${scopes.join(", ")}`); + } + const acceptedScopes = normalizeSlackScopeList(data?.response_metadata?.acceptedScopes); + if (acceptedScopes.length) { + details.push(`accepted: ${acceptedScopes.join(", ")}`); + } + return `${err.message || `An API error occurred: ${code}`}${ + details.length ? ` (${details.join("; ")})` : "" + }`; +} + +function enrichSlackWebApiError(err: unknown): unknown { + const message = formatSlackWebApiErrorMessage(err); + if (!message || !(err instanceof Error) || message === err.message) { + return err; + } + return new Error(message); +} + +function isSlackCustomizeScopeError(err: unknown): boolean { + const data = getSlackWebApiErrorData(err); + const code = normalizeLowercaseStringOrEmpty(normalizeSlackApiString(data?.error)); if (code !== "missing_scope") { return false; } - const needed = normalizeLowercaseStringOrEmpty(maybeData.data?.needed); + const needed = normalizeLowercaseStringOrEmpty(normalizeSlackApiString(data?.needed)); if (needed?.includes("chat:write.customize")) { return true; } const scopes = [ - ...(maybeData.data?.response_metadata?.scopes ?? []), - ...(maybeData.data?.response_metadata?.acceptedScopes ?? []), + ...normalizeSlackScopeList(data?.response_metadata?.scopes), + ...normalizeSlackScopeList(data?.response_metadata?.acceptedScopes), ].map((scope) => normalizeLowercaseStringOrEmpty(scope)); return scopes.includes("chat:write.customize"); } @@ -401,6 +465,22 @@ async function sendMessageSlackQueued(params: { token: string; recipient: SlackRecipient; blocks?: (Block | KnownBlock)[]; +}): Promise { + try { + return await sendMessageSlackQueuedInner(params); + } catch (err) { + throw enrichSlackWebApiError(err); + } +} + +async function sendMessageSlackQueuedInner(params: { + trimmedMessage: string; + opts: SlackSendOpts; + cfg: OpenClawConfig; + account: ReturnType; + token: string; + recipient: SlackRecipient; + blocks?: (Block | KnownBlock)[]; }): Promise { const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params; const client = opts.client ?? getSlackWriteClient(token);