mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 02:52:15 +00:00
fix: refresh slash command routing config (#39617)
Use the active runtime snapshot for Discord and Slack native command routing and Discord autocomplete after config hot writes. Fixes #39605 Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
// Discord tests cover native command.options plugin behavior.
|
||||
import { ApplicationCommandType, ChannelType, InteractionContextType } from "discord-api-types/v10";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { logVerboseMock } = vi.hoisted(() => ({
|
||||
logVerboseMock: vi.fn(),
|
||||
@@ -222,10 +226,15 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
logVerboseMock.mockReset();
|
||||
loggerWarnMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
it("uses autocomplete for /acp action so inline action values are accepted", async () => {
|
||||
const command = createNativeCommand("acp");
|
||||
const action = requireOption(command, "action");
|
||||
@@ -399,6 +408,78 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes autocomplete authorization and dynamic choices between invocations", async () => {
|
||||
const restoreMatchPluginCommand = nativeCommandTesting.setMatchPluginCommand((prompt) =>
|
||||
prompt === "/scope" ? ({ command: { name: "scope" }, args: "" } as never) : null,
|
||||
);
|
||||
const sourceCfg = {
|
||||
session: { dmScope: "main" },
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "disabled" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const runtimeCfg = {
|
||||
session: { dmScope: "per-channel-peer" },
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
try {
|
||||
const command = createDiscordNativeCommand({
|
||||
command: {
|
||||
name: "scope",
|
||||
description: "Scope",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "value",
|
||||
description: "Scope value",
|
||||
type: "string",
|
||||
preferAutocomplete: true,
|
||||
choices: ({ cfg }) => {
|
||||
const dmScope = cfg?.session?.dmScope ?? "missing";
|
||||
return [{ label: dmScope, value: dmScope }];
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
cfg: sourceCfg,
|
||||
discordConfig: sourceCfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
const value = requireOption(command, "value");
|
||||
const autocomplete = requireAutocomplete(
|
||||
value,
|
||||
"scope value option did not wire autocomplete",
|
||||
);
|
||||
const autocompleteParams = {
|
||||
userId: "owner",
|
||||
channelType: ChannelType.DM,
|
||||
channelId: "dm-1",
|
||||
channelName: "dm-1",
|
||||
focusedValue: "",
|
||||
} as const;
|
||||
|
||||
const blockedRespond = await runAutocomplete(autocomplete, autocompleteParams);
|
||||
expect(blockedRespond).toHaveBeenCalledWith([]);
|
||||
|
||||
setRuntimeConfigSnapshot(runtimeCfg, runtimeCfg);
|
||||
const refreshedRespond = await runAutocomplete(autocomplete, autocompleteParams);
|
||||
expect(refreshedRespond).toHaveBeenCalledWith([
|
||||
{ name: "per-channel-peer", value: "per-channel-peer" },
|
||||
]);
|
||||
} finally {
|
||||
nativeCommandTesting.setMatchPluginCommand(restoreMatchPluginCommand);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns no autocomplete choices outside the Discord allowlist when commands.useAccessGroups is false and commands.allowFrom is not configured", async () => {
|
||||
const command = createNativeCommand("think", {
|
||||
cfg: {
|
||||
|
||||
@@ -58,12 +58,13 @@ function resolveDiscordCommandLogLabel(command: ChatCommandDefinition): string {
|
||||
export function buildDiscordCommandOptions(params: {
|
||||
command: ChatCommandDefinition;
|
||||
cfg: OpenClawConfig;
|
||||
resolveConfig?: () => OpenClawConfig;
|
||||
authorizeChoiceContext?: (interaction: AutocompleteInteraction) => Promise<boolean>;
|
||||
resolveChoiceContext?: (
|
||||
interaction: AutocompleteInteraction,
|
||||
) => Promise<{ provider?: string; model?: string } | null>;
|
||||
}): CommandOptions | undefined {
|
||||
const { command, cfg, authorizeChoiceContext, resolveChoiceContext } = params;
|
||||
const { command, cfg, resolveConfig, authorizeChoiceContext, resolveChoiceContext } = params;
|
||||
const commandLabel = resolveDiscordCommandLogLabel(command);
|
||||
const args = command.args;
|
||||
if (!args || args.length === 0) {
|
||||
@@ -115,10 +116,11 @@ export function buildDiscordCommandOptions(params: {
|
||||
typeof arg.choices === "function" && resolveChoiceContext
|
||||
? await resolveChoiceContext(interaction)
|
||||
: null;
|
||||
const currentCfg = resolveConfig?.() ?? cfg;
|
||||
const choices = resolveCommandArgChoices({
|
||||
command,
|
||||
arg,
|
||||
cfg,
|
||||
cfg: currentCfg,
|
||||
provider: context?.provider,
|
||||
model: context?.model,
|
||||
});
|
||||
|
||||
@@ -14,8 +14,12 @@ import {
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defineThrowingDiscordChannelGetter } from "../test-support/partial-channel.js";
|
||||
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
|
||||
import {
|
||||
@@ -415,6 +419,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
vi.clearAllMocks();
|
||||
clearPluginCommands();
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
@@ -461,6 +466,50 @@ describe("Discord native plugin command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
it("refreshes native command routing config between invocations", async () => {
|
||||
const sourceCfg = {
|
||||
...createConfig(),
|
||||
session: { dmScope: "main" },
|
||||
} as OpenClawConfig;
|
||||
const runtimeCfg = {
|
||||
...sourceCfg,
|
||||
session: { dmScope: "per-channel-peer" },
|
||||
} as OpenClawConfig;
|
||||
const resolveRouteState = vi.fn(async (params: { cfg: OpenClawConfig }) =>
|
||||
createUnboundRouteState({
|
||||
sessionKey:
|
||||
params.cfg.session?.dmScope === "per-channel-peer"
|
||||
? "agent:main:discord:direct:owner"
|
||||
: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(
|
||||
resolveRouteState as typeof resolveDiscordNativeInteractionRouteState,
|
||||
);
|
||||
const command = await createStatusCommand(sourceCfg);
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(
|
||||
createInteraction() as unknown,
|
||||
);
|
||||
setRuntimeConfigSnapshot(runtimeCfg, runtimeCfg);
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(
|
||||
createInteraction() as unknown,
|
||||
);
|
||||
|
||||
expect(runtimeModuleMocks.resolveDirectStatusReplyForSession).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ sessionKey: "agent:main:main" }),
|
||||
);
|
||||
expect(runtimeModuleMocks.resolveDirectStatusReplyForSession).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ sessionKey: "agent:main:discord:direct:owner" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("executes plugin commands from the real registry through the native Discord command path", async () => {
|
||||
const cfg = createConfig();
|
||||
const interaction = createInteraction();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type NativeCommandSpec,
|
||||
} from "openclaw/plugin-sdk/native-command-registry";
|
||||
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import {
|
||||
@@ -112,13 +113,15 @@ export function createDiscordNativeCommand(params: {
|
||||
includeBundledChannelFallback: false,
|
||||
}) ?? fallbackCommandDefinition);
|
||||
const argDefinitions = commandDefinition.args ?? command.args;
|
||||
const resolveCurrentConfig = () => getRuntimeConfigSnapshot() ?? cfg;
|
||||
const commandOptions = buildDiscordCommandOptions({
|
||||
command: commandDefinition,
|
||||
cfg,
|
||||
resolveConfig: resolveCurrentConfig,
|
||||
authorizeChoiceContext: async (interaction) =>
|
||||
await resolveDiscordNativeAutocompleteAuthorized({
|
||||
interaction,
|
||||
cfg,
|
||||
cfg: resolveCurrentConfig(),
|
||||
discordConfig,
|
||||
accountId,
|
||||
skipCommandOwnerAllowFrom: pluginCommandMatch !== null,
|
||||
@@ -126,7 +129,7 @@ export function createDiscordNativeCommand(params: {
|
||||
resolveChoiceContext: async (interaction) =>
|
||||
resolveDiscordNativeChoiceContext({
|
||||
interaction,
|
||||
cfg,
|
||||
cfg: resolveCurrentConfig(),
|
||||
accountId,
|
||||
threadBindings,
|
||||
}),
|
||||
@@ -215,7 +218,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
prompt,
|
||||
command,
|
||||
commandArgs,
|
||||
cfg,
|
||||
cfg: inputConfig,
|
||||
discordConfig,
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
@@ -224,6 +227,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
responseEphemeral,
|
||||
suppressReplies,
|
||||
} = params;
|
||||
const cfg = getRuntimeConfigSnapshot() ?? inputConfig;
|
||||
const commandName = command.nativeName ?? command.key;
|
||||
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
|
||||
const ephemeral = options?.ephemeral ?? responseEphemeral;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Slack tests cover slash plugin behavior.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js";
|
||||
|
||||
vi.mock("./slash-commands.runtime.js", () => {
|
||||
@@ -260,9 +265,14 @@ const { registerSlackMonitorSlashCommands } = (await import("./slash.js")) as {
|
||||
const { dispatchMock } = getSlackSlashMocks();
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
resetSlackSlashMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
async function registerCommands(ctx: unknown, account: unknown, trackEvent?: () => void) {
|
||||
await registerSlackMonitorSlashCommands({
|
||||
ctx: ctx as never,
|
||||
@@ -1252,7 +1262,61 @@ describe("slack slash commands access groups", () => {
|
||||
});
|
||||
|
||||
describe("slack slash command session metadata", () => {
|
||||
const { deliverSlackSlashRepliesMock, recordSessionMetaFromInboundMock } = getSlackSlashMocks();
|
||||
const { deliverSlackSlashRepliesMock, recordSessionMetaFromInboundMock, resolveAgentRouteMock } =
|
||||
getSlackSlashMocks();
|
||||
|
||||
it("refreshes slash routing config between invocations", async () => {
|
||||
const harness = createPolicyHarness({
|
||||
channelId: "D123",
|
||||
channelName: "directmessage",
|
||||
resolveChannelName: async () => ({ name: "directmessage", type: "im" }),
|
||||
});
|
||||
const sourceCfg = (harness.ctx as { cfg: OpenClawConfig }).cfg;
|
||||
const runtimeCfg = {
|
||||
...sourceCfg,
|
||||
session: { dmScope: "per-channel-peer" },
|
||||
} as OpenClawConfig;
|
||||
resolveAgentRouteMock.mockImplementation((params: { cfg: OpenClawConfig }) => ({
|
||||
agentId: "main",
|
||||
accountId: "acct",
|
||||
sessionKey:
|
||||
params.cfg.session?.dmScope === "per-channel-peer"
|
||||
? "agent:main:slack:direct:U1"
|
||||
: "agent:main:main",
|
||||
}));
|
||||
await registerCommands(harness.ctx, harness.account);
|
||||
|
||||
await runSlashHandler({
|
||||
commands: harness.commands,
|
||||
command: {
|
||||
channel_id: harness.channelId,
|
||||
channel_name: harness.channelName,
|
||||
},
|
||||
});
|
||||
setRuntimeConfigSnapshot(runtimeCfg, runtimeCfg);
|
||||
await runSlashHandler({
|
||||
commands: harness.commands,
|
||||
command: {
|
||||
channel_id: harness.channelId,
|
||||
channel_name: harness.channelName,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({ CommandTargetSessionKey: "agent:main:main" }),
|
||||
}),
|
||||
);
|
||||
expect(dispatchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
CommandTargetSessionKey: "agent:main:slack:direct:U1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls recordSessionMetaFromInbound after dispatching a slash command", async () => {
|
||||
const harness = createPolicyHarness({ groupPolicy: "open" });
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/native-command-config-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import {
|
||||
@@ -374,7 +375,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
trackEvent?: () => void;
|
||||
}): Promise<void> {
|
||||
const { ctx, account, trackEvent } = params;
|
||||
const cfg = ctx.cfg;
|
||||
const startupCfg = ctx.cfg;
|
||||
const runtime = ctx.runtime;
|
||||
|
||||
const supportsInteractiveArgMenus =
|
||||
@@ -395,6 +396,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
commandDefinition?: ChatCommandDefinition;
|
||||
}) => {
|
||||
const { command, ack, respond, body, prompt, commandArgs, commandDefinition } = p;
|
||||
const cfg = getRuntimeConfigSnapshot() ?? ctx.cfg;
|
||||
try {
|
||||
if (ctx.shouldDropMismatchedSlackEvent?.(body)) {
|
||||
await ack();
|
||||
@@ -788,12 +790,12 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: account.config.commands?.native,
|
||||
globalSetting: cfg.commands?.native,
|
||||
globalSetting: startupCfg.commands?.native,
|
||||
});
|
||||
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: account.config.commands?.nativeSkills,
|
||||
globalSetting: cfg.commands?.nativeSkills,
|
||||
globalSetting: startupCfg.commands?.nativeSkills,
|
||||
});
|
||||
|
||||
let nativeCommands: Array<{ name: string }> = [];
|
||||
@@ -801,9 +803,9 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
if (nativeEnabled) {
|
||||
slashCommandsRuntime = await loadSlashCommandsRuntime();
|
||||
const skillCommands = nativeSkillsEnabled
|
||||
? (await loadSlashSkillCommandsRuntime()).listSkillCommandsForAgents({ cfg })
|
||||
? (await loadSlashSkillCommandsRuntime()).listSkillCommandsForAgents({ cfg: startupCfg })
|
||||
: [];
|
||||
nativeCommands = slashCommandsRuntime.listNativeCommandSpecsForConfig(cfg, {
|
||||
nativeCommands = slashCommandsRuntime.listNativeCommandSpecsForConfig(startupCfg, {
|
||||
skillCommands,
|
||||
provider: "slack",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user