mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 05:27:39 +00:00
Discord: add component v2 UI tool support (#17419)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,28 @@
|
||||
import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon";
|
||||
import type {
|
||||
ButtonInteraction,
|
||||
ComponentData,
|
||||
ModalInteraction,
|
||||
StringSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import type { Client } from "@buape/carbon";
|
||||
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { DiscordAccountConfig } from "../../config/types.discord.js";
|
||||
import type { DiscordChannelConfigResolved } from "./allow-list.js";
|
||||
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||
import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
|
||||
import {
|
||||
clearDiscordComponentEntries,
|
||||
registerDiscordComponentEntries,
|
||||
resolveDiscordComponentEntry,
|
||||
resolveDiscordModalEntry,
|
||||
} from "../components-registry.js";
|
||||
import {
|
||||
createAgentComponentButton,
|
||||
createAgentSelectMenu,
|
||||
createDiscordComponentButton,
|
||||
createDiscordComponentModal,
|
||||
} from "./agent-components.js";
|
||||
import {
|
||||
resolveDiscordMemberAllowed,
|
||||
resolveDiscordOwnerAllowFrom,
|
||||
@@ -29,6 +46,12 @@ import {
|
||||
const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
|
||||
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
|
||||
const dispatchReplyMock = vi.hoisted(() => vi.fn());
|
||||
const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
|
||||
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
|
||||
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
|
||||
const resolveStorePathMock = vi.hoisted(() => vi.fn());
|
||||
let lastDispatchCtx: Record<string, unknown> | undefined;
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
@@ -43,6 +66,27 @@ vi.mock("../../infra/system-events.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./reply-delivery.js", () => ({
|
||||
deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/session.js", () => ({
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
|
||||
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("agent components", () => {
|
||||
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
||||
|
||||
@@ -128,6 +172,167 @@ describe("agent components", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("discord component interactions", () => {
|
||||
const createCfg = (): OpenClawConfig =>
|
||||
({
|
||||
channels: {
|
||||
discord: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig;
|
||||
|
||||
const createDiscordConfig = (overrides?: Partial<DiscordAccountConfig>): DiscordAccountConfig =>
|
||||
({
|
||||
replyToMode: "first",
|
||||
...overrides,
|
||||
}) as DiscordAccountConfig;
|
||||
|
||||
type DispatchParams = {
|
||||
ctx: Record<string, unknown>;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: { text?: string }) => Promise<void> | void;
|
||||
};
|
||||
};
|
||||
|
||||
const createComponentContext = (
|
||||
overrides?: Partial<Parameters<typeof createDiscordComponentButton>[0]>,
|
||||
) =>
|
||||
({
|
||||
cfg: createCfg(),
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123456789"],
|
||||
discordConfig: createDiscordConfig(),
|
||||
token: "token",
|
||||
...overrides,
|
||||
}) as Parameters<typeof createDiscordComponentButton>[0];
|
||||
|
||||
const createComponentButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const defer = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = {
|
||||
rawData: { channel_id: "dm-channel", id: "interaction-1" },
|
||||
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
|
||||
customId: "occomp:cid=btn_1",
|
||||
message: { id: "msg-1" },
|
||||
client: { rest: {} },
|
||||
defer,
|
||||
reply,
|
||||
...overrides,
|
||||
} as unknown as ButtonInteraction;
|
||||
return { interaction, defer, reply };
|
||||
};
|
||||
|
||||
const createModalInteraction = (overrides: Partial<ModalInteraction> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const acknowledge = vi.fn().mockResolvedValue(undefined);
|
||||
const fields = {
|
||||
getText: (key: string) => (key === "fld_1" ? "Casey" : undefined),
|
||||
getStringSelect: (_key: string) => undefined,
|
||||
getRoleSelect: (_key: string) => [],
|
||||
getUserSelect: (_key: string) => [],
|
||||
};
|
||||
const interaction = {
|
||||
rawData: { channel_id: "dm-channel", id: "interaction-2" },
|
||||
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
|
||||
customId: "ocmodal:mid=mdl_1",
|
||||
fields,
|
||||
acknowledge,
|
||||
reply,
|
||||
client: { rest: {} },
|
||||
...overrides,
|
||||
} as unknown as ModalInteraction;
|
||||
return { interaction, acknowledge, reply };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearDiscordComponentEntries();
|
||||
lastDispatchCtx = undefined;
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
enqueueSystemEventMock.mockReset();
|
||||
dispatchReplyMock.mockReset().mockImplementation(async (params: DispatchParams) => {
|
||||
lastDispatchCtx = params.ctx;
|
||||
await params.dispatcherOptions.deliver({ text: "ok" });
|
||||
});
|
||||
deliverDiscordReplyMock.mockReset();
|
||||
recordInboundSessionMock.mockReset().mockResolvedValue(undefined);
|
||||
readSessionUpdatedAtMock.mockReset().mockReturnValue(undefined);
|
||||
resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions-test.json");
|
||||
});
|
||||
|
||||
it("routes button clicks with reply references", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "btn_1",
|
||||
kind: "button",
|
||||
label: "Approve",
|
||||
messageId: "msg-1",
|
||||
sessionKey: "session-1",
|
||||
agentId: "agent-1",
|
||||
accountId: "default",
|
||||
},
|
||||
],
|
||||
modals: [],
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const { interaction, reply } = createComponentButtonInteraction();
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓" });
|
||||
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
|
||||
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-1");
|
||||
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("routes modal submissions with field values", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [],
|
||||
modals: [
|
||||
{
|
||||
id: "mdl_1",
|
||||
title: "Details",
|
||||
messageId: "msg-2",
|
||||
sessionKey: "session-2",
|
||||
agentId: "agent-2",
|
||||
accountId: "default",
|
||||
fields: [
|
||||
{
|
||||
id: "fld_1",
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const modal = createDiscordComponentModal(
|
||||
createComponentContext({
|
||||
discordConfig: createDiscordConfig({ replyToMode: "all" }),
|
||||
}),
|
||||
);
|
||||
const { interaction, acknowledge } = createModalInteraction();
|
||||
|
||||
await modal.run(interaction, { mid: "mdl_1" } as ComponentData);
|
||||
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1);
|
||||
expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.');
|
||||
expect(lastDispatchCtx?.BodyForAgent).toContain("- Name: Casey");
|
||||
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
|
||||
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordOwnerAllowFrom", () => {
|
||||
it("returns undefined when no allowlist is configured", () => {
|
||||
const result = resolveDiscordOwnerAllowFrom({
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { Client, ReadyListener, type BaseMessageInteractiveComponent } from "@buape/carbon";
|
||||
import {
|
||||
Client,
|
||||
ReadyListener,
|
||||
type BaseMessageInteractiveComponent,
|
||||
type Modal,
|
||||
} from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { inspect } from "node:util";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
@@ -33,7 +38,17 @@ import { fetchDiscordApplicationId } from "../probe.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
|
||||
import {
|
||||
createAgentComponentButton,
|
||||
createAgentSelectMenu,
|
||||
createDiscordComponentButton,
|
||||
createDiscordComponentChannelSelect,
|
||||
createDiscordComponentMentionableSelect,
|
||||
createDiscordComponentModal,
|
||||
createDiscordComponentRoleSelect,
|
||||
createDiscordComponentStringSelect,
|
||||
createDiscordComponentUserSelect,
|
||||
} from "./agent-components.js";
|
||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
|
||||
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
|
||||
@@ -432,30 +447,32 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
sessionPrefix,
|
||||
}),
|
||||
];
|
||||
const modals: Modal[] = [];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
if (agentComponentsEnabled) {
|
||||
components.push(
|
||||
createAgentComponentButton({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
}),
|
||||
);
|
||||
components.push(
|
||||
createAgentSelectMenu({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
}),
|
||||
);
|
||||
const componentContext = {
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
runtime,
|
||||
token,
|
||||
};
|
||||
components.push(createAgentComponentButton(componentContext));
|
||||
components.push(createAgentSelectMenu(componentContext));
|
||||
components.push(createDiscordComponentButton(componentContext));
|
||||
components.push(createDiscordComponentStringSelect(componentContext));
|
||||
components.push(createDiscordComponentUserSelect(componentContext));
|
||||
components.push(createDiscordComponentRoleSelect(componentContext));
|
||||
components.push(createDiscordComponentMentionableSelect(componentContext));
|
||||
components.push(createDiscordComponentChannelSelect(componentContext));
|
||||
modals.push(createDiscordComponentModal(componentContext));
|
||||
}
|
||||
|
||||
class DiscordStatusReadyListener extends ReadyListener {
|
||||
@@ -487,6 +504,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
commands,
|
||||
listeners: [new DiscordStatusReadyListener()],
|
||||
components,
|
||||
modals,
|
||||
},
|
||||
[createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime })],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user