| import { ChannelType } from "discord-api-types/v10"; |
| import { beforeEach, describe, expect, it, vi } from "vitest"; |
| import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; |
| import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; |
| import type { OpenClawConfig } from "../../config/config.js"; |
| import type { DiscordAccountConfig } from "../../config/types.discord.js"; |
| import * as pluginCommandsModule from "../../plugins/commands.js"; |
| import { createDiscordNativeCommand } from "./native-command.js"; |
| import { |
| createMockCommandInteraction, |
| type MockCommandInteraction, |
| } from "./native-command.test-helpers.js"; |
| import { createNoopThreadBindingManager } from "./thread-bindings.js"; |
|
|
| function createInteraction(params?: { userId?: string }): MockCommandInteraction { |
| return createMockCommandInteraction({ |
| userId: params?.userId ?? "123456789012345678", |
| username: "discord-user", |
| globalName: "Discord User", |
| channelType: ChannelType.GuildText, |
| channelId: "234567890123456789", |
| guildId: "345678901234567890", |
| guildName: "Test Guild", |
| interactionId: "interaction-1", |
| }); |
| } |
|
|
| function createConfig(): OpenClawConfig { |
| return { |
| commands: { |
| allowFrom: { |
| discord: ["user:123456789012345678"], |
| }, |
| }, |
| channels: { |
| discord: { |
| groupPolicy: "allowlist", |
| guilds: { |
| "345678901234567890": { |
| channels: { |
| "234567890123456789": { |
| allow: true, |
| requireMention: false, |
| }, |
| }, |
| }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig; |
| } |
|
|
| function createCommand(cfg: OpenClawConfig, discordConfig?: DiscordAccountConfig) { |
| const commandSpec: NativeCommandSpec = { |
| name: "status", |
| description: "Status", |
| acceptsArgs: false, |
| }; |
| return createDiscordNativeCommand({ |
| command: commandSpec, |
| cfg, |
| discordConfig: discordConfig ?? cfg.channels?.discord ?? {}, |
| accountId: "default", |
| sessionPrefix: "discord:slash", |
| ephemeralDefault: true, |
| threadBindings: createNoopThreadBindingManager("default"), |
| }); |
| } |
|
|
| function createDispatchSpy() { |
| return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({ |
| counts: { |
| final: 1, |
| block: 0, |
| tool: 0, |
| }, |
| } as never); |
| } |
|
|
| async function runGuildSlashCommand(params?: { |
| userId?: string; |
| mutateConfig?: (cfg: OpenClawConfig) => void; |
| runtimeDiscordConfig?: DiscordAccountConfig; |
| }) { |
| const cfg = createConfig(); |
| params?.mutateConfig?.(cfg); |
| const command = createCommand(cfg, params?.runtimeDiscordConfig); |
| const interaction = createInteraction({ userId: params?.userId }); |
| vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); |
| const dispatchSpy = createDispatchSpy(); |
| await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown); |
| return { dispatchSpy, interaction }; |
| } |
|
|
| function expectNotUnauthorizedReply(interaction: MockCommandInteraction) { |
| expect(interaction.reply).not.toHaveBeenCalledWith( |
| expect.objectContaining({ content: "You are not authorized to use this command." }), |
| ); |
| } |
|
|
| function expectUnauthorizedReply(interaction: MockCommandInteraction) { |
| expect(interaction.reply).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| content: "You are not authorized to use this command.", |
| ephemeral: true, |
| }), |
| ); |
| } |
|
|
| describe("Discord native slash commands with commands.allowFrom", () => { |
| beforeEach(() => { |
| vi.restoreAllMocks(); |
| }); |
|
|
| it("authorizes guild slash commands when commands.allowFrom.discord matches the sender", async () => { |
| const { dispatchSpy, interaction } = await runGuildSlashCommand(); |
| expect(dispatchSpy).toHaveBeenCalledTimes(1); |
| expectNotUnauthorizedReply(interaction); |
| }); |
|
|
| it("authorizes guild slash commands from the global commands.allowFrom list when provider-specific allowFrom is missing", async () => { |
| const { dispatchSpy, interaction } = await runGuildSlashCommand({ |
| mutateConfig: (cfg) => { |
| cfg.commands = { |
| allowFrom: { |
| "*": ["user:123456789012345678"], |
| }, |
| }; |
| }, |
| }); |
| expect(dispatchSpy).toHaveBeenCalledTimes(1); |
| expectNotUnauthorizedReply(interaction); |
| }); |
|
|
| it("authorizes guild slash commands when commands.useAccessGroups is false and commands.allowFrom.discord matches the sender", async () => { |
| const { dispatchSpy, interaction } = await runGuildSlashCommand({ |
| mutateConfig: (cfg) => { |
| cfg.commands = { |
| ...cfg.commands, |
| useAccessGroups: false, |
| }; |
| }, |
| }); |
| expect(dispatchSpy).toHaveBeenCalledTimes(1); |
| expectNotUnauthorizedReply(interaction); |
| }); |
|
|
| it("rejects guild slash commands when commands.allowFrom.discord does not match the sender", async () => { |
| const { dispatchSpy, interaction } = await runGuildSlashCommand({ |
| userId: "999999999999999999", |
| }); |
| expect(dispatchSpy).not.toHaveBeenCalled(); |
| expectUnauthorizedReply(interaction); |
| }); |
|
|
| it("rejects guild slash commands when commands.useAccessGroups is false and commands.allowFrom.discord does not match the sender", async () => { |
| const { dispatchSpy, interaction } = await runGuildSlashCommand({ |
| userId: "999999999999999999", |
| mutateConfig: (cfg) => { |
| cfg.commands = { |
| ...cfg.commands, |
| useAccessGroups: false, |
| }; |
| }, |
| }); |
| expect(dispatchSpy).not.toHaveBeenCalled(); |
| expectUnauthorizedReply(interaction); |
| }); |
|
|
| it("uses the root discord maxLinesPerMessage when runtime discordConfig omits it", async () => { |
| const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n"); |
| const { interaction } = await runGuildSlashCommand({ |
| mutateConfig: (cfg) => { |
| cfg.channels = { |
| ...cfg.channels, |
| discord: { |
| ...cfg.channels?.discord, |
| maxLinesPerMessage: 120, |
| }, |
| }; |
| }, |
| runtimeDiscordConfig: { |
| groupPolicy: "allowlist", |
| guilds: { |
| "345678901234567890": { |
| channels: { |
| "234567890123456789": { |
| allow: true, |
| requireMention: false, |
| }, |
| }, |
| }, |
| }, |
| }, |
| }); |
|
|
| const dispatchCall = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock |
| .calls[0]?.[0] as |
| | Parameters<typeof dispatcherModule.dispatchReplyWithDispatcher>[0] |
| | undefined; |
| await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" }); |
|
|
| expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ content: longReply })); |
| expect(interaction.followUp).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|