diff --git a/extensions/discord/src/channel-actions.contract.test.ts b/extensions/discord/src/channel-actions.contract.test.ts index b4388d0f687f..03aba6e79145 100644 --- a/extensions/discord/src/channel-actions.contract.test.ts +++ b/extensions/discord/src/channel-actions.contract.test.ts @@ -1,7 +1,7 @@ // Discord tests cover channel actions.contract plugin behavior. import { installChannelActionsContractSuite } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe } from "vitest"; +import { describe, expect, it } from "vitest"; import { discordPlugin } from "../api.js"; describe("discord actions contract", () => { @@ -39,9 +39,13 @@ describe("discord actions contract", () => { }, } as OpenClawConfig, expectedActions: ["send", "poll", "react", "reactions", "emoji-list"], - expectedCanonicalOutboundActions: ["poll"], expectedCapabilities: ["presentation"], }, ], }); + + it("declines poll actions so canonical outbound delivery owns them", () => { + expect(discordPlugin.actions?.supportsAction?.({ action: "poll" })).toBe(false); + expect(discordPlugin.outbound?.sendPoll).toBeTypeOf("function"); + }); }); diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index fdbb07da81c8..2b048a103c4c 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -176,11 +176,13 @@ function describeDiscordMessageTool({ } export const discordMessageActions: ChannelMessageActionAdapter = { + providerOwnedReadGates: true, // Credential-only Discord actions run in the gateway when one is available. // Send/file-style actions stay local because core owns their thread, media, // component, and client-local payload semantics. resolveExecutionMode: resolveDiscordActionExecutionMode, describeMessageTool: describeDiscordMessageTool, + supportsAction: ({ action }) => action !== "poll", requiresTrustedRequesterSender: ({ action, toolContext }) => Boolean(toolContext) && isTrustedRequesterGuildAdminAction(action), extractToolSend: ({ args }) => { diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index b983f6f8d493..5fb1faf51608 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -116,16 +116,12 @@ async function expectDiscordStartupDelay( } function installDiscordRuntime( - discord: Record, openKeyedStore: (options: Record) => unknown = vi.fn(() => ({ lookup: vi.fn(async () => undefined), register: vi.fn(async () => undefined), })), ) { setDiscordRuntime({ - channel: { - discord, - }, logging: { shouldLogVerbose: () => false, }, @@ -167,7 +163,7 @@ afterEach(() => { beforeEach(async () => { vi.useRealTimers(); - installDiscordRuntime({}); + installDiscordRuntime(); }); beforeAll(async () => { @@ -453,12 +449,6 @@ describe("discordPlugin outbound", () => { }); it("uses direct Discord probe helpers for status probes", async () => { - const runtimeProbeDiscord = vi.fn(async () => { - throw new Error("runtime Discord probe should not be used"); - }); - installDiscordRuntime({ - probeDiscord: runtimeProbeDiscord, - }); probeDiscordMock.mockResolvedValue({ ok: true, bot: { username: "Bob" }, @@ -487,7 +477,6 @@ describe("discordPlugin outbound", () => { const forwardedTimeoutMs = Number(argAt(probeDiscordMock, 0, 1)); expect(forwardedTimeoutMs).toBeGreaterThan(0); expect(forwardedTimeoutMs).toBeLessThanOrEqual(5_000); - expect(runtimeProbeDiscord).not.toHaveBeenCalled(); }); it("subtracts lazy probe loading from the status budget", async () => { @@ -577,16 +566,6 @@ describe("discordPlugin outbound", () => { }); it("uses direct Discord startup helpers for async startup enrichment", async () => { - const runtimeProbeDiscord = vi.fn(async () => { - throw new Error("runtime Discord probe should not be used"); - }); - const runtimeMonitorDiscordProvider = vi.fn(async () => { - throw new Error("runtime Discord monitor should not be used"); - }); - installDiscordRuntime({ - probeDiscord: runtimeProbeDiscord, - monitorDiscordProvider: runtimeMonitorDiscordProvider, - }); probeDiscordMock.mockResolvedValue({ ok: true, bot: { username: "Bob" }, @@ -613,8 +592,6 @@ describe("discordPlugin outbound", () => { expect(monitorParams.token).toBe("discord-token"); expect(monitorParams.accountId).toBe("default"); expect(sleepWithAbortMock).not.toHaveBeenCalled(); - expect(runtimeProbeDiscord).not.toHaveBeenCalled(); - expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled(); }); it("fails loudly before provider startup when a token SecretRef is configured but unresolved", async () => { @@ -705,7 +682,7 @@ describe("discordPlugin outbound", () => { register: vi.fn(async () => undefined), }; const openKeyedStore = vi.fn(() => commandDeployHashStore); - installDiscordRuntime({}, openKeyedStore); + installDiscordRuntime(openKeyedStore); await startDiscordAccount(createCfg()); @@ -721,7 +698,7 @@ describe("discordPlugin outbound", () => { it("continues Discord startup when the command deployment cache cannot open", async () => { prepareDiscordStartupMocks(); - installDiscordRuntime({}, () => { + installDiscordRuntime(() => { throw new Error("SQLite unavailable"); }); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 66ced14fc9f5..25b400a0b51d 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -4,10 +4,6 @@ import { createAccountScopedAllowlistNameResolver, createNestedAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; -import type { - ChannelMessageActionAdapter, - ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; @@ -31,7 +27,7 @@ import { } from "./accounts.js"; import { getDiscordApprovalCapability } from "./approval-native.js"; import { resolveRequiredDiscordChannelPermissions } from "./audit-core.js"; -import { discordMessageActions as discordMessageActionsImpl } from "./channel-actions.js"; +import { discordMessageActions } from "./channel-actions.js"; import { buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, @@ -177,61 +173,6 @@ function shouldTreatDiscordDeliveredTextAsVisible(params: { ); } -function resolveRuntimeDiscordMessageActions() { - try { - return getDiscordRuntime().channel?.discord?.messageActions ?? null; - } catch { - return null; - } -} - -const discordMessageActions: ChannelMessageActionAdapter = { - providerOwnedReadGates: true, - resolveExecutionMode: ( - ctx: Parameters>[0], - ) => - resolveRuntimeDiscordMessageActions()?.resolveExecutionMode?.(ctx) ?? - discordMessageActionsImpl.resolveExecutionMode?.(ctx) ?? - "local", - describeMessageTool: ( - ctx: Parameters>[0], - ): ChannelMessageToolDiscovery | null => - resolveRuntimeDiscordMessageActions()?.describeMessageTool?.(ctx) ?? - discordMessageActionsImpl.describeMessageTool?.(ctx) ?? - null, - requiresTrustedRequesterSender: ( - ctx: Parameters>[0], - ) => - resolveRuntimeDiscordMessageActions()?.requiresTrustedRequesterSender?.(ctx) ?? - discordMessageActionsImpl.requiresTrustedRequesterSender?.(ctx) ?? - false, - extractToolSend: ( - ctx: Parameters>[0], - ) => - resolveRuntimeDiscordMessageActions()?.extractToolSend?.(ctx) ?? - discordMessageActionsImpl.extractToolSend?.(ctx) ?? - null, - prepareSendPayload: ( - ctx: Parameters>[0], - ) => - resolveRuntimeDiscordMessageActions()?.prepareSendPayload?.(ctx) ?? - discordMessageActionsImpl.prepareSendPayload?.(ctx) ?? - null, - supportsAction: ({ action }) => action !== "poll", - handleAction: async ( - ctx: Parameters>[0], - ) => { - const runtimeHandleAction = resolveRuntimeDiscordMessageActions()?.handleAction; - if (runtimeHandleAction) { - return await runtimeHandleAction(ctx); - } - if (!discordMessageActionsImpl.handleAction) { - throw new Error("Discord message actions not available"); - } - return await discordMessageActionsImpl.handleAction(ctx); - }, -}; - function resolveDiscordStartupDelayMs(cfg: OpenClawConfig, accountId: string): number { const startupAccountIds = listDiscordStartupAccountIds(cfg); const startupIndex = startupAccountIds.findIndex((candidateId) => candidateId === accountId); diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 20f321799a0f..dc1e54df20be 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -2,22 +2,11 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/channel-core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -type DiscordChannelRuntime = { - messageActions?: typeof import("./channel-actions.js").discordMessageActions; - sendMessageDiscord?: typeof import("./send.js").sendMessageDiscord; -}; - -type DiscordRuntime = PluginRuntime & { - channel: PluginRuntime["channel"] & { - discord?: DiscordChannelRuntime; - }; -}; - const { setRuntime: setDiscordRuntime, tryGetRuntime: getOptionalDiscordRuntime, getRuntime: getDiscordRuntime, -} = createPluginRuntimeStore({ +} = createPluginRuntimeStore({ pluginId: "discord", errorMessage: "Discord runtime not initialized", }); diff --git a/src/plugin-sdk/test-helpers/channel-contract-suites.ts b/src/plugin-sdk/test-helpers/channel-contract-suites.ts index c04357a62c48..cde04cb99a18 100644 --- a/src/plugin-sdk/test-helpers/channel-contract-suites.ts +++ b/src/plugin-sdk/test-helpers/channel-contract-suites.ts @@ -66,23 +66,12 @@ type ChannelActionsContractCase = { name: string; cfg: OpenClawConfig; expectedActions: readonly ChannelMessageActionName[]; - expectedCanonicalOutboundActions?: readonly ChannelMessageActionName[]; expectedCapabilities?: readonly ChannelMessageCapability[]; beforeTest?: () => void; }; -function hasCanonicalOutboundAction( - plugin: Pick, - action: ChannelMessageActionName, -) { - if (action !== "poll") { - return false; - } - return Boolean(plugin.outbound?.sendPoll); -} - export function installChannelActionsContractSuite(params: { - plugin: Pick; + plugin: Pick; cases: readonly ChannelActionsContractCase[]; unsupportedAction?: ChannelMessageActionName; }) { @@ -107,29 +96,14 @@ export function installChannelActionsContractSuite(params: { expect(sortStrings(actions)).toEqual(sortStrings(testCase.expectedActions)); expect(sortStrings(capabilities)).toEqual(sortStrings(testCase.expectedCapabilities ?? [])); - const canonicalOutboundActions = new Set(testCase.expectedCanonicalOutboundActions ?? []); - for (const action of canonicalOutboundActions) { - expect(actions).toContain(action); - expect(hasCanonicalOutboundAction(params.plugin, action)).toBe(true); - expect(params.plugin.actions?.supportsAction).toBeTypeOf("function"); - expect(params.plugin.actions?.supportsAction?.({ action })).toBe(false); - } - - if (params.plugin.actions?.supportsAction) { - for (const action of testCase.expectedActions) { - if (canonicalOutboundActions.has(action)) { - continue; - } - expect(params.plugin.actions.supportsAction({ action })).toBe(true); - } - if ( - params.unsupportedAction && - !testCase.expectedActions.includes(params.unsupportedAction) - ) { - expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe( - false, - ); - } + if ( + params.plugin.actions?.supportsAction && + params.unsupportedAction && + !testCase.expectedActions.includes(params.unsupportedAction) + ) { + expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe( + false, + ); } }); }