diff --git a/extensions/discord/src/channel-actions.test.ts b/extensions/discord/src/channel-actions.test.ts new file mode 100644 index 00000000000..e6c080e494c --- /dev/null +++ b/extensions/discord/src/channel-actions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +const handleDiscordMessageActionMock = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); + +vi.mock("./actions/handle-action.js", () => ({ + handleDiscordMessageAction: handleDiscordMessageActionMock, +})); + +import { discordMessageActions } from "./channel-actions.js"; + +describe("discordMessageActions", () => { + it("returns no tool actions when no token-sourced Discord accounts are enabled", () => { + const discovery = discordMessageActions.describeMessageTool?.({ + cfg: { + channels: { + discord: { + enabled: true, + }, + }, + } as OpenClawConfig, + }); + + expect(discovery).toEqual({ + actions: [], + capabilities: [], + schema: null, + }); + }); + + it("describes enabled Discord actions for token-backed accounts", () => { + const discovery = discordMessageActions.describeMessageTool?.({ + cfg: { + channels: { + discord: { + token: "Bot token-main", + actions: { + polls: true, + reactions: true, + permissions: true, + channels: false, + roles: false, + }, + }, + }, + } as OpenClawConfig, + }); + + expect(discovery?.capabilities).toEqual(["interactive", "components"]); + expect(discovery?.schema).not.toBeNull(); + expect(discovery?.actions).toEqual( + expect.arrayContaining([ + "send", + "poll", + "react", + "reactions", + "emoji-list", + "permissions", + ]), + ); + expect(discovery?.actions).not.toContain("channel-create"); + expect(discovery?.actions).not.toContain("role-add"); + }); + + it("extracts send targets for message and thread reply actions", () => { + expect( + discordMessageActions.extractToolSend?.({ + args: { action: "sendMessage", to: "channel:123" }, + }), + ).toEqual({ to: "channel:123" }); + + expect( + discordMessageActions.extractToolSend?.({ + args: { action: "threadReply", channelId: "987" }, + }), + ).toEqual({ to: "channel:987" }); + + expect( + discordMessageActions.extractToolSend?.({ + args: { action: "threadReply", channelId: " " }, + }), + ).toBeNull(); + }); + + it("delegates action handling to the Discord action handler", async () => { + const cfg = { + channels: { + discord: { + token: "Bot token-main", + }, + }, + } as OpenClawConfig; + const toolContext = { sessionId: "s1" }; + const mediaLocalRoots = ["/tmp/media"]; + + await discordMessageActions.handleAction?.({ + action: "send", + params: { to: "channel:123", text: "hello" }, + cfg, + accountId: "ops", + requesterSenderId: "user-1", + toolContext, + mediaLocalRoots, + }); + + expect(handleDiscordMessageActionMock).toHaveBeenCalledWith({ + action: "send", + params: { to: "channel:123", text: "hello" }, + cfg, + accountId: "ops", + requesterSenderId: "user-1", + toolContext, + mediaLocalRoots, + }); + }); +}); diff --git a/extensions/discord/src/draft-stream.test.ts b/extensions/discord/src/draft-stream.test.ts new file mode 100644 index 00000000000..2f0854c71b2 --- /dev/null +++ b/extensions/discord/src/draft-stream.test.ts @@ -0,0 +1,83 @@ +import { Routes } from "discord-api-types/v10"; +import { describe, expect, it, vi } from "vitest"; +import { createDiscordDraftStream } from "./draft-stream.js"; + +describe("createDiscordDraftStream", () => { + it("holds the first preview until minInitialChars is reached", async () => { + const rest = { + post: vi.fn(async () => ({ id: "m1" })), + patch: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const stream = createDiscordDraftStream({ + rest: rest as never, + channelId: "c1", + throttleMs: 250, + minInitialChars: 5, + }); + + stream.update("hey"); + await stream.flush(); + + expect(rest.post).not.toHaveBeenCalled(); + expect(stream.messageId()).toBeUndefined(); + }); + + it("sends a reply preview, then edits the same message on later flushes", async () => { + const rest = { + post: vi.fn(async () => ({ id: "m1" })), + patch: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const stream = createDiscordDraftStream({ + rest: rest as never, + channelId: "c1", + throttleMs: 250, + replyToMessageId: () => " parent-1 ", + }); + + stream.update("first draft"); + await stream.flush(); + stream.update("second draft"); + await stream.flush(); + + expect(rest.post).toHaveBeenCalledWith(Routes.channelMessages("c1"), { + body: { + content: "first draft", + message_reference: { + message_id: "parent-1", + fail_if_not_exists: false, + }, + }, + }); + expect(rest.patch).toHaveBeenCalledWith(Routes.channelMessage("c1", "m1"), { + body: { content: "second draft" }, + }); + expect(stream.messageId()).toBe("m1"); + }); + + it("stops previewing and warns once text exceeds the configured limit", async () => { + const rest = { + post: vi.fn(async () => ({ id: "m1" })), + patch: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const warn = vi.fn(); + const stream = createDiscordDraftStream({ + rest: rest as never, + channelId: "c1", + maxChars: 5, + throttleMs: 250, + warn, + }); + + stream.update("123456"); + await stream.flush(); + + expect(rest.post).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("discord stream preview stopped"), + ); + expect(stream.messageId()).toBeUndefined(); + }); +}); diff --git a/extensions/discord/src/normalize.test.ts b/extensions/discord/src/normalize.test.ts new file mode 100644 index 00000000000..a6325c1b20e --- /dev/null +++ b/extensions/discord/src/normalize.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + looksLikeDiscordTargetId, + normalizeDiscordMessagingTarget, + normalizeDiscordOutboundTarget, +} from "./normalize.js"; + +describe("discord target normalization", () => { + it("normalizes bare messaging target ids to channel targets", () => { + expect(normalizeDiscordMessagingTarget("1234567890")).toBe("channel:1234567890"); + }); + + it("keeps explicit outbound targets and rejects missing recipients", () => { + expect(normalizeDiscordOutboundTarget("1234567890")).toEqual({ + ok: true, + to: "channel:1234567890", + }); + expect(normalizeDiscordOutboundTarget("user:42")).toEqual({ + ok: true, + to: "user:42", + }); + + const result = normalizeDiscordOutboundTarget(" "); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("Discord recipient is required"); + } + }); + + it("detects Discord-style target identifiers", () => { + expect(looksLikeDiscordTargetId("<@!123456>")).toBe(true); + expect(looksLikeDiscordTargetId("user:123456")).toBe(true); + expect(looksLikeDiscordTargetId("discord:123456")).toBe(true); + expect(looksLikeDiscordTargetId("123456")).toBe(true); + expect(looksLikeDiscordTargetId("hello world")).toBe(false); + }); +}); diff --git a/extensions/discord/src/status-issues.test.ts b/extensions/discord/src/status-issues.test.ts new file mode 100644 index 00000000000..162b8dc6c64 --- /dev/null +++ b/extensions/discord/src/status-issues.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; +import { collectDiscordStatusIssues } from "./status-issues.js"; + +describe("collectDiscordStatusIssues", () => { + it("reports disabled message content intent and unresolved channel ids", () => { + const issues = collectDiscordStatusIssues([ + { + accountId: "ops", + enabled: true, + configured: true, + application: { + intents: { + messageContent: "disabled", + }, + }, + audit: { + unresolvedChannels: 2, + }, + } as ChannelAccountSnapshot, + ]); + + expect(issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channel: "discord", + accountId: "ops", + kind: "intent", + }), + expect.objectContaining({ + channel: "discord", + accountId: "ops", + kind: "config", + }), + ]), + ); + }); + + it("reports channel permission failures with match metadata", () => { + const issues = collectDiscordStatusIssues([ + { + accountId: "ops", + enabled: true, + configured: true, + audit: { + channels: [ + { + channelId: "123", + ok: false, + missing: ["ViewChannel", "SendMessages"], + error: "403", + matchKey: "alerts", + matchSource: "guilds.ops.channels", + }, + ], + }, + } as ChannelAccountSnapshot, + ]); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + channel: "discord", + accountId: "ops", + kind: "permissions", + }); + expect(issues[0]?.message).toContain("Channel 123 permission check failed"); + expect(issues[0]?.message).toContain("alerts"); + expect(issues[0]?.message).toContain("guilds.ops.channels"); + }); + + it("ignores accounts that are not enabled and configured", () => { + expect( + collectDiscordStatusIssues([ + { + accountId: "ops", + enabled: false, + configured: true, + } as ChannelAccountSnapshot, + ]), + ).toEqual([]); + }); +});