mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:28:37 +00:00
fix(discord): harden slash command routing
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
|
- Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
|
||||||
- Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.
|
- Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.
|
||||||
- Discord/Agent-scoped media roots: pass `mediaLocalRoots` through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow.
|
- Discord/Agent-scoped media roots: pass `mediaLocalRoots` through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow.
|
||||||
|
- Discord/slash command handling: intercept text-based slash commands in channels, register plugin commands as native, and send fallback acknowledgments for empty slash runs so interactions do not hang. Thanks @thewilloftheshadow.
|
||||||
- Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.
|
- Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.
|
||||||
- Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
|
- Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
|
||||||
- Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
|
- Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
|
||||||
|
|||||||
@@ -103,7 +103,11 @@ export function createInboundDebouncer<T>(params: InboundDebounceCreateParams<T>
|
|||||||
if (key && buffers.has(key)) {
|
if (key && buffers.has(key)) {
|
||||||
await flushKey(key);
|
await flushKey(key);
|
||||||
}
|
}
|
||||||
await params.onFlush([item]);
|
try {
|
||||||
|
await params.onFlush([item]);
|
||||||
|
} catch (err) {
|
||||||
|
params.onError?.(err, [item]);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,14 @@ export async function preflightDiscordMessage(
|
|||||||
const messageText = resolveDiscordMessageText(message, {
|
const messageText = resolveDiscordMessageText(message, {
|
||||||
includeForwarded: true,
|
includeForwarded: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Intercept text-only slash commands (e.g. user typing "/reset" instead of using Discord's slash command picker)
|
||||||
|
// These should not be forwarded to the agent; proper slash command interactions are handled elsewhere
|
||||||
|
if (!isDirectMessage && baseText && hasControlCommand(baseText, params.cfg)) {
|
||||||
|
logVerbose(`discord: drop text-based slash command ${message.id} (intercepted at gateway)`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
recordChannelActivity({
|
recordChannelActivity({
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
|||||||
113
src/discord/monitor/native-command.plugin-dispatch.test.ts
Normal file
113
src/discord/monitor/native-command.plugin-dispatch.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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 * as pluginCommandsModule from "../../plugins/commands.js";
|
||||||
|
import { createDiscordNativeCommand } from "./native-command.js";
|
||||||
|
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||||
|
|
||||||
|
type MockCommandInteraction = {
|
||||||
|
user: { id: string; username: string; globalName: string };
|
||||||
|
channel: { type: ChannelType; id: string };
|
||||||
|
guild: null;
|
||||||
|
rawData: { id: string; member: { roles: string[] } };
|
||||||
|
options: {
|
||||||
|
getString: ReturnType<typeof vi.fn>;
|
||||||
|
getNumber: ReturnType<typeof vi.fn>;
|
||||||
|
getBoolean: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
reply: ReturnType<typeof vi.fn>;
|
||||||
|
followUp: ReturnType<typeof vi.fn>;
|
||||||
|
client: object;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createInteraction(): MockCommandInteraction {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: "owner",
|
||||||
|
username: "tester",
|
||||||
|
globalName: "Tester",
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
type: ChannelType.DM,
|
||||||
|
id: "dm-1",
|
||||||
|
},
|
||||||
|
guild: null,
|
||||||
|
rawData: {
|
||||||
|
id: "interaction-1",
|
||||||
|
member: { roles: [] },
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
getString: vi.fn().mockReturnValue(null),
|
||||||
|
getNumber: vi.fn().mockReturnValue(null),
|
||||||
|
getBoolean: vi.fn().mockReturnValue(null),
|
||||||
|
},
|
||||||
|
reply: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
followUp: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
client: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfig(): OpenClawConfig {
|
||||||
|
return {
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
dm: { enabled: true, policy: "open" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Discord native plugin command dispatch", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes matched plugin commands directly without invoking the agent dispatcher", async () => {
|
||||||
|
const cfg = createConfig();
|
||||||
|
const commandSpec: NativeCommandSpec = {
|
||||||
|
name: "cron_jobs",
|
||||||
|
description: "List cron jobs",
|
||||||
|
acceptsArgs: false,
|
||||||
|
};
|
||||||
|
const command = createDiscordNativeCommand({
|
||||||
|
command: commandSpec,
|
||||||
|
cfg,
|
||||||
|
discordConfig: cfg.channels?.discord ?? {},
|
||||||
|
accountId: "default",
|
||||||
|
sessionPrefix: "discord:slash",
|
||||||
|
ephemeralDefault: true,
|
||||||
|
threadBindings: createNoopThreadBindingManager("default"),
|
||||||
|
});
|
||||||
|
const interaction = createInteraction();
|
||||||
|
const pluginMatch = {
|
||||||
|
command: {
|
||||||
|
name: "cron_jobs",
|
||||||
|
description: "List cron jobs",
|
||||||
|
pluginId: "cron-jobs",
|
||||||
|
acceptsArgs: false,
|
||||||
|
handler: vi.fn().mockResolvedValue({ text: "jobs" }),
|
||||||
|
},
|
||||||
|
args: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(
|
||||||
|
pluginMatch as ReturnType<typeof pluginCommandsModule.matchPluginCommand>,
|
||||||
|
);
|
||||||
|
const executeSpy = vi
|
||||||
|
.spyOn(pluginCommandsModule, "executePluginCommand")
|
||||||
|
.mockResolvedValue({ text: "direct plugin output" });
|
||||||
|
const dispatchSpy = vi
|
||||||
|
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||||
|
.mockResolvedValue({} as never);
|
||||||
|
|
||||||
|
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||||
|
|
||||||
|
expect(executeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(interaction.reply).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: "direct plugin output" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,7 @@ import { logVerbose } from "../../globals.js";
|
|||||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||||
|
import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||||
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
|
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
|
||||||
@@ -215,6 +216,19 @@ function isDiscordUnknownInteraction(error: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
|
||||||
|
if ((payload.text ?? "").trim()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ((payload.mediaUrl ?? "").trim()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (payload.mediaUrls?.some((entry) => entry.trim())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function safeDiscordInteractionCall<T>(
|
async function safeDiscordInteractionCall<T>(
|
||||||
label: string,
|
label: string,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
@@ -1455,6 +1469,46 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginMatch = matchPluginCommand(prompt);
|
||||||
|
if (pluginMatch) {
|
||||||
|
if (suppressReplies) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channelId = rawChannelId || "unknown";
|
||||||
|
const pluginReply = await executePluginCommand({
|
||||||
|
command: pluginMatch.command,
|
||||||
|
args: pluginMatch.args,
|
||||||
|
senderId: sender.id,
|
||||||
|
channel: "discord",
|
||||||
|
channelId,
|
||||||
|
isAuthorizedSender: commandAuthorized,
|
||||||
|
commandBody: prompt,
|
||||||
|
config: cfg,
|
||||||
|
from: isDirectMessage
|
||||||
|
? `discord:${user.id}`
|
||||||
|
: isGroupDm
|
||||||
|
? `discord:group:${channelId}`
|
||||||
|
: `discord:channel:${channelId}`,
|
||||||
|
to: `slash:${user.id}`,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
if (!hasRenderableReplyPayload(pluginReply)) {
|
||||||
|
await respond("Done.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deliverDiscordInteractionReply({
|
||||||
|
interaction,
|
||||||
|
payload: pluginReply,
|
||||||
|
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
|
||||||
|
fallbackLimit: 2000,
|
||||||
|
}),
|
||||||
|
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||||
|
preferFollowUp,
|
||||||
|
chunkMode: resolveChunkMode(cfg, "discord", accountId),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pickerCommandContext = shouldOpenDiscordModelPickerFromCommand({
|
const pickerCommandContext = shouldOpenDiscordModelPickerFromCommand({
|
||||||
command,
|
command,
|
||||||
commandArgs,
|
commandArgs,
|
||||||
@@ -1571,7 +1625,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
|
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
|
||||||
|
|
||||||
let didReply = false;
|
let didReply = false;
|
||||||
await dispatchReplyWithDispatcher({
|
const dispatchResult = await dispatchReplyWithDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
@@ -1616,6 +1670,29 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
onModelSelected,
|
onModelSelected,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fallback: if the agent turn produced no deliverable replies (for example,
|
||||||
|
// a skill only used message.send side effects), close the interaction with
|
||||||
|
// a minimal acknowledgment so Discord does not stay in a pending state.
|
||||||
|
if (
|
||||||
|
!suppressReplies &&
|
||||||
|
!didReply &&
|
||||||
|
dispatchResult.counts.final === 0 &&
|
||||||
|
dispatchResult.counts.block === 0 &&
|
||||||
|
dispatchResult.counts.tool === 0
|
||||||
|
) {
|
||||||
|
await safeDiscordInteractionCall("interaction empty fallback", async () => {
|
||||||
|
const payload = {
|
||||||
|
content: "✅ Done.",
|
||||||
|
ephemeral: true,
|
||||||
|
};
|
||||||
|
if (preferFollowUp) {
|
||||||
|
await interaction.followUp(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deliverDiscordInteractionReply(params: {
|
async function deliverDiscordInteractionReply(params: {
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
|
|
||||||
|
type NativeCommandSpecMock = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
acceptsArgs: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginCommandSpecMock = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
acceptsArgs: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
clientFetchUserMock,
|
clientFetchUserMock,
|
||||||
clientGetPluginMock,
|
clientGetPluginMock,
|
||||||
@@ -13,6 +25,7 @@ const {
|
|||||||
createThreadBindingManagerMock,
|
createThreadBindingManagerMock,
|
||||||
reconcileAcpThreadBindingsOnStartupMock,
|
reconcileAcpThreadBindingsOnStartupMock,
|
||||||
createdBindingManagers,
|
createdBindingManagers,
|
||||||
|
getPluginCommandSpecsMock,
|
||||||
listNativeCommandSpecsForConfigMock,
|
listNativeCommandSpecsForConfigMock,
|
||||||
listSkillCommandsForAgentsMock,
|
listSkillCommandsForAgentsMock,
|
||||||
monitorLifecycleMock,
|
monitorLifecycleMock,
|
||||||
@@ -50,7 +63,10 @@ const {
|
|||||||
staleSessionKeys: [],
|
staleSessionKeys: [],
|
||||||
})),
|
})),
|
||||||
createdBindingManagers,
|
createdBindingManagers,
|
||||||
listNativeCommandSpecsForConfigMock: vi.fn(() => [{ name: "cmd" }]),
|
getPluginCommandSpecsMock: vi.fn<() => PluginCommandSpecMock[]>(() => []),
|
||||||
|
listNativeCommandSpecsForConfigMock: vi.fn<() => NativeCommandSpecMock[]>(() => [
|
||||||
|
{ name: "cmd", description: "built-in", acceptsArgs: false },
|
||||||
|
]),
|
||||||
listSkillCommandsForAgentsMock: vi.fn(() => []),
|
listSkillCommandsForAgentsMock: vi.fn(() => []),
|
||||||
monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => {
|
monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => {
|
||||||
params.threadBindings.stop();
|
params.threadBindings.stop();
|
||||||
@@ -148,6 +164,10 @@ vi.mock("../../logging/subsystem.js", () => ({
|
|||||||
createSubsystemLogger: () => ({ info: vi.fn(), error: vi.fn() }),
|
createSubsystemLogger: () => ({ info: vi.fn(), error: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/commands.js", () => ({
|
||||||
|
getPluginCommandSpecs: getPluginCommandSpecsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../runtime.js", () => ({
|
vi.mock("../../runtime.js", () => ({
|
||||||
createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }),
|
createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
@@ -298,7 +318,10 @@ describe("monitorDiscordProvider", () => {
|
|||||||
staleSessionKeys: [],
|
staleSessionKeys: [],
|
||||||
});
|
});
|
||||||
createdBindingManagers.length = 0;
|
createdBindingManagers.length = 0;
|
||||||
listNativeCommandSpecsForConfigMock.mockClear().mockReturnValue([{ name: "cmd" }]);
|
getPluginCommandSpecsMock.mockClear().mockReturnValue([]);
|
||||||
|
listNativeCommandSpecsForConfigMock
|
||||||
|
.mockClear()
|
||||||
|
.mockReturnValue([{ name: "cmd", description: "built-in", acceptsArgs: false }]);
|
||||||
listSkillCommandsForAgentsMock.mockClear().mockReturnValue([]);
|
listSkillCommandsForAgentsMock.mockClear().mockReturnValue([]);
|
||||||
monitorLifecycleMock.mockClear().mockImplementation(async (params) => {
|
monitorLifecycleMock.mockClear().mockImplementation(async (params) => {
|
||||||
params.threadBindings.stop();
|
params.threadBindings.stop();
|
||||||
@@ -405,6 +428,27 @@ describe("monitorDiscordProvider", () => {
|
|||||||
expect(eventQueue?.listenerTimeout).toBe(300_000);
|
expect(eventQueue?.listenerTimeout).toBe(300_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers plugin commands as native Discord commands", async () => {
|
||||||
|
const { monitorDiscordProvider } = await import("./provider.js");
|
||||||
|
listNativeCommandSpecsForConfigMock.mockReturnValue([
|
||||||
|
{ name: "cmd", description: "built-in", acceptsArgs: false },
|
||||||
|
]);
|
||||||
|
getPluginCommandSpecsMock.mockReturnValue([
|
||||||
|
{ name: "cron_jobs", description: "List cron jobs", acceptsArgs: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await monitorDiscordProvider({
|
||||||
|
config: baseConfig(),
|
||||||
|
runtime: baseRuntime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandNames = (createDiscordNativeCommandMock.mock.calls as Array<unknown[]>)
|
||||||
|
.map((call) => (call[0] as { command?: { name?: string } } | undefined)?.command?.name)
|
||||||
|
.filter((value): value is string => typeof value === "string");
|
||||||
|
expect(commandNames).toContain("cmd");
|
||||||
|
expect(commandNames).toContain("cron_jobs");
|
||||||
|
});
|
||||||
|
|
||||||
it("reports connected status on startup and shutdown", async () => {
|
it("reports connected status on startup and shutdown", async () => {
|
||||||
const { monitorDiscordProvider } = await import("./provider.js");
|
const { monitorDiscordProvider } = await import("./provider.js");
|
||||||
const setStatus = vi.fn();
|
const setStatus = vi.fn();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { GatewayCloseCodes, type GatewayPlugin } from "@buape/carbon/gateway";
|
|||||||
import { VoicePlugin } from "@buape/carbon/voice";
|
import { VoicePlugin } from "@buape/carbon/voice";
|
||||||
import { Routes } from "discord-api-types/v10";
|
import { Routes } from "discord-api-types/v10";
|
||||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||||
|
import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js";
|
||||||
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
|
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
|
||||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||||
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
||||||
@@ -37,6 +38,7 @@ import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
|
|||||||
import { formatErrorMessage } from "../../infra/errors.js";
|
import { formatErrorMessage } from "../../infra/errors.js";
|
||||||
import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
|
import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
|
||||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
import { getPluginCommandSpecs } from "../../plugins/commands.js";
|
||||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
|
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||||
import { resolveDiscordAccount } from "../accounts.js";
|
import { resolveDiscordAccount } from "../accounts.js";
|
||||||
import { fetchDiscordApplicationId } from "../probe.js";
|
import { fetchDiscordApplicationId } from "../probe.js";
|
||||||
@@ -141,6 +143,37 @@ function dedupeSkillCommandsForDiscord(
|
|||||||
return deduped;
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendPluginCommandSpecs(params: {
|
||||||
|
commandSpecs: NativeCommandSpec[];
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
}): NativeCommandSpec[] {
|
||||||
|
const merged = [...params.commandSpecs];
|
||||||
|
const existingNames = new Set(
|
||||||
|
merged.map((spec) => spec.name.trim().toLowerCase()).filter(Boolean),
|
||||||
|
);
|
||||||
|
for (const pluginCommand of getPluginCommandSpecs()) {
|
||||||
|
const normalizedName = pluginCommand.name.trim().toLowerCase();
|
||||||
|
if (!normalizedName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existingNames.has(normalizedName)) {
|
||||||
|
params.runtime.error?.(
|
||||||
|
danger(
|
||||||
|
`discord: plugin command "/${normalizedName}" duplicates an existing native command. Skipping.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existingNames.add(normalizedName);
|
||||||
|
merged.push({
|
||||||
|
name: pluginCommand.name,
|
||||||
|
description: pluginCommand.description,
|
||||||
|
acceptsArgs: pluginCommand.acceptsArgs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
async function deployDiscordCommands(params: {
|
async function deployDiscordCommands(params: {
|
||||||
client: Client;
|
client: Client;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
@@ -317,10 +350,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
let commandSpecs = nativeEnabled
|
let commandSpecs = nativeEnabled
|
||||||
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "discord" })
|
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "discord" })
|
||||||
: [];
|
: [];
|
||||||
|
if (nativeEnabled) {
|
||||||
|
commandSpecs = appendPluginCommandSpecs({ commandSpecs, runtime });
|
||||||
|
}
|
||||||
const initialCommandCount = commandSpecs.length;
|
const initialCommandCount = commandSpecs.length;
|
||||||
if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) {
|
if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) {
|
||||||
skillCommands = [];
|
skillCommands = [];
|
||||||
commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [], provider: "discord" });
|
commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [], provider: "discord" });
|
||||||
|
commandSpecs = appendPluginCommandSpecs({ commandSpecs, runtime });
|
||||||
runtime.log?.(
|
runtime.log?.(
|
||||||
warn(
|
warn(
|
||||||
`discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`,
|
`discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`,
|
||||||
|
|||||||
@@ -322,9 +322,11 @@ export function listPluginCommands(): Array<{
|
|||||||
export function getPluginCommandSpecs(): Array<{
|
export function getPluginCommandSpecs(): Array<{
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
acceptsArgs: boolean;
|
||||||
}> {
|
}> {
|
||||||
return Array.from(pluginCommands.values()).map((cmd) => ({
|
return Array.from(pluginCommands.values()).map((cmd) => ({
|
||||||
name: cmd.name,
|
name: cmd.name,
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
|
acceptsArgs: cmd.acceptsArgs ?? false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user