fix(slack): preserve api scope errors

This commit is contained in:
Peter Steinberger
2026-05-02 04:46:44 +01:00
parent c51c83955d
commit 67fd3bfca2
3 changed files with 140 additions and 17 deletions

View File

@@ -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 `<!subteam^...>` 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.

View File

@@ -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();
});
});

View File

@@ -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<SlackSendResult> {
try {
return await sendMessageSlackQueuedInner(params);
} catch (err) {
throw enrichSlackWebApiError(err);
}
}
async function sendMessageSlackQueuedInner(params: {
trimmedMessage: string;
opts: SlackSendOpts;
cfg: OpenClawConfig;
account: ReturnType<typeof resolveSlackAccount>;
token: string;
recipient: SlackRecipient;
blocks?: (Block | KnownBlock)[];
}): Promise<SlackSendResult> {
const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params;
const client = opts.client ?? getSlackWriteClient(token);