mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 13:50:27 +00:00
* 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
510 lines
16 KiB
TypeScript
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");
|
|
});
|
|
});
|