mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(slack): use live directory readers in cli
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user