From 37426a6e64781e6cbb9304f57281cb7ae800079a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 03:04:21 +0100 Subject: [PATCH] fix(slack): use live directory readers in cli --- CHANGELOG.md | 1 + extensions/slack/src/channel.ts | 1 + .../slack/src/directory-contract.test.ts | 83 ++++++++++++++++++- extensions/slack/src/directory-live.ts | 77 ++++++++++++----- .../plugins/runtime-forwarders.test.ts | 9 +- src/channels/plugins/runtime-forwarders.ts | 15 +++- src/cli/directory-cli.test.ts | 74 +++++++++++++++++ src/cli/directory-cli.ts | 4 +- 8 files changed, 239 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9df5de71f85..626b64a4354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570. - macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. - Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129. +- Slack/directory: make `openclaw directory peers/groups list --channel slack` prefer token-backed live readers and return the connected Slack account from `directory self`, so valid Slack tokens no longer produce empty directory CLI results. Fixes #50776. Thanks @pjaillon. - Slack: keep the assistant typing status and temporary typing reaction active for group/channel turns that use message-tool-only visible replies, while still suppressing automatic source replies. Fixes #75877. Thanks @teosborne. - Slack: recover full inbound DM text from top-level rich-text blocks when Slack sends a shortened message preview, so long direct messages still reach the agent intact. Fixes #55358. Thanks @tonyjwinter. - Replies: strip legacy `[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]` pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua. diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 29444a3a3b2..91ef8e64b53 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -433,6 +433,7 @@ export const slackPlugin: ChannelPlugin = crea (await loadSlackDirectoryConfigModule()).listSlackDirectoryGroupsFromConfig(params), ...createRuntimeDirectoryLiveAdapter({ getRuntime: loadSlackDirectoryLiveModule, + self: (runtime) => runtime.getSlackDirectorySelfLive, listPeersLive: (runtime) => runtime.listSlackDirectoryPeersLive, listGroupsLive: (runtime) => runtime.listSlackDirectoryGroupsLive, }), diff --git a/extensions/slack/src/directory-contract.test.ts b/extensions/slack/src/directory-contract.test.ts index f0d9397261f..3e0a73888b9 100644 --- a/extensions/slack/src/directory-contract.test.ts +++ b/extensions/slack/src/directory-contract.test.ts @@ -1,14 +1,32 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { expectDirectoryIds } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { describe, expect, expectTypeOf, it } from "vitest"; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; import { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, } from "../directory-contract-api.js"; +import { getSlackDirectorySelfLive } from "./directory-live.js"; import type { SlackProbe } from "./probe.js"; +const slackClientMocks = vi.hoisted(() => ({ + authTest: vi.fn(), + usersInfo: vi.fn(), +})); + +vi.mock("./client.js", () => ({ + createSlackWebClient: () => ({ + auth: { test: slackClientMocks.authTest }, + users: { info: slackClientMocks.usersInfo }, + }), +})); + describe("Slack directory contract", () => { + beforeEach(() => { + slackClientMocks.authTest.mockReset(); + slackClientMocks.usersInfo.mockReset(); + }); + it("keeps public probe aligned with base contract", () => { expectTypeOf().toMatchTypeOf(); }); @@ -77,4 +95,67 @@ describe("Slack directory contract", () => { expect(peers).toHaveLength(2); expect(peers.every((entry) => entry.id.startsWith("user:u"))).toBe(true); }); + + it("resolves current Slack account identity from live auth", async () => { + slackClientMocks.authTest.mockResolvedValue({ + ok: true, + user_id: "USELF", + user: "ada", + team_id: "T1", + team: "Test Team", + }); + slackClientMocks.usersInfo.mockResolvedValue({ + user: { + id: "USELF", + name: "ada", + profile: { + display_name: "Ada", + real_name: "Ada Lovelace", + }, + }, + }); + const cfg = { + channels: { + slack: { + userToken: "xoxp-test", + }, + }, + } as unknown as OpenClawConfig; + + await expect(getSlackDirectorySelfLive({ cfg, accountId: "default" })).resolves.toEqual( + expect.objectContaining({ + kind: "user", + id: "user:USELF", + name: "Ada", + handle: "@ada", + }), + ); + expect(slackClientMocks.authTest).toHaveBeenCalled(); + expect(slackClientMocks.usersInfo).toHaveBeenCalledWith({ user: "USELF" }); + }); + + it("falls back to auth identity when live user profile lookup fails", async () => { + slackClientMocks.authTest.mockResolvedValue({ + ok: true, + user_id: "USELF", + user: "ada", + }); + slackClientMocks.usersInfo.mockRejectedValue(new Error("missing_scope")); + const cfg = { + channels: { + slack: { + userToken: "xoxp-test", + }, + }, + } as unknown as OpenClawConfig; + + await expect(getSlackDirectorySelfLive({ cfg, accountId: "default" })).resolves.toEqual( + expect.objectContaining({ + kind: "user", + id: "user:USELF", + name: "ada", + handle: "@ada", + }), + ); + }); }); diff --git a/extensions/slack/src/directory-live.ts b/extensions/slack/src/directory-live.ts index c6e00ef3b12..c409835fb28 100644 --- a/extensions/slack/src/directory-live.ts +++ b/extensions/slack/src/directory-live.ts @@ -41,6 +41,14 @@ type SlackListChannelsResponse = { response_metadata?: { next_cursor?: string }; }; +type SlackAuthTestResponse = { + ok?: boolean; + user_id?: string; + user?: string; + team_id?: string; + team?: string; +}; + function resolveReadToken(params: DirectoryConfigParams): string | undefined { const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); return account.userToken ?? account.botToken?.trim(); @@ -65,6 +73,54 @@ function buildChannelRank(channel: SlackChannel): number { return channel.is_archived ? 0 : 1; } +function slackUserToDirectoryEntry( + user: SlackUser, + fallback?: { id?: string; name?: string }, +): ChannelDirectoryEntry | null { + const id = normalizeOptionalString(user.id) ?? normalizeOptionalString(fallback?.id); + if (!id) { + return null; + } + const handle = normalizeOptionalString(user.name) ?? normalizeOptionalString(fallback?.name); + const display = + normalizeOptionalString(user.profile?.display_name) || + normalizeOptionalString(user.profile?.real_name) || + normalizeOptionalString(user.real_name) || + handle; + return { + kind: "user", + id: `user:${id}`, + name: display || undefined, + handle: handle ? `@${handle}` : undefined, + rank: buildUserRank(user), + raw: user, + }; +} + +export async function getSlackDirectorySelfLive( + params: DirectoryConfigParams, +): Promise { + const token = resolveReadToken(params); + if (!token) { + return null; + } + const client = createSlackWebClient(token); + const auth = (await client.auth.test()) as SlackAuthTestResponse; + const userId = normalizeOptionalString(auth.user_id); + if (!userId) { + return null; + } + try { + const info = (await client.users.info({ user: userId })) as { user?: SlackUser }; + return slackUserToDirectoryEntry(info.user ?? {}, { id: userId, name: auth.user }); + } catch { + return slackUserToDirectoryEntry( + { id: userId, name: auth.user }, + { id: userId, name: auth.user }, + ); + } +} + export async function listSlackDirectoryPeersLive( params: DirectoryConfigParams, ): Promise { @@ -103,26 +159,7 @@ export async function listSlackDirectoryPeersLive( }); const rows = filtered - .map((member) => { - const id = member.id?.trim(); - if (!id) { - return null; - } - const handle = normalizeOptionalString(member.name); - const display = - normalizeOptionalString(member.profile?.display_name) || - normalizeOptionalString(member.profile?.real_name) || - normalizeOptionalString(member.real_name) || - handle; - return { - kind: "user", - id: `user:${id}`, - name: display || undefined, - handle: handle ? `@${handle}` : undefined, - rank: buildUserRank(member), - raw: member, - } satisfies ChannelDirectoryEntry; - }) + .map((member) => slackUserToDirectoryEntry(member)) .filter(Boolean) as ChannelDirectoryEntry[]; if (typeof params.limit === "number" && params.limit > 0) { diff --git a/src/channels/plugins/runtime-forwarders.test.ts b/src/channels/plugins/runtime-forwarders.test.ts index 8b927a319f3..e780f20407f 100644 --- a/src/channels/plugins/runtime-forwarders.test.ts +++ b/src/channels/plugins/runtime-forwarders.test.ts @@ -6,15 +6,22 @@ import { describe("createRuntimeDirectoryLiveAdapter", () => { it("forwards live directory calls through the runtime getter", async () => { + const self = vi.fn(async (_ctx: unknown) => ({ kind: "user" as const, id: "self" })); const listPeersLive = vi.fn(async (_ctx: unknown) => [{ kind: "user" as const, id: "alice" }]); const adapter = createRuntimeDirectoryLiveAdapter({ - getRuntime: async () => ({ listPeersLive }), + getRuntime: async () => ({ self, listPeersLive }), + self: (runtime) => runtime.self, listPeersLive: (runtime) => runtime.listPeersLive, }); + await expect(adapter.self?.({ cfg: {} as never, runtime: {} as never })).resolves.toEqual({ + kind: "user", + id: "self", + }); await expect( adapter.listPeersLive?.({ cfg: {} as never, runtime: {} as never, query: "a", limit: 1 }), ).resolves.toEqual([{ kind: "user", id: "alice" }]); + expect(self).toHaveBeenCalled(); expect(listPeersLive).toHaveBeenCalled(); }); }); diff --git a/src/channels/plugins/runtime-forwarders.ts b/src/channels/plugins/runtime-forwarders.ts index 9730e4a94e8..fff074bdc1e 100644 --- a/src/channels/plugins/runtime-forwarders.ts +++ b/src/channels/plugins/runtime-forwarders.ts @@ -2,9 +2,10 @@ import type { ChannelDirectoryAdapter, ChannelOutboundAdapter } from "./types.ad type MaybePromise = T | Promise; -type DirectoryListMethod = "listPeersLive" | "listGroupsLive" | "listGroupMembers"; +type DirectoryMethod = "self" | "listPeersLive" | "listGroupsLive" | "listGroupMembers"; type OutboundMethod = "sendText" | "sendMedia" | "sendPoll"; +type DirectorySelfParams = Parameters>[0]; type DirectoryListParams = Parameters>[0]; type DirectoryGroupMembersParams = Parameters< NonNullable @@ -28,6 +29,7 @@ async function resolveForwardedMethod(params: { export function createRuntimeDirectoryLiveAdapter(params: { getRuntime: () => MaybePromise; + self?: (runtime: Runtime) => ChannelDirectoryAdapter["self"] | null | undefined; listPeersLive?: (runtime: Runtime) => ChannelDirectoryAdapter["listPeersLive"] | null | undefined; listGroupsLive?: ( runtime: Runtime, @@ -35,8 +37,17 @@ export function createRuntimeDirectoryLiveAdapter(params: { listGroupMembers?: ( runtime: Runtime, ) => ChannelDirectoryAdapter["listGroupMembers"] | null | undefined; -}): Pick { +}): Pick { return { + self: params.self + ? async (ctx: DirectorySelfParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.self!, + }) + )(ctx) + : undefined, listPeersLive: params.listPeersLive ? async (ctx: DirectoryListParams) => await ( diff --git a/src/cli/directory-cli.test.ts b/src/cli/directory-cli.test.ts index 84ec9b19123..4cdad04c76f 100644 --- a/src/cli/directory-cli.test.ts +++ b/src/cli/directory-cli.test.ts @@ -159,4 +159,78 @@ describe("registerDirectoryCli", () => { baseHash: "config-1", }); }); + + it("prefers live directory list readers when available", async () => { + const listPeers = vi.fn().mockResolvedValue([{ id: "user:config", kind: "user" }]); + const listPeersLive = vi.fn().mockResolvedValue([{ id: "user:live", kind: "user" }]); + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: { channels: { slack: {} } }, + channelId: "slack", + plugin: { + id: "slack", + directory: { listPeers, listPeersLive }, + }, + configChanged: false, + }); + + const program = new Command().name("openclaw"); + registerDirectoryCli(program); + + await program.parseAsync( + [ + "directory", + "peers", + "list", + "--channel", + "slack", + "--query", + "ada", + "--limit", + "5", + "--json", + ], + { from: "user" }, + ); + + expect(listPeersLive).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "default", + query: "ada", + limit: 5, + }), + ); + expect(listPeers).not.toHaveBeenCalled(); + expect(runtimeState.defaultRuntime.log).toHaveBeenCalledWith( + JSON.stringify([{ id: "user:live", kind: "user" }], null, 2), + ); + }); + + it("falls back to config-backed directory list readers when live readers are absent", async () => { + const listGroups = vi.fn().mockResolvedValue([{ id: "channel:config", kind: "group" }]); + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: { channels: { slack: {} } }, + channelId: "slack", + plugin: { + id: "slack", + directory: { listGroups }, + }, + configChanged: false, + }); + + const program = new Command().name("openclaw"); + registerDirectoryCli(program); + + await program.parseAsync(["directory", "groups", "list", "--channel", "slack", "--json"], { + from: "user", + }); + + expect(listGroups).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "default", + }), + ); + expect(runtimeState.defaultRuntime.log).toHaveBeenCalledWith( + JSON.stringify([{ id: "channel:config", kind: "group" }], null, 2), + ); + }); }); diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index b4547e50df7..67324051888 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -169,7 +169,9 @@ export function registerDirectoryCli(program: Command) { account: params.opts.account as string | undefined, }); const fn = - params.action === "listPeers" ? plugin.directory?.listPeers : plugin.directory?.listGroups; + params.action === "listPeers" + ? (plugin.directory?.listPeersLive ?? plugin.directory?.listPeers) + : (plugin.directory?.listGroupsLive ?? plugin.directory?.listGroups); if (!fn) { throw new Error(`Channel ${channelId} does not support directory ${params.unsupported}`); }