Files
openclaw/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts
scoootscooob 5682ec37fa refactor: move Discord channel implementation to extensions/ (#45660)
* refactor: move Discord channel implementation to extensions/discord/src/

Move all Discord source files from src/discord/ to extensions/discord/src/,
following the extension migration pattern. Source files in src/discord/ are
replaced with re-export shims. Channel-plugin files from
src/channels/plugins/*/discord* are similarly moved and shimmed.

- Copy all .ts source files preserving subdirectory structure (monitor/, voice/)
- Move channel-plugin files (actions, normalize, onboarding, outbound, status-issues)
- Fix all relative imports to use correct paths from new location
- Create re-export shims at original locations for backward compatibility
- Delete test files from shim locations (tests live in extension now)
- Update tsconfig.plugin-sdk.dts.json rootDir from "src" to "." to accommodate
  extension files outside src/
- Update write-plugin-sdk-entry-dts.ts to match new declaration output paths

* fix: add importOriginal to thread-bindings session-meta mock for extensions test

* style: fix formatting in thread-bindings lifecycle test
2026-03-14 02:53:57 -07:00

510 lines
16 KiB
TypeScript

import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createReplyDispatcherWithTyping } from "../../../src/auto-reply/reply/reply-dispatcher.js";
import {
dispatchMock,
readAllowFromStoreMock,
sendMock,
updateLastRouteMock,
upsertPairingRequestMock,
} from "./monitor.tool-result.test-harness.js";
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js";
const loadConfigMock = vi.fn();
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
return {
...actual,
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
};
});
beforeEach(() => {
vi.useRealTimers();
sendMock.mockClear().mockResolvedValue(undefined);
updateLastRouteMock.mockClear();
dispatchMock.mockClear().mockImplementation(async (params: unknown) => {
if (
typeof params === "object" &&
params !== null &&
"dispatcher" in params &&
typeof params.dispatcher === "object" &&
params.dispatcher !== null &&
"sendFinalReply" in params.dispatcher &&
typeof params.dispatcher.sendFinalReply === "function"
) {
params.dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
}
if (
typeof params === "object" &&
params !== null &&
"dispatcherOptions" in params &&
params.dispatcherOptions
) {
const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions as Parameters<typeof createReplyDispatcherWithTyping>[0],
);
dispatcher.sendFinalReply({ text: "final reply" });
await dispatcher.waitForIdle();
markDispatchIdle();
return { queuedFinal: true, counts: dispatcher.getQueuedCounts() };
}
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
});
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true });
loadConfigMock.mockClear().mockReturnValue({});
__resetDiscordChannelInfoCacheForTest();
});
const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000;
type LoadedConfig = ReturnType<(typeof import("../../../src/config/config.js"))["loadConfig"]>;
let createDiscordMessageHandler: typeof import("./monitor.js").createDiscordMessageHandler;
let createDiscordNativeCommand: typeof import("./monitor.js").createDiscordNativeCommand;
beforeAll(async () => {
({ createDiscordMessageHandler, createDiscordNativeCommand } = await import("./monitor.js"));
});
function makeRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
}
async function createHandler(cfg: LoadedConfig) {
return createDiscordMessageHandler({
cfg,
discordConfig: cfg.channels?.discord,
accountId: "default",
token: "token",
runtime: makeRuntime(),
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: cfg.channels?.discord?.guilds,
threadBindings: createNoopThreadBindingManager("default"),
});
}
function captureNextDispatchCtx<
T extends {
SessionKey?: string;
ParentSessionKey?: string;
ThreadStarterBody?: string;
ThreadLabel?: string;
},
>(): () => T | undefined {
let capturedCtx: T | undefined;
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
capturedCtx = ctx as T;
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
return () => capturedCtx;
}
function createDefaultThreadConfig(): LoadedConfig {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/openclaw",
},
},
session: { store: "/tmp/openclaw-sessions.json" },
messages: { responsePrefix: "PFX" },
channels: {
discord: {
dm: { enabled: true, policy: "open" },
groupPolicy: "open",
guilds: { "*": { requireMention: false } },
},
},
} as LoadedConfig;
}
function createGuildChannelPolicyConfig(requireMention: boolean) {
return {
dm: { enabled: true, policy: "open" as const },
groupPolicy: "open" as const,
guilds: { "*": { requireMention } },
};
}
function createMentionRequiredGuildConfig(
params: {
messages?: LoadedConfig["messages"];
} = {},
): LoadedConfig {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/openclaw",
},
},
session: { store: "/tmp/openclaw-sessions.json" },
channels: { discord: createGuildChannelPolicyConfig(true) },
...(params.messages ? { messages: params.messages } : {}),
} as LoadedConfig;
}
function createGuildTextClient() {
return {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
}),
} as unknown as Client;
}
function createGuildMessageEvent(params: {
messageId: string;
content: string;
messagePatch?: Record<string, unknown>;
eventPatch?: Record<string, unknown>;
}) {
const messageBase = createDiscordMessageMeta();
return {
message: {
id: params.messageId,
content: params.content,
channelId: "c1",
...messageBase,
author: { id: "u1", bot: false, username: "Ada" },
...params.messagePatch,
},
author: { id: "u1", bot: false, username: "Ada" },
member: { nickname: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
...params.eventPatch,
};
}
function createDiscordMessageMeta() {
return {
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
};
}
function createThreadChannel(params: { includeStarter?: boolean } = {}) {
return {
type: ChannelType.GuildText,
name: "thread-name",
parentId: "p1",
parent: { id: "p1", name: "general" },
isThread: () => true,
...(params.includeStarter
? {
fetchStarterMessage: async () => ({
content: "starter message",
author: { tag: "Alice#1", username: "Alice" },
createdTimestamp: Date.now(),
}),
}
: {}),
};
}
function createThreadClient(
params: {
fetchChannel?: ReturnType<typeof vi.fn>;
restGet?: ReturnType<typeof vi.fn>;
} = {},
) {
return {
fetchChannel:
params.fetchChannel ??
vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "thread-name",
}),
rest: {
get:
params.restGet ??
vi.fn().mockResolvedValue({
content: "starter message",
author: { id: "u1", username: "Alice", discriminator: "0001" },
timestamp: new Date().toISOString(),
}),
},
} as unknown as Client;
}
function createThreadEvent(messageId: string, channel?: unknown) {
const messageBase = createDiscordMessageMeta();
return {
message: {
id: messageId,
content: "thread reply",
channelId: "t1",
channel,
...messageBase,
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
},
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
member: { displayName: "Bob" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
};
}
function captureThreadDispatchCtx() {
return captureNextDispatchCtx<{
SessionKey?: string;
ParentSessionKey?: string;
ThreadStarterBody?: string;
ThreadLabel?: string;
}>();
}
describe("discord tool result dispatch", () => {
it(
"accepts guild messages when mentionPatterns match",
async () => {
const cfg = createMentionRequiredGuildConfig({
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
},
});
const handler = await createHandler(cfg);
const client = createGuildTextClient();
await handler(
createGuildMessageEvent({ messageId: "m2", content: "openclaw: hello" }),
client,
);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledTimes(1);
},
MENTION_PATTERNS_TEST_TIMEOUT_MS,
);
it(
"skips tool results for native slash commands",
{ timeout: MENTION_PATTERNS_TEST_TIMEOUT_MS },
async () => {
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
humanDelay: { mode: "off" },
workspace: "/tmp/openclaw",
},
},
session: { store: "/tmp/openclaw-sessions.json" },
channels: {
discord: { dm: { enabled: true, policy: "open" } },
},
} as ReturnType<typeof import("../../../src/config/config.js").loadConfig>;
const command = createDiscordNativeCommand({
command: {
name: "verbose",
description: "Toggle verbose mode.",
acceptsArgs: true,
},
cfg,
discordConfig: cfg.channels!.discord!,
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const reply = vi.fn().mockResolvedValue(undefined);
const followUp = vi.fn().mockResolvedValue(undefined);
const interaction = {
user: { id: "u1", username: "Ada", globalName: "Ada" },
channel: { type: ChannelType.DM },
guild: null,
rawData: { id: "i1" },
options: { getString: vi.fn().mockReturnValue("on") },
reply,
followUp,
} as unknown as Parameters<typeof command.run>[0];
await command.run(interaction);
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(reply).toHaveBeenCalledTimes(1);
expect(followUp).toHaveBeenCalledTimes(0);
expect(reply.mock.calls[0]?.[0]?.content).toContain("final");
},
);
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
const cfg = createMentionRequiredGuildConfig();
const handler = await createHandler(cfg);
const client = createGuildTextClient();
await handler(
createGuildMessageEvent({
messageId: "m3",
content: "following up",
messagePatch: {
referencedMessage: {
id: "m2",
channelId: "c1",
content: "bot reply",
...createDiscordMessageMeta(),
author: { id: "bot-id", bot: true, username: "OpenClaw" },
},
},
eventPatch: {
channel: { id: "c1", type: ChannelType.GuildText },
client,
data: {
id: "m3",
content: "following up",
channel_id: "c1",
guild_id: "g1",
type: MessageType.Default,
mentions: [],
},
},
}),
client,
);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
expect(dispatchMock).toHaveBeenCalledTimes(1);
const payload = dispatchMock.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
expect(payload.WasMentioned).toBe(true);
});
it("forks thread sessions and injects starter context", async () => {
const getCapturedCtx = captureThreadDispatchCtx();
const cfg = createDefaultThreadConfig();
const handler = await createHandler(cfg);
const threadChannel = createThreadChannel({ includeStarter: true });
const client = createThreadClient();
await handler(createThreadEvent("m4", threadChannel), client);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
const capturedCtx = getCapturedCtx();
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:p1");
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
});
it("skips thread starter context when disabled", async () => {
const getCapturedCtx = captureNextDispatchCtx<{ ThreadStarterBody?: string }>();
const cfg = {
...createDefaultThreadConfig(),
channels: {
discord: {
dm: { enabled: true, policy: "open" },
groupPolicy: "open",
guilds: {
"*": {
requireMention: false,
channels: {
"*": { includeThreadStarter: false },
},
},
},
},
},
} as LoadedConfig;
const handler = await createHandler(cfg);
const threadChannel = createThreadChannel();
const client = createThreadClient();
await handler(createThreadEvent("m7", threadChannel), client);
const capturedCtx = getCapturedCtx();
expect(capturedCtx?.ThreadStarterBody).toBeUndefined();
});
it("treats forum threads as distinct sessions without channel payloads", async () => {
const getCapturedCtx = captureThreadDispatchCtx();
const cfg = {
...createDefaultThreadConfig(),
routing: { allowFrom: [] },
} as ReturnType<typeof import("../../../src/config/config.js").loadConfig>;
const handler = await createHandler(cfg);
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({
type: ChannelType.PublicThread,
name: "topic-1",
parentId: "forum-1",
})
.mockResolvedValueOnce({
type: ChannelType.GuildForum,
name: "support",
});
const restGet = vi.fn().mockResolvedValue({
content: "starter message",
author: { id: "u1", username: "Alice", discriminator: "0001" },
timestamp: new Date().toISOString(),
});
const client = createThreadClient({ fetchChannel, restGet });
await handler(createThreadEvent("m6"), client);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
const capturedCtx = getCapturedCtx();
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:forum-1");
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support");
expect(restGet).toHaveBeenCalledWith(Routes.channelMessage("t1", "t1"));
});
it("scopes thread sessions to the routed agent", async () => {
const getCapturedCtx = captureNextDispatchCtx<{
SessionKey?: string;
ParentSessionKey?: string;
}>();
const cfg = {
...createDefaultThreadConfig(),
bindings: [{ agentId: "support", match: { channel: "discord", guildId: "g1" } }],
} as LoadedConfig;
loadConfigMock.mockReturnValue(cfg);
const handler = await createHandler(cfg);
const threadChannel = createThreadChannel();
const client = createThreadClient();
await handler(createThreadEvent("m5", threadChannel), client);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
const capturedCtx = getCapturedCtx();
expect(capturedCtx?.SessionKey).toBe("agent:support:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe("agent:support:discord:channel:p1");
});
});