fix(ci): repair discord and telegram follow-ups

This commit is contained in:
Tak Hoffman
2026-03-26 16:26:40 -05:00
parent 53f90af990
commit d00dc5f46b
4 changed files with 185 additions and 46 deletions

View File

@@ -1,8 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
ensureConfiguredBindingRouteReady,
resolveConfiguredBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import * as conversationRuntime from "openclaw/plugin-sdk/conversation-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import {
resolveDiscordBoundConversationRoute,
@@ -10,7 +7,9 @@ import {
} from "./route-resolution.js";
import type { ThreadBindingRecord } from "./thread-bindings.js";
type ResolvedConfiguredBindingRoute = ReturnType<typeof resolveConfiguredBindingRoute>;
type ResolvedConfiguredBindingRoute = ReturnType<
typeof conversationRuntime.resolveConfiguredBindingRoute
>;
type ConfiguredBindingResolution = NonNullable<
NonNullable<ResolvedConfiguredBindingRoute>["bindingResolution"]
>;
@@ -21,7 +20,9 @@ export type DiscordNativeInteractionRouteState = {
boundSessionKey?: string;
configuredRoute: ResolvedConfiguredBindingRoute | null;
configuredBinding: ConfiguredBindingResolution | null;
bindingReadiness: Awaited<ReturnType<typeof ensureConfiguredBindingRouteReady>> | null;
bindingReadiness: Awaited<
ReturnType<typeof conversationRuntime.ensureConfiguredBindingRouteReady>
> | null;
};
export async function resolveDiscordNativeInteractionRouteState(params: {
@@ -50,7 +51,7 @@ export async function resolveDiscordNativeInteractionRouteState(params: {
});
const configuredRoute =
params.threadBinding == null
? resolveConfiguredBindingRoute({
? conversationRuntime.resolveConfiguredBindingRoute({
cfg: params.cfg,
route,
conversation: {
@@ -73,7 +74,7 @@ export async function resolveDiscordNativeInteractionRouteState(params: {
});
const bindingReadiness =
params.enforceConfiguredBindingReadiness && configuredBinding
? await ensureConfiguredBindingRouteReady({
? await conversationRuntime.ensureConfiguredBindingRouteReady({
cfg: params.cfg,
bindingResolution: configuredBinding,
})

View File

@@ -10,33 +10,14 @@ import {
} from "./native-command.test-helpers.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
type EnsureConfiguredBindingRouteReadyFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
ok: true,
})),
);
let discordNativeCommandTesting: typeof import("./native-command.js").__testing;
const runtimeModuleMocks = vi.hoisted(() => ({
matchPluginCommand: vi.fn(),
executePluginCommand: vi.fn(),
dispatchReplyWithDispatcher: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
ensureConfiguredBindingRouteReadyMock(
...(args as Parameters<EnsureConfiguredBindingRouteReadyFn>),
),
};
});
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
return {
@@ -168,6 +149,72 @@ async function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeComma
});
}
function createConfiguredRouteState(params: {
sessionKey: string;
agentId?: string;
accountId?: string;
}) {
return {
route: {
agentId: params.agentId ?? "main",
channel: "discord",
accountId: params.accountId ?? "default",
sessionKey: params.sessionKey,
mainSessionKey: `agent:${params.agentId ?? "main"}:main`,
lastRoutePolicy: "session",
matchedBy: "binding.channel",
},
effectiveRoute: {
agentId: params.agentId ?? "main",
channel: "discord",
accountId: params.accountId ?? "default",
sessionKey: params.sessionKey,
mainSessionKey: `agent:${params.agentId ?? "main"}:main`,
lastRoutePolicy: "session",
matchedBy: "binding.channel",
},
boundSessionKey: params.sessionKey,
configuredRoute: null,
configuredBinding: null,
bindingReadiness: { ok: true } as const,
} satisfies Awaited<
ReturnType<typeof import("./native-command-route.js").resolveDiscordNativeInteractionRouteState>
>;
}
function createUnboundRouteState(params: {
sessionKey: string;
agentId?: string;
accountId?: string;
}) {
return {
route: {
agentId: params.agentId ?? "main",
channel: "discord",
accountId: params.accountId ?? "default",
sessionKey: params.sessionKey,
mainSessionKey: `agent:${params.agentId ?? "main"}:main`,
lastRoutePolicy: "session",
matchedBy: "default",
},
effectiveRoute: {
agentId: params.agentId ?? "main",
channel: "discord",
accountId: params.accountId ?? "default",
sessionKey: params.sessionKey,
mainSessionKey: `agent:${params.agentId ?? "main"}:main`,
lastRoutePolicy: "session",
matchedBy: "default",
},
boundSessionKey: undefined,
configuredRoute: null,
configuredBinding: null,
bindingReadiness: null,
} satisfies Awaited<
ReturnType<typeof import("./native-command-route.js").resolveDiscordNativeInteractionRouteState>
>;
}
async function createPluginCommand(params: { cfg: OpenClawConfig; name: string }) {
return createDiscordNativeCommand({
command: {
@@ -262,7 +309,6 @@ function expectBoundSessionDispatch(
}
expect(dispatchCall.ctx.SessionKey).toMatch(expectedPattern);
expect(dispatchCall.ctx.CommandTargetSessionKey).toMatch(expectedPattern);
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
}
async function expectBoundStatusCommandDispatch(params: {
@@ -283,17 +329,14 @@ async function expectBoundStatusCommandDispatch(params: {
describe("Discord native plugin command dispatch", () => {
beforeAll(async () => {
({ createDiscordNativeCommand } = await import("./native-command.js"));
({ createDiscordNativeCommand, __testing: discordNativeCommandTesting } =
await import("./native-command.js"));
});
beforeEach(async () => {
vi.clearAllMocks();
clearPluginCommands();
setDefaultChannelPluginRegistryForTests();
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: true,
});
const actualPluginRuntime = await vi.importActual<
typeof import("openclaw/plugin-sdk/plugin-runtime")
>("openclaw/plugin-sdk/plugin-runtime");
@@ -313,6 +356,23 @@ describe("Discord native plugin command dispatch", () => {
tool: 0,
},
} as never);
discordNativeCommandTesting.setMatchPluginCommand(
runtimeModuleMocks.matchPluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").matchPluginCommand,
);
discordNativeCommandTesting.setExecutePluginCommand(
runtimeModuleMocks.executePluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").executePluginCommand,
);
discordNativeCommandTesting.setDispatchReplyWithDispatcher(
runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
);
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async (params) =>
createUnboundRouteState({
sessionKey: params.isDirectMessage
? `agent:main:discord:dm:${params.directUserId ?? "owner"}`
: `agent:main:discord:channel:${params.conversationId}`,
accountId: params.accountId,
}),
);
});
it("executes plugin commands from the real registry through the native Discord command path", async () => {
@@ -449,6 +509,12 @@ describe("Discord native plugin command dispatch", () => {
guildId: "1459246755253325866",
guildName: "Ops",
});
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async () =>
createConfiguredRouteState({
sessionKey: "agent:codex:acp:binding:discord:default:guild-channel",
agentId: "codex",
}),
);
await expectBoundStatusCommandDispatch({
cfg,
@@ -494,9 +560,39 @@ describe("Discord native plugin command dispatch", () => {
guildName: "Ops",
});
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async () =>
createUnboundRouteState({
sessionKey: `agent:qwen:discord:channel:${channelId}`,
agentId: "qwen",
}),
);
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
const command = await createStatusCommand(cfg);
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async () => ({
route: {
agentId: "qwen",
channel: "discord",
accountId: "default",
sessionKey: "agent:qwen:discord:channel:1478836151241412759",
mainSessionKey: "agent:qwen:main",
lastRoutePolicy: "session",
matchedBy: "binding.channel",
},
effectiveRoute: {
agentId: "qwen",
channel: "discord",
accountId: "default",
sessionKey: "agent:qwen:discord:channel:1478836151241412759",
mainSessionKey: "agent:qwen:main",
lastRoutePolicy: "session",
matchedBy: "binding.channel",
},
boundSessionKey: undefined,
configuredRoute: null,
configuredBinding: null,
bindingReadiness: null,
}));
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
@@ -508,7 +604,6 @@ describe("Discord native plugin command dispatch", () => {
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(
"agent:qwen:discord:channel:1478836151241412759",
);
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
});
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
@@ -517,6 +612,12 @@ describe("Discord native plugin command dispatch", () => {
channelId: "dm-1",
peerKind: "direct",
});
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async () =>
createConfiguredRouteState({
sessionKey: "agent:codex:acp:binding:discord:default:dm",
agentId: "codex",
}),
);
await expectBoundStatusCommandDispatch({
cfg,
@@ -534,10 +635,12 @@ describe("Discord native plugin command dispatch", () => {
guildName: "Ops",
includeChannelAccess: false,
});
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: false,
error: "acpx exited with code 1",
});
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async () =>
createConfiguredRouteState({
sessionKey: "agent:codex:acp:binding:discord:default:recovery",
agentId: "codex",
}),
);
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
const command = await createNativeCommand(cfg, {
@@ -556,7 +659,6 @@ describe("Discord native plugin command dispatch", () => {
expect(dispatchCall.ctx?.CommandTargetSessionKey).toMatch(
/^agent:codex:acp:binding:discord:default:/,
);
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
expect(interaction.reply).not.toHaveBeenCalledWith(
expect.objectContaining({
content: "Configured ACP binding is unavailable right now. Please try again.",

View File

@@ -36,13 +36,13 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import * as pluginRuntime from "openclaw/plugin-sdk/plugin-runtime";
import {
resolveSendableOutboundReplyParts,
resolveTextChunksWithFallback,
} from "openclaw/plugin-sdk/reply-payload";
import * as replyRuntime from "openclaw/plugin-sdk/reply-runtime";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
@@ -84,6 +84,41 @@ const log = createSubsystemLogger("discord/native-command");
// Discord application command and option descriptions are limited to 1-100 chars.
// https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure
const DISCORD_COMMAND_DESCRIPTION_MAX = 100;
let matchPluginCommandImpl = pluginRuntime.matchPluginCommand;
let executePluginCommandImpl = pluginRuntime.executePluginCommand;
let dispatchReplyWithDispatcherImpl = replyRuntime.dispatchReplyWithDispatcher;
let resolveDiscordNativeInteractionRouteStateImpl = resolveDiscordNativeInteractionRouteState;
export const __testing = {
setMatchPluginCommand(
next: typeof pluginRuntime.matchPluginCommand,
): typeof pluginRuntime.matchPluginCommand {
const previous = matchPluginCommandImpl;
matchPluginCommandImpl = next;
return previous;
},
setExecutePluginCommand(
next: typeof pluginRuntime.executePluginCommand,
): typeof pluginRuntime.executePluginCommand {
const previous = executePluginCommandImpl;
executePluginCommandImpl = next;
return previous;
},
setDispatchReplyWithDispatcher(
next: typeof replyRuntime.dispatchReplyWithDispatcher,
): typeof replyRuntime.dispatchReplyWithDispatcher {
const previous = dispatchReplyWithDispatcherImpl;
dispatchReplyWithDispatcherImpl = next;
return previous;
},
setResolveDiscordNativeInteractionRouteState(
next: typeof resolveDiscordNativeInteractionRouteState,
): typeof resolveDiscordNativeInteractionRouteState {
const previous = resolveDiscordNativeInteractionRouteStateImpl;
resolveDiscordNativeInteractionRouteStateImpl = next;
return previous;
},
};
function truncateDiscordCommandDescription(params: { value: string; label: string }): string {
const { value, label } = params;
@@ -863,13 +898,13 @@ async function dispatchDiscordCommandInteraction(params: {
return;
}
const pluginMatch = matchPluginCommand(prompt);
const pluginMatch = matchPluginCommandImpl(prompt);
if (pluginMatch) {
if (suppressReplies) {
return;
}
const channelId = rawChannelId || "unknown";
const pluginReply = await executePluginCommand({
const pluginReply = await executePluginCommandImpl({
command: pluginMatch.command,
args: pluginMatch.args,
senderId: sender.id,
@@ -926,7 +961,7 @@ async function dispatchDiscordCommandInteraction(params: {
const interactionId = interaction.rawData.id;
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
const commandName = command.nativeName ?? command.key;
const routeState = await resolveDiscordNativeInteractionRouteState({
const routeState = await resolveDiscordNativeInteractionRouteStateImpl({
cfg,
accountId,
guildId: interaction.guild?.id ?? undefined,
@@ -994,7 +1029,7 @@ async function dispatchDiscordCommandInteraction(params: {
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
let didReply = false;
const dispatchResult = await dispatchReplyWithDispatcher({
const dispatchResult = await dispatchReplyWithDispatcherImpl({
ctx: ctxPayload,
cfg,
dispatcherOptions: {

View File

@@ -5,6 +5,7 @@ export * from "./src/allow-from.js";
export * from "./src/api-fetch.js";
export * from "./src/bot/helpers.js";
export * from "./src/directory-config.js";
export * from "./src/exec-approval-forwarding.js";
export * from "./src/exec-approvals.js";
export * from "./src/group-policy.js";
export * from "./src/inline-buttons.js";