fix(slack): discover bot scopes via auth test

This commit is contained in:
Peter Steinberger
2026-05-02 06:13:40 +01:00
parent 9a9fefd21f
commit 8734635b73
3 changed files with 74 additions and 2 deletions

View File

@@ -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=<user id>)` 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.

View File

@@ -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<typeof vi.fn>) {
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",
});
});
});

View File

@@ -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<Record<string, unknown> | null> {
try {
const result = await client.apiCall(method);
@@ -87,7 +91,7 @@ export async function fetchSlackScopes(
timeoutMs: number,
): Promise<SlackScopesResult> {
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) {