mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(slack): preserve api scope errors
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user