From 8734635b738022bee3e5a6bc9ffa8869f8c0f119 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 06:13:40 +0100 Subject: [PATCH] fix(slack): discover bot scopes via auth test --- CHANGELOG.md | 1 + extensions/slack/src/scopes.test.ts | 67 +++++++++++++++++++++++++++++ extensions/slack/src/scopes.ts | 8 +++- 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 extensions/slack/src/scopes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0707768eb60..4654f4fe034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai - 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/capabilities: read granted scopes from `auth.test` response metadata before trying legacy scope APIs, so modern bot tokens no longer report `unknown_method` for channel capabilities. Fixes #44625. Thanks @Qquanwei and @martingarramon. - 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/message actions: prefer the account bound to the outbound target peer before falling back to the agent's first channel account, so multi-workspace sends use the intended Slack account. Supersedes #66807. Thanks @rijhsinghani. diff --git a/extensions/slack/src/scopes.test.ts b/extensions/slack/src/scopes.test.ts new file mode 100644 index 00000000000..788e69f9400 --- /dev/null +++ b/extensions/slack/src/scopes.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createSlackWebClientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./client.js", () => ({ + createSlackWebClient: createSlackWebClientMock, +})); + +const { fetchSlackScopes } = await import("./scopes.js"); + +function mockSlackClient(apiCall: ReturnType) { + createSlackWebClientMock.mockReturnValue({ apiCall }); +} + +describe("fetchSlackScopes", () => { + beforeEach(() => { + createSlackWebClientMock.mockReset(); + }); + + it("uses auth.test response metadata scopes for modern bot tokens", async () => { + const apiCall = vi.fn().mockResolvedValue({ + ok: true, + user_id: "U123", + response_metadata: { scopes: ["chat:write", "im:history"] }, + }); + mockSlackClient(apiCall); + + await expect(fetchSlackScopes("xoxb-token", 1234)).resolves.toEqual({ + ok: true, + scopes: ["chat:write", "im:history"], + source: "auth.test", + }); + expect(createSlackWebClientMock).toHaveBeenCalledWith("xoxb-token", { timeout: 1234 }); + expect(apiCall).toHaveBeenCalledTimes(1); + expect(apiCall).toHaveBeenCalledWith("auth.test"); + }); + + it("falls back to legacy scope methods when auth.test has no scope metadata", async () => { + const apiCall = vi + .fn() + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, scopes: "channels:read,chat:write" }); + mockSlackClient(apiCall); + + await expect(fetchSlackScopes("xoxb-token", 5000)).resolves.toEqual({ + ok: true, + scopes: ["channels:read", "chat:write"], + source: "auth.scopes", + }); + expect(apiCall.mock.calls.map((call) => call[0])).toEqual(["auth.test", "auth.scopes"]); + }); + + it("includes auth.test in the diagnostic when every method fails", async () => { + const apiCall = vi + .fn() + .mockResolvedValueOnce({ ok: false, error: "invalid_auth" }) + .mockResolvedValueOnce({ ok: false, error: "unknown_method" }) + .mockResolvedValueOnce({ ok: false, error: "unknown_method" }); + mockSlackClient(apiCall); + + await expect(fetchSlackScopes("xoxb-token", 5000)).resolves.toEqual({ + ok: false, + error: + "auth.test: invalid_auth | auth.scopes: unknown_method | apps.permissions.info: unknown_method", + }); + }); +}); diff --git a/extensions/slack/src/scopes.ts b/extensions/slack/src/scopes.ts index 8be033e3b65..e27616e03c0 100644 --- a/extensions/slack/src/scopes.ts +++ b/extensions/slack/src/scopes.ts @@ -11,6 +11,7 @@ export type SlackScopesResult = { }; type SlackScopesSource = "auth.scopes" | "apps.permissions.info"; +type SlackScopesMethod = "auth.test" | SlackScopesSource; function collectScopes(value: unknown, into: string[]) { if (!value) { @@ -58,6 +59,9 @@ function extractScopes(payload: unknown): string[] { const scopes: string[] = []; collectScopes(payload.scopes, scopes); collectScopes(payload.scope, scopes); + if (isRecord(payload.response_metadata)) { + collectScopes(payload.response_metadata.scopes, scopes); + } if (isRecord(payload.info)) { collectScopes(payload.info.scopes, scopes); collectScopes(payload.info.scope, scopes); @@ -69,7 +73,7 @@ function extractScopes(payload: unknown): string[] { async function callSlack( client: WebClient, - method: SlackScopesSource, + method: SlackScopesMethod, ): Promise | null> { try { const result = await client.apiCall(method); @@ -87,7 +91,7 @@ export async function fetchSlackScopes( timeoutMs: number, ): Promise { const client = createSlackWebClient(token, { timeout: timeoutMs }); - const attempts: SlackScopesSource[] = ["auth.scopes", "apps.permissions.info"]; + const attempts: SlackScopesMethod[] = ["auth.test", "auth.scopes", "apps.permissions.info"]; const errors: string[] = []; for (const method of attempts) {