From d00dc5f46bceb7b4e74ae4e28b4e5902b32d7410 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:26:40 -0500 Subject: [PATCH] fix(ci): repair discord and telegram follow-ups --- .../src/monitor/native-command-route.ts | 17 +- .../native-command.plugin-dispatch.test.ts | 166 ++++++++++++++---- .../discord/src/monitor/native-command.ts | 47 ++++- extensions/telegram/api.ts | 1 + 4 files changed, 185 insertions(+), 46 deletions(-) diff --git a/extensions/discord/src/monitor/native-command-route.ts b/extensions/discord/src/monitor/native-command-route.ts index 754f461f937..4c5966c6d28 100644 --- a/extensions/discord/src/monitor/native-command-route.ts +++ b/extensions/discord/src/monitor/native-command-route.ts @@ -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; +type ResolvedConfiguredBindingRoute = ReturnType< + typeof conversationRuntime.resolveConfiguredBindingRoute +>; type ConfiguredBindingResolution = NonNullable< NonNullable["bindingResolution"] >; @@ -21,7 +20,9 @@ export type DiscordNativeInteractionRouteState = { boundSessionKey?: string; configuredRoute: ResolvedConfiguredBindingRoute | null; configuredBinding: ConfiguredBindingResolution | null; - bindingReadiness: Awaited> | null; + bindingReadiness: Awaited< + ReturnType + > | 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, }) diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 5abfebd5637..63b8850eca6 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -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(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(); - return { - ...actual, - ensureConfiguredBindingRouteReady: (...args: unknown[]) => - ensureConfiguredBindingRouteReadyMock( - ...(args as Parameters), - ), - }; -}); - vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => { const actual = await importOriginal(); 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 + >; +} + +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 + >; +} + 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 }).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.", diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index e781ee393bf..a9f17a697a1 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -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: { diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts index 2626b26d817..29834ef5874 100644 --- a/extensions/telegram/api.ts +++ b/extensions/telegram/api.ts @@ -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";