fix(slack): use live directory readers in cli

This commit is contained in:
Peter Steinberger
2026-05-02 03:04:21 +01:00
parent d180bcad6a
commit 37426a6e64
8 changed files with 239 additions and 25 deletions

View File

@@ -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.

View File

@@ -433,6 +433,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
(await loadSlackDirectoryConfigModule()).listSlackDirectoryGroupsFromConfig(params),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadSlackDirectoryLiveModule,
self: (runtime) => runtime.getSlackDirectorySelfLive,
listPeersLive: (runtime) => runtime.listSlackDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listSlackDirectoryGroupsLive,
}),

View File

@@ -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<SlackProbe>().toMatchTypeOf<BaseProbeResult>();
});
@@ -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",
}),
);
});
});

View File

@@ -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<ChannelDirectoryEntry | null> {
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<ChannelDirectoryEntry[]> {
@@ -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) {

View File

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

View File

@@ -2,9 +2,10 @@ import type { ChannelDirectoryAdapter, ChannelOutboundAdapter } from "./types.ad
type MaybePromise<T> = T | Promise<T>;
type DirectoryListMethod = "listPeersLive" | "listGroupsLive" | "listGroupMembers";
type DirectoryMethod = "self" | "listPeersLive" | "listGroupsLive" | "listGroupMembers";
type OutboundMethod = "sendText" | "sendMedia" | "sendPoll";
type DirectorySelfParams = Parameters<NonNullable<ChannelDirectoryAdapter["self"]>>[0];
type DirectoryListParams = Parameters<NonNullable<ChannelDirectoryAdapter["listPeersLive"]>>[0];
type DirectoryGroupMembersParams = Parameters<
NonNullable<ChannelDirectoryAdapter["listGroupMembers"]>
@@ -28,6 +29,7 @@ async function resolveForwardedMethod<Runtime, Fn>(params: {
export function createRuntimeDirectoryLiveAdapter<Runtime>(params: {
getRuntime: () => MaybePromise<Runtime>;
self?: (runtime: Runtime) => ChannelDirectoryAdapter["self"] | null | undefined;
listPeersLive?: (runtime: Runtime) => ChannelDirectoryAdapter["listPeersLive"] | null | undefined;
listGroupsLive?: (
runtime: Runtime,
@@ -35,8 +37,17 @@ export function createRuntimeDirectoryLiveAdapter<Runtime>(params: {
listGroupMembers?: (
runtime: Runtime,
) => ChannelDirectoryAdapter["listGroupMembers"] | null | undefined;
}): Pick<ChannelDirectoryAdapter, DirectoryListMethod> {
}): Pick<ChannelDirectoryAdapter, DirectoryMethod> {
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 (

View File

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

View File

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