From 0a90b07f8d0ccd339ec86ce62de1bc8f23d26f8c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 20:15:02 -0700 Subject: [PATCH 01/21] Agents: honor workspace Anthropic provider capabilities --- .../pi-embedded-runner-extraparams.test.ts | 77 +++++++++++++++ .../anthropic-stream-wrappers.ts | 49 +++++++--- src/agents/pi-embedded-runner/extra-params.ts | 6 +- src/agents/pi-embedded-runner/run/attempt.ts | 1 + src/agents/provider-capabilities.test.ts | 18 ++++ src/agents/provider-capabilities.ts | 93 ++++++++++++++----- 6 files changed, 210 insertions(+), 34 deletions(-) diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 9b22c59b594..b597b0b19a0 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -2,6 +2,21 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +const resolveProviderCapabilitiesWithPluginMock = vi.fn( + (params: { provider: string; workspaceDir?: string }) => { + if ( + params.provider === "workspace-anthropic-proxy" && + params.workspaceDir === "/tmp/workspace-capabilities" + ) { + return { + anthropicToolSchemaMode: "openai-functions", + anthropicToolChoiceMode: "openai-string-modes", + }; + } + return undefined; + }, +); + vi.mock("../plugins/provider-runtime.js", async (importOriginal) => { const actual = await importOriginal(); const { @@ -65,6 +80,10 @@ vi.mock("../plugins/provider-runtime.js", async (importOriginal) => { const thinkingLevel = skipReasoningInjection ? undefined : params.context.thinkingLevel; return createOpenRouterSystemCacheWrapper(createOpenRouterWrapper(streamFn, thinkingLevel)); }, + resolveProviderCapabilitiesWithPlugin: (params: { + provider: string; + workspaceDir?: string; + }) => resolveProviderCapabilitiesWithPluginMock(params), }; }); @@ -1047,6 +1066,64 @@ describe("applyExtraParamsToAgent", () => { ]); }); + it("uses workspace plugin capability metadata for anthropic tool payload normalization", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + tools: [ + { + name: "read", + description: "Read file", + input_schema: { type: "object", properties: {} }, + }, + ], + tool_choice: { type: "any" }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + { plugins: { enabled: true } }, + "workspace-anthropic-proxy", + "proxy-model", + undefined, + "low", + undefined, + "/tmp/workspace-capabilities", + ); + + const model = { + api: "anthropic-messages", + provider: "workspace-anthropic-proxy", + id: "proxy-model", + } as Model<"anthropic-messages">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.tools).toEqual([ + { + type: "function", + function: { + name: "read", + description: "Read file", + parameters: { type: "object", properties: {} }, + }, + }, + ]); + expect(payloads[0]?.tool_choice).toBe("required"); + expect(resolveProviderCapabilitiesWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "workspace-anthropic-proxy", + workspaceDir: "/tmp/workspace-capabilities", + }), + ); + }); + it("removes invalid negative Google thinkingBudget and maps Gemini 3.1 to thinkingLevel", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { diff --git a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts index e04de8a5d6b..83a5be8f783 100644 --- a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts @@ -1,7 +1,9 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { streamSimple } from "@mariozechner/pi-ai"; +import type { OpenClawConfig } from "../../config/config.js"; import { resolveFastModeParam } from "../fast-mode.js"; import { + type ProviderCapabilityLookupOptions, requiresOpenAiCompatibleAnthropicToolPayload, usesOpenAiFunctionAnthropicToolSchema, usesOpenAiStringModeAnthropicToolChoice, @@ -23,6 +25,7 @@ const PI_AI_OAUTH_ANTHROPIC_BETAS = [ type AnthropicServiceTier = "auto" | "standard_only"; type CacheRetention = "none" | "short" | "long"; +type AnthropicToolPayloadResolverOptions = ProviderCapabilityLookupOptions; function isAnthropic1MModel(modelId: string): boolean { const normalized = modelId.trim().toLowerCase(); @@ -90,14 +93,14 @@ function requiresAnthropicToolPayloadCompatibilityForModel(model: { api?: unknown; provider?: unknown; compat?: unknown; -}): boolean { +}, options?: AnthropicToolPayloadResolverOptions): boolean { if (model.api !== "anthropic-messages") { return false; } if ( typeof model.provider === "string" && - requiresOpenAiCompatibleAnthropicToolPayload(model.provider) + requiresOpenAiCompatibleAnthropicToolPayload(model.provider, options) ) { return true; } @@ -107,8 +110,11 @@ function requiresAnthropicToolPayloadCompatibilityForModel(model: { function usesOpenAiFunctionAnthropicToolSchemaForModel(model: { provider?: unknown; compat?: unknown; -}): boolean { - if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) { +}, options?: AnthropicToolPayloadResolverOptions): boolean { + if ( + typeof model.provider === "string" && + usesOpenAiFunctionAnthropicToolSchema(model.provider, options) + ) { return true; } return hasOpenAiAnthropicToolPayloadCompatFlag(model); @@ -117,10 +123,10 @@ function usesOpenAiFunctionAnthropicToolSchemaForModel(model: { function usesOpenAiStringModeAnthropicToolChoiceForModel(model: { provider?: unknown; compat?: unknown; -}): boolean { +}, options?: AnthropicToolPayloadResolverOptions): boolean { if ( typeof model.provider === "string" && - usesOpenAiStringModeAnthropicToolChoice(model.provider) + usesOpenAiStringModeAnthropicToolChoice(model.provider, options) ) { return true; } @@ -284,28 +290,47 @@ export function createAnthropicBetaHeadersWrapper( export function createAnthropicToolPayloadCompatibilityWrapper( baseStreamFn: StreamFn | undefined, + resolverOptions?: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + }, ): StreamFn { const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => { - const originalOnPayload = options?.onPayload; + return (model, context, streamOptions) => { + const originalOnPayload = streamOptions?.onPayload; return underlying(model, context, { - ...options, + ...streamOptions, onPayload: (payload) => { if ( payload && typeof payload === "object" && - requiresAnthropicToolPayloadCompatibilityForModel(model) + requiresAnthropicToolPayloadCompatibilityForModel(model, { + config: resolverOptions?.config, + workspaceDir: resolverOptions?.workspaceDir, + env: resolverOptions?.env, + }) ) { const payloadObj = payload as Record; if ( Array.isArray(payloadObj.tools) && - usesOpenAiFunctionAnthropicToolSchemaForModel(model) + usesOpenAiFunctionAnthropicToolSchemaForModel(model, { + config: resolverOptions?.config, + workspaceDir: resolverOptions?.workspaceDir, + env: resolverOptions?.env, + }) ) { payloadObj.tools = payloadObj.tools .map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool)) .filter((tool): tool is Record => !!tool); } - if (usesOpenAiStringModeAnthropicToolChoiceForModel(model)) { + if ( + usesOpenAiStringModeAnthropicToolChoiceForModel(model, { + config: resolverOptions?.config, + workspaceDir: resolverOptions?.workspaceDir, + env: resolverOptions?.env, + }) + ) { payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice( payloadObj.tool_choice, ); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index e3aa8b1dbcc..3008fd97904 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -277,6 +277,7 @@ export function applyExtraParamsToAgent( extraParamsOverride?: Record, thinkingLevel?: ThinkLevel, agentId?: string, + workspaceDir?: string, ): void { const resolvedExtraParams = resolveExtraParams({ cfg, @@ -337,7 +338,10 @@ export function applyExtraParamsToAgent( agent.streamFn = createSiliconFlowThinkingWrapper(agent.streamFn); } - agent.streamFn = createAnthropicToolPayloadCompatibilityWrapper(agent.streamFn); + agent.streamFn = createAnthropicToolPayloadCompatibilityWrapper(agent.streamFn, { + config: cfg, + workspaceDir, + }); const providerStreamBase = agent.streamFn; const pluginWrappedStreamFn = wrapProviderStreamFn({ provider, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 9a46beca5d2..6f8f2f48a32 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1987,6 +1987,7 @@ export async function runEmbeddedAttempt( }, params.thinkLevel, sessionAgentId, + effectiveWorkspace, ); if (cacheTrace) { diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index fa3b12b8d4d..1712f6f810e 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -156,4 +156,22 @@ describe("resolveProviderCapabilities", () => { }), ).toBe(true); }); + + it("forwards config and workspace context to plugin capability lookup", () => { + const config = { plugins: { enabled: true } }; + const env = { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; + + resolveProviderCapabilities("anthropic", { + config, + workspaceDir: "/tmp/workspace", + env, + }); + + expect(resolveProviderCapabilitiesWithPluginMock).toHaveBeenLastCalledWith({ + provider: "anthropic", + config, + workspaceDir: "/tmp/workspace", + env, + }); + }); }); diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index dab9fa8d812..0539faf31b8 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -1,5 +1,6 @@ import { resolveProviderCapabilitiesWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeProviderId } from "./model-selection.js"; +import type { OpenClawConfig } from "../config/config.js"; export type ProviderCapabilities = { anthropicToolSchemaMode: "native" | "openai-functions"; @@ -14,6 +15,12 @@ export type ProviderCapabilities = { dropThinkingBlockModelHints: string[]; }; +export type ProviderCapabilityLookupOptions = { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}; + const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", @@ -66,10 +73,18 @@ const PLUGIN_CAPABILITIES_FALLBACKS: Record normalized.includes(hint)); } -export function isOpenAiProviderFamily(provider?: string | null): boolean { - return resolveProviderCapabilities(provider).providerFamily === "openai"; +export function isOpenAiProviderFamily( + provider?: string | null, + options?: ProviderCapabilityLookupOptions, +): boolean { + return resolveProviderCapabilities(provider, options).providerFamily === "openai"; } -export function isAnthropicProviderFamily(provider?: string | null): boolean { - return resolveProviderCapabilities(provider).providerFamily === "anthropic"; +export function isAnthropicProviderFamily( + provider?: string | null, + options?: ProviderCapabilityLookupOptions, +): boolean { + return resolveProviderCapabilities(provider, options).providerFamily === "anthropic"; } export function shouldDropThinkingBlocksForModel(params: { provider?: string | null; modelId?: string | null; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; }): boolean { return modelIncludesAnyHint( params.modelId, - resolveProviderCapabilities(params.provider).dropThinkingBlockModelHints, + resolveProviderCapabilities(params.provider, params).dropThinkingBlockModelHints, ); } export function shouldSanitizeGeminiThoughtSignaturesForModel(params: { provider?: string | null; modelId?: string | null; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; }): boolean { - const capabilities = resolveProviderCapabilities(params.provider); + const capabilities = resolveProviderCapabilities(params.provider, params); return ( capabilities.geminiThoughtSignatureSanitization && modelIncludesAnyHint(params.modelId, capabilities.geminiThoughtSignatureModelHints) @@ -143,8 +193,9 @@ export function shouldSanitizeGeminiThoughtSignaturesForModel(params: { export function resolveTranscriptToolCallIdMode( provider?: string | null, modelId?: string | null, + options?: ProviderCapabilityLookupOptions, ): "strict9" | undefined { - const capabilities = resolveProviderCapabilities(provider); + const capabilities = resolveProviderCapabilities(provider, options); const mode = capabilities.transcriptToolCallIdMode; if (mode === "strict9") { return mode; From 56066dccb0861bed3b79af412c49a8ed9e886123 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:18:42 -0500 Subject: [PATCH 02/21] docs(ui): harden legacy query token guidance (#49053) --- docs/web/control-ui.md | 2 +- ui/src/ui/app-settings.test.ts | 12 ++++++++++++ ui/src/ui/app-settings.ts | 3 +++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9e156bb339a..952f6f71c1d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -242,7 +242,7 @@ http://localhost:5173/?gatewayUrl=wss://:18789#token= { expect(window.location.search).toBe(""); }); + it("prefers fragment tokens over legacy query tokens when both are present", () => { + setTestWindowUrl("https://control.example/ui/overview?token=query-token#token=hash-token"); + const host = createHost("overview"); + host.settings.gatewayUrl = "wss://control.example/openclaw"; + + applySettingsFromUrl(host); + + expect(host.settings.token).toBe("hash-token"); + expect(window.location.search).toBe(""); + expect(window.location.hash).toBe(""); + }); + it("resets stale persisted session selection to main when a token is supplied without a session", () => { setTestWindowUrl("https://control.example/chat#token=test-token"); const host = createHost("chat"); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index bd924915b76..6c379aef4d0 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -97,6 +97,9 @@ export function applySettingsFromUrl(host: SettingsHost) { const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl"); const nextGatewayUrl = gatewayUrlRaw?.trim() ?? ""; const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== host.settings.gatewayUrl); + // Prefer fragment tokens over query tokens. Fragments avoid server-side request + // logs and referrer leakage; query-param tokens remain a one-time legacy fallback + // for compatibility with older deep links. const tokenRaw = hashParams.get("token") ?? params.get("token"); const passwordRaw = params.get("password") ?? hashParams.get("password"); const sessionRaw = params.get("session") ?? hashParams.get("session"); From 1777b99ccc39bf55e871c2b30599cd42d1bf2e38 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:19:23 +0000 Subject: [PATCH 03/21] Signal: move message actions behind plugin boundary --- extensions/signal/src/channel.test.ts | 19 +++++++++++++++++ extensions/signal/src/channel.ts | 16 +------------- extensions/signal/src/index.ts | 1 + .../signal/src/message-actions.ts | 21 +++++++++---------- extensions/signal/src/send-reactions.test.ts | 12 +++++++---- src/channels/plugins/actions/actions.test.ts | 4 ++-- src/plugin-sdk/channel-runtime.ts | 1 + src/plugins/runtime/runtime-signal.ts | 2 +- src/plugins/runtime/types-channel.ts | 2 +- tsdown.config.ts | 1 - 10 files changed, 44 insertions(+), 35 deletions(-) rename src/channels/plugins/actions/signal.ts => extensions/signal/src/message-actions.ts (90%) diff --git a/extensions/signal/src/channel.test.ts b/extensions/signal/src/channel.test.ts index ee15deb0ec8..9a821a6f329 100644 --- a/extensions/signal/src/channel.test.ts +++ b/extensions/signal/src/channel.test.ts @@ -32,3 +32,22 @@ describe("signalPlugin outbound sendMedia", () => { ); }); }); + +describe("signalPlugin actions", () => { + it("owns unified message tool discovery", () => { + const discovery = signalPlugin.actions?.describeMessageTool?.({ + cfg: { + channels: { + signal: { + actions: { reactions: false }, + accounts: { + work: { account: "+15550001111", actions: { reactions: true } }, + }, + }, + }, + } as never, + }); + + expect(discovery?.actions).toEqual(["send", "react"]); + }); +}); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 80519620cc6..454eaa2cb9f 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -19,7 +19,6 @@ import { normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - type ChannelMessageActionAdapter, type ChannelPlugin, } from "openclaw/plugin-sdk/signal"; import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; @@ -30,25 +29,12 @@ import { resolveSignalRecipient, resolveSignalSender, } from "./identity.js"; +import { signalMessageActions } from "./message-actions.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { signalSetupAdapter } from "./setup-core.js"; import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js"; -const signalMessageActions: ChannelMessageActionAdapter = { - describeMessageTool: (ctx) => - getSignalRuntime().channel.signal.messageActions?.describeMessageTool?.(ctx) ?? null, - supportsAction: (ctx) => - getSignalRuntime().channel.signal.messageActions?.supportsAction?.(ctx) ?? false, - handleAction: async (ctx) => { - const ma = getSignalRuntime().channel.signal.messageActions; - if (!ma?.handleAction) { - throw new Error("Signal message actions not available"); - } - return ma.handleAction(ctx); - }, -}; - type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; function resolveSignalSendContext(params: { diff --git a/extensions/signal/src/index.ts b/extensions/signal/src/index.ts index 29f2411493a..d8f18aa40f0 100644 --- a/extensions/signal/src/index.ts +++ b/extensions/signal/src/index.ts @@ -3,3 +3,4 @@ export { probeSignal } from "./probe.js"; export { sendMessageSignal } from "./send.js"; export { sendReactionSignal, removeReactionSignal } from "./send-reactions.js"; export { resolveSignalReactionLevel } from "./reaction-level.js"; +export { signalMessageActions } from "./message-actions.js"; diff --git a/src/channels/plugins/actions/signal.ts b/extensions/signal/src/message-actions.ts similarity index 90% rename from src/channels/plugins/actions/signal.ts rename to extensions/signal/src/message-actions.ts index 073496ab2e2..c6082848f02 100644 --- a/src/channels/plugins/actions/signal.ts +++ b/extensions/signal/src/message-actions.ts @@ -1,13 +1,14 @@ -import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js"; -import { resolveSignalAccount } from "../../../plugin-sdk/account-resolution.js"; import { - listEnabledSignalAccounts, - removeReactionSignal, - resolveSignalReactionLevel, - sendReactionSignal, -} from "../../../plugin-sdk/signal.js"; -import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; -import { resolveReactionMessageId } from "./reaction-message-id.js"; + createActionGate, + jsonResult, + readStringParam, + resolveReactionMessageId, + type ChannelMessageActionAdapter, + type ChannelMessageActionName, +} from "openclaw/plugin-sdk/channel-runtime"; +import { listEnabledSignalAccounts, resolveSignalAccount } from "./accounts.js"; +import { resolveSignalReactionLevel } from "./reaction-level.js"; +import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js"; const providerId = "signal"; const GROUP_PREFIX = "group:"; @@ -103,7 +104,6 @@ export const signalMessageActions: ChannelMessageActionAdapter = { } if (action === "react") { - // Check reaction level first const reactionLevelInfo = resolveSignalReactionLevel({ cfg, accountId: accountId ?? undefined, @@ -115,7 +115,6 @@ export const signalMessageActions: ChannelMessageActionAdapter = { ); } - // Also check the action gate for backward compatibility const actionConfig = resolveSignalAccount({ cfg, accountId }).config.actions; const isActionEnabled = createActionGate(actionConfig); if (!isActionEnabled("reactions")) { diff --git a/extensions/signal/src/send-reactions.test.ts b/extensions/signal/src/send-reactions.test.ts index 47f0bbd8814..698d836df0e 100644 --- a/extensions/signal/src/send-reactions.test.ts +++ b/extensions/signal/src/send-reactions.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js"; const rpcMock = vi.fn(); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => ({}), @@ -25,9 +24,14 @@ vi.mock("./client.js", () => ({ signalRpcRequest: (...args: unknown[]) => rpcMock(...args), })); +let sendReactionSignal: typeof import("./send-reactions.js").sendReactionSignal; +let removeReactionSignal: typeof import("./send-reactions.js").removeReactionSignal; + describe("sendReactionSignal", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); rpcMock.mockClear().mockResolvedValue({ timestamp: 123 }); + ({ sendReactionSignal, removeReactionSignal } = await import("./send-reactions.js")); }); it("uses recipients array and targetAuthor for uuid dms", async () => { diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index fca5b468066..f740a6da4ea 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -28,7 +28,7 @@ vi.mock("../../../../extensions/slack/src/action-runtime.js", () => ({ let discordMessageActions: typeof import("./discord.js").discordMessageActions; let handleDiscordMessageAction: typeof import("./discord/handle-action.js").handleDiscordMessageAction; let telegramMessageActions: typeof import("./telegram.js").telegramMessageActions; -let signalMessageActions: typeof import("./signal.js").signalMessageActions; +let signalMessageActions: typeof import("../../../../extensions/signal/src/message-actions.js").signalMessageActions; let createSlackActions: typeof import("../../../../extensions/slack/src/channel-actions.js").createSlackActions; function getDescribedActions(params: { @@ -204,7 +204,7 @@ beforeEach(async () => { ({ discordMessageActions } = await import("./discord.js")); ({ handleDiscordMessageAction } = await import("./discord/handle-action.js")); ({ telegramMessageActions } = await import("./telegram.js")); - ({ signalMessageActions } = await import("./signal.js")); + ({ signalMessageActions } = await import("../../../../extensions/signal/src/message-actions.js")); ({ createSlackActions } = await import("../../../../extensions/slack/src/channel-actions.js")); vi.clearAllMocks(); }); diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index cf916194580..0479efd5820 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -44,6 +44,7 @@ export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../infra/outbound/send-deps.js"; export * from "../polls.js"; export * from "../utils/message-channel.js"; +export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js"; export * from "./channel-lifecycle.js"; export type { InteractiveButtonStyle, diff --git a/src/plugins/runtime/runtime-signal.ts b/src/plugins/runtime/runtime-signal.ts index dc83f3fd1e2..5eade131012 100644 --- a/src/plugins/runtime/runtime-signal.ts +++ b/src/plugins/runtime/runtime-signal.ts @@ -1,9 +1,9 @@ import { monitorSignalProvider, probeSignal, + signalMessageActions, sendMessageSignal, } from "../../../extensions/signal/runtime-api.js"; -import { signalMessageActions } from "../../channels/plugins/actions/signal.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeSignal(): PluginRuntimeChannel["signal"] { diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index f13dd010c0e..0f98d85ed90 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -197,7 +197,7 @@ export type PluginRuntimeChannel = { probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal; sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal; monitorSignalProvider: typeof import("../../../extensions/signal/runtime-api.js").monitorSignalProvider; - messageActions: typeof import("../../channels/plugins/actions/signal.js").signalMessageActions; + messageActions: typeof import("../../../extensions/signal/runtime-api.js").signalMessageActions; }; imessage: { monitorIMessageProvider: typeof import("../../../extensions/imessage/runtime-api.js").monitorIMessageProvider; diff --git a/tsdown.config.ts b/tsdown.config.ts index 48e69927f98..58a578d812b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -164,7 +164,6 @@ function buildCoreDistEntries(): Record { "channels/plugins/agent-tools/whatsapp-login": "src/channels/plugins/agent-tools/whatsapp-login.ts", "channels/plugins/actions/discord": "src/channels/plugins/actions/discord.ts", - "channels/plugins/actions/signal": "src/channels/plugins/actions/signal.ts", "channels/plugins/actions/telegram": "src/channels/plugins/actions/telegram.ts", "telegram/audit": "extensions/telegram/src/audit.ts", "telegram/token": "extensions/telegram/src/token.ts", From 2f21eeb3cb0d4c90d88af0881ccec50c437475dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 20:17:22 -0700 Subject: [PATCH 04/21] Plugins: internalize bluebubbles SDK imports --- extensions/bluebubbles/src/account-resolve.ts | 2 +- extensions/bluebubbles/src/accounts.ts | 2 +- extensions/bluebubbles/src/actions.ts | 8 ++-- extensions/bluebubbles/src/attachments.ts | 2 +- extensions/bluebubbles/src/channel.ts | 26 ++++++------- extensions/bluebubbles/src/chat.ts | 2 +- extensions/bluebubbles/src/config-apply.ts | 2 +- extensions/bluebubbles/src/config-schema.ts | 2 +- extensions/bluebubbles/src/history.ts | 2 +- extensions/bluebubbles/src/media-send.ts | 2 +- .../bluebubbles/src/monitor-debounce.ts | 2 +- .../bluebubbles/src/monitor-normalize.ts | 2 +- .../bluebubbles/src/monitor-processing.ts | 38 +++++++++---------- extensions/bluebubbles/src/monitor-shared.ts | 2 +- extensions/bluebubbles/src/monitor.ts | 14 +++---- extensions/bluebubbles/src/probe.ts | 2 +- extensions/bluebubbles/src/reactions.ts | 2 +- extensions/bluebubbles/src/request-url.ts | 2 +- extensions/bluebubbles/src/runtime-api.ts | 1 + extensions/bluebubbles/src/runtime.ts | 2 +- extensions/bluebubbles/src/secret-input.ts | 2 +- extensions/bluebubbles/src/send.ts | 4 +- extensions/bluebubbles/src/targets.ts | 2 +- extensions/bluebubbles/src/types.ts | 4 +- .../channel-import-guardrails.test.ts | 1 + 25 files changed, 66 insertions(+), 64 deletions(-) create mode 100644 extensions/bluebubbles/src/runtime-api.ts diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 7d28d0dd3c8..f4ac1f06618 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { normalizeResolvedSecretInputString } from "./secret-input.js"; export type BlueBubblesAccountResolveOpts = { diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts index d7c5a281473..0584922dfca 100644 --- a/extensions/bluebubbles/src/accounts.ts +++ b/extensions/bluebubbles/src/accounts.ts @@ -1,5 +1,5 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; +import { createAccountListHelpers, type OpenClawConfig } from "./runtime-api.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js"; diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index aeb99e8ddd3..cc8f66ca770 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -1,3 +1,6 @@ +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; import { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS, @@ -10,10 +13,7 @@ import { readStringParam, type ChannelMessageActionAdapter, type ChannelMessageActionName, -} from "openclaw/plugin-sdk/bluebubbles"; -import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { resolveBlueBubblesAccount } from "./accounts.js"; -import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; +} from "./runtime-api.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { normalizeBlueBubblesHandle, diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index c5392fd2595..5aab9fd3b68 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -1,6 +1,5 @@ import crypto from "node:crypto"; import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; import { @@ -8,6 +7,7 @@ import { isBlueBubblesPrivateApiStatusEnabled, } from "./probe.js"; import { resolveRequestUrl } from "./request-url.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { resolveChatGuidForTarget } from "./send.js"; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 9d9e49e74ab..7f52e2b8a15 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -1,17 +1,4 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/bluebubbles"; -import { - buildChannelConfigSchema, - buildComputedAccountStatusSnapshot, - buildProbeChannelStatusSummary, - collectBlueBubblesStatusIssues, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - PAIRING_APPROVED_MESSAGE, - resolveBlueBubblesGroupRequireMention, - resolveBlueBubblesGroupToolPolicy, - setAccountEnabledInConfigSection, -} from "openclaw/plugin-sdk/bluebubbles"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { @@ -28,6 +15,19 @@ import { import { bluebubblesMessageActions } from "./actions.js"; import type { BlueBubblesProbe } from "./channel.runtime.js"; import { BlueBubblesConfigSchema } from "./config-schema.js"; +import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js"; +import { + buildChannelConfigSchema, + buildComputedAccountStatusSnapshot, + buildProbeChannelStatusSummary, + collectBlueBubblesStatusIssues, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + PAIRING_APPROVED_MESSAGE, + resolveBlueBubblesGroupRequireMention, + resolveBlueBubblesGroupToolPolicy, + setAccountEnabledInConfigSection, +} from "./runtime-api.js"; import { blueBubblesSetupAdapter } from "./setup-core.js"; import { blueBubblesSetupWizard } from "./setup-surface.js"; import { diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 17340b7f980..5d027ef97e8 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -1,9 +1,9 @@ import crypto from "node:crypto"; import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; export type BlueBubblesChatOpts = { diff --git a/extensions/bluebubbles/src/config-apply.ts b/extensions/bluebubbles/src/config-apply.ts index 70b8c7cae37..e70d718a804 100644 --- a/extensions/bluebubbles/src/config-apply.ts +++ b/extensions/bluebubbles/src/config-apply.ts @@ -1,4 +1,4 @@ -import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; +import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "./runtime-api.js"; type BlueBubblesConfigPatch = { serverUrl?: string; diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index da66869708e..b85f6b72841 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -1,4 +1,3 @@ -import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles"; import { AllowFromListSchema, buildCatchallMultiAccountChannelSchema, @@ -6,6 +5,7 @@ import { GroupPolicySchema, } from "openclaw/plugin-sdk/channel-config-schema"; import { z } from "zod"; +import { MarkdownConfigSchema, ToolPolicySchema } from "./runtime-api.js"; import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js"; const bluebubblesActionSchema = z diff --git a/extensions/bluebubbles/src/history.ts b/extensions/bluebubbles/src/history.ts index 388af325d1a..d917512beb8 100644 --- a/extensions/bluebubbles/src/history.ts +++ b/extensions/bluebubbles/src/history.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; export type BlueBubblesHistoryEntry = { diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts index 8bd505efcf7..42703f960dc 100644 --- a/extensions/bluebubbles/src/media-send.ts +++ b/extensions/bluebubbles/src/media-send.ts @@ -3,10 +3,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { sendBlueBubblesAttachment } from "./attachments.js"; import { resolveBlueBubblesMessageId } from "./monitor.js"; +import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "./runtime-api.js"; import { getBlueBubblesRuntime } from "./runtime.js"; import { sendMessageBlueBubbles } from "./send.js"; diff --git a/extensions/bluebubbles/src/monitor-debounce.ts b/extensions/bluebubbles/src/monitor-debounce.ts index 3a3189cc7ea..298be3e4921 100644 --- a/extensions/bluebubbles/src/monitor-debounce.ts +++ b/extensions/bluebubbles/src/monitor-debounce.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import type { NormalizedWebhookMessage } from "./monitor-normalize.js"; import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js"; +import type { OpenClawConfig } from "./runtime-api.js"; /** * Entry type for debouncing inbound messages. diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts index 085bd8923e1..339f380ba89 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -1,4 +1,4 @@ -import { parseFiniteNumber } from "openclaw/plugin-sdk/bluebubbles"; +import { parseFiniteNumber } from "./runtime-api.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; import type { BlueBubblesAttachment } from "./types.js"; diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 9cf72ea1efd..958c629f766 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -1,22 +1,3 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; -import { - DM_GROUP_ACCESS_REASON, - createScopedPairingAccess, - createReplyPrefixOptions, - evictOldHistoryKeys, - issuePairingChallenge, - logAckFailure, - logInboundDrop, - logTypingFailure, - mapAllowFromEntries, - readStoreAllowFromForDmPolicy, - recordPendingHistoryEntryIfEnabled, - resolveAckReaction, - resolveDmGroupAccessWithLists, - resolveControlCommandGate, - stripMarkdown, - type HistoryEntry, -} from "openclaw/plugin-sdk/bluebubbles"; import { downloadBlueBubblesAttachment } from "./attachments.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { fetchBlueBubblesHistory } from "./history.js"; @@ -49,6 +30,25 @@ import type { } from "./monitor-shared.js"; import { isBlueBubblesPrivateApiEnabled } from "./probe.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; +import type { OpenClawConfig } from "./runtime-api.js"; +import { + DM_GROUP_ACCESS_REASON, + createScopedPairingAccess, + createReplyPrefixOptions, + evictOldHistoryKeys, + issuePairingChallenge, + logAckFailure, + logInboundDrop, + logTypingFailure, + mapAllowFromEntries, + readStoreAllowFromForDmPolicy, + recordPendingHistoryEntryIfEnabled, + resolveAckReaction, + resolveDmGroupAccessWithLists, + resolveControlCommandGate, + stripMarkdown, + type HistoryEntry, +} from "./runtime-api.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { diff --git a/extensions/bluebubbles/src/monitor-shared.ts b/extensions/bluebubbles/src/monitor-shared.ts index 2d40ac7b8d8..9f0776094a0 100644 --- a/extensions/bluebubbles/src/monitor-shared.ts +++ b/extensions/bluebubbles/src/monitor-shared.ts @@ -1,5 +1,5 @@ -import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; +import { normalizeWebhookPath, type OpenClawConfig } from "./runtime-api.js"; import { getBlueBubblesRuntime } from "./runtime.js"; import type { BlueBubblesAccountConfig } from "./types.js"; diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 1dc503e5340..89d0a78a485 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1,12 +1,5 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { - createWebhookInFlightLimiter, - registerWebhookTargetWithPluginRoute, - readWebhookBodyOrReject, - resolveWebhookTargetWithAuthOrRejectSync, - withResolvedWebhookRequestPipeline, -} from "openclaw/plugin-sdk/bluebubbles"; import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js"; import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js"; import { logVerbose, processMessage, processReaction } from "./monitor-processing.js"; @@ -22,6 +15,13 @@ import { type WebhookTarget, } from "./monitor-shared.js"; import { fetchBlueBubblesServerInfo } from "./probe.js"; +import { + createWebhookInFlightLimiter, + registerWebhookTargetWithPluginRoute, + readWebhookBodyOrReject, + resolveWebhookTargetWithAuthOrRejectSync, + withResolvedWebhookRequestPipeline, +} from "./runtime-api.js"; import { getBlueBubblesRuntime } from "./runtime.js"; const webhookTargets = new Map(); diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index 8e12a621e41..02134051aa5 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/bluebubbles"; +import type { BaseProbeResult } from "./runtime-api.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js"; diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts index 8a3837c12e4..1036972a9bb 100644 --- a/extensions/bluebubbles/src/reactions.ts +++ b/extensions/bluebubbles/src/reactions.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; export type BlueBubblesReactionOpts = { diff --git a/extensions/bluebubbles/src/request-url.ts b/extensions/bluebubbles/src/request-url.ts index cd1527f186f..abb6dd05918 100644 --- a/extensions/bluebubbles/src/request-url.ts +++ b/extensions/bluebubbles/src/request-url.ts @@ -1 +1 @@ -export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles"; +export { resolveRequestUrl } from "./runtime-api.js"; diff --git a/extensions/bluebubbles/src/runtime-api.ts b/extensions/bluebubbles/src/runtime-api.ts new file mode 100644 index 00000000000..23c09660d96 --- /dev/null +++ b/extensions/bluebubbles/src/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/bluebubbles"; diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index eae7bb24a29..2ac1c68ad91 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "./runtime-api.js"; const runtimeStore = createPluginRuntimeStore("BlueBubbles runtime not initialized"); type LegacyRuntimeLogShape = { log?: (message: string) => void }; diff --git a/extensions/bluebubbles/src/secret-input.ts b/extensions/bluebubbles/src/secret-input.ts index a5aa73ebda0..b32083456e7 100644 --- a/extensions/bluebubbles/src/secret-input.ts +++ b/extensions/bluebubbles/src/secret-input.ts @@ -3,7 +3,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/bluebubbles"; +} from "./runtime-api.js"; export { buildSecretInputSchema, diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 8c12e88bd23..8fe622d13ff 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -1,11 +1,11 @@ import crypto from "node:crypto"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; -import { stripMarkdown } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { getCachedBlueBubblesPrivateApiStatus, isBlueBubblesPrivateApiStatusEnabled, } from "./probe.js"; +import type { OpenClawConfig } from "./runtime-api.js"; +import { stripMarkdown } from "./runtime-api.js"; import { warnBlueBubbles } from "./runtime.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index ab297471fc3..ef6a69ae8e4 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -5,7 +5,7 @@ import { type ParsedChatTarget, resolveServicePrefixedAllowTarget, resolveServicePrefixedTarget, -} from "openclaw/plugin-sdk/bluebubbles"; +} from "./runtime-api.js"; export type BlueBubblesService = "imessage" | "sms" | "auto"; diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 11a1d486652..1b1190c703c 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -1,6 +1,6 @@ -import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles"; +import type { DmPolicy, GroupPolicy } from "./runtime-api.js"; -export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles"; +export type { DmPolicy, GroupPolicy } from "./runtime-api.js"; export type BlueBubblesGroupConfig = { /** If true, only respond in this group when mentioned. */ diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 24a70ec69f4..df7d67f1230 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -119,6 +119,7 @@ const SETUP_BARREL_GUARDS: GuardedSource[] = [ ]; const LOCAL_EXTENSION_API_BARREL_GUARDS = [ + "bluebubbles", "device-pair", "diagnostics-otel", "diffs", From abaa9107c521c7ea564add8f99326929f9ff8ec6 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:21:44 +0000 Subject: [PATCH 05/21] Build: remove legacy channel action shim entries --- knip.config.ts | 3 --- src/channels/plugins/actions/discord.ts | 2 -- src/channels/plugins/actions/telegram.ts | 2 -- tsdown.config.ts | 2 -- 4 files changed, 9 deletions(-) delete mode 100644 src/channels/plugins/actions/discord.ts delete mode 100644 src/channels/plugins/actions/telegram.ts diff --git a/knip.config.ts b/knip.config.ts index 9ceda2575d8..1c1f510a9b7 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -5,9 +5,6 @@ const rootEntries = [ "src/cli/daemon-cli.ts!", "src/infra/warning-filter.ts!", "src/channels/plugins/agent-tools/whatsapp-login.ts!", - "src/channels/plugins/actions/discord.ts!", - "src/channels/plugins/actions/signal.ts!", - "src/channels/plugins/actions/telegram.ts!", "extensions/telegram/src/audit.ts!", "extensions/telegram/src/token.ts!", "src/line/accounts.ts!", diff --git a/src/channels/plugins/actions/discord.ts b/src/channels/plugins/actions/discord.ts deleted file mode 100644 index 4615a88f3c5..00000000000 --- a/src/channels/plugins/actions/discord.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Public entrypoint for the Discord channel action adapter. -export * from "../../../plugin-sdk/discord.js"; diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts deleted file mode 100644 index e811e757b94..00000000000 --- a/src/channels/plugins/actions/telegram.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Public entrypoint for the Telegram channel action adapter. -export * from "../../../plugin-sdk/telegram.js"; diff --git a/tsdown.config.ts b/tsdown.config.ts index 58a578d812b..ad1e6ea7748 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -163,8 +163,6 @@ function buildCoreDistEntries(): Record { // Keep sync lazy-runtime channel modules as concrete dist files. "channels/plugins/agent-tools/whatsapp-login": "src/channels/plugins/agent-tools/whatsapp-login.ts", - "channels/plugins/actions/discord": "src/channels/plugins/actions/discord.ts", - "channels/plugins/actions/telegram": "src/channels/plugins/actions/telegram.ts", "telegram/audit": "extensions/telegram/src/audit.ts", "telegram/token": "extensions/telegram/src/token.ts", "line/accounts": "src/line/accounts.ts", From 889011c08c42efbe6ecdc616ed18f8155b17519d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:23:27 +0000 Subject: [PATCH 06/21] Build: remove legacy WhatsApp login shim --- knip.config.ts | 1 - src/channels/plugins/agent-tools/whatsapp-login.ts | 2 -- tsdown.config.ts | 3 --- 3 files changed, 6 deletions(-) delete mode 100644 src/channels/plugins/agent-tools/whatsapp-login.ts diff --git a/knip.config.ts b/knip.config.ts index 1c1f510a9b7..0df45bd6e87 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -4,7 +4,6 @@ const rootEntries = [ "src/entry.ts!", "src/cli/daemon-cli.ts!", "src/infra/warning-filter.ts!", - "src/channels/plugins/agent-tools/whatsapp-login.ts!", "extensions/telegram/src/audit.ts!", "extensions/telegram/src/token.ts!", "src/line/accounts.ts!", diff --git a/src/channels/plugins/agent-tools/whatsapp-login.ts b/src/channels/plugins/agent-tools/whatsapp-login.ts deleted file mode 100644 index 2204225bdda..00000000000 --- a/src/channels/plugins/agent-tools/whatsapp-login.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shim: keep legacy import path while the runtime loads the plugin SDK surface. -export * from "../../../plugin-sdk/whatsapp.js"; diff --git a/tsdown.config.ts b/tsdown.config.ts index ad1e6ea7748..0d643b046ac 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -160,9 +160,6 @@ function buildCoreDistEntries(): Record { // Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports. "cli/daemon-cli": "src/cli/daemon-cli.ts", "infra/warning-filter": "src/infra/warning-filter.ts", - // Keep sync lazy-runtime channel modules as concrete dist files. - "channels/plugins/agent-tools/whatsapp-login": - "src/channels/plugins/agent-tools/whatsapp-login.ts", "telegram/audit": "extensions/telegram/src/audit.ts", "telegram/token": "extensions/telegram/src/token.ts", "line/accounts": "src/line/accounts.ts", From 9e556f75f57ad0089d0112af3596746a63a1f9de Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:26:21 +0000 Subject: [PATCH 07/21] Slack: move group policy behind plugin boundary --- extensions/slack/api.ts | 1 + extensions/slack/src/channel.ts | 3 +- extensions/slack/src/group-policy.test.ts | 55 +++++++++++++++ extensions/slack/src/group-policy.ts | 74 +++++++++++++++++++++ extensions/slack/src/index.ts | 1 + src/channels/plugins/group-mentions.test.ts | 55 --------------- src/channels/plugins/group-mentions.ts | 53 +-------------- src/plugin-sdk/channel-policy.ts | 6 +- src/plugin-sdk/core.ts | 1 + src/plugin-sdk/slack.ts | 2 +- 10 files changed, 140 insertions(+), 111 deletions(-) create mode 100644 extensions/slack/src/group-policy.test.ts create mode 100644 extensions/slack/src/group-policy.ts diff --git a/extensions/slack/api.ts b/extensions/slack/api.ts index 37aaf02b027..70ae694652d 100644 --- a/extensions/slack/api.ts +++ b/extensions/slack/api.ts @@ -6,6 +6,7 @@ export * from "./src/blocks-render.js"; export * from "./src/http/index.js"; export * from "./src/interactive-replies.js"; export * from "./src/message-actions.js"; +export * from "./src/group-policy.js"; export * from "./src/sent-thread-cache.js"; export * from "./src/targets.js"; export * from "./src/threading-tool-context.js"; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index b6f82f19afd..d2c59c25468 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -20,8 +20,6 @@ import { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromRequiredCredentialStatuses, - resolveSlackGroupRequireMention, - resolveSlackGroupToolPolicy, type ChannelPlugin, type OpenClawConfig, type SlackActionContext, @@ -36,6 +34,7 @@ import { import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackActions } from "./channel-actions.js"; import { createSlackWebClient } from "./client.js"; +import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; import type { SlackProbe } from "./probe.js"; diff --git a/extensions/slack/src/group-policy.test.ts b/extensions/slack/src/group-policy.test.ts new file mode 100644 index 00000000000..8606a9da674 --- /dev/null +++ b/extensions/slack/src/group-policy.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; + +const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + channels: { + alerts: { + requireMention: false, + tools: { allow: ["message.send"] }, + toolsBySender: { + "id:user:alice": { allow: ["sessions.list"] }, + }, + }, + "*": { + requireMention: true, + tools: { deny: ["exec"] }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any +} as any; + +describe("slack group policy", () => { + it("uses matched channel requireMention and wildcard fallback", () => { + expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#alerts" })).toBe(false); + expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#missing" })).toBe(true); + }); + + it("resolves sender override, then channel tools, then wildcard tools", () => { + const senderOverride = resolveSlackGroupToolPolicy({ + cfg, + groupChannel: "#alerts", + senderId: "user:alice", + }); + expect(senderOverride).toEqual({ allow: ["sessions.list"] }); + + const channelTools = resolveSlackGroupToolPolicy({ + cfg, + groupChannel: "#alerts", + senderId: "user:bob", + }); + expect(channelTools).toEqual({ allow: ["message.send"] }); + + const wildcardTools = resolveSlackGroupToolPolicy({ + cfg, + groupChannel: "#missing", + senderId: "user:bob", + }); + expect(wildcardTools).toEqual({ deny: ["exec"] }); + }); +}); diff --git a/extensions/slack/src/group-policy.ts b/extensions/slack/src/group-policy.ts new file mode 100644 index 00000000000..d49138fb5f8 --- /dev/null +++ b/extensions/slack/src/group-policy.ts @@ -0,0 +1,74 @@ +import { + resolveToolsBySender, + type GroupToolPolicyBySenderConfig, + type GroupToolPolicyConfig, +} from "openclaw/plugin-sdk/channel-policy"; +import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeHyphenSlug } from "openclaw/plugin-sdk/core"; +import { inspectSlackAccount } from "./account-inspect.js"; + +type SlackChannelPolicyEntry = { + requireMention?: boolean; + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; +}; + +function resolveSlackChannelPolicyEntry( + params: ChannelGroupContext, +): SlackChannelPolicyEntry | undefined { + const account = inspectSlackAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const channels = (account.channels ?? {}) as Record; + if (Object.keys(channels).length === 0) { + return undefined; + } + const channelId = params.groupId?.trim(); + const groupChannel = params.groupChannel; + const channelName = groupChannel?.replace(/^#/, ""); + const normalizedName = normalizeHyphenSlug(channelName); + const candidates = [ + channelId ?? "", + channelName ? `#${channelName}` : "", + channelName ?? "", + normalizedName, + ].filter(Boolean); + for (const candidate of candidates) { + if (candidate && channels[candidate]) { + return channels[candidate]; + } + } + return channels["*"]; +} + +function resolveSenderToolsEntry( + entry: SlackChannelPolicyEntry | undefined, + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + if (!entry) { + return undefined; + } + const senderPolicy = resolveToolsBySender({ + toolsBySender: entry.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + return senderPolicy ?? entry.tools; +} + +export function resolveSlackGroupRequireMention(params: ChannelGroupContext): boolean { + const resolved = resolveSlackChannelPolicyEntry(params); + if (typeof resolved?.requireMention === "boolean") { + return resolved.requireMention; + } + return true; +} + +export function resolveSlackGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + return resolveSenderToolsEntry(resolveSlackChannelPolicyEntry(params), params); +} diff --git a/extensions/slack/src/index.ts b/extensions/slack/src/index.ts index 7798ea9c605..f7b5f436fc4 100644 --- a/extensions/slack/src/index.ts +++ b/extensions/slack/src/index.ts @@ -22,4 +22,5 @@ export { export { monitorSlackProvider } from "./monitor.js"; export { probeSlack } from "./probe.js"; export { sendMessageSlack } from "./send.js"; +export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; export { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; diff --git a/src/channels/plugins/group-mentions.test.ts b/src/channels/plugins/group-mentions.test.ts index 5f8e4ed43e9..5bcedcf4d8f 100644 --- a/src/channels/plugins/group-mentions.test.ts +++ b/src/channels/plugins/group-mentions.test.ts @@ -6,65 +6,10 @@ import { resolveDiscordGroupToolPolicy, resolveLineGroupRequireMention, resolveLineGroupToolPolicy, - resolveSlackGroupRequireMention, - resolveSlackGroupToolPolicy, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, } from "./group-mentions.js"; -const cfg = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - channels: { - alerts: { - requireMention: false, - tools: { allow: ["message.send"] }, - toolsBySender: { - "id:user:alice": { allow: ["sessions.list"] }, - }, - }, - "*": { - requireMention: true, - tools: { deny: ["exec"] }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any -} as any; - -describe("group mentions (slack)", () => { - it("uses matched channel requireMention and wildcard fallback", () => { - expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#alerts" })).toBe(false); - expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#missing" })).toBe(true); - }); - - it("resolves sender override, then channel tools, then wildcard tools", () => { - const senderOverride = resolveSlackGroupToolPolicy({ - cfg, - groupChannel: "#alerts", - senderId: "user:alice", - }); - expect(senderOverride).toEqual({ allow: ["sessions.list"] }); - - const channelTools = resolveSlackGroupToolPolicy({ - cfg, - groupChannel: "#alerts", - senderId: "user:bob", - }); - expect(channelTools).toEqual({ allow: ["message.send"] }); - - const wildcardTools = resolveSlackGroupToolPolicy({ - cfg, - groupChannel: "#missing", - senderId: "user:bob", - }); - expect(wildcardTools).toEqual({ deny: ["exec"] }); - }); -}); - describe("group mentions (telegram)", () => { it("resolves topic-level requireMention and chat-level tools for topic ids", () => { const telegramCfg = { diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index f825fc73fe5..215c22e2942 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -10,8 +10,7 @@ import type { GroupToolPolicyConfig, } from "../../config/types.tools.js"; import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js"; -import { inspectSlackAccount } from "../../plugin-sdk/slack.js"; -import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js"; +import { normalizeAtHashSlug } from "../../shared/string-normalization.js"; import type { ChannelGroupContext } from "./types.js"; type GroupMentionParams = ChannelGroupContext; @@ -110,12 +109,6 @@ function resolveDiscordChannelEntry( ); } -type SlackChannelPolicyEntry = { - requireMention?: boolean; - tools?: GroupToolPolicyConfig; - toolsBySender?: GroupToolPolicyBySenderConfig; -}; - type SenderScopedToolsEntry = { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig; @@ -129,35 +122,6 @@ type ChannelGroupPolicyChannel = | "bluebubbles" | "line"; -function resolveSlackChannelPolicyEntry( - params: GroupMentionParams, -): SlackChannelPolicyEntry | undefined { - const account = inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - const channels = (account.channels ?? {}) as Record; - if (Object.keys(channels).length === 0) { - return undefined; - } - const channelId = params.groupId?.trim(); - const groupChannel = params.groupChannel; - const channelName = groupChannel?.replace(/^#/, ""); - const normalizedName = normalizeHyphenSlug(channelName); - const candidates = [ - channelId ?? "", - channelName ? `#${channelName}` : "", - channelName ?? "", - normalizedName, - ].filter(Boolean); - for (const candidate of candidates) { - if (candidate && channels[candidate]) { - return channels[candidate]; - } - } - return channels["*"]; -} - function resolveChannelRequireMention( params: GroupMentionParams, channel: ChannelGroupPolicyChannel, @@ -270,14 +234,6 @@ export function resolveGoogleChatGroupToolPolicy( return resolveChannelToolPolicyForSender(params, "googlechat"); } -export function resolveSlackGroupRequireMention(params: GroupMentionParams): boolean { - const resolved = resolveSlackChannelPolicyEntry(params); - if (typeof resolved?.requireMention === "boolean") { - return resolved.requireMention; - } - return true; -} - export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean { return resolveChannelRequireMention(params, "bluebubbles"); } @@ -312,13 +268,6 @@ export function resolveDiscordGroupToolPolicy( return resolveSenderToolsEntry(context.guildEntry, params); } -export function resolveSlackGroupToolPolicy( - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - const resolved = resolveSlackChannelPolicyEntry(params); - return resolveSenderToolsEntry(resolved, params); -} - export function resolveBlueBubblesGroupToolPolicy( params: GroupMentionParams, ): GroupToolPolicyConfig | undefined { diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index 62538b68dd6..b7166262eb6 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -1,4 +1,8 @@ /** Shared policy warnings and DM/group policy helpers for channel plugins. */ +export type { + GroupToolPolicyBySenderConfig, + GroupToolPolicyConfig, +} from "../config/types.tools.js"; export { buildOpenGroupPolicyConfigureRouteAllowlistWarning, buildOpenGroupPolicyRestrictSendersWarning, @@ -10,7 +14,7 @@ export { collectOpenProviderGroupPolicyWarnings, } from "../channels/plugins/group-policy-warnings.js"; export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; -export { resolveChannelGroupRequireMention } from "../config/group-policy.js"; +export { resolveChannelGroupRequireMention, resolveToolsBySender } from "../config/group-policy.js"; export { DM_GROUP_ACCESS_REASON, readStoreAllowFromForDmPolicy, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 1628506a055..8fef540da68 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -89,6 +89,7 @@ export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secre export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; +export { normalizeHyphenSlug } from "../shared/string-normalization.js"; export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type { diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 31b857c0d2a..80b49010142 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -43,7 +43,7 @@ export { export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, -} from "../channels/plugins/group-mentions.js"; +} from "../../extensions/slack/src/group-policy.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; From 9350cb19dded53d93a1793041d63559158842455 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 03:26:31 +0000 Subject: [PATCH 08/21] refactor: deduplicate plugin setup and channel config helpers --- extensions/bluebubbles/src/channel.ts | 79 ++- extensions/bluebubbles/src/setup-core.ts | 11 +- extensions/discord/src/channel.ts | 23 +- extensions/discord/src/setup-core.ts | 110 +--- extensions/discord/src/setup-surface.ts | 90 ++-- extensions/feishu/src/channel.ts | 35 +- extensions/feishu/src/setup-surface.ts | 168 +++--- extensions/googlechat/src/setup-surface.ts | 54 +- extensions/imessage/src/channel.ts | 21 +- extensions/irc/src/channel.ts | 79 ++- extensions/irc/src/setup-core.ts | 24 +- extensions/line/src/setup-surface.ts | 12 +- extensions/matrix/src/setup-surface.ts | 68 +-- extensions/mattermost/src/channel.ts | 59 +-- extensions/msteams/src/channel.ts | 61 ++- extensions/msteams/src/setup-surface.ts | 47 +- extensions/nextcloud-talk/src/channel.ts | 83 ++- extensions/nextcloud-talk/src/setup-core.ts | 17 +- extensions/nostr/src/channel.ts | 34 +- extensions/nostr/src/setup-surface.ts | 75 +-- extensions/signal/src/channel.ts | 22 +- extensions/slack/src/channel.ts | 23 +- extensions/slack/src/setup-core.ts | 113 +--- extensions/slack/src/setup-surface.ts | 80 +-- extensions/synology-chat/src/channel.test.ts | 4 +- extensions/synology-chat/src/channel.ts | 89 ++-- extensions/tlon/src/channel.ts | 76 +-- extensions/zalo/src/channel.ts | 70 ++- extensions/zalo/src/setup-surface.ts | 18 +- extensions/zalouser/src/channel.ts | 23 +- extensions/zalouser/src/setup-surface.ts | 19 +- .../plugins/setup-wizard-helpers.test.ts | 484 ++++++++++++++++++ src/channels/plugins/setup-wizard-helpers.ts | 451 +++++++++++++++- src/plugin-sdk/channel-config-helpers.test.ts | 189 +++++++ src/plugin-sdk/channel-config-helpers.ts | 174 ++++++- src/plugin-sdk/compat.ts | 2 + src/plugin-sdk/setup.ts | 18 + src/plugin-sdk/subpaths.test.ts | 10 + 38 files changed, 1933 insertions(+), 1082 deletions(-) diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 7f52e2b8a15..c9ab4c7dc47 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -1,10 +1,11 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; -import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { - buildAccountScopedDmSecurityPolicy, - collectOpenGroupPolicyRestrictSendersWarnings, -} from "openclaw/plugin-sdk/channel-policy"; + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { collectOpenGroupPolicyRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, @@ -22,11 +23,9 @@ import { buildProbeChannelStatusSummary, collectBlueBubblesStatusIssues, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, PAIRING_APPROVED_MESSAGE, resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupToolPolicy, - setAccountEnabledInConfigSection, } from "./runtime-api.js"; import { blueBubblesSetupAdapter } from "./setup-core.js"; import { blueBubblesSetupWizard } from "./setup-surface.js"; @@ -43,6 +42,32 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport( "blueBubblesChannelRuntime", ); +const bluebubblesConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveBlueBubblesAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatNormalizedAllowFromEntries({ + allowFrom, + normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), + }), +}); + +const bluebubblesConfigBase = createScopedChannelConfigBase({ + sectionKey: "bluebubbles", + listAccountIds: listBlueBubblesAccountIds, + resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultBlueBubblesAccountId, + clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], +}); + +const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver({ + channelKey: "bluebubbles", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), +}); + const meta = { id: "bluebubbles", label: "BlueBubbles", @@ -85,24 +110,7 @@ export const bluebubblesPlugin: ChannelPlugin = { configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), setupWizard: blueBubblesSetupWizard, config: { - listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg, - sectionKey: "bluebubbles", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg, - sectionKey: "bluebubbles", - accountId, - clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], - }), + ...bluebubblesConfigBase, isConfigured: (account) => account.configured, describeAccount: (account): ChannelAccountSnapshot => ({ accountId: account.accountId, @@ -111,28 +119,11 @@ export const bluebubblesPlugin: ChannelPlugin = { configured: account.configured, baseUrl: account.baseUrl, }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatNormalizedAllowFromEntries({ - allowFrom, - normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), - }), + ...bluebubblesConfigAccessors, }, actions: bluebubblesMessageActions, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "bluebubbles", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), - }); - }, + resolveDmPolicy: resolveBlueBubblesDmPolicy, collectWarnings: ({ account }) => { const groupPolicy = account.config.groupPolicy ?? "allowlist"; return collectOpenGroupPolicyRestrictSendersWarnings({ diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index a8d3261b7ff..df8cf016b0b 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -1,8 +1,8 @@ import { + createTopLevelChannelDmPolicySetter, normalizeAccountId, patchScopedAccountConfig, prepareScopedSetupConfig, - setTopLevelChannelDmPolicyWithAllowFrom, type ChannelSetupAdapter, type DmPolicy, type OpenClawConfig, @@ -10,13 +10,12 @@ import { import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; const channel = "bluebubbles" as const; +const setBlueBubblesTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({ + channel, +}); export function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }); + return setBlueBubblesTopLevelDmPolicy(cfg, dmPolicy); } export function setBlueBubblesAllowFrom( diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index a99ba1c3e0c..21348036a46 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -3,8 +3,8 @@ import { buildAccountScopedAllowlistConfigEditor, resolveLegacyDmAllowlistConfigPaths, } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; @@ -61,6 +61,14 @@ type DiscordSendFn = ReturnType< const meta = getChatChannelMeta("discord"); const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; +const resolveDiscordDmPolicy = createScopedDmSecurityResolver({ + channelKey: "discord", + resolvePolicy: (account) => account.config.dm?.policy, + resolveAllowFrom: (account) => account.config.dm?.allowFrom, + allowFromPathSuffix: "dm.", + normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), +}); + function formatDiscordIntents(intents?: { messageContent?: string; guildMembers?: string; @@ -300,18 +308,7 @@ export const discordPlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "discord", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dm?.policy, - allowFrom: account.config.dm?.allowFrom ?? [], - allowFromPathSuffix: "dm.", - normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), - }); - }, + resolveDmPolicy: resolveDiscordDmPolicy, collectWarnings: ({ account, cfg }) => { const guildEntries = account.config.guilds ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index a05a9af65b1..ba0ba5e66be 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,12 +1,12 @@ import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { + createAccountScopedAllowFromSection, + createAccountScopedGroupAccessSection, + createLegacyCompatChannelDmPolicy, DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, - noteChannelLookupFailure, - noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, - setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; @@ -88,21 +88,11 @@ export function createDiscordSetupWizardBase(handlers: { NonNullable["resolveAllowlist"]> >; }) { - const discordDmPolicy: ChannelSetupDmPolicy = { + const discordDmPolicy: ChannelSetupDmPolicy = createLegacyCompatChannelDmPolicy({ label: "Discord", channel, - policyKey: "channels.discord.dmPolicy", - allowFromKey: "channels.discord.allowFrom", - getCurrent: (cfg: OpenClawConfig) => - cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", - setPolicy: (cfg: OpenClawConfig, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), promptAllowFrom: handlers.promptAllowFrom, - }; + }); return { channel, @@ -145,7 +135,8 @@ export function createDiscordSetupWizardBase(handlers: { }, }, ], - groupAccess: { + groupAccess: createAccountScopedGroupAccessSection({ + channel, label: "Discord channels", placeholder: "My Server/#general, guildId/channelId, #support", currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => @@ -164,57 +155,8 @@ export function createDiscordSetupWizardBase(handlers: { ), updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds), - setPolicy: ({ - cfg, - accountId, - policy, - }: { - cfg: OpenClawConfig; - accountId: string; - policy: "open" | "allowlist" | "disabled"; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { groupPolicy: policy }, - }), - resolveAllowlist: async ({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { token?: string }; - entries: string[]; - prompter: { note: (message: string, title?: string) => Promise }; - }) => { - try { - return await handlers.resolveGroupAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Discord channels", - error, - }); - await noteChannelLookupSummary({ - prompter, - label: "Discord channels", - resolvedSections: [], - unresolved: entries, - }); - return entries.map((input) => ({ input, resolved: false })); - } - }, + resolveAllowlist: handlers.resolveGroupAllowlist, + fallbackResolved: (entries) => entries.map((input) => ({ input, resolved: false })), applyAllowlist: ({ cfg, accountId, @@ -224,8 +166,9 @@ export function createDiscordSetupWizardBase(handlers: { accountId: string; resolved: unknown; }) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never), - }, - allowFrom: { + }), + allowFrom: createAccountScopedAllowFromSection({ + channel, credentialInputKey: "token", helpTitle: "Discord allowlist", helpLines: [ @@ -242,33 +185,8 @@ export function createDiscordSetupWizardBase(handlers: { invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.", parseId: parseDiscordAllowFromId, - resolveEntries: async ({ - cfg, - accountId, - credentialValues, - entries, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { token?: string }; - entries: string[]; - }) => await handlers.resolveAllowFromEntries({ cfg, accountId, credentialValues, entries }), - apply: async ({ - cfg, - accountId, - allowFrom, - }: { - cfg: OpenClawConfig; - accountId: string; - allowFrom: string[]; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, + resolveEntries: handlers.resolveAllowFromEntries, + }), dmPolicy: discordDmPolicy, disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index d27c7862c99..fae95d56916 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,17 +1,13 @@ import { + resolveEntriesWithOptionalToken, type OpenClawConfig, - promptLegacyChannelAllowFrom, - resolveSetupAccountId, + promptLegacyChannelAllowFromForAccount, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; -import { normalizeDiscordSlug } from "./monitor/allow-list.js"; -import { - resolveDiscordChannelAllowlist, - type DiscordChannelResolution, -} from "./resolve-channels.js"; +import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { createDiscordSetupWizardBase, @@ -23,22 +19,26 @@ import { const channel = "discord" as const; async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) { - if (!params.token?.trim()) { - return params.entries.map((input) => ({ + return await resolveEntriesWithOptionalToken({ + token: params.token, + entries: params.entries, + buildWithoutToken: (input) => ({ input, resolved: false, id: null, - })); - } - const resolved = await resolveDiscordUserAllowlist({ - token: params.token, - entries: params.entries, + }), + resolveEntries: async ({ token, entries }) => + ( + await resolveDiscordUserAllowlist({ + token, + entries, + }) + ).map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })), }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id ?? null, - })); } async function promptDiscordAllowFrom(params: { @@ -46,17 +46,15 @@ async function promptDiscordAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveSetupAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), - }); - const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId }); - return promptLegacyChannelAllowFrom({ + return await promptLegacyChannelAllowFromForAccount({ cfg: params.cfg, channel, prompter: params.prompter, - existing: resolved.config.allowFrom ?? resolved.config.dm?.allowFrom ?? [], - token: resolved.token, + accountId: params.accountId, + defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + resolveExisting: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom ?? [], + resolveToken: (account) => account.token, noteTitle: "Discord allowlist", noteLines: [ "Allowlist Discord DMs by username (we resolve to user ids).", @@ -71,11 +69,17 @@ async function promptDiscordAllowFrom(params: { placeholder: "@alice, 123456789012345678", parseId: parseDiscordAllowFromId, invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.", - resolveEntries: ({ token, entries }) => - resolveDiscordUserAllowlist({ - token, - entries, - }), + resolveEntries: async ({ token, entries }) => + ( + await resolveDiscordUserAllowlist({ + token, + entries, + }) + ).map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })), }); } @@ -85,18 +89,20 @@ async function resolveDiscordGroupAllowlist(params: { credentialValues: { token?: string }; entries: string[]; }) { - const token = - resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token || - (typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""); - if (!token || params.entries.length === 0) { - return params.entries.map((input) => ({ + return await resolveEntriesWithOptionalToken({ + token: + resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token || + (typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""), + entries: params.entries, + buildWithoutToken: (input) => ({ input, resolved: false, - })); - } - return await resolveDiscordChannelAllowlist({ - token, - entries: params.entries, + }), + resolveEntries: async ({ token, entries }) => + await resolveDiscordChannelAllowlist({ + token, + entries, + }), }); } diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 5bac3945608..649c51a7ad9 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,5 +1,8 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createHybridChannelConfigBase, + createScopedAccountConfigAccessors, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { @@ -126,6 +129,21 @@ function setFeishuNamedAccountEnabled( }; } +const feishuConfigBase = createHybridChannelConfigBase({ + sectionKey: "feishu", + listAccountIds: listFeishuAccountIds, + resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultFeishuAccountId, + clearBaseFields: [], +}); + +const feishuConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => + resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }), + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), +}); + function isFeishuReactionsActionEnabled(params: { cfg: ClawdbotConfig; account: ResolvedFeishuAccount; @@ -377,15 +395,10 @@ export const feishuPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.feishu"] }, configSchema: buildChannelConfigSchema(FeishuConfigSchema), config: { - listAccountIds: (cfg) => listFeishuAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg), + ...feishuConfigBase, setAccountEnabled: ({ cfg, accountId, enabled }) => { - const account = resolveFeishuAccount({ cfg, accountId }); const isDefault = accountId === DEFAULT_ACCOUNT_ID; - if (isDefault) { - // For default account, set top-level enabled return { ...cfg, channels: { @@ -397,8 +410,6 @@ export const feishuPlugin: ChannelPlugin = { }, }; } - - // For named accounts, set enabled in accounts[accountId] return setFeishuNamedAccountEnabled(cfg, accountId, enabled); }, deleteAccount: ({ cfg, accountId }) => { @@ -442,11 +453,7 @@ export const feishuPlugin: ChannelPlugin = { appId: account.appId, domain: account.domain, }), - resolveAllowFrom: ({ cfg, accountId }) => { - const account = resolveFeishuAccount({ cfg, accountId }); - return mapAllowFromEntries(account.config?.allowFrom); - }, - formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), + ...feishuConfigAccessors, }, actions: { describeMessageTool: describeFeishuMessageTool, diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index e990f308624..9a98f171bca 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,17 +1,17 @@ import { buildSingleChannelSecretPromptState, + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, + createTopLevelChannelGroupPolicySetter, DEFAULT_ACCOUNT_ID, formatDocsLink, hasConfiguredSecretInput, mergeAllowFromEntries, + patchTopLevelChannelConfigSection, promptSingleChannelSecretInput, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - setTopLevelChannelGroupPolicy, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, - type DmPolicy, type OpenClawConfig, type SecretInput, } from "openclaw/plugin-sdk/setup"; @@ -21,6 +21,13 @@ import { feishuSetupAdapter } from "./setup-core.js"; import type { FeishuConfig } from "./types.js"; const channel = "feishu" as const; +const setFeishuAllowFrom = createTopLevelChannelAllowFromSetter({ + channel, +}); +const setFeishuGroupPolicy = createTopLevelChannelGroupPolicySetter({ + channel, + enabled: true, +}); function normalizeString(value: unknown): string | undefined { if (typeof value !== "string") { @@ -30,34 +37,6 @@ function normalizeString(value: unknown): string | undefined { return trimmed || undefined; } -function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as OpenClawConfig; -} - -function setFeishuAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }) as OpenClawConfig; -} - -function setFeishuGroupPolicy( - cfg: OpenClawConfig, - groupPolicy: "open" | "allowlist" | "disabled", -): OpenClawConfig { - return setTopLevelChannelGroupPolicy({ - cfg, - channel, - groupPolicy, - enabled: true, - }) as OpenClawConfig; -} - function setFeishuGroupAllowFrom(cfg: OpenClawConfig, groupAllowFrom: string[]): OpenClawConfig { return { ...cfg, @@ -177,15 +156,14 @@ async function promptFeishuAppId(params: { ).trim(); } -const feishuDmPolicy: ChannelSetupDmPolicy = { +const feishuDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Feishu", channel, policyKey: "channels.feishu.dmPolicy", allowFromKey: "channels.feishu.allowFrom", getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: promptFeishuAllowFrom, -}; +}); export { feishuSetupAdapter } from "./setup-core.js"; @@ -263,13 +241,12 @@ export const feishuSetupWizard: ChannelSetupWizard = { }); if (appSecretResult.action === "use-env") { - next = { - ...next, - channels: { - ...next.channels, - feishu: { ...next.channels?.feishu, enabled: true }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + enabled: true, + patch: {}, + }) as OpenClawConfig; } else if (appSecretResult.action === "set") { appSecret = appSecretResult.value; appSecretProbeValue = appSecretResult.resolvedValue; @@ -281,18 +258,15 @@ export const feishuSetupWizard: ChannelSetupWizard = { } if (appId && appSecret) { - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - enabled: true, - appId, - appSecret, - }, + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + enabled: true, + patch: { + appId, + appSecret, }, - }; + }) as OpenClawConfig; try { const probe = await probeFeishu({ @@ -326,16 +300,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { ], initialValue: currentMode, })) as "websocket" | "webhook"; - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - connectionMode, - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { connectionMode }, + }) as OpenClawConfig; if (connectionMode === "webhook") { const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined) @@ -357,16 +326,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { preferredEnvVar: "FEISHU_VERIFICATION_TOKEN", }); if (verificationTokenResult.action === "set") { - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - verificationToken: verificationTokenResult.value, - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { verificationToken: verificationTokenResult.value }, + }) as OpenClawConfig; } const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey; @@ -387,16 +351,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { preferredEnvVar: "FEISHU_ENCRYPT_KEY", }); if (encryptKeyResult.action === "set") { - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - encryptKey: encryptKeyResult.value, - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { encryptKey: encryptKeyResult.value }, + }) as OpenClawConfig; } const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath; @@ -407,16 +366,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }), ).trim(); - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - webhookPath, - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { webhookPath }, + }) as OpenClawConfig; } const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu"; @@ -428,16 +382,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { ], initialValue: currentDomain, }); - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - domain: domain as "feishu" | "lark", - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { domain: domain as "feishu" | "lark" }, + }) as OpenClawConfig; const groupPolicy = (await prompter.select({ message: "Group chat policy", @@ -468,11 +417,10 @@ export const feishuSetupWizard: ChannelSetupWizard = { return { cfg: next }; }, dmPolicy: feishuDmPolicy, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { ...cfg.channels?.feishu, enabled: false }, - }, - }), + disable: (cfg) => + patchTopLevelChannelConfigSection({ + cfg, + channel, + patch: { enabled: false }, + }), }; diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index 0af6e3d4f54..93f6d37d82e 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -1,15 +1,14 @@ import { - addWildcardAllowFrom, applySetupAccountConfigPatch, + createNestedChannelDmPolicy, DEFAULT_ACCOUNT_ID, formatDocsLink, mergeAllowFromEntries, migrateBaseNameToDefaultAccount, - setTopLevelChannelDmPolicyWithAllowFrom, + patchNestedChannelConfigSection, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, - type DmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import { @@ -25,25 +24,6 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; const USE_ENV_FLAG = "__googlechatUseEnv"; const AUTH_METHOD_FLAG = "__googlechatAuthMethod"; -function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) { - const allowFrom = - policy === "open" ? addWildcardAllowFrom(cfg.channels?.googlechat?.dm?.allowFrom) : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - googlechat: { - ...cfg.channels?.googlechat, - dm: { - ...cfg.channels?.googlechat?.dm, - policy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }, - }; -} - async function promptAllowFrom(params: { cfg: OpenClawConfig; prompter: Parameters>[0]["prompter"]; @@ -57,32 +37,28 @@ async function promptAllowFrom(params: { }); const parts = splitSetupEntries(String(entry)); const unique = mergeAllowFromEntries(undefined, parts); - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - googlechat: { - ...params.cfg.channels?.googlechat, - enabled: true, - dm: { - ...params.cfg.channels?.googlechat?.dm, - policy: "allowlist", - allowFrom: unique, - }, - }, + return patchNestedChannelConfigSection({ + cfg: params.cfg, + channel, + section: "dm", + enabled: true, + patch: { + policy: "allowlist", + allowFrom: unique, }, - }; + }); } -const googlechatDmPolicy: ChannelSetupDmPolicy = { +const googlechatDmPolicy: ChannelSetupDmPolicy = createNestedChannelDmPolicy({ label: "Google Chat", channel, + section: "dm", policyKey: "channels.googlechat.dm.policy", allowFromKey: "channels.googlechat.dm.allowFrom", getCurrent: (cfg) => cfg.channels?.googlechat?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy), promptAllowFrom, -}; + enabled: true, +}); export { googlechatSetupAdapter } from "./setup-core.js"; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 3c34cea1be7..2eadc5a8a90 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,6 +1,6 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedDmSecurityPolicy, + createScopedDmSecurityResolver, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; @@ -26,6 +26,13 @@ import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); +const resolveIMessageDmPolicy = createScopedDmSecurityResolver({ + channelKey: "imessage", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", +}); + function buildIMessageBaseSessionKey(params: { cfg: Parameters[0]["cfg"]; agentId: string; @@ -127,17 +134,7 @@ export const imessagePlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "imessage", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - }); - }, + resolveDmPolicy: resolveIMessageDmPolicy, collectWarnings: ({ account, cfg }) => { return collectAllowlistProviderRestrictSendersWarnings({ cfg, diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index ed754933e68..18fa8953045 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -1,7 +1,10 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import { createScopedAccountConfigAccessors } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildAccountScopedDmSecurityPolicy, + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; @@ -11,10 +14,8 @@ import { buildChannelConfigSchema, createAccountStatusSink, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, - setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk/irc"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; @@ -61,6 +62,33 @@ const ircConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo, }); +const ircConfigBase = createScopedChannelConfigBase({ + sectionKey: "irc", + listAccountIds: listIrcAccountIds, + resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultIrcAccountId, + clearBaseFields: [ + "name", + "host", + "port", + "tls", + "nick", + "username", + "realname", + "password", + "passwordFile", + "channels", + ], +}); + +const resolveIrcDmPolicy = createScopedDmSecurityResolver({ + channelKey: "irc", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeIrcAllowEntry(raw), +}); + export const ircPlugin: ChannelPlugin = { id: "irc", meta: { @@ -88,35 +116,7 @@ export const ircPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.irc"] }, configSchema: buildChannelConfigSchema(IrcConfigSchema), config: { - listAccountIds: (cfg) => listIrcAccountIds(cfg as CoreConfig), - resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), - defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg as CoreConfig), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "irc", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "irc", - accountId, - clearBaseFields: [ - "name", - "host", - "port", - "tls", - "nick", - "username", - "realname", - "password", - "passwordFile", - "channels", - ], - }), + ...ircConfigBase, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -132,18 +132,7 @@ export const ircPlugin: ChannelPlugin = { ...ircConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "irc", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeIrcAllowEntry(raw), - }); - }, + resolveDmPolicy: resolveIrcDmPolicy, collectWarnings: ({ account, cfg }) => { const warnings = collectAllowlistProviderGroupPolicyWarnings({ cfg, diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 23422e30ba0..8e3a347e35a 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -4,15 +4,19 @@ import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { applyAccountNameToChannelSection, + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicySetter, patchScopedAccountConfig, } from "openclaw/plugin-sdk/setup"; -import { - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/setup"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const channel = "irc" as const; +const setIrcTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({ + channel, +}); +const setIrcTopLevelAllowFrom = createTopLevelChannelAllowFromSetter({ + channel, +}); type IrcSetupInput = ChannelSetupInput & { host?: string; @@ -53,19 +57,11 @@ export function updateIrcAccountConfig( } export function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as CoreConfig; + return setIrcTopLevelDmPolicy(cfg, dmPolicy) as CoreConfig; } export function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }) as CoreConfig; + return setIrcTopLevelAllowFrom(cfg, allowFrom) as CoreConfig; } export function setIrcNickServ( diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 154419d7527..c4f8c3b7da3 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,9 +1,9 @@ import { + createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, formatDocsLink, resolveLineAccount, setSetupChannelEnabled, - setTopLevelChannelDmPolicyWithAllowFrom, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, @@ -35,19 +35,13 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; -const lineDmPolicy: ChannelSetupDmPolicy = { +const lineDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "LINE", channel, policyKey: "channels.line.dmPolicy", allowFromKey: "channels.line.allowFrom", getCurrent: (cfg) => cfg.channels?.line?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), -}; +}); export { lineSetupAdapter } from "./setup-core.js"; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index 09e9438a410..bf2a3769d96 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,16 +1,16 @@ import { - addWildcardAllowFrom, buildSingleChannelSecretPromptState, + createNestedChannelDmPolicy, + createTopLevelChannelGroupPolicySetter, DEFAULT_ACCOUNT_ID, formatDocsLink, formatResolvedUnresolvedNote, hasConfiguredSecretInput, mergeAllowFromEntries, + patchNestedChannelConfigSection, promptSingleChannelSecretInput, - setTopLevelChannelGroupPolicy, type ChannelSetupDmPolicy, type ChannelSetupWizard, - type DmPolicy, type OpenClawConfig, type SecretInput, type WizardPrompter, @@ -23,25 +23,10 @@ import { buildMatrixConfigUpdate, matrixSetupAdapter } from "./setup-core.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; - -function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { - const allowFrom = - policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.matrix, - dm: { - ...cfg.channels?.matrix?.dm, - policy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }, - }; -} +const setMatrixGroupPolicy = createTopLevelChannelGroupPolicySetter({ + channel, + enabled: true, +}); async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { await prompter.note( @@ -128,33 +113,19 @@ async function promptMatrixAllowFrom(params: { } const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds); - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.matrix, - enabled: true, - dm: { - ...cfg.channels?.matrix?.dm, - policy: "allowlist", - allowFrom: unique, - }, - }, + return patchNestedChannelConfigSection({ + cfg, + channel, + section: "dm", + enabled: true, + patch: { + policy: "allowlist", + allowFrom: unique, }, - }; + }) as CoreConfig; } } -function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { - return setTopLevelChannelGroupPolicy({ - cfg, - channel: "matrix", - groupPolicy, - enabled: true, - }) as CoreConfig; -} - function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); return { @@ -242,15 +213,16 @@ const matrixGroupAccess: NonNullable = { setMatrixGroupRooms(cfg as CoreConfig, resolved as string[]), }; -const matrixDmPolicy: ChannelSetupDmPolicy = { +const matrixDmPolicy: ChannelSetupDmPolicy = createNestedChannelDmPolicy({ label: "Matrix", channel, + section: "dm", policyKey: "channels.matrix.dm.policy", allowFromKey: "channels.matrix.dm.allowFrom", getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), promptAllowFrom: promptMatrixAllowFrom, -}; + enabled: true, +}); export { matrixSetupAdapter } from "./setup-core.js"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 4bc716ac27e..e0392728b41 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -1,9 +1,10 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import { createScopedAccountConfigAccessors } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/channel-policy"; + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -31,10 +32,8 @@ import { buildChannelConfigSchema, createAccountStatusSink, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelMessageActionName, type ChannelPlugin, @@ -258,6 +257,22 @@ const mattermostConfigAccessors = createScopedAccountConfigAccessors({ }), }); +const mattermostConfigBase = createScopedChannelConfigBase({ + sectionKey: "mattermost", + listAccountIds: listMattermostAccountIds, + resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultMattermostAccountId, + clearBaseFields: ["botToken", "baseUrl", "name"], +}); + +const resolveMattermostDmPolicy = createScopedDmSecurityResolver({ + channelKey: "mattermost", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeAllowEntry(raw), +}); + export const mattermostPlugin: ChannelPlugin = { id: "mattermost", meta: { @@ -295,24 +310,7 @@ export const mattermostPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.mattermost"] }, configSchema: buildChannelConfigSchema(MattermostConfigSchema), config: { - listAccountIds: (cfg) => listMattermostAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "mattermost", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "mattermost", - accountId, - clearBaseFields: ["botToken", "baseUrl", "name"], - }), + ...mattermostConfigBase, isConfigured: (account) => Boolean(account.botToken && account.baseUrl), describeAccount: (account) => ({ accountId: account.accountId, @@ -325,18 +323,7 @@ export const mattermostPlugin: ChannelPlugin = { ...mattermostConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "mattermost", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeAllowEntry(raw), - }); - }, + resolveDmPolicy: resolveMattermostDmPolicy, collectWarnings: ({ account, cfg }) => { return collectAllowlistProviderRestrictSendersWarnings({ cfg, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 77061d037de..730d425f9a0 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,4 +1,8 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { + createScopedAccountConfigAccessors, + createTopLevelChannelConfigBase, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { @@ -63,6 +67,30 @@ const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( "msTeamsChannelRuntime", ); +const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({ + allowFrom: cfg.channels?.msteams?.allowFrom, + defaultTo: cfg.channels?.msteams?.defaultTo, +}); + +const msteamsConfigBase = createTopLevelChannelConfigBase({ + sectionKey: "msteams", + resolveAccount: (cfg) => ({ + accountId: DEFAULT_ACCOUNT_ID, + enabled: cfg.channels?.msteams?.enabled !== false, + configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), + }), +}); + +const msteamsConfigAccessors = createScopedAccountConfigAccessors<{ + allowFrom?: Array; + defaultTo?: string; +}>({ + resolveAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg), + resolveAllowFrom: (account) => account.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account) => account.defaultTo, +}); + function describeMSTeamsMessageTool({ cfg, }: Parameters< @@ -128,43 +156,14 @@ export const msteamsPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.msteams"] }, configSchema: buildChannelConfigSchema(MSTeamsConfigSchema), config: { - listAccountIds: () => [DEFAULT_ACCOUNT_ID], - resolveAccount: (cfg) => ({ - accountId: DEFAULT_ACCOUNT_ID, - enabled: cfg.channels?.msteams?.enabled !== false, - configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), - }), - defaultAccountId: () => DEFAULT_ACCOUNT_ID, - setAccountEnabled: ({ cfg, enabled }) => ({ - ...cfg, - channels: { - ...cfg.channels, - msteams: { - ...cfg.channels?.msteams, - enabled, - }, - }, - }), - deleteAccount: ({ cfg }) => { - const next = { ...cfg } as OpenClawConfig; - const nextChannels = { ...cfg.channels }; - delete nextChannels.msteams; - if (Object.keys(nextChannels).length > 0) { - next.channels = nextChannels; - } else { - delete next.channels; - } - return next; - }, + ...msteamsConfigBase, isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), describeAccount: (account) => ({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, }), - resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: ({ cfg }) => cfg.channels?.msteams?.defaultTo?.trim() || undefined, + ...msteamsConfigAccessors, }, security: { collectWarnings: ({ cfg }) => { diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index 769e68cd58c..3407a25187f 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,14 +1,13 @@ import { + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, + createTopLevelChannelGroupPolicySetter, DEFAULT_ACCOUNT_ID, formatDocsLink, mergeAllowFromEntries, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - setTopLevelChannelGroupPolicy, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, - type DmPolicy, type OpenClawConfig, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; @@ -23,22 +22,13 @@ import { msteamsSetupAdapter } from "./setup-core.js"; import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js"; const channel = "msteams" as const; - -function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }); -} - -function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }); -} +const setMSTeamsAllowFrom = createTopLevelChannelAllowFromSetter({ + channel, +}); +const setMSTeamsGroupPolicy = createTopLevelChannelGroupPolicySetter({ + channel, + enabled: true, +}); function looksLikeGuid(value: string): boolean { return /^[0-9a-fA-F-]{16,}$/.test(value); @@ -146,18 +136,6 @@ async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise, @@ -281,15 +259,14 @@ const msteamsGroupAccess: NonNullable = { setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>), }; -const msteamsDmPolicy: ChannelSetupDmPolicy = { +const msteamsDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "MS Teams", channel, policyKey: "channels.msteams.dmPolicy", allowFromKey: "channels.msteams.allowFrom", getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy), promptAllowFrom: promptMSTeamsAllowFrom, -}; +}); export { msteamsSetupAdapter } from "./setup-core.js"; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 16910b7371e..a9dbad6018d 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -1,8 +1,11 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { - buildAccountScopedDmSecurityPolicy, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, } from "openclaw/plugin-sdk/channel-policy"; @@ -13,8 +16,6 @@ import { buildRuntimeAccountStatusSnapshot, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, } from "../runtime-api.js"; @@ -49,6 +50,37 @@ const meta = { quickstartAllowFrom: true, }; +const nextcloudTalkConfigAccessors = + createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => + resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }), + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ + allowFrom, + stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i, + }), + }); + +const nextcloudTalkConfigBase = createScopedChannelConfigBase< + ResolvedNextcloudTalkAccount, + CoreConfig +>({ + sectionKey: "nextcloud-talk", + listAccountIds: listNextcloudTalkAccountIds, + resolveAccount: (cfg, accountId) => resolveNextcloudTalkAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultNextcloudTalkAccountId, + clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"], +}); + +const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver({ + channelKey: "nextcloud-talk", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), +}); + export const nextcloudTalkPlugin: ChannelPlugin = { id: "nextcloud-talk", meta, @@ -72,25 +104,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = reload: { configPrefixes: ["channels.nextcloud-talk"] }, configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema), config: { - listAccountIds: (cfg) => listNextcloudTalkAccountIds(cfg as CoreConfig), - resolveAccount: (cfg, accountId) => - resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }), - defaultAccountId: (cfg) => resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "nextcloud-talk", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "nextcloud-talk", - accountId, - clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"], - }), + ...nextcloudTalkConfigBase, isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()), describeAccount: (account) => ({ accountId: account.accountId, @@ -100,29 +114,10 @@ export const nextcloudTalkPlugin: ChannelPlugin = secretSource: account.secretSource, baseUrl: account.baseUrl ? "[set]" : "[missing]", }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries( - resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom, - ).map((entry) => entry.toLowerCase()), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ - allowFrom, - stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i, - }), + ...nextcloudTalkConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "nextcloud-talk", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), - }); - }, + resolveDmPolicy: resolveNextcloudTalkDmPolicy, collectWarnings: ({ account, cfg }) => { const roomAllowlistConfigured = account.config.rooms && Object.keys(account.config.rooms).length > 0; diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 4e976605b85..5994890f8b2 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -8,9 +8,9 @@ import { } from "openclaw/plugin-sdk/setup"; import { mergeAllowFromEntries, + createTopLevelChannelDmPolicy, resolveSetupAccountId, setSetupChannelEnabled, - setTopLevelChannelDmPolicyWithAllowFrom, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; @@ -21,7 +21,7 @@ import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount, } from "./accounts.js"; -import type { CoreConfig, DmPolicy } from "./types.js"; +import type { CoreConfig } from "./types.js"; const channel = "nextcloud-talk" as const; @@ -46,14 +46,6 @@ export function validateNextcloudTalkBaseUrl(value: string): string | undefined return undefined; } -function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as CoreConfig; -} - export function setNextcloudTalkAccountConfig( cfg: CoreConfig, accountId: string, @@ -174,15 +166,14 @@ async function promptNextcloudTalkAllowFromForAccount(params: { }); } -export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { +export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", allowFromKey: "channels.nextcloud-talk.allowFrom", getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), promptAllowFrom: promptNextcloudTalkAllowFromForAccount, -}; +}); export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 21dfce3a9da..b75ad26b0ba 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -31,6 +32,22 @@ const activeBuses = new Map(); // Store metrics snapshots per account (for status reporting) const metricsSnapshots = new Map(); +const resolveNostrDmPolicy = createScopedDmSecurityResolver({ + channelKey: "nostr", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + defaultPolicy: "pairing", + approveHint: formatPairingApproveHint("nostr"), + normalizeEntry: (raw) => { + try { + return normalizePubkey(raw.replace(/^nostr:/i, "").trim()); + } catch { + return raw.trim(); + } + }, +}); + export const nostrPlugin: ChannelPlugin = { id: "nostr", meta: { @@ -101,22 +118,7 @@ export const nostrPlugin: ChannelPlugin = { }, security: { - resolveDmPolicy: ({ account }) => { - return { - policy: account.config.dmPolicy ?? "pairing", - allowFrom: account.config.allowFrom ?? [], - policyPath: "channels.nostr.dmPolicy", - allowFromPath: "channels.nostr.allowFrom", - approveHint: formatPairingApproveHint("nostr"), - normalizeEntry: (raw) => { - try { - return normalizePubkey(raw.replace(/^nostr:/i, "").trim()); - } catch { - return raw.trim(); - } - }, - }; - }, + resolveDmPolicy: resolveNostrDmPolicy, }, messaging: { diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index fca302e75fb..2cf2fb46d61 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -1,12 +1,12 @@ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, mergeAllowFromEntries, parseSetupEntriesWithParser, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, + patchTopLevelChannelConfigSection, splitSetupEntries, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; @@ -18,6 +18,9 @@ import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; import { resolveNostrAccount } from "./types.js"; const channel = "nostr" as const; +const setNostrAllowFrom = createTopLevelChannelAllowFromSetter({ + channel, +}); const NOSTR_SETUP_HELP_LINES = [ "Use a Nostr private key in nsec or 64-character hex format.", @@ -36,46 +39,6 @@ const NOSTR_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`, ]; -function patchNostrConfig(params: { - cfg: OpenClawConfig; - patch: Record; - clearFields?: string[]; - enabled?: boolean; -}): OpenClawConfig { - const existing = (params.cfg.channels?.nostr ?? {}) as Record; - const nextNostr = { ...existing }; - for (const field of params.clearFields ?? []) { - delete nextNostr[field]; - } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - nostr: { - ...nextNostr, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }; -} - -function setNostrDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }); -} - -function setNostrAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }); -} - function parseRelayUrls(raw: string): { relays: string[]; error?: string } { const entries = splitSetupEntries(raw); const relays: string[] = []; @@ -126,21 +89,21 @@ async function promptNostrAllowFrom(params: { return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries)); } -const nostrDmPolicy: ChannelSetupDmPolicy = { +const nostrDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Nostr", channel, policyKey: "channels.nostr.dmPolicy", allowFromKey: "channels.nostr.allowFrom", getCurrent: (cfg) => cfg.channels?.nostr?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setNostrDmPolicy(cfg, policy), promptAllowFrom: promptNostrAllowFrom, -}; +}); export const nostrSetupAdapter: ChannelSetupAdapter = { resolveAccountId: () => DEFAULT_ACCOUNT_ID, applyAccountName: ({ cfg, name }) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, patch: name?.trim() ? { name: name.trim() } : {}, }), validateInput: ({ input }) => { @@ -174,8 +137,9 @@ export const nostrSetupAdapter: ChannelSetupAdapter = { const relayResult = typedInput.relayUrls?.trim() ? parseRelayUrls(typedInput.relayUrls) : { relays: [] }; - return patchNostrConfig({ + return patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, clearFields: typedInput.useEnv ? ["privateKey"] : undefined, patch: { @@ -218,8 +182,9 @@ export const nostrSetupWizard: ChannelSetupWizard = { Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) && !resolveNostrAccount({ cfg, accountId }).config.privateKey?.trim(), apply: async ({ cfg }) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, clearFields: ["privateKey"], patch: {}, @@ -247,15 +212,17 @@ export const nostrSetupWizard: ChannelSetupWizard = { }; }, applyUseEnv: async ({ cfg }) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, clearFields: ["privateKey"], patch: {}, }), applySet: async ({ cfg, resolvedValue }) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, patch: { privateKey: resolvedValue }, }), @@ -280,8 +247,9 @@ export const nostrSetupWizard: ChannelSetupWizard = { validate: ({ value }) => parseRelayUrls(value).error, applySet: async ({ cfg, value }) => { const relayResult = parseRelayUrls(value); - return patchNostrConfig({ + return patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, clearFields: relayResult.relays.length > 0 ? undefined : ["relays"], patch: relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}, @@ -291,8 +259,9 @@ export const nostrSetupWizard: ChannelSetupWizard = { ], dmPolicy: nostrDmPolicy, disable: (cfg) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, patch: { enabled: false }, }), }; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 454eaa2cb9f..b9d10dd25f5 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,7 +1,7 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, + createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; @@ -35,6 +35,13 @@ import { getSignalRuntime } from "./runtime.js"; import { signalSetupAdapter } from "./setup-core.js"; import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js"; +const resolveSignalDmPolicy = createScopedDmSecurityResolver({ + channelKey: "signal", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), +}); type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; function resolveSignalSendContext(params: { @@ -297,18 +304,7 @@ export const signalPlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "signal", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), - }); - }, + resolveDmPolicy: resolveSignalDmPolicy, collectWarnings: ({ account, cfg }) => { return collectAllowlistProviderRestrictSendersWarnings({ cfg, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index d2c59c25468..6024f7b5ed6 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -3,7 +3,7 @@ import { resolveLegacyDmAllowlistConfigPaths, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedDmSecurityPolicy, + createScopedDmSecurityResolver, collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; @@ -54,6 +54,14 @@ import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; const SLACK_CHANNEL_TYPE_CACHE = new Map(); +const resolveSlackDmPolicy = createScopedDmSecurityResolver({ + channelKey: "slack", + resolvePolicy: (account) => account.dm?.policy, + resolveAllowFrom: (account) => account.dm?.allowFrom, + allowFromPathSuffix: "dm.", + normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), +}); + // Select the appropriate Slack token for read/write operations. function getTokenForOperation( account: ResolvedSlackAccount, @@ -351,18 +359,7 @@ export const slackPlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "slack", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.dm?.policy, - allowFrom: account.dm?.allowFrom ?? [], - allowFromPathSuffix: "dm.", - normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), - }); - }, + resolveDmPolicy: resolveSlackDmPolicy, collectWarnings: ({ account, cfg }) => { const channelAllowlistConfigured = Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 5a8fe1feab4..bb9495767b0 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,15 +1,14 @@ import { createAllowlistSetupWizardProxy, + createAccountScopedAllowFromSection, + createAccountScopedGroupAccessSection, + createLegacyCompatChannelDmPolicy, DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, hasConfiguredSecretInput, type OpenClawConfig, - noteChannelLookupFailure, - noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, - setAccountGroupPolicyForChannel, - setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, } from "openclaw/plugin-sdk/setup"; import { @@ -112,21 +111,11 @@ export function createSlackSetupWizardBase(handlers: { NonNullable["resolveAllowlist"]> >; }) { - const slackDmPolicy: ChannelSetupDmPolicy = { + const slackDmPolicy: ChannelSetupDmPolicy = createLegacyCompatChannelDmPolicy({ label: "Slack", channel, - policyKey: "channels.slack.dmPolicy", - allowFromKey: "channels.slack.allowFrom", - getCurrent: (cfg: OpenClawConfig) => - cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", - setPolicy: (cfg: OpenClawConfig, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), promptAllowFrom: handlers.promptAllowFrom, - }; + }); return { channel, @@ -178,7 +167,9 @@ export function createSlackSetupWizardBase(handlers: { }), ], dmPolicy: slackDmPolicy, - allowFrom: { + allowFrom: createAccountScopedAllowFromSection({ + channel, + credentialInputKey: "botToken", helpTitle: "Slack allowlist", helpLines: [ "Allowlist Slack DMs by username (we resolve to user ids).", @@ -188,7 +179,6 @@ export function createSlackSetupWizardBase(handlers: { "Multiple entries: comma-separated.", `Docs: ${formatDocsLink("/slack", "slack")}`, ], - credentialInputKey: "botToken", message: "Slack allowFrom (usernames or ids)", placeholder: "@alice, U12345678", invalidWithoutCredentialNote: "Slack token missing; use user ids (or mention form) only.", @@ -200,34 +190,10 @@ export function createSlackSetupWizardBase(handlers: { idPattern: /^[A-Z][A-Z0-9]+$/i, normalizeId: (id) => id.toUpperCase(), }), - resolveEntries: async ({ - cfg, - accountId, - credentialValues, - entries, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { botToken?: string }; - entries: string[]; - }) => await handlers.resolveAllowFromEntries({ cfg, accountId, credentialValues, entries }), - apply: ({ - cfg, - accountId, - allowFrom, - }: { - cfg: OpenClawConfig; - accountId: string; - allowFrom: string[]; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, - groupAccess: { + resolveEntries: handlers.resolveAllowFromEntries, + }), + groupAccess: createAccountScopedGroupAccessSection({ + channel, label: "Slack channels", placeholder: "#general, #private, C123", currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => @@ -238,57 +204,8 @@ export function createSlackSetupWizardBase(handlers: { .map(([key]) => key), updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), - setPolicy: ({ - cfg, - accountId, - policy, - }: { - cfg: OpenClawConfig; - accountId: string; - policy: "open" | "allowlist" | "disabled"; - }) => - setAccountGroupPolicyForChannel({ - cfg, - channel, - accountId, - groupPolicy: policy, - }), - resolveAllowlist: async ({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { botToken?: string }; - entries: string[]; - prompter: { note: (message: string, title?: string) => Promise }; - }) => { - try { - return await handlers.resolveGroupAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Slack channels", - error, - }); - await noteChannelLookupSummary({ - prompter, - label: "Slack channels", - resolvedSections: [], - unresolved: entries, - }); - return entries; - } - }, + resolveAllowlist: handlers.resolveGroupAllowlist, + fallbackResolved: (entries) => entries, applyAllowlist: ({ cfg, accountId, @@ -298,7 +215,7 @@ export function createSlackSetupWizardBase(handlers: { accountId: string; resolved: unknown; }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), - }, + }), disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 6731ddff84b..3f3e17301f5 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,10 +1,10 @@ import { noteChannelLookupFailure, noteChannelLookupSummary, + resolveEntriesWithOptionalToken, type OpenClawConfig, parseMentionOrPrefixedId, - promptLegacyChannelAllowFrom, - resolveSetupAccountId, + promptLegacyChannelAllowFromForAccount, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { @@ -22,22 +22,26 @@ async function resolveSlackAllowFromEntries(params: { token?: string; entries: string[]; }): Promise { - if (!params.token?.trim()) { - return params.entries.map((input) => ({ + return await resolveEntriesWithOptionalToken({ + token: params.token, + entries: params.entries, + buildWithoutToken: (input) => ({ input, resolved: false, id: null, - })); - } - const resolved = await resolveSlackUserAllowlist({ - token: params.token, - entries: params.entries, + }), + resolveEntries: async ({ token, entries }) => + ( + await resolveSlackUserAllowlist({ + token, + entries, + }) + ).map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })), }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id ?? null, - })); } async function promptSlackAllowFrom(params: { @@ -45,14 +49,6 @@ async function promptSlackAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveSetupAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultSlackAccountId(params.cfg), - }); - const resolved = resolveSlackAccount({ cfg: params.cfg, accountId }); - const token = resolved.userToken ?? resolved.botToken ?? ""; - const existing = - params.cfg.channels?.slack?.allowFrom ?? params.cfg.channels?.slack?.dm?.allowFrom ?? []; const parseId = (value: string) => parseMentionOrPrefixedId({ value, @@ -62,12 +58,16 @@ async function promptSlackAllowFrom(params: { normalizeId: (id) => id.toUpperCase(), }); - return promptLegacyChannelAllowFrom({ + return await promptLegacyChannelAllowFromForAccount({ cfg: params.cfg, channel, prompter: params.prompter, - existing, - token, + accountId: params.accountId, + defaultAccountId: resolveDefaultSlackAccountId(params.cfg), + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + resolveExisting: (_account, cfg) => + cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom ?? [], + resolveToken: (account) => account.userToken ?? account.botToken ?? "", noteTitle: "Slack allowlist", noteLines: [ "Allowlist Slack DMs by username (we resolve to user ids).", @@ -81,11 +81,17 @@ async function promptSlackAllowFrom(params: { placeholder: "@alice, U12345678", parseId, invalidWithoutTokenNote: "Slack token missing; use user ids (or mention form) only.", - resolveEntries: ({ token, entries }) => - resolveSlackUserAllowlist({ - token, - entries, - }), + resolveEntries: async ({ token, entries }) => + ( + await resolveSlackUserAllowlist({ + token, + entries, + }) + ).map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })), }); } @@ -102,11 +108,21 @@ async function resolveSlackGroupAllowlist(params: { accountId: params.accountId, }); const activeBotToken = accountWithTokens.botToken || params.credentialValues.botToken || ""; - if (activeBotToken && params.entries.length > 0) { + if (params.entries.length > 0) { try { - const resolved = await resolveSlackChannelAllowlist({ + const resolved = await resolveEntriesWithOptionalToken<{ + input: string; + resolved: boolean; + id?: string; + }>({ token: activeBotToken, entries: params.entries, + buildWithoutToken: (input) => ({ input, resolved: false, id: undefined }), + resolveEntries: async ({ token, entries }) => + await resolveSlackChannelAllowlist({ + token, + entries, + }), }); const resolvedKeys = resolved .filter((entry) => entry.resolved && entry.id) diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 4e2b9a27890..851b6e92561 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -55,7 +55,7 @@ describe("createSynologyChatPlugin", () => { it("defaultAccountId returns 'default'", () => { const plugin = createSynologyChatPlugin(); - expect(plugin.config.defaultAccountId({})).toBe("default"); + expect(plugin.config.defaultAccountId?.({})).toBe("default"); }); }); @@ -79,7 +79,7 @@ describe("createSynologyChatPlugin", () => { expect(result.policy).toBe("allowlist"); expect(result.allowFrom).toEqual(["user1"]); expect(typeof result.normalizeEntry).toBe("function"); - expect(result.normalizeEntry(" USER1 ")).toBe("user1"); + expect(result.normalizeEntry?.(" USER1 ")).toBe("user1"); }); }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 67aadff1c12..3a3cbb99eb2 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -4,13 +4,12 @@ * Implements the ChannelPlugin interface following the LINE pattern. */ -import { z } from "zod"; import { - DEFAULT_ACCOUNT_ID, - setAccountEnabledInConfigSection, - registerPluginHttpRoute, - buildChannelConfigSchema, -} from "../api.js"; + createHybridChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { z } from "zod"; +import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; @@ -23,6 +22,34 @@ const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrou const activeRouteUnregisters = new Map void>(); +const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver({ + channelKey: CHANNEL_ID, + resolvePolicy: (account) => account.dmPolicy, + resolveAllowFrom: (account) => account.allowedUserIds, + policyPathSuffix: "dmPolicy", + defaultPolicy: "allowlist", + approveHint: "openclaw pairing approve synology-chat ", + normalizeEntry: (raw) => raw.toLowerCase().trim(), +}); + +const synologyChatConfigBase = createHybridChannelConfigBase({ + sectionKey: CHANNEL_ID, + listAccountIds: (cfg: any) => listAccountIds(cfg), + resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + clearBaseFields: [ + "token", + "incomingUrl", + "nasHost", + "webhookPath", + "dmPolicy", + "allowedUserIds", + "rateLimitPerMinute", + "botName", + "allowInsecureSsl", + ], +}); + function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise { return new Promise((resolve) => { const complete = () => { @@ -73,30 +100,7 @@ export function createSynologyChatPlugin() { setupWizard: synologyChatSetupWizard, config: { - listAccountIds: (cfg: any) => listAccountIds(cfg), - - resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId), - - defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID, - - setAccountEnabled: ({ cfg, accountId, enabled }: any) => { - const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {}; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - [CHANNEL_ID]: { ...channelConfig, enabled }, - }, - }; - } - return setAccountEnabledInConfigSection({ - cfg, - sectionKey: `channels.${CHANNEL_ID}`, - accountId, - enabled, - }); - }, + ...synologyChatConfigBase, }, pairing: { @@ -115,30 +119,7 @@ export function createSynologyChatPlugin() { }, security: { - resolveDmPolicy: ({ - cfg, - accountId, - account, - }: { - cfg: any; - accountId?: string | null; - account: ResolvedSynologyChatAccount; - }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const channelCfg = (cfg as any).channels?.["synology-chat"]; - const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]); - const basePath = useAccountPath - ? `channels.synology-chat.accounts.${resolvedAccountId}.` - : "channels.synology-chat."; - return { - policy: account.dmPolicy ?? "allowlist", - allowFrom: account.allowedUserIds ?? [], - policyPath: `${basePath}dmPolicy`, - allowFromPath: basePath, - approveHint: "openclaw pairing approve synology-chat ", - normalizeEntry: (raw: string) => raw.toLowerCase().trim(), - }; - }, + resolveDmPolicy: resolveSynologyChatDmPolicy, collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => { const warnings: string[] = []; if (!account.token) { diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index ea23ab19815..bf60086c653 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,3 +1,4 @@ +import { createHybridChannelConfigBase } from "openclaw/plugin-sdk/channel-config-helpers"; import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; @@ -37,6 +38,16 @@ const tlonSetupWizardProxy = createTlonSetupWizardBase({ ).tlonSetupWizard.finalize!(params), }) satisfies NonNullable; +const tlonConfigBase = createHybridChannelConfigBase({ + sectionKey: TLON_CHANNEL_ID, + listAccountIds: (cfg: OpenClawConfig) => listTlonAccountIds(cfg), + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => + resolveTlonAccount(cfg, accountId ?? undefined), + defaultAccountId: () => "default", + clearBaseFields: ["ship", "code", "url", "name"], + preserveSectionOnDefaultDelete: true, +}); + export const tlonPlugin: ChannelPlugin = { id: TLON_CHANNEL_ID, meta: { @@ -60,70 +71,7 @@ export const tlonPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.tlon"] }, configSchema: tlonChannelConfigSchema, config: { - listAccountIds: (cfg) => listTlonAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveTlonAccount(cfg, accountId ?? undefined), - defaultAccountId: () => "default", - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const useDefault = !accountId || accountId === "default"; - if (useDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - enabled, - }, - }, - } as OpenClawConfig; - } - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - accounts: { - ...cfg.channels?.tlon?.accounts, - [accountId]: { - ...cfg.channels?.tlon?.accounts?.[accountId], - enabled, - }, - }, - }, - }, - } as OpenClawConfig; - }, - deleteAccount: ({ cfg, accountId }) => { - const useDefault = !accountId || accountId === "default"; - if (useDefault) { - const { - ship: _ship, - code: _code, - url: _url, - name: _name, - ...rest - } = cfg.channels?.tlon ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: rest, - }, - } as OpenClawConfig; - } - const { [accountId]: _removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - accounts: remainingAccounts, - }, - }, - } as OpenClawConfig; - }, + ...tlonConfigBase, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 80b03ea00c5..3828664e2f0 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -1,6 +1,10 @@ -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildAccountScopedDmSecurityPolicy, + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, + mapAllowFromEntries, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, collectOpenProviderGroupPolicyWarnings, @@ -17,13 +21,11 @@ import { buildTokenChannelStatusSummary, buildChannelSendResult, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, chunkTextForOutbound, formatAllowFromLowercase, listDirectoryUserEntriesFromAllowFrom, isNumericTargetId, sendPayloadWithChunkedTextAndMedia, - setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/zalo"; import { listZaloAccountIds, @@ -59,6 +61,29 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); +const zaloConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveZaloAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedZaloAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }), +}); + +const zaloConfigBase = createScopedChannelConfigBase({ + sectionKey: "zalo", + listAccountIds: listZaloAccountIds, + resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultZaloAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); + +const resolveZaloDmPolicy = createScopedDmSecurityResolver({ + channelKey: "zalo", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), +}); + export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, @@ -76,24 +101,7 @@ export const zaloPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.zalo"] }, configSchema: buildChannelConfigSchema(ZaloConfigSchema), config: { - listAccountIds: (cfg) => listZaloAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg, - sectionKey: "zalo", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg, - sectionKey: "zalo", - accountId, - clearBaseFields: ["botToken", "tokenFile", "name"], - }), + ...zaloConfigBase, isConfigured: (account) => Boolean(account.token?.trim()), describeAccount: (account): ChannelAccountSnapshot => ({ accountId: account.accountId, @@ -102,24 +110,10 @@ export const zaloPlugin: ChannelPlugin = { configured: Boolean(account.token?.trim()), tokenSource: account.tokenSource, }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }), + ...zaloConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "zalo", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), - }); - }, + resolveDmPolicy: resolveZaloDmPolicy, collectWarnings: ({ account, cfg }) => { return collectOpenProviderGroupPolicyWarnings({ cfg, diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 50e6761b35a..c97e189ba4a 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,5 +1,6 @@ import { buildSingleChannelSecretPromptState, + createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, formatDocsLink, hasConfiguredSecretInput, @@ -7,7 +8,6 @@ import { normalizeAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - setTopLevelChannelDmPolicyWithAllowFrom, type ChannelSetupDmPolicy, type ChannelSetupWizard, type OpenClawConfig, @@ -20,17 +20,6 @@ const channel = "zalo" as const; type UpdateMode = "polling" | "webhook"; -function setZaloDmPolicy( - cfg: OpenClawConfig, - dmPolicy: "pairing" | "allowlist" | "open" | "disabled", -) { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as OpenClawConfig; -} - function setZaloUpdateMode( cfg: OpenClawConfig, accountId: string, @@ -183,13 +172,12 @@ async function promptZaloAllowFrom(params: { } as OpenClawConfig; } -const zaloDmPolicy: ChannelSetupDmPolicy = { +const zaloDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Zalo", channel, policyKey: "channels.zalo.dmPolicy", allowFromKey: "channels.zalo.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing", - setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) @@ -201,7 +189,7 @@ const zaloDmPolicy: ChannelSetupDmPolicy = { accountId: id, }); }, -}; +}); export { zaloSetupAdapter } from "./setup-core.js"; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 61318d84e20..f0170af4aa1 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,5 +1,5 @@ +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; -import { buildAccountScopedDmSecurityPolicy } from "openclaw/plugin-sdk/channel-policy"; import type { ChannelAccountSnapshot, ChannelDirectoryEntry, @@ -217,6 +217,14 @@ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean { return true; } +const resolveZalouserDmPolicy = createScopedDmSecurityResolver({ + channelKey: "zalouser", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""), +}); + const zalouserMessageActions: ChannelMessageActionAdapter = { describeMessageTool: ({ cfg }) => { const accounts = listZalouserAccountIds(cfg) @@ -292,18 +300,7 @@ export const zalouserPlugin: ChannelPlugin = { setup: zalouserSetupAdapter, }), security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "zalouser", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""), - }); - }, + resolveDmPolicy: resolveZalouserDmPolicy, }, groups: { resolveRequireMention: resolveZalouserRequireMention, diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index 1249bf9b5de..b8eb5bc022b 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,4 +1,6 @@ import { + createTopLevelChannelDmPolicy, + createTopLevelChannelDmPolicySetter, DEFAULT_ACCOUNT_ID, formatCliCommand, formatDocsLink, @@ -6,7 +8,6 @@ import { mergeAllowFromEntries, normalizeAccountId, patchScopedAccountConfig, - setTopLevelChannelDmPolicyWithAllowFrom, type ChannelSetupDmPolicy, type ChannelSetupWizard, type DmPolicy, @@ -29,6 +30,9 @@ import { } from "./zalo-js.js"; const channel = "zalouser" as const; +const setZalouserDmPolicy = createTopLevelChannelDmPolicySetter({ + channel, +}); const ZALOUSER_ALLOW_FROM_PLACEHOLDER = "Alice, 123456789, or leave empty to configure later"; const ZALOUSER_GROUPS_PLACEHOLDER = "Family, Work, 123456789, or leave empty for now"; const ZALOUSER_DM_ACCESS_TITLE = "Zalo Personal DM access"; @@ -57,14 +61,6 @@ function setZalouserAccountScopedConfig( }) as OpenClawConfig; } -function setZalouserDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as OpenClawConfig; -} - function setZalouserGroupPolicy( cfg: OpenClawConfig, accountId: string, @@ -193,13 +189,12 @@ async function promptZalouserAllowFrom(params: { } } -const zalouserDmPolicy: ChannelSetupDmPolicy = { +const zalouserDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Zalo Personal", channel, policyKey: "channels.zalouser.dmPolicy", allowFromKey: "channels.zalouser.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as DmPolicy, - setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) @@ -211,7 +206,7 @@ const zalouserDmPolicy: ChannelSetupDmPolicy = { accountId: id, }); }, -}; +}); async function promptZalouserQuickstartDmPolicy(params: { cfg: OpenClawConfig; diff --git a/src/channels/plugins/setup-wizard-helpers.test.ts b/src/channels/plugins/setup-wizard-helpers.test.ts index 3c20f51242f..87c6a6de61c 100644 --- a/src/channels/plugins/setup-wizard-helpers.test.ts +++ b/src/channels/plugins/setup-wizard-helpers.test.ts @@ -4,27 +4,45 @@ import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { applySingleTokenPromptResult, buildSingleChannelSecretPromptState, + createAccountScopedAllowFromSection, + createAccountScopedGroupAccessSection, + createLegacyCompatChannelDmPolicy, + createNestedChannelAllowFromSetter, + createNestedChannelDmPolicy, + createNestedChannelDmPolicySetter, + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, + createTopLevelChannelDmPolicySetter, + createTopLevelChannelGroupPolicySetter, normalizeAllowFromEntries, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, parseSetupEntriesAllowingWildcard, patchChannelConfigForAccount, + patchNestedChannelConfigSection, patchLegacyDmChannelConfig, + patchTopLevelChannelConfigSection, promptLegacyChannelAllowFrom, + promptLegacyChannelAllowFromForAccount, parseSetupEntriesWithParser, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, promptSingleChannelToken, promptResolvedAllowFrom, resolveAccountIdForConfigure, + resolveEntriesWithOptionalToken, + resolveGroupAllowlistWithLookupNotes, resolveSetupAccountId, + setAccountDmAllowFromForChannel, setAccountAllowFromForChannel, setAccountGroupPolicyForChannel, setChannelDmPolicyWithAllowFrom, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, + setNestedChannelAllowFrom, + setNestedChannelDmPolicyWithAllowFrom, setLegacyChannelAllowFrom, setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, @@ -265,6 +283,45 @@ describe("promptLegacyChannelAllowFrom", () => { }); }); +describe("promptLegacyChannelAllowFromForAccount", () => { + it("resolves the account before delegating to the shared prompt flow", async () => { + const prompter = createPrompter(["alice"]); + + const next = await promptLegacyChannelAllowFromForAccount({ + cfg: { + channels: { + slack: { + dm: { + allowFrom: ["U0"], + }, + }, + }, + } as OpenClawConfig, + channel: "slack", + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + defaultAccountId: DEFAULT_ACCOUNT_ID, + resolveAccount: () => ({ + botToken: "xoxb-token", + dmAllowFrom: ["U0"], + }), + resolveExisting: (account) => account.dmAllowFrom, + resolveToken: (account) => account.botToken, + noteTitle: "Slack allowlist", + noteLines: ["line"], + message: "Slack allowFrom", + placeholder: "@alice", + parseId: () => null, + invalidWithoutTokenNote: "need ids", + resolveEntries: async ({ entries }) => + entries.map((input) => ({ input, resolved: true, id: input.toUpperCase() })), + }); + + expect(next.channels?.slack?.allowFrom).toEqual(["U0", "ALICE"]); + expect(prompter.note).toHaveBeenCalledWith("line", "Slack allowlist"); + }); +}); + describe("promptSingleChannelToken", () => { it("uses env tokens when confirmed", async () => { const prompter = createTokenPrompter({ confirms: [true], texts: [] }); @@ -1005,6 +1062,400 @@ describe("setTopLevelChannelGroupPolicy", () => { }); }); +describe("patchTopLevelChannelConfigSection", () => { + it("clears requested fields before applying a patch", () => { + const next = patchTopLevelChannelConfigSection({ + cfg: { + channels: { + nostr: { + privateKey: "nsec1", + relays: ["wss://old.example"], + }, + }, + }, + channel: "nostr", + clearFields: ["privateKey"], + patch: { relays: ["wss://new.example"] }, + enabled: true, + }); + + expect(next.channels?.nostr?.privateKey).toBeUndefined(); + expect(next.channels?.nostr?.relays).toEqual(["wss://new.example"]); + expect(next.channels?.nostr?.enabled).toBe(true); + }); +}); + +describe("patchNestedChannelConfigSection", () => { + it("clears requested nested fields before applying a patch", () => { + const next = patchNestedChannelConfigSection({ + cfg: { + channels: { + matrix: { + dm: { + policy: "pairing", + allowFrom: ["@alice:example.org"], + }, + }, + }, + }, + channel: "matrix", + section: "dm", + clearFields: ["allowFrom"], + enabled: true, + patch: { policy: "disabled" }, + }); + + expect(next.channels?.matrix?.enabled).toBe(true); + expect(next.channels?.matrix?.dm?.policy).toBe("disabled"); + expect(next.channels?.matrix?.dm?.allowFrom).toBeUndefined(); + }); +}); + +describe("createTopLevelChannelDmPolicy", () => { + it("creates a reusable dm policy definition", () => { + const dmPolicy = createTopLevelChannelDmPolicy({ + label: "LINE", + channel: "line", + policyKey: "channels.line.dmPolicy", + allowFromKey: "channels.line.allowFrom", + getCurrent: (cfg) => cfg.channels?.line?.dmPolicy ?? "pairing", + }); + + const next = dmPolicy.setPolicy( + { + channels: { + line: { + dmPolicy: "pairing", + allowFrom: ["U123"], + }, + }, + }, + "open", + ); + + expect(dmPolicy.getCurrent({})).toBe("pairing"); + expect(next.channels?.line?.dmPolicy).toBe("open"); + expect(next.channels?.line?.allowFrom).toEqual(["U123", "*"]); + }); +}); + +describe("createTopLevelChannelDmPolicySetter", () => { + it("reuses the shared top-level dmPolicy writer", () => { + const setPolicy = createTopLevelChannelDmPolicySetter({ + channel: "zalo", + }); + const next = setPolicy( + { + channels: { + zalo: { + allowFrom: ["12345"], + }, + }, + }, + "open", + ); + + expect(next.channels?.zalo?.dmPolicy).toBe("open"); + expect(next.channels?.zalo?.allowFrom).toEqual(["12345", "*"]); + }); +}); + +describe("setNestedChannelAllowFrom", () => { + it("writes nested allowFrom and can force enabled state", () => { + const next = setNestedChannelAllowFrom({ + cfg: {}, + channel: "googlechat", + section: "dm", + allowFrom: ["users/123"], + enabled: true, + }); + + expect(next.channels?.googlechat?.enabled).toBe(true); + expect(next.channels?.googlechat?.dm?.allowFrom).toEqual(["users/123"]); + }); +}); + +describe("setNestedChannelDmPolicyWithAllowFrom", () => { + it("adds wildcard allowFrom for open policy inside a nested section", () => { + const next = setNestedChannelDmPolicyWithAllowFrom({ + cfg: { + channels: { + matrix: { + dm: { + policy: "pairing", + allowFrom: ["@alice:example.org"], + }, + }, + }, + }, + channel: "matrix", + section: "dm", + dmPolicy: "open", + enabled: true, + }); + + expect(next.channels?.matrix?.enabled).toBe(true); + expect(next.channels?.matrix?.dm?.policy).toBe("open"); + expect(next.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org", "*"]); + }); +}); + +describe("createNestedChannelDmPolicy", () => { + it("creates a reusable nested dm policy definition", () => { + const dmPolicy = createNestedChannelDmPolicy({ + label: "Matrix", + channel: "matrix", + section: "dm", + policyKey: "channels.matrix.dm.policy", + allowFromKey: "channels.matrix.dm.allowFrom", + getCurrent: (cfg) => cfg.channels?.matrix?.dm?.policy ?? "pairing", + enabled: true, + }); + + const next = dmPolicy.setPolicy( + { + channels: { + matrix: { + dm: { + allowFrom: ["@alice:example.org"], + }, + }, + }, + }, + "open", + ); + + expect(next.channels?.matrix?.enabled).toBe(true); + expect(next.channels?.matrix?.dm?.policy).toBe("open"); + expect(next.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org", "*"]); + }); +}); + +describe("createNestedChannelDmPolicySetter", () => { + it("reuses the shared nested dmPolicy writer", () => { + const setPolicy = createNestedChannelDmPolicySetter({ + channel: "googlechat", + section: "dm", + enabled: true, + }); + const next = setPolicy({}, "disabled"); + + expect(next.channels?.googlechat?.enabled).toBe(true); + expect(next.channels?.googlechat?.dm?.policy).toBe("disabled"); + }); +}); + +describe("createNestedChannelAllowFromSetter", () => { + it("reuses the shared nested allowFrom writer", () => { + const setAllowFrom = createNestedChannelAllowFromSetter({ + channel: "googlechat", + section: "dm", + enabled: true, + }); + const next = setAllowFrom({}, ["users/123"]); + + expect(next.channels?.googlechat?.enabled).toBe(true); + expect(next.channels?.googlechat?.dm?.allowFrom).toEqual(["users/123"]); + }); +}); + +describe("createTopLevelChannelAllowFromSetter", () => { + it("reuses the shared top-level allowFrom writer", () => { + const setAllowFrom = createTopLevelChannelAllowFromSetter({ + channel: "msteams", + enabled: true, + }); + const next = setAllowFrom({}, ["user-1"]); + + expect(next.channels?.msteams?.allowFrom).toEqual(["user-1"]); + expect(next.channels?.msteams?.enabled).toBe(true); + }); +}); + +describe("createLegacyCompatChannelDmPolicy", () => { + it("reads nested legacy dm policy and writes top-level compat fields", () => { + const dmPolicy = createLegacyCompatChannelDmPolicy({ + label: "Slack", + channel: "slack", + }); + + expect( + dmPolicy.getCurrent({ + channels: { + slack: { + dm: { + policy: "open", + }, + }, + }, + }), + ).toBe("open"); + + const next = dmPolicy.setPolicy( + { + channels: { + slack: { + dm: { + allowFrom: ["U123"], + }, + }, + }, + }, + "open", + ); + + expect(next.channels?.slack?.dmPolicy).toBe("open"); + expect(next.channels?.slack?.allowFrom).toEqual(["U123", "*"]); + }); +}); + +describe("createTopLevelChannelGroupPolicySetter", () => { + it("reuses the shared top-level groupPolicy writer", () => { + const setGroupPolicy = createTopLevelChannelGroupPolicySetter({ + channel: "feishu", + enabled: true, + }); + const next = setGroupPolicy({}, "allowlist"); + + expect(next.channels?.feishu?.groupPolicy).toBe("allowlist"); + expect(next.channels?.feishu?.enabled).toBe(true); + }); +}); + +describe("setAccountDmAllowFromForChannel", () => { + it("writes account-scoped allowlist dm config", () => { + const next = setAccountDmAllowFromForChannel({ + cfg: {}, + channel: "discord", + accountId: DEFAULT_ACCOUNT_ID, + allowFrom: ["123"], + }); + + expect(next.channels?.discord?.dmPolicy).toBe("allowlist"); + expect(next.channels?.discord?.allowFrom).toEqual(["123"]); + }); +}); + +describe("resolveGroupAllowlistWithLookupNotes", () => { + it("returns resolved values when lookup succeeds", async () => { + const prompter = createPrompter([]); + await expect( + resolveGroupAllowlistWithLookupNotes({ + label: "Discord channels", + prompter, + entries: ["general"], + fallback: [], + resolve: async () => ["guild/channel"], + }), + ).resolves.toEqual(["guild/channel"]); + expect(prompter.note).not.toHaveBeenCalled(); + }); + + it("notes lookup failure and returns the fallback", async () => { + const prompter = createPrompter([]); + await expect( + resolveGroupAllowlistWithLookupNotes({ + label: "Slack channels", + prompter, + entries: ["general"], + fallback: ["general"], + resolve: async () => { + throw new Error("boom"); + }, + }), + ).resolves.toEqual(["general"]); + expect(prompter.note).toHaveBeenCalledTimes(2); + }); +}); + +describe("createAccountScopedAllowFromSection", () => { + it("builds an account-scoped allowFrom section with shared apply wiring", async () => { + const section = createAccountScopedAllowFromSection({ + channel: "discord", + credentialInputKey: "token", + message: "Discord allowFrom", + placeholder: "@alice", + invalidWithoutCredentialNote: "need ids", + parseId: (value) => value.trim() || null, + resolveEntries: async ({ entries }) => + entries.map((input) => ({ input, resolved: true, id: input.toUpperCase() })), + }); + + expect(section.credentialInputKey).toBe("token"); + await expect( + section.resolveEntries({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + credentialValues: {}, + entries: ["alice"], + }), + ).resolves.toEqual([{ input: "alice", resolved: true, id: "ALICE" }]); + + const next = await section.apply({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + allowFrom: ["123"], + }); + + expect(next.channels?.discord?.dmPolicy).toBe("allowlist"); + expect(next.channels?.discord?.allowFrom).toEqual(["123"]); + }); +}); + +describe("createAccountScopedGroupAccessSection", () => { + it("builds group access with shared setPolicy and fallback lookup notes", async () => { + const prompter = createPrompter([]); + const section = createAccountScopedGroupAccessSection({ + channel: "slack", + label: "Slack channels", + placeholder: "#general", + currentPolicy: () => "allowlist", + currentEntries: () => [], + updatePrompt: () => false, + resolveAllowlist: async () => { + throw new Error("boom"); + }, + fallbackResolved: (entries) => entries, + applyAllowlist: ({ cfg, resolved, accountId }) => + patchChannelConfigForAccount({ + cfg, + channel: "slack", + accountId, + patch: { + channels: Object.fromEntries(resolved.map((entry) => [entry, { allow: true }])), + }, + }), + }); + + const policyNext = section.setPolicy({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + policy: "open", + }); + expect(policyNext.channels?.slack?.groupPolicy).toBe("open"); + + await expect( + section.resolveAllowlist?.({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + credentialValues: {}, + entries: ["general"], + prompter, + }), + ).resolves.toEqual(["general"]); + expect(prompter.note).toHaveBeenCalledTimes(2); + + const allowlistNext = section.applyAllowlist?.({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + resolved: ["C123"], + }); + expect(allowlistNext?.channels?.slack?.channels).toEqual({ + C123: { allow: true }, + }); + }); +}); + describe("splitSetupEntries", () => { it("splits comma/newline/semicolon input and trims blanks", () => { expect(splitSetupEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); @@ -1060,6 +1511,39 @@ describe("parseSetupEntriesAllowingWildcard", () => { }); }); +describe("resolveEntriesWithOptionalToken", () => { + it("returns unresolved entries when token is missing", async () => { + await expect( + resolveEntriesWithOptionalToken({ + entries: ["alice", "bob"], + buildWithoutToken: (input) => ({ input, resolved: false, id: null }), + resolveEntries: async () => { + throw new Error("should not run"); + }, + }), + ).resolves.toEqual([ + { input: "alice", resolved: false, id: null }, + { input: "bob", resolved: false, id: null }, + ]); + }); + + it("delegates to the resolver when token exists", async () => { + await expect( + resolveEntriesWithOptionalToken<{ + input: string; + resolved: boolean; + id: string | null; + }>({ + token: "xoxb-test", + entries: ["alice"], + buildWithoutToken: (input) => ({ input, resolved: false, id: null }), + resolveEntries: async ({ token, entries }) => + entries.map((input) => ({ input, resolved: true, id: `${token}:${input}` })), + }), + ).resolves.toEqual([{ input: "alice", resolved: true, id: "xoxb-test:alice" }]); + }); +}); + describe("parseMentionOrPrefixedId", () => { it("parses mention ids", () => { expect( diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index c80a00dd324..187036bcfff 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -11,7 +11,12 @@ import { moveSingleAccountChannelSectionToDefaultAccount, patchScopedAccountConfig, } from "./setup-helpers.js"; -import type { PromptAccountId, PromptAccountIdParams } from "./setup-wizard-types.js"; +import type { + ChannelSetupDmPolicy, + PromptAccountId, + PromptAccountIdParams, +} from "./setup-wizard-types.js"; +import type { ChannelSetupWizard } from "./setup-wizard.js"; export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { const existingIds = params.listAccountIds(params.cfg); @@ -192,14 +197,19 @@ export function setAccountAllowFromForChannel(params: { }); } -function patchTopLevelChannelConfig(params: { +export function patchTopLevelChannelConfigSection(params: { cfg: OpenClawConfig; channel: string; enabled?: boolean; + clearFields?: string[]; patch: Record; }): OpenClawConfig { - const channelConfig = - (params.cfg.channels?.[params.channel] as Record | undefined) ?? {}; + const channelConfig = { + ...(params.cfg.channels?.[params.channel] as Record | undefined), + }; + for (const field of params.clearFields ?? []) { + delete channelConfig[field]; + } return { ...params.cfg, channels: { @@ -213,13 +223,46 @@ function patchTopLevelChannelConfig(params: { }; } +export function patchNestedChannelConfigSection(params: { + cfg: OpenClawConfig; + channel: string; + section: string; + enabled?: boolean; + clearFields?: string[]; + patch: Record; +}): OpenClawConfig { + const channelConfig = { + ...(params.cfg.channels?.[params.channel] as Record | undefined), + }; + const sectionConfig = { + ...(channelConfig[params.section] as Record | undefined), + }; + for (const field of params.clearFields ?? []) { + delete sectionConfig[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [params.channel]: { + ...channelConfig, + ...(params.enabled ? { enabled: true } : {}), + [params.section]: { + ...sectionConfig, + ...params.patch, + }, + }, + }, + }; +} + export function setTopLevelChannelAllowFrom(params: { cfg: OpenClawConfig; channel: string; allowFrom: string[]; enabled?: boolean; }): OpenClawConfig { - return patchTopLevelChannelConfig({ + return patchTopLevelChannelConfigSection({ cfg: params.cfg, channel: params.channel, enabled: params.enabled, @@ -227,6 +270,22 @@ export function setTopLevelChannelAllowFrom(params: { }); } +export function setNestedChannelAllowFrom(params: { + cfg: OpenClawConfig; + channel: string; + section: string; + allowFrom: string[]; + enabled?: boolean; +}): OpenClawConfig { + return patchNestedChannelConfigSection({ + cfg: params.cfg, + channel: params.channel, + section: params.section, + enabled: params.enabled, + patch: { allowFrom: params.allowFrom }, + }); +} + export function setTopLevelChannelDmPolicyWithAllowFrom(params: { cfg: OpenClawConfig; channel: string; @@ -241,7 +300,7 @@ export function setTopLevelChannelDmPolicyWithAllowFrom(params: { undefined; const allowFrom = params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; - return patchTopLevelChannelConfig({ + return patchTopLevelChannelConfigSection({ cfg: params.cfg, channel: params.channel, patch: { @@ -251,13 +310,43 @@ export function setTopLevelChannelDmPolicyWithAllowFrom(params: { }); } +export function setNestedChannelDmPolicyWithAllowFrom(params: { + cfg: OpenClawConfig; + channel: string; + section: string; + dmPolicy: DmPolicy; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; + enabled?: boolean; +}): OpenClawConfig { + const channelConfig = + (params.cfg.channels?.[params.channel] as Record | undefined) ?? {}; + const sectionConfig = + (channelConfig[params.section] as Record | undefined) ?? {}; + const existingAllowFrom = + params.getAllowFrom?.(params.cfg) ?? + (sectionConfig.allowFrom as Array | undefined) ?? + undefined; + const allowFrom = + params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; + return patchNestedChannelConfigSection({ + cfg: params.cfg, + channel: params.channel, + section: params.section, + enabled: params.enabled, + patch: { + policy: params.dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }); +} + export function setTopLevelChannelGroupPolicy(params: { cfg: OpenClawConfig; channel: string; groupPolicy: GroupPolicy; enabled?: boolean; }): OpenClawConfig { - return patchTopLevelChannelConfig({ + return patchTopLevelChannelConfigSection({ cfg: params.cfg, channel: params.channel, enabled: params.enabled, @@ -265,6 +354,129 @@ export function setTopLevelChannelGroupPolicy(params: { }); } +export function createTopLevelChannelDmPolicy(params: { + label: string; + channel: string; + policyKey: string; + allowFromKey: string; + getCurrent: (cfg: OpenClawConfig) => DmPolicy; + promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; +}): ChannelSetupDmPolicy { + const setPolicy = createTopLevelChannelDmPolicySetter({ + channel: params.channel, + getAllowFrom: params.getAllowFrom, + }); + return { + label: params.label, + channel: params.channel, + policyKey: params.policyKey, + allowFromKey: params.allowFromKey, + getCurrent: params.getCurrent, + setPolicy, + ...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}), + }; +} + +export function createNestedChannelDmPolicy(params: { + label: string; + channel: string; + section: string; + policyKey: string; + allowFromKey: string; + getCurrent: (cfg: OpenClawConfig) => DmPolicy; + promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; + enabled?: boolean; +}): ChannelSetupDmPolicy { + const setPolicy = createNestedChannelDmPolicySetter({ + channel: params.channel, + section: params.section, + getAllowFrom: params.getAllowFrom, + enabled: params.enabled, + }); + return { + label: params.label, + channel: params.channel, + policyKey: params.policyKey, + allowFromKey: params.allowFromKey, + getCurrent: params.getCurrent, + setPolicy, + ...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}), + }; +} + +export function createTopLevelChannelDmPolicySetter(params: { + channel: string; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; +}): (cfg: OpenClawConfig, dmPolicy: DmPolicy) => OpenClawConfig { + return (cfg, dmPolicy) => + setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel: params.channel, + dmPolicy, + getAllowFrom: params.getAllowFrom, + }); +} + +export function createNestedChannelDmPolicySetter(params: { + channel: string; + section: string; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; + enabled?: boolean; +}): (cfg: OpenClawConfig, dmPolicy: DmPolicy) => OpenClawConfig { + return (cfg, dmPolicy) => + setNestedChannelDmPolicyWithAllowFrom({ + cfg, + channel: params.channel, + section: params.section, + dmPolicy, + getAllowFrom: params.getAllowFrom, + enabled: params.enabled, + }); +} + +export function createTopLevelChannelAllowFromSetter(params: { + channel: string; + enabled?: boolean; +}): (cfg: OpenClawConfig, allowFrom: string[]) => OpenClawConfig { + return (cfg, allowFrom) => + setTopLevelChannelAllowFrom({ + cfg, + channel: params.channel, + allowFrom, + enabled: params.enabled, + }); +} + +export function createNestedChannelAllowFromSetter(params: { + channel: string; + section: string; + enabled?: boolean; +}): (cfg: OpenClawConfig, allowFrom: string[]) => OpenClawConfig { + return (cfg, allowFrom) => + setNestedChannelAllowFrom({ + cfg, + channel: params.channel, + section: params.section, + allowFrom, + enabled: params.enabled, + }); +} + +export function createTopLevelChannelGroupPolicySetter(params: { + channel: string; + enabled?: boolean; +}): (cfg: OpenClawConfig, groupPolicy: "open" | "allowlist" | "disabled") => OpenClawConfig { + return (cfg, groupPolicy) => + setTopLevelChannelGroupPolicy({ + cfg, + channel: params.channel, + groupPolicy, + enabled: params.enabled, + }); +} + export function setChannelDmPolicyWithAllowFrom(params: { cfg: OpenClawConfig; channel: "imessage" | "signal" | "telegram"; @@ -339,6 +551,177 @@ export function setAccountGroupPolicyForChannel(params: { }); } +export function setAccountDmAllowFromForChannel(params: { + cfg: OpenClawConfig; + channel: "discord" | "slack"; + accountId: string; + allowFrom: string[]; +}): OpenClawConfig { + return patchChannelConfigForAccount({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + patch: { dmPolicy: "allowlist", allowFrom: params.allowFrom }, + }); +} + +export function createLegacyCompatChannelDmPolicy(params: { + label: string; + channel: LegacyDmChannel; + promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; +}): ChannelSetupDmPolicy { + return { + label: params.label, + channel: params.channel, + policyKey: `channels.${params.channel}.dmPolicy`, + allowFromKey: `channels.${params.channel}.allowFrom`, + getCurrent: (cfg) => + ( + cfg.channels?.[params.channel] as + | { + dmPolicy?: DmPolicy; + dm?: { policy?: DmPolicy }; + } + | undefined + )?.dmPolicy ?? + ( + cfg.channels?.[params.channel] as + | { + dmPolicy?: DmPolicy; + dm?: { policy?: DmPolicy }; + } + | undefined + )?.dm?.policy ?? + "pairing", + setPolicy: (cfg, policy) => + setLegacyChannelDmPolicyWithAllowFrom({ + cfg, + channel: params.channel, + dmPolicy: policy, + }), + ...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}), + }; +} + +export async function resolveGroupAllowlistWithLookupNotes(params: { + label: string; + prompter: Pick; + entries: string[]; + fallback: TResolved; + resolve: () => Promise; +}): Promise { + try { + return await params.resolve(); + } catch (error) { + await noteChannelLookupFailure({ + prompter: params.prompter, + label: params.label, + error, + }); + await noteChannelLookupSummary({ + prompter: params.prompter, + label: params.label, + resolvedSections: [], + unresolved: params.entries, + }); + return params.fallback; + } +} + +export function createAccountScopedAllowFromSection(params: { + channel: "discord" | "slack"; + credentialInputKey?: NonNullable["credentialInputKey"]; + helpTitle?: string; + helpLines?: string[]; + message: string; + placeholder: string; + invalidWithoutCredentialNote: string; + parseId: NonNullable["parseId"]>; + resolveEntries: NonNullable["resolveEntries"]>; +}): NonNullable { + return { + ...(params.helpTitle ? { helpTitle: params.helpTitle } : {}), + ...(params.helpLines ? { helpLines: params.helpLines } : {}), + ...(params.credentialInputKey ? { credentialInputKey: params.credentialInputKey } : {}), + message: params.message, + placeholder: params.placeholder, + invalidWithoutCredentialNote: params.invalidWithoutCredentialNote, + parseId: params.parseId, + resolveEntries: params.resolveEntries, + apply: ({ cfg, accountId, allowFrom }) => + setAccountDmAllowFromForChannel({ + cfg, + channel: params.channel, + accountId, + allowFrom, + }), + }; +} + +export function createAccountScopedGroupAccessSection(params: { + channel: "discord" | "slack"; + label: string; + placeholder: string; + helpTitle?: string; + helpLines?: string[]; + skipAllowlistEntries?: boolean; + currentPolicy: NonNullable["currentPolicy"]; + currentEntries: NonNullable["currentEntries"]; + updatePrompt: NonNullable["updatePrompt"]; + resolveAllowlist?: NonNullable< + NonNullable["resolveAllowlist"] + >; + fallbackResolved: (entries: string[]) => TResolved; + applyAllowlist: (params: { + cfg: OpenClawConfig; + accountId: string; + resolved: TResolved; + }) => OpenClawConfig; +}): NonNullable { + return { + label: params.label, + placeholder: params.placeholder, + ...(params.helpTitle ? { helpTitle: params.helpTitle } : {}), + ...(params.helpLines ? { helpLines: params.helpLines } : {}), + ...(params.skipAllowlistEntries ? { skipAllowlistEntries: true } : {}), + currentPolicy: params.currentPolicy, + currentEntries: params.currentEntries, + updatePrompt: params.updatePrompt, + setPolicy: ({ cfg, accountId, policy }) => + setAccountGroupPolicyForChannel({ + cfg, + channel: params.channel, + accountId, + groupPolicy: policy, + }), + ...(params.resolveAllowlist + ? { + resolveAllowlist: ({ cfg, accountId, credentialValues, entries, prompter }) => + resolveGroupAllowlistWithLookupNotes({ + label: params.label, + prompter, + entries, + fallback: params.fallbackResolved(entries), + resolve: async () => + await params.resolveAllowlist!({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }), + }), + } + : {}), + applyAllowlist: ({ cfg, accountId, resolved }) => + params.applyAllowlist({ + cfg, + accountId, + resolved: resolved as TResolved, + }), + }; +} + type AccountScopedChannel = "discord" | "slack" | "telegram" | "imessage" | "signal"; type LegacyDmChannel = "discord" | "slack"; @@ -753,6 +1136,22 @@ type AllowFromResolution = { id?: string | null; }; +export async function resolveEntriesWithOptionalToken(params: { + token?: string | null; + entries: string[]; + buildWithoutToken: (input: string) => TResult; + resolveEntries: (params: { token: string; entries: string[] }) => Promise; +}): Promise { + const token = params.token?.trim(); + if (!token) { + return params.entries.map(params.buildWithoutToken); + } + return await params.resolveEntries({ + token, + entries: params.entries, + }); +} + export async function promptResolvedAllowFrom(params: { prompter: WizardPrompter; existing: Array; @@ -838,3 +1237,41 @@ export async function promptLegacyChannelAllowFrom(params: { allowFrom: unique, }); } + +export async function promptLegacyChannelAllowFromForAccount(params: { + cfg: OpenClawConfig; + channel: LegacyDmChannel; + prompter: WizardPrompter; + accountId?: string; + defaultAccountId: string; + resolveAccount: (cfg: OpenClawConfig, accountId: string) => TAccount; + resolveExisting: (account: TAccount, cfg: OpenClawConfig) => Array; + resolveToken: (account: TAccount) => string | null | undefined; + noteTitle: string; + noteLines: string[]; + message: string; + placeholder: string; + parseId: (value: string) => string | null; + invalidWithoutTokenNote: string; + resolveEntries: (params: { token: string; entries: string[] }) => Promise; +}): Promise { + const accountId = resolveSetupAccountId({ + accountId: params.accountId, + defaultAccountId: params.defaultAccountId, + }); + const account = params.resolveAccount(params.cfg, accountId); + return await promptLegacyChannelAllowFrom({ + cfg: params.cfg, + channel: params.channel, + prompter: params.prompter, + existing: params.resolveExisting(account, params.cfg), + token: params.resolveToken(account), + noteTitle: params.noteTitle, + noteLines: params.noteLines, + message: params.message, + placeholder: params.placeholder, + parseId: params.parseId, + invalidWithoutTokenNote: params.invalidWithoutTokenNote, + resolveEntries: params.resolveEntries, + }); +} diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index 3a432006b6b..084d6e26532 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, + createTopLevelChannelConfigBase, + createHybridChannelConfigBase, mapAllowFromEntries, resolveOptionalConfigString, } from "./channel-config-helpers.js"; @@ -72,3 +76,188 @@ describe("createScopedAccountConfigAccessors", () => { expect(accessors.resolveDefaultTo).toBeUndefined(); }); }); + +describe("createScopedChannelConfigBase", () => { + it("wires shared account config CRUD through the section helper", () => { + const base = createScopedChannelConfigBase({ + sectionKey: "demo", + listAccountIds: () => ["default", "alt"], + resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }), + defaultAccountId: () => "default", + clearBaseFields: ["token"], + }); + + expect(base.listAccountIds({})).toEqual(["default", "alt"]); + expect(base.resolveAccount({}, "alt")).toEqual({ accountId: "alt" }); + expect(base.defaultAccountId!({})).toBe("default"); + expect( + base.setAccountEnabled!({ + cfg: {}, + accountId: "default", + enabled: true, + }).channels?.demo, + ).toEqual({ enabled: true }); + expect( + base.deleteAccount!({ + cfg: { + channels: { + demo: { + token: "secret", + }, + }, + }, + accountId: "default", + }).channels, + ).toBeUndefined(); + }); +}); + +describe("createScopedDmSecurityResolver", () => { + it("builds account-aware DM policy payloads", () => { + const resolveDmPolicy = createScopedDmSecurityResolver<{ + accountId?: string | null; + dmPolicy?: string; + allowFrom?: string[]; + }>({ + channelKey: "demo", + resolvePolicy: (account) => account.dmPolicy, + resolveAllowFrom: (account) => account.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.toLowerCase(), + }); + + expect( + resolveDmPolicy({ + cfg: { + channels: { + demo: { + accounts: { + alt: {}, + }, + }, + }, + }, + accountId: "alt", + account: { + accountId: "alt", + dmPolicy: "allowlist", + allowFrom: ["Owner"], + }, + }), + ).toEqual({ + policy: "allowlist", + allowFrom: ["Owner"], + policyPath: "channels.demo.accounts.alt.dmPolicy", + allowFromPath: "channels.demo.accounts.alt.", + approveHint: "Approve via: openclaw pairing list demo / openclaw pairing approve demo ", + normalizeEntry: expect.any(Function), + }); + }); +}); + +describe("createTopLevelChannelConfigBase", () => { + it("wires top-level enable/delete semantics", () => { + const base = createTopLevelChannelConfigBase({ + sectionKey: "demo", + resolveAccount: () => ({ accountId: "default" }), + }); + + expect(base.listAccountIds({})).toEqual(["default"]); + expect(base.defaultAccountId!({})).toBe("default"); + expect( + base.setAccountEnabled!({ + cfg: {}, + accountId: "default", + enabled: true, + }).channels?.demo, + ).toEqual({ enabled: true }); + expect( + base.deleteAccount!({ + cfg: { + channels: { + demo: { + enabled: true, + }, + }, + }, + accountId: "default", + }).channels, + ).toBeUndefined(); + }); +}); + +describe("createHybridChannelConfigBase", () => { + it("writes default account enable at the channel root and named accounts under accounts", () => { + const base = createHybridChannelConfigBase({ + sectionKey: "demo", + listAccountIds: () => ["default", "alt"], + resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }), + defaultAccountId: () => "default", + clearBaseFields: ["token"], + }); + + expect( + base.setAccountEnabled!({ + cfg: { + channels: { + demo: { + accounts: { + alt: { enabled: false }, + }, + }, + }, + }, + accountId: "default", + enabled: true, + }).channels?.demo, + ).toEqual({ + accounts: { + alt: { enabled: false }, + }, + enabled: true, + }); + expect( + base.setAccountEnabled!({ + cfg: {}, + accountId: "alt", + enabled: true, + }).channels?.demo, + ).toEqual({ + accounts: { + alt: { enabled: true }, + }, + }); + }); + + it("can preserve the section when deleting the default account", () => { + const base = createHybridChannelConfigBase({ + sectionKey: "demo", + listAccountIds: () => ["default", "alt"], + resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }), + defaultAccountId: () => "default", + clearBaseFields: ["token", "name"], + preserveSectionOnDefaultDelete: true, + }); + + expect( + base.deleteAccount!({ + cfg: { + channels: { + demo: { + token: "secret", + name: "bot", + accounts: { + alt: { enabled: true }, + }, + }, + }, + }, + accountId: "default", + }).channels?.demo, + ).toEqual({ + accounts: { + alt: { enabled: true }, + }, + }); + }); +}); diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 556e2a0c1c1..af6813e13a1 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -14,7 +14,7 @@ import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize import { getChannelPlugin } from "../channels/plugins/registry.js"; import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../config/config.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; /** Coerce mixed allowlist config values into plain strings without trimming or deduping. */ @@ -116,6 +116,178 @@ export function createScopedChannelConfigBase< }; } +function setTopLevelChannelEnabledInConfigSection(params: { + cfg: Config; + sectionKey: string; + enabled: boolean; +}): Config { + const section = params.cfg.channels?.[params.sectionKey] as Record | undefined; + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [params.sectionKey]: { + ...section, + enabled: params.enabled, + }, + }, + } as Config; +} + +function removeTopLevelChannelConfigSection(params: { + cfg: Config; + sectionKey: string; +}): Config { + const nextChannels = { ...params.cfg.channels } as Record; + delete nextChannels[params.sectionKey]; + const nextCfg = { ...params.cfg }; + if (Object.keys(nextChannels).length > 0) { + nextCfg.channels = nextChannels as Config["channels"]; + } else { + delete nextCfg.channels; + } + return nextCfg; +} + +function clearTopLevelChannelConfigFields(params: { + cfg: Config; + sectionKey: string; + clearBaseFields: string[]; +}): Config { + const section = params.cfg.channels?.[params.sectionKey] as Record | undefined; + if (!section) { + return params.cfg; + } + const nextSection = { ...section }; + for (const field of params.clearBaseFields) { + delete nextSection[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [params.sectionKey]: nextSection, + }, + } as Config; +} + +/** Build CRUD/config helpers for top-level single-account channels. */ +export function createTopLevelChannelConfigBase< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + sectionKey: string; + resolveAccount: (cfg: Config) => ResolvedAccount; + listAccountIds?: (cfg: Config) => string[]; + defaultAccountId?: (cfg: Config) => string; + inspectAccount?: (cfg: Config) => unknown; + deleteMode?: "remove-section" | "clear-fields"; + clearBaseFields?: string[]; +}): Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" +> { + return { + listAccountIds: (cfg) => params.listAccountIds?.(cfg as Config) ?? [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg) => params.resolveAccount(cfg as Config), + inspectAccount: params.inspectAccount + ? (cfg) => params.inspectAccount?.(cfg as Config) + : undefined, + defaultAccountId: (cfg) => params.defaultAccountId?.(cfg as Config) ?? DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => + setTopLevelChannelEnabledInConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + enabled, + }), + deleteAccount: ({ cfg }) => + params.deleteMode === "clear-fields" + ? clearTopLevelChannelConfigFields({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + clearBaseFields: params.clearBaseFields ?? [], + }) + : removeTopLevelChannelConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + }), + }; +} + +/** Build CRUD/config helpers for channels where the default account lives at channel root and named accounts live under `accounts`. */ +export function createHybridChannelConfigBase< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + sectionKey: string; + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + defaultAccountId: (cfg: Config) => string; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + clearBaseFields: string[]; + preserveSectionOnDefaultDelete?: boolean; +}): Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" +> { + return { + listAccountIds: (cfg) => params.listAccountIds(cfg as Config), + resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), + inspectAccount: params.inspectAccount + ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) + : undefined, + defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { + return setTopLevelChannelEnabledInConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + enabled, + }); + } + return setAccountEnabledInConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + enabled, + }); + }, + deleteAccount: ({ cfg, accountId }) => { + if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { + if (params.preserveSectionOnDefaultDelete) { + return clearTopLevelChannelConfigFields({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + clearBaseFields: params.clearBaseFields, + }); + } + return deleteAccountFromConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + clearBaseFields: params.clearBaseFields, + }); + } + return deleteAccountFromConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + clearBaseFields: params.clearBaseFields, + }); + }, + }; +} + /** Convert account-specific DM security fields into the shared runtime policy resolver shape. */ export function createScopedDmSecurityResolver< ResolvedAccount extends { accountId?: string | null }, diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index ad8d9ff5293..05a85d56e2a 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -25,9 +25,11 @@ export { createPluginRuntimeStore } from "./runtime-store.js"; export { KeyedAsyncQueue } from "./keyed-async-queue.js"; export { + createHybridChannelConfigBase, createScopedAccountConfigAccessors, createScopedChannelConfigBase, createScopedDmSecurityResolver, + createTopLevelChannelConfigBase, mapAllowFromEntries, } from "./channel-config-helpers.js"; export { formatAllowFromLowercase, formatNormalizedAllowFromEntries } from "./allow-from.js"; diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index 065fbfeed9c..5865de6396e 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -33,6 +33,16 @@ export { export { addWildcardAllowFrom, buildSingleChannelSecretPromptState, + createAccountScopedAllowFromSection, + createAccountScopedGroupAccessSection, + createLegacyCompatChannelDmPolicy, + createNestedChannelAllowFromSetter, + createNestedChannelDmPolicy, + createNestedChannelDmPolicySetter, + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, + createTopLevelChannelDmPolicySetter, + createTopLevelChannelGroupPolicySetter, mergeAllowFromEntries, normalizeAllowFromEntries, noteChannelLookupFailure, @@ -40,16 +50,24 @@ export { parseMentionOrPrefixedId, parseSetupEntriesAllowingWildcard, parseSetupEntriesWithParser, + patchNestedChannelConfigSection, + patchTopLevelChannelConfigSection, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, + promptLegacyChannelAllowFromForAccount, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, promptResolvedAllowFrom, + resolveEntriesWithOptionalToken, resolveSetupAccountId, + resolveGroupAllowlistWithLookupNotes, runSingleChannelSecretStep, + setAccountDmAllowFromForChannel, setAccountGroupPolicyForChannel, setChannelDmPolicyWithAllowFrom, setLegacyChannelDmPolicyWithAllowFrom, + setNestedChannelAllowFrom, + setNestedChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 06a1108a45e..bf7943bbfc5 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -97,8 +97,18 @@ describe("plugin-sdk subpath exports", () => { it("exports shared setup helpers from the dedicated subpath", () => { expect(typeof setupSdk.DEFAULT_ACCOUNT_ID).toBe("string"); + expect(typeof setupSdk.createAccountScopedAllowFromSection).toBe("function"); + expect(typeof setupSdk.createAccountScopedGroupAccessSection).toBe("function"); + expect(typeof setupSdk.createLegacyCompatChannelDmPolicy).toBe("function"); + expect(typeof setupSdk.createNestedChannelDmPolicy).toBe("function"); + expect(typeof setupSdk.createTopLevelChannelDmPolicy).toBe("function"); + expect(typeof setupSdk.createTopLevelChannelDmPolicySetter).toBe("function"); expect(typeof setupSdk.formatDocsLink).toBe("function"); expect(typeof setupSdk.mergeAllowFromEntries).toBe("function"); + expect(typeof setupSdk.patchNestedChannelConfigSection).toBe("function"); + expect(typeof setupSdk.patchTopLevelChannelConfigSection).toBe("function"); + expect(typeof setupSdk.resolveGroupAllowlistWithLookupNotes).toBe("function"); + expect(typeof setupSdk.setAccountDmAllowFromForChannel).toBe("function"); expect(typeof setupSdk.setTopLevelChannelDmPolicyWithAllowFrom).toBe("function"); expect(typeof setupSdk.formatResolvedUnresolvedNote).toBe("function"); }); From 0bfaa3612642784dbeba7cd92aef9c90fc6212dc Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:30:02 +0000 Subject: [PATCH 09/21] Discord: move group policy behind plugin boundary --- extensions/discord/api.ts | 1 + extensions/discord/src/channel.test.ts | 39 +++++++ extensions/discord/src/channel.ts | 6 +- extensions/discord/src/group-policy.test.ts | 79 ++++++++++++++ extensions/discord/src/group-policy.ts | 111 +++++++++++++++++++ src/channels/plugins/group-mentions.test.ts | 76 ------------- src/channels/plugins/group-mentions.ts | 113 +------------------- src/plugin-sdk/core.ts | 2 +- src/plugin-sdk/discord.ts | 2 +- 9 files changed, 237 insertions(+), 192 deletions(-) create mode 100644 extensions/discord/src/group-policy.test.ts create mode 100644 extensions/discord/src/group-policy.ts diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts index 858255c0495..19a5b926ff0 100644 --- a/extensions/discord/api.ts +++ b/extensions/discord/api.ts @@ -3,6 +3,7 @@ export * from "./src/accounts.js"; export * from "./src/actions/handle-action.guild-admin.js"; export * from "./src/actions/handle-action.js"; export * from "./src/components.js"; +export * from "./src/group-policy.js"; export * from "./src/normalize.js"; export * from "./src/pluralkit.js"; export * from "./src/session-key-normalization.js"; diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 5e47dda6334..b5f2224b1dd 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -209,3 +209,42 @@ describe("discordPlugin outbound", () => { expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled(); }); }); + +describe("discordPlugin groups", () => { + it("uses plugin-owned group policy resolvers", () => { + const cfg = { + channels: { + discord: { + token: "discord-test", + guilds: { + guild1: { + requireMention: false, + tools: { allow: ["message.guild"] }, + channels: { + "123": { + requireMention: true, + tools: { allow: ["message.channel"] }, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect( + discordPlugin.groups?.resolveRequireMention?.({ + cfg, + groupSpace: "guild1", + groupId: "123", + }), + ).toBe(true); + expect( + discordPlugin.groups?.resolveToolPolicy?.({ + cfg, + groupSpace: "guild1", + groupId: "123", + }), + ).toEqual({ allow: ["message.channel"] }); + }); +}); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 21348036a46..30a5535a6d9 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -21,8 +21,6 @@ import { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, - resolveDiscordGroupRequireMention, - resolveDiscordGroupToolPolicy, type ChannelMessageActionAdapter, type ChannelPlugin, type OpenClawConfig, @@ -38,6 +36,10 @@ import { isDiscordExecApprovalClientEnabled, shouldSuppressLocalDiscordExecApprovalPrompt, } from "./exec-approvals.js"; +import { + resolveDiscordGroupRequireMention, + resolveDiscordGroupToolPolicy, +} from "./group-policy.js"; import { monitorDiscordProvider } from "./monitor.js"; import { looksLikeDiscordTargetId, diff --git a/extensions/discord/src/group-policy.test.ts b/extensions/discord/src/group-policy.test.ts new file mode 100644 index 00000000000..249df3fa8a7 --- /dev/null +++ b/extensions/discord/src/group-policy.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + resolveDiscordGroupRequireMention, + resolveDiscordGroupToolPolicy, +} from "./group-policy.js"; + +describe("discord group policy", () => { + it("prefers channel policy, then guild policy, with sender-specific overrides", () => { + const discordCfg = { + channels: { + discord: { + token: "discord-test", + guilds: { + guild1: { + requireMention: false, + tools: { allow: ["message.guild"] }, + toolsBySender: { + "id:user:guild-admin": { allow: ["sessions.list"] }, + }, + channels: { + "123": { + requireMention: true, + tools: { allow: ["message.channel"] }, + toolsBySender: { + "id:user:channel-admin": { deny: ["exec"] }, + }, + }, + }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect( + resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }), + ).toBe(true); + expect( + resolveDiscordGroupRequireMention({ + cfg: discordCfg, + groupSpace: "guild1", + groupId: "missing", + }), + ).toBe(false); + expect( + resolveDiscordGroupToolPolicy({ + cfg: discordCfg, + groupSpace: "guild1", + groupId: "123", + senderId: "user:channel-admin", + }), + ).toEqual({ deny: ["exec"] }); + expect( + resolveDiscordGroupToolPolicy({ + cfg: discordCfg, + groupSpace: "guild1", + groupId: "123", + senderId: "user:someone", + }), + ).toEqual({ allow: ["message.channel"] }); + expect( + resolveDiscordGroupToolPolicy({ + cfg: discordCfg, + groupSpace: "guild1", + groupId: "missing", + senderId: "user:guild-admin", + }), + ).toEqual({ allow: ["sessions.list"] }); + expect( + resolveDiscordGroupToolPolicy({ + cfg: discordCfg, + groupSpace: "guild1", + groupId: "missing", + senderId: "user:someone", + }), + ).toEqual({ allow: ["message.guild"] }); + }); +}); diff --git a/extensions/discord/src/group-policy.ts b/extensions/discord/src/group-policy.ts new file mode 100644 index 00000000000..f327a761ea0 --- /dev/null +++ b/extensions/discord/src/group-policy.ts @@ -0,0 +1,111 @@ +import { + resolveToolsBySender, + type GroupToolPolicyBySenderConfig, + type GroupToolPolicyConfig, +} from "openclaw/plugin-sdk/channel-policy"; +import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core"; +import type { DiscordConfig } from "openclaw/plugin-sdk/discord"; + +function normalizeDiscordSlug(value?: string | null) { + return normalizeAtHashSlug(value); +} + +type SenderScopedToolsEntry = { + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; + requireMention?: boolean; +}; + +function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: string | null) { + if (!guilds || Object.keys(guilds).length === 0) { + return null; + } + const space = groupSpace?.trim() ?? ""; + if (space && guilds[space]) { + return guilds[space]; + } + const normalized = normalizeDiscordSlug(space); + if (normalized && guilds[normalized]) { + return guilds[normalized]; + } + if (normalized) { + const match = Object.values(guilds).find( + (entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized, + ); + if (match) { + return match; + } + } + return guilds["*"] ?? null; +} + +function resolveDiscordChannelEntry( + channelEntries: Record | undefined, + params: { groupId?: string | null; groupChannel?: string | null }, +): TEntry | undefined { + if (!channelEntries || Object.keys(channelEntries).length === 0) { + return undefined; + } + const groupChannel = params.groupChannel; + const channelSlug = normalizeDiscordSlug(groupChannel); + return ( + (params.groupId ? channelEntries[params.groupId] : undefined) ?? + (channelSlug + ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) + : undefined) ?? + (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined) + ); +} + +function resolveSenderToolsEntry( + entry: SenderScopedToolsEntry | undefined | null, + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + if (!entry) { + return undefined; + } + const senderPolicy = resolveToolsBySender({ + toolsBySender: entry.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + return senderPolicy ?? entry.tools; +} + +function resolveDiscordPolicyContext(params: ChannelGroupContext) { + const guildEntry = resolveDiscordGuildEntry( + params.cfg.channels?.discord?.guilds, + params.groupSpace, + ); + const channelEntries = guildEntry?.channels; + const channelEntry = + channelEntries && Object.keys(channelEntries).length > 0 + ? resolveDiscordChannelEntry(channelEntries, params) + : undefined; + return { guildEntry, channelEntry }; +} + +export function resolveDiscordGroupRequireMention(params: ChannelGroupContext): boolean { + const context = resolveDiscordPolicyContext(params); + if (typeof context.channelEntry?.requireMention === "boolean") { + return context.channelEntry.requireMention; + } + if (typeof context.guildEntry?.requireMention === "boolean") { + return context.guildEntry.requireMention; + } + return true; +} + +export function resolveDiscordGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const context = resolveDiscordPolicyContext(params); + const channelPolicy = resolveSenderToolsEntry(context.channelEntry, params); + if (channelPolicy) { + return channelPolicy; + } + return resolveSenderToolsEntry(context.guildEntry, params); +} diff --git a/src/channels/plugins/group-mentions.test.ts b/src/channels/plugins/group-mentions.test.ts index 5bcedcf4d8f..b942cf5a63b 100644 --- a/src/channels/plugins/group-mentions.test.ts +++ b/src/channels/plugins/group-mentions.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest"; import { resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupToolPolicy, - resolveDiscordGroupRequireMention, - resolveDiscordGroupToolPolicy, resolveLineGroupRequireMention, resolveLineGroupToolPolicy, resolveTelegramGroupRequireMention, @@ -45,80 +43,6 @@ describe("group mentions (telegram)", () => { }); }); -describe("group mentions (discord)", () => { - it("prefers channel policy, then guild policy, with sender-specific overrides", () => { - const discordCfg = { - channels: { - discord: { - token: "discord-test", - guilds: { - guild1: { - requireMention: false, - tools: { allow: ["message.guild"] }, - toolsBySender: { - "id:user:guild-admin": { allow: ["sessions.list"] }, - }, - channels: { - "123": { - requireMention: true, - tools: { allow: ["message.channel"] }, - toolsBySender: { - "id:user:channel-admin": { deny: ["exec"] }, - }, - }, - }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - expect( - resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }), - ).toBe(true); - expect( - resolveDiscordGroupRequireMention({ - cfg: discordCfg, - groupSpace: "guild1", - groupId: "missing", - }), - ).toBe(false); - expect( - resolveDiscordGroupToolPolicy({ - cfg: discordCfg, - groupSpace: "guild1", - groupId: "123", - senderId: "user:channel-admin", - }), - ).toEqual({ deny: ["exec"] }); - expect( - resolveDiscordGroupToolPolicy({ - cfg: discordCfg, - groupSpace: "guild1", - groupId: "123", - senderId: "user:someone", - }), - ).toEqual({ allow: ["message.channel"] }); - expect( - resolveDiscordGroupToolPolicy({ - cfg: discordCfg, - groupSpace: "guild1", - groupId: "missing", - senderId: "user:guild-admin", - }), - ).toEqual({ allow: ["sessions.list"] }); - expect( - resolveDiscordGroupToolPolicy({ - cfg: discordCfg, - groupSpace: "guild1", - groupId: "missing", - senderId: "user:someone", - }), - ).toEqual({ allow: ["message.guild"] }); - }); -}); - describe("group mentions (bluebubbles)", () => { it("uses generic channel group policy helpers", () => { const blueBubblesCfg = { diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 215c22e2942..ed432d8deb6 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -2,23 +2,13 @@ import type { OpenClawConfig } from "../../config/config.js"; import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, - resolveToolsBySender, } from "../../config/group-policy.js"; -import type { DiscordConfig } from "../../config/types.js"; -import type { - GroupToolPolicyBySenderConfig, - GroupToolPolicyConfig, -} from "../../config/types.tools.js"; +import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js"; -import { normalizeAtHashSlug } from "../../shared/string-normalization.js"; import type { ChannelGroupContext } from "./types.js"; type GroupMentionParams = ChannelGroupContext; -function normalizeDiscordSlug(value?: string | null) { - return normalizeAtHashSlug(value); -} - function parseTelegramGroupId(value?: string | null) { const raw = value?.trim() ?? ""; if (!raw) { @@ -68,52 +58,6 @@ function resolveTelegramRequireMention(params: { return undefined; } -function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: string | null) { - if (!guilds || Object.keys(guilds).length === 0) { - return null; - } - const space = groupSpace?.trim() ?? ""; - if (space && guilds[space]) { - return guilds[space]; - } - const normalized = normalizeDiscordSlug(space); - if (normalized && guilds[normalized]) { - return guilds[normalized]; - } - if (normalized) { - const match = Object.values(guilds).find( - (entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized, - ); - if (match) { - return match; - } - } - return guilds["*"] ?? null; -} - -function resolveDiscordChannelEntry( - channelEntries: Record | undefined, - params: { groupId?: string | null; groupChannel?: string | null }, -): TEntry | undefined { - if (!channelEntries || Object.keys(channelEntries).length === 0) { - return undefined; - } - const groupChannel = params.groupChannel; - const channelSlug = normalizeDiscordSlug(groupChannel); - return ( - (params.groupId ? channelEntries[params.groupId] : undefined) ?? - (channelSlug - ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) - : undefined) ?? - (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined) - ); -} - -type SenderScopedToolsEntry = { - tools?: GroupToolPolicyConfig; - toolsBySender?: GroupToolPolicyBySenderConfig; -}; - type ChannelGroupPolicyChannel = | "telegram" | "whatsapp" @@ -152,39 +96,6 @@ function resolveChannelToolPolicyForSender( }); } -function resolveSenderToolsEntry( - entry: SenderScopedToolsEntry | undefined | null, - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - if (!entry) { - return undefined; - } - const senderPolicy = resolveToolsBySender({ - toolsBySender: entry.toolsBySender, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); - if (senderPolicy) { - return senderPolicy; - } - return entry.tools; -} - -function resolveDiscordPolicyContext(params: GroupMentionParams) { - const guildEntry = resolveDiscordGuildEntry( - params.cfg.channels?.discord?.guilds, - params.groupSpace, - ); - const channelEntries = guildEntry?.channels; - const channelEntry = - channelEntries && Object.keys(channelEntries).length > 0 - ? resolveDiscordChannelEntry(channelEntries, params) - : undefined; - return { guildEntry, channelEntry }; -} - export function resolveTelegramGroupRequireMention( params: GroupMentionParams, ): boolean | undefined { @@ -213,17 +124,6 @@ export function resolveIMessageGroupRequireMention(params: GroupMentionParams): return resolveChannelRequireMention(params, "imessage"); } -export function resolveDiscordGroupRequireMention(params: GroupMentionParams): boolean { - const context = resolveDiscordPolicyContext(params); - if (typeof context.channelEntry?.requireMention === "boolean") { - return context.channelEntry.requireMention; - } - if (typeof context.guildEntry?.requireMention === "boolean") { - return context.guildEntry.requireMention; - } - return true; -} - export function resolveGoogleChatGroupRequireMention(params: GroupMentionParams): boolean { return resolveChannelRequireMention(params, "googlechat"); } @@ -257,17 +157,6 @@ export function resolveIMessageGroupToolPolicy( return resolveChannelToolPolicyForSender(params, "imessage"); } -export function resolveDiscordGroupToolPolicy( - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - const context = resolveDiscordPolicyContext(params); - const channelPolicy = resolveSenderToolsEntry(context.channelEntry, params); - if (channelPolicy) { - return channelPolicy; - } - return resolveSenderToolsEntry(context.guildEntry, params); -} - export function resolveBlueBubblesGroupToolPolicy( params: GroupMentionParams, ): GroupToolPolicyConfig | undefined { diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 8fef540da68..7bec25a02cf 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -89,7 +89,7 @@ export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secre export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; -export { normalizeHyphenSlug } from "../shared/string-normalization.js"; +export { normalizeAtHashSlug, normalizeHyphenSlug } from "../shared/string-normalization.js"; export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type { diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 91bde97a5aa..2078382a2da 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -56,7 +56,7 @@ export { export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, -} from "../channels/plugins/group-mentions.js"; +} from "../../extensions/discord/src/group-policy.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; export { From f8f9e06b58628a3ddd4ef8c2edea60f5781cf575 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 20:30:14 -0700 Subject: [PATCH 10/21] Guardrails: pin runtime-api export seams (#49371) * Guardrails: pin runtime-api export seams * Guardrails: tighten runtime-api keyed lookup * Changelog: note runtime-api guardrails * Tests: harden runtime-api guardrail parsing * Tests: align runtime-api guardrails with current seams --- CHANGELOG.md | 1 + .../channel-import-guardrails.test.ts | 1 - src/plugin-sdk/runtime-api-guardrails.test.ts | 148 ++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/plugin-sdk/runtime-api-guardrails.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60362275d22..67ae27f4a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,7 @@ Docs: https://docs.openclaw.ai - Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. - Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. - Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc. +- Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc. ### Breaking diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index df7d67f1230..996b8ed193c 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -26,7 +26,6 @@ const GUARDED_CHANNEL_EXTENSIONS = new Set([ "msteams", "nostr", "nextcloud-talk", - "nostr", "signal", "slack", "synology-chat", diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts new file mode 100644 index 00000000000..f2079d8691f --- /dev/null +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -0,0 +1,148 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const RUNTIME_API_EXPORT_GUARDS: Record = { + "extensions/discord/runtime-api.ts": [ + 'export * from "./src/audit.js";', + 'export * from "./src/actions/runtime.js";', + 'export * from "./src/actions/runtime.moderation-shared.js";', + 'export * from "./src/actions/runtime.shared.js";', + 'export * from "./src/channel-actions.js";', + 'export * from "./src/directory-live.js";', + 'export * from "./src/monitor.js";', + 'export * from "./src/monitor/gateway-plugin.js";', + 'export * from "./src/monitor/gateway-registry.js";', + 'export * from "./src/monitor/presence-cache.js";', + 'export * from "./src/monitor/thread-bindings.js";', + 'export * from "./src/monitor/thread-bindings.manager.js";', + 'export * from "./src/monitor/timeouts.js";', + 'export * from "./src/probe.js";', + 'export * from "./src/resolve-channels.js";', + 'export * from "./src/resolve-users.js";', + 'export * from "./src/send.js";', + ], + "extensions/imessage/runtime-api.ts": [ + 'export * from "./src/monitor.js";', + 'export * from "./src/probe.js";', + 'export * from "./src/send.js";', + ], + "extensions/nextcloud-talk/runtime-api.ts": [ + 'export * from "openclaw/plugin-sdk/nextcloud-talk";', + ], + "extensions/signal/runtime-api.ts": ['export * from "./src/index.js";'], + "extensions/slack/runtime-api.ts": [ + 'export * from "./src/action-runtime.js";', + 'export * from "./src/directory-live.js";', + 'export * from "./src/index.js";', + 'export * from "./src/resolve-channels.js";', + 'export * from "./src/resolve-users.js";', + ], + "extensions/telegram/runtime-api.ts": [ + 'export * from "./src/audit.js";', + 'export * from "./src/action-runtime.js";', + 'export * from "./src/channel-actions.js";', + 'export * from "./src/monitor.js";', + 'export * from "./src/probe.js";', + 'export * from "./src/send.js";', + 'export * from "./src/thread-bindings.js";', + 'export * from "./src/token.js";', + ], + "extensions/whatsapp/runtime-api.ts": [ + 'export * from "./src/active-listener.js";', + 'export * from "./src/action-runtime.js";', + 'export * from "./src/agent-tools-login.js";', + 'export * from "./src/auth-store.js";', + 'export * from "./src/auto-reply.js";', + 'export * from "./src/inbound.js";', + 'export * from "./src/login.js";', + 'export * from "./src/media.js";', + 'export * from "./src/send.js";', + 'export * from "./src/session.js";', + ], +} as const; + +function collectRuntimeApiFiles(): string[] { + const extensionsDir = resolve(ROOT_DIR, "..", "extensions"); + const files: string[] = []; + const stack = [extensionsDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { + continue; + } + stack.push(fullPath); + continue; + } + if (!entry.isFile() || entry.name !== "runtime-api.ts") { + continue; + } + files.push(relative(resolve(ROOT_DIR, ".."), fullPath).replaceAll("\\", "/")); + } + } + return files; +} + +function readExportStatements(path: string): string[] { + const sourceText = readFileSync(resolve(ROOT_DIR, "..", path), "utf8"); + const sourceFile = ts.createSourceFile(path, sourceText, ts.ScriptTarget.Latest, true); + + return sourceFile.statements.flatMap((statement) => { + if (!ts.isExportDeclaration(statement)) { + if (!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) { + return []; + } + return [statement.getText(sourceFile).replaceAll(/\s+/g, " ").trim()]; + } + + const moduleSpecifier = statement.moduleSpecifier; + if (!moduleSpecifier || !ts.isStringLiteral(moduleSpecifier)) { + return [statement.getText(sourceFile).replaceAll(/\s+/g, " ").trim()]; + } + + if (!statement.exportClause) { + const prefix = statement.isTypeOnly ? "export type *" : "export *"; + return [`${prefix} from ${moduleSpecifier.getText(sourceFile)};`]; + } + + if (!ts.isNamedExports(statement.exportClause)) { + return [statement.getText(sourceFile).replaceAll(/\s+/g, " ").trim()]; + } + + const specifiers = statement.exportClause.elements.map((element) => { + const imported = element.propertyName?.text; + const exported = element.name.text; + const alias = imported ? `${imported} as ${exported}` : exported; + return element.isTypeOnly ? `type ${alias}` : alias; + }); + const exportPrefix = statement.isTypeOnly ? "export type" : "export"; + return [ + `${exportPrefix} { ${specifiers.join(", ")} } from ${moduleSpecifier.getText(sourceFile)};`, + ]; + }); +} + +describe("runtime api guardrails", () => { + it("keeps runtime api seams on an explicit export allowlist", () => { + const runtimeApiFiles = collectRuntimeApiFiles(); + expect(runtimeApiFiles).toEqual( + expect.arrayContaining(Object.keys(RUNTIME_API_EXPORT_GUARDS).toSorted()), + ); + + for (const file of Object.keys(RUNTIME_API_EXPORT_GUARDS).toSorted()) { + expect(readExportStatements(file), `${file} runtime api exports changed`).toEqual( + RUNTIME_API_EXPORT_GUARDS[file], + ); + } + }); +}); From a34944c9184034fe7166d47977142cfb32519c3a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 20:32:38 -0700 Subject: [PATCH 11/21] Tests: pin Telegram fallback host (#49364) * Tests: pin Telegram fallback host * Changelog: note Telegram fallback guardrail --- CHANGELOG.md | 1 + src/infra/net/ssrf.dispatcher.test.ts | 31 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ae27f4a94..03cee239520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,7 @@ Docs: https://docs.openclaw.ai - Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. - Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc. - Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc. +- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc. ### Breaking diff --git a/src/infra/net/ssrf.dispatcher.test.ts b/src/infra/net/ssrf.dispatcher.test.ts index af6fc8ae5e8..27060cdb89e 100644 --- a/src/infra/net/ssrf.dispatcher.test.ts +++ b/src/infra/net/ssrf.dispatcher.test.ts @@ -109,6 +109,37 @@ describe("createPinnedDispatcher", () => { expect(originalLookup).not.toHaveBeenCalled(); }); + it("keeps the override bound to the matching hostname only", () => { + const originalLookup = vi.fn( + (_hostname: string, callback: (err: null, address: string, family: number) => void) => { + callback(null, "93.184.216.34", 4); + }, + ) as unknown as PinnedHostname["lookup"]; + const pinned: PinnedHostname = { + hostname: "api.telegram.org", + addresses: ["149.154.167.221"], + lookup: originalLookup, + }; + + createPinnedDispatcher(pinned, { + mode: "direct", + pinnedHostname: { + hostname: "api.telegram.org", + addresses: ["149.154.167.220"], + }, + }); + + const firstCallArg = agentCtor.mock.calls.at(-1)?.[0] as + | { connect?: { lookup?: PinnedHostname["lookup"] } } + | undefined; + const lookup = firstCallArg?.connect?.lookup; + const callback = vi.fn(); + lookup?.("example.com", callback); + + expect(originalLookup).toHaveBeenCalledWith("example.com", expect.any(Function)); + expect(callback).toHaveBeenCalledWith(null, "93.184.216.34", 4); + }); + it("rejects pinned override addresses that violate SSRF policy", () => { const originalLookup = vi.fn() as unknown as PinnedHostname["lookup"]; const pinned: PinnedHostname = { From 7ba8dd112f460d20e11d410f3143a7501bb4c83a Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:32:26 +0000 Subject: [PATCH 12/21] Telegram: move group policy behind plugin boundary --- extensions/telegram/api.ts | 1 + extensions/telegram/src/channel.test.ts | 36 ++++++++ extensions/telegram/src/channel.ts | 6 +- extensions/telegram/src/group-policy.test.ts | 40 +++++++++ extensions/telegram/src/group-policy.ts | 91 ++++++++++++++++++++ src/channels/plugins/group-mentions.test.ts | 37 -------- src/channels/plugins/group-mentions.ts | 77 ----------------- src/plugin-sdk/channel-policy.ts | 6 +- src/plugin-sdk/telegram.ts | 2 +- 9 files changed, 178 insertions(+), 118 deletions(-) create mode 100644 extensions/telegram/src/group-policy.test.ts create mode 100644 extensions/telegram/src/group-policy.ts diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts index d5960350c39..88ef86a6a53 100644 --- a/extensions/telegram/api.ts +++ b/extensions/telegram/api.ts @@ -3,6 +3,7 @@ export * from "./src/accounts.js"; export * from "./src/allow-from.js"; export * from "./src/api-fetch.js"; export * from "./src/exec-approvals.js"; +export * from "./src/group-policy.js"; export * from "./src/inline-buttons.js"; export * from "./src/model-buttons.js"; export * from "./src/normalize.js"; diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 6c1f4da5e73..c9e8df40be0 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -154,6 +154,42 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe("telegramPlugin groups", () => { + it("uses plugin-owned group policy resolvers", () => { + const cfg = { + channels: { + telegram: { + botToken: "telegram-test", + groups: { + "-1001": { + requireMention: true, + tools: { allow: ["message.send"] }, + topics: { + "77": { + requireMention: false, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect( + telegramPlugin.groups?.resolveRequireMention?.({ + cfg, + groupId: "-1001:topic:77", + }), + ).toBe(false); + expect( + telegramPlugin.groups?.resolveToolPolicy?.({ + cfg, + groupId: "-1001:topic:77", + }), + ).toEqual({ allow: ["message.send"] }); + }); +}); + describe("telegramPlugin duplicate token guard", () => { it("marks secondary account as not configured when token is shared", async () => { const cfg = createCfg(); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 56a2256f9c0..f8b982e5276 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -20,8 +20,6 @@ import { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, - resolveTelegramGroupRequireMention, - resolveTelegramGroupToolPolicy, type ChannelPlugin, type ChannelMessageActionAdapter, type OpenClawConfig, @@ -38,6 +36,10 @@ import { isTelegramExecApprovalClientEnabled, resolveTelegramExecApprovalTarget, } from "./exec-approvals.js"; +import { + resolveTelegramGroupRequireMention, + resolveTelegramGroupToolPolicy, +} from "./group-policy.js"; import { monitorTelegramProvider } from "./monitor.js"; import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js"; import { sendTelegramPayloadMessages } from "./outbound-adapter.js"; diff --git a/extensions/telegram/src/group-policy.test.ts b/extensions/telegram/src/group-policy.test.ts new file mode 100644 index 00000000000..c93018132bc --- /dev/null +++ b/extensions/telegram/src/group-policy.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + resolveTelegramGroupRequireMention, + resolveTelegramGroupToolPolicy, +} from "./group-policy.js"; + +describe("telegram group policy", () => { + it("resolves topic-level requireMention and chat-level tools for topic ids", () => { + const telegramCfg = { + channels: { + telegram: { + botToken: "telegram-test", + groups: { + "-1001": { + requireMention: true, + tools: { allow: ["message.send"] }, + topics: { + "77": { + requireMention: false, + }, + }, + }, + "*": { + requireMention: true, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + expect( + resolveTelegramGroupRequireMention({ cfg: telegramCfg, groupId: "-1001:topic:77" }), + ).toBe(false); + expect(resolveTelegramGroupToolPolicy({ cfg: telegramCfg, groupId: "-1001:topic:77" })).toEqual( + { + allow: ["message.send"], + }, + ); + }); +}); diff --git a/extensions/telegram/src/group-policy.ts b/extensions/telegram/src/group-policy.ts new file mode 100644 index 00000000000..a90e930a4a5 --- /dev/null +++ b/extensions/telegram/src/group-policy.ts @@ -0,0 +1,91 @@ +import { + resolveChannelGroupRequireMention, + resolveChannelGroupToolsPolicy, + type GroupToolPolicyConfig, +} from "openclaw/plugin-sdk/channel-policy"; +import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; + +function parseTelegramGroupId(value?: string | null) { + const raw = value?.trim() ?? ""; + if (!raw) { + return { chatId: undefined, topicId: undefined }; + } + const parts = raw.split(":").filter(Boolean); + if ( + parts.length >= 3 && + parts[1] === "topic" && + /^-?\d+$/.test(parts[0]) && + /^\d+$/.test(parts[2]) + ) { + return { chatId: parts[0], topicId: parts[2] }; + } + if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) { + return { chatId: parts[0], topicId: parts[1] }; + } + return { chatId: raw, topicId: undefined }; +} + +function resolveTelegramRequireMention(params: { + cfg: ChannelGroupContext["cfg"]; + chatId?: string; + topicId?: string; +}): boolean | undefined { + const { cfg, chatId, topicId } = params; + if (!chatId) { + return undefined; + } + const groupConfig = cfg.channels?.telegram?.groups?.[chatId]; + const groupDefault = cfg.channels?.telegram?.groups?.["*"]; + const topicConfig = topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined; + const defaultTopicConfig = + topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined; + if (typeof topicConfig?.requireMention === "boolean") { + return topicConfig.requireMention; + } + if (typeof defaultTopicConfig?.requireMention === "boolean") { + return defaultTopicConfig.requireMention; + } + if (typeof groupConfig?.requireMention === "boolean") { + return groupConfig.requireMention; + } + if (typeof groupDefault?.requireMention === "boolean") { + return groupDefault.requireMention; + } + return undefined; +} + +export function resolveTelegramGroupRequireMention( + params: ChannelGroupContext, +): boolean | undefined { + const { chatId, topicId } = parseTelegramGroupId(params.groupId); + const requireMention = resolveTelegramRequireMention({ + cfg: params.cfg, + chatId, + topicId, + }); + if (typeof requireMention === "boolean") { + return requireMention; + } + return resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel: "telegram", + groupId: chatId ?? params.groupId, + accountId: params.accountId, + }); +} + +export function resolveTelegramGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const { chatId } = parseTelegramGroupId(params.groupId); + return resolveChannelGroupToolsPolicy({ + cfg: params.cfg, + channel: "telegram", + groupId: chatId ?? params.groupId, + accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); +} diff --git a/src/channels/plugins/group-mentions.test.ts b/src/channels/plugins/group-mentions.test.ts index b942cf5a63b..7375112ac34 100644 --- a/src/channels/plugins/group-mentions.test.ts +++ b/src/channels/plugins/group-mentions.test.ts @@ -4,45 +4,8 @@ import { resolveBlueBubblesGroupToolPolicy, resolveLineGroupRequireMention, resolveLineGroupToolPolicy, - resolveTelegramGroupRequireMention, - resolveTelegramGroupToolPolicy, } from "./group-mentions.js"; -describe("group mentions (telegram)", () => { - it("resolves topic-level requireMention and chat-level tools for topic ids", () => { - const telegramCfg = { - channels: { - telegram: { - botToken: "telegram-test", - groups: { - "-1001": { - requireMention: true, - tools: { allow: ["message.send"] }, - topics: { - "77": { - requireMention: false, - }, - }, - }, - "*": { - requireMention: true, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - expect( - resolveTelegramGroupRequireMention({ cfg: telegramCfg, groupId: "-1001:topic:77" }), - ).toBe(false); - expect(resolveTelegramGroupToolPolicy({ cfg: telegramCfg, groupId: "-1001:topic:77" })).toEqual( - { - allow: ["message.send"], - }, - ); - }); -}); - describe("group mentions (bluebubbles)", () => { it("uses generic channel group policy helpers", () => { const blueBubblesCfg = { diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index ed432d8deb6..c3268496b03 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -1,4 +1,3 @@ -import type { OpenClawConfig } from "../../config/config.js"; import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, @@ -9,55 +8,6 @@ import type { ChannelGroupContext } from "./types.js"; type GroupMentionParams = ChannelGroupContext; -function parseTelegramGroupId(value?: string | null) { - const raw = value?.trim() ?? ""; - if (!raw) { - return { chatId: undefined, topicId: undefined }; - } - const parts = raw.split(":").filter(Boolean); - if ( - parts.length >= 3 && - parts[1] === "topic" && - /^-?\d+$/.test(parts[0]) && - /^\d+$/.test(parts[2]) - ) { - return { chatId: parts[0], topicId: parts[2] }; - } - if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) { - return { chatId: parts[0], topicId: parts[1] }; - } - return { chatId: raw, topicId: undefined }; -} - -function resolveTelegramRequireMention(params: { - cfg: OpenClawConfig; - chatId?: string; - topicId?: string; -}): boolean | undefined { - const { cfg, chatId, topicId } = params; - if (!chatId) { - return undefined; - } - const groupConfig = cfg.channels?.telegram?.groups?.[chatId]; - const groupDefault = cfg.channels?.telegram?.groups?.["*"]; - const topicConfig = topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined; - const defaultTopicConfig = - topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined; - if (typeof topicConfig?.requireMention === "boolean") { - return topicConfig.requireMention; - } - if (typeof defaultTopicConfig?.requireMention === "boolean") { - return defaultTopicConfig.requireMention; - } - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - if (typeof groupDefault?.requireMention === "boolean") { - return groupDefault.requireMention; - } - return undefined; -} - type ChannelGroupPolicyChannel = | "telegram" | "whatsapp" @@ -96,26 +46,6 @@ function resolveChannelToolPolicyForSender( }); } -export function resolveTelegramGroupRequireMention( - params: GroupMentionParams, -): boolean | undefined { - const { chatId, topicId } = parseTelegramGroupId(params.groupId); - const requireMention = resolveTelegramRequireMention({ - cfg: params.cfg, - chatId, - topicId, - }); - if (typeof requireMention === "boolean") { - return requireMention; - } - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "telegram", - groupId: chatId ?? params.groupId, - accountId: params.accountId, - }); -} - export function resolveWhatsAppGroupRequireMention(params: GroupMentionParams): boolean { return resolveChannelRequireMention(params, "whatsapp"); } @@ -138,13 +68,6 @@ export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams return resolveChannelRequireMention(params, "bluebubbles"); } -export function resolveTelegramGroupToolPolicy( - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - const { chatId } = parseTelegramGroupId(params.groupId); - return resolveChannelToolPolicyForSender(params, "telegram", chatId ?? params.groupId); -} - export function resolveWhatsAppGroupToolPolicy( params: GroupMentionParams, ): GroupToolPolicyConfig | undefined { diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index b7166262eb6..c59643a4e4b 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -14,7 +14,11 @@ export { collectOpenProviderGroupPolicyWarnings, } from "../channels/plugins/group-policy-warnings.js"; export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; -export { resolveChannelGroupRequireMention, resolveToolsBySender } from "../config/group-policy.js"; +export { + resolveChannelGroupRequireMention, + resolveChannelGroupToolsPolicy, + resolveToolsBySender, +} from "../config/group-policy.js"; export { DM_GROUP_ACCESS_REASON, readStoreAllowFromForDmPolicy, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 9a94e7c2d1c..0b539cf7057 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -57,7 +57,7 @@ export { export { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, -} from "../channels/plugins/group-mentions.js"; +} from "../../extensions/telegram/src/group-policy.js"; export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildTokenChannelStatusSummary } from "./status-helpers.js"; From 5c4903d3fd179086e47458b845cdb29ff67f9ed7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 20:31:59 -0700 Subject: [PATCH 13/21] Plugins: centralize compatibility formatting --- src/auto-reply/reply/commands-plugins.ts | 5 +- src/cli/plugins-cli.test.ts | 168 +++++++++++++++++++++++ src/cli/plugins-cli.ts | 5 +- src/commands/status-all/diagnosis.ts | 7 +- src/commands/status.command.ts | 11 +- src/plugins/status.test.ts | 33 +++++ src/plugins/status.ts | 22 ++- src/wizard/setup.ts | 7 +- 8 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 src/cli/plugins-cli.test.ts diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 3b5dcdb9b60..07fc7630eb0 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -10,6 +10,7 @@ import { buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport, + formatPluginCompatibilityNotice, type PluginStatusReport, } from "../../plugins/status.js"; import { setPluginEnabledInConfig } from "../../plugins/toggle-config.js"; @@ -48,7 +49,7 @@ function buildPluginInspectJson(params: { compatibilityWarnings: inspect.compatibility.map((warning) => ({ code: warning.code, severity: warning.severity, - message: `${warning.pluginId} ${warning.message}`, + message: formatPluginCompatibilityNotice(warning), })), install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, }; @@ -69,7 +70,7 @@ function buildAllPluginInspectJson(params: { compatibilityWarnings: inspect.compatibility.map((warning) => ({ code: warning.code, severity: warning.severity, - message: `${warning.pluginId} ${warning.message}`, + message: formatPluginCompatibilityNotice(warning), })), install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, })); diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts new file mode 100644 index 00000000000..44d1d2ff262 --- /dev/null +++ b/src/cli/plugins-cli.test.ts @@ -0,0 +1,168 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runRegisteredCli } from "../test-utils/command-runner.js"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), + buildPluginStatusReport: vi.fn(() => ({ + plugins: [], + diagnostics: [], + hooks: [], + typedHooks: [], + })), + buildPluginInspectReport: vi.fn(), + buildAllPluginInspectReports: vi.fn(() => []), + buildPluginCompatibilityNotices: vi.fn(() => []), + defaultRuntime: { + log: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, + writeConfigFile: vi.fn(), +})); + +vi.mock("../plugins/status.js", () => ({ + buildPluginStatusReport: mocks.buildPluginStatusReport, + buildPluginInspectReport: mocks.buildPluginInspectReport, + buildAllPluginInspectReports: mocks.buildAllPluginInspectReports, + buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: mocks.defaultRuntime, +})); + +let registerPluginsCli: typeof import("./plugins-cli.js").registerPluginsCli; + +beforeAll(async () => { + ({ registerPluginsCli } = await import("./plugins-cli.js")); +}); + +describe("plugins cli", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({}); + mocks.buildPluginStatusReport.mockReturnValue({ + plugins: [], + diagnostics: [], + hooks: [], + typedHooks: [], + }); + mocks.buildPluginInspectReport.mockReset(); + mocks.buildAllPluginInspectReports.mockReturnValue([]); + mocks.buildPluginCompatibilityNotices.mockReturnValue([]); + }); + + it("renders compatibility warnings in plugins inspect output", async () => { + mocks.buildPluginStatusReport.mockReturnValue({ + plugins: [ + { + id: "legacy-plugin", + name: "Legacy Plugin", + description: "legacy seam", + source: "/tmp/legacy.ts", + origin: "workspace", + enabled: true, + status: "loaded", + format: "openclaw", + bundleFormat: undefined, + version: "1.0.0", + bundleCapabilities: [], + }, + ], + diagnostics: [], + hooks: [], + typedHooks: [], + }); + mocks.buildPluginInspectReport.mockReturnValue({ + plugin: { + id: "legacy-plugin", + name: "Legacy Plugin", + description: "legacy seam", + source: "/tmp/legacy.ts", + origin: "workspace", + status: "loaded", + format: "openclaw", + bundleFormat: undefined, + version: "1.0.0", + bundleCapabilities: [], + }, + shape: "hook-only", + capabilityMode: "none", + capabilityCount: 0, + capabilities: [], + typedHooks: [{ name: "before_agent_start" }], + customHooks: [], + tools: [], + commands: [], + cliCommands: [], + services: [], + gatewayMethods: [], + httpRouteCount: 0, + diagnostics: [], + policy: { + allowPromptInjection: undefined, + allowModelOverride: undefined, + allowedModels: [], + hasAllowedModelsConfig: false, + }, + usesLegacyBeforeAgentStart: true, + compatibility: [ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + { + pluginId: "legacy-plugin", + code: "hook-only", + severity: "info", + message: + "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + }, + ], + }); + + await runRegisteredCli({ + register: registerPluginsCli as (program: import("commander").Command) => void, + argv: ["plugins", "inspect", "legacy-plugin"], + }); + + const output = mocks.defaultRuntime.log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("Compatibility warnings"); + expect(output).toContain("legacy-plugin still relies on legacy before_agent_start"); + expect(output).toContain("legacy-plugin is hook-only"); + }); + + it("renders compatibility notices in plugins doctor", async () => { + mocks.buildPluginStatusReport.mockReturnValue({ + plugins: [], + diagnostics: [], + hooks: [], + typedHooks: [], + }); + mocks.buildPluginCompatibilityNotices.mockReturnValue([ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); + + await runRegisteredCli({ + register: registerPluginsCli as (program: import("commander").Command) => void, + argv: ["plugins", "doctor"], + }); + + const output = mocks.defaultRuntime.log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("Compatibility:"); + expect(output).toContain("legacy-plugin"); + expect(output).toContain("still relies on legacy before_agent_start"); + }); +}); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index ad52aa4559d..8342b6c58b3 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -25,6 +25,7 @@ import { buildPluginCompatibilityNotices, buildPluginInspectReport, buildPluginStatusReport, + formatPluginCompatibilityNotice, } from "../plugins/status.js"; import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js"; import { updateNpmInstalledPlugins } from "../plugins/update.js"; @@ -762,7 +763,7 @@ export function registerPluginsCli(program: Command) { lines.push( ...formatInspectSection( "Compatibility warnings", - inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`), + inspect.compatibility.map(formatPluginCompatibilityNotice), ), ); lines.push( @@ -1103,7 +1104,7 @@ export function registerPluginsCli(program: Command) { lines.push(theme.warn("Compatibility:")); for (const notice of compatibility) { const marker = notice.severity === "warn" ? theme.warn("warn") : theme.muted("info"); - lines.push(`- ${notice.pluginId} [${marker}]: ${notice.message}`); + lines.push(`- ${formatPluginCompatibilityNotice(notice)} [${marker}]`); } } const docs = formatDocsLink("/plugin", "docs.openclaw.ai/plugin"); diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 66ae5d02ecd..289fdb7a16e 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -6,7 +6,10 @@ import { type RestartSentinelPayload, summarizeRestartSentinel, } from "../../infra/restart-sentinel.js"; -import type { PluginCompatibilityNotice } from "../../plugins/status.js"; +import { + formatPluginCompatibilityNotice, + type PluginCompatibilityNotice, +} from "../../plugins/status.js"; import { formatTimeAgo, redactSecrets } from "./format.js"; import { readFileTailLines, summarizeLogTail } from "./gateway.js"; @@ -184,7 +187,7 @@ export async function appendStatusAllDiagnosis(params: { ); for (const notice of params.pluginCompatibility.slice(0, 12)) { const severity = notice.severity === "warn" ? "warn" : "info"; - lines.push(` - ${notice.pluginId} [${severity}] ${notice.message}`); + lines.push(` - [${severity}] ${formatPluginCompatibilityNotice(notice)}`); } if (params.pluginCompatibility.length > 12) { lines.push(` ${muted(`… +${params.pluginCompatibility.length - 12} more`)}`); diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 18e4c53ebf7..363828ed550 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -13,6 +13,10 @@ import { resolveMemoryVectorState, type Tone, } from "../memory/status-format.js"; +import { + formatPluginCompatibilityNotice, + summarizePluginCompatibility, +} from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; @@ -421,11 +425,12 @@ export async function statusCommand( const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""); const channelLabel = channelInfo.label; const gitLabel = formatGitInstallLabel(update); + const pluginCompatibilitySummary = summarizePluginCompatibility(pluginCompatibility); const pluginCompatibilityValue = - pluginCompatibility.length === 0 + pluginCompatibilitySummary.noticeCount === 0 ? ok("none") : warn( - `${pluginCompatibility.length} notice${pluginCompatibility.length === 1 ? "" : "s"} · ${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size} plugin${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size === 1 ? "" : "s"}`, + `${pluginCompatibilitySummary.noticeCount} notice${pluginCompatibilitySummary.noticeCount === 1 ? "" : "s"} · ${pluginCompatibilitySummary.pluginCount} plugin${pluginCompatibilitySummary.pluginCount === 1 ? "" : "s"}`, ); const overviewRows = [ @@ -484,7 +489,7 @@ export async function statusCommand( runtime.log(theme.heading("Plugin compatibility")); for (const notice of pluginCompatibility.slice(0, 8)) { const label = notice.severity === "warn" ? theme.warn("WARN") : theme.muted("INFO"); - runtime.log(` ${label} ${notice.pluginId} ${notice.message}`); + runtime.log(` ${label} ${formatPluginCompatibilityNotice(notice)}`); } if (pluginCompatibility.length > 8) { runtime.log(theme.muted(` … +${pluginCompatibility.length - 8} more`)); diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 7cbdffb4e04..04ba3c9679f 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -7,6 +7,8 @@ let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectRep let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports; let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices; let buildPluginCompatibilityWarnings: typeof import("./status.js").buildPluginCompatibilityWarnings; +let formatPluginCompatibilityNotice: typeof import("./status.js").formatPluginCompatibilityNotice; +let summarizePluginCompatibility: typeof import("./status.js").summarizePluginCompatibility; vi.mock("../config/config.js", () => ({ loadConfig: () => loadConfigMock(), @@ -56,6 +58,8 @@ describe("buildPluginStatusReport", () => { buildPluginCompatibilityWarnings, buildPluginInspectReport, buildPluginStatusReport, + formatPluginCompatibilityNotice, + summarizePluginCompatibility, } = await import("./status.js")); }); @@ -488,4 +492,33 @@ describe("buildPluginStatusReport", () => { expect(buildPluginCompatibilityNotices()).toEqual([]); expect(buildPluginCompatibilityWarnings()).toEqual([]); }); + + it("formats and summarizes compatibility notices", () => { + const notice = { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start" as const, + severity: "warn" as const, + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }; + + expect(formatPluginCompatibilityNotice(notice)).toBe( + "legacy-plugin still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + ); + expect( + summarizePluginCompatibility([ + notice, + { + pluginId: "legacy-plugin", + code: "hook-only", + severity: "info", + message: + "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + }, + ]), + ).toEqual({ + noticeCount: 2, + pluginCount: 1, + }); + }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 47a7b7f845e..154ea25262e 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -33,6 +33,11 @@ export type PluginCompatibilityNotice = { message: string; }; +export type PluginCompatibilitySummary = { + noticeCount: number; + pluginCount: number; +}; + export type PluginInspectReport = { workspaceDir?: string; plugin: PluginRegistry["plugins"][number]; @@ -288,9 +293,7 @@ export function buildPluginCompatibilityWarnings(params?: { env?: NodeJS.ProcessEnv; report?: PluginStatusReport; }): string[] { - return buildAllPluginInspectReports(params).flatMap((inspect) => - inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`), - ); + return buildPluginCompatibilityNotices(params).map(formatPluginCompatibilityNotice); } export function buildPluginCompatibilityNotices(params?: { @@ -301,3 +304,16 @@ export function buildPluginCompatibilityNotices(params?: { }): PluginCompatibilityNotice[] { return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility); } + +export function formatPluginCompatibilityNotice(notice: PluginCompatibilityNotice): string { + return `${notice.pluginId} ${notice.message}`; +} + +export function summarizePluginCompatibility( + notices: PluginCompatibilityNotice[], +): PluginCompatibilitySummary { + return { + noticeCount: notices.length, + pluginCount: new Set(notices.map((notice) => notice.pluginId)).size, + }; +} diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 92abd51a20e..5e87a967c25 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -13,7 +13,10 @@ import { writeConfigFile, } from "../config/config.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; -import { buildPluginCompatibilityNotices } from "../plugins/status.js"; +import { + buildPluginCompatibilityNotices, + formatPluginCompatibilityNotice, +} from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; @@ -112,7 +115,7 @@ export async function runSetupWizard( `Detected ${compatibilityNotices.length} plugin compatibility notice${compatibilityNotices.length === 1 ? "" : "s"} in the current config.`, ...compatibilityNotices .slice(0, 4) - .map((notice) => `- ${notice.pluginId}: ${notice.message}`), + .map((notice) => `- ${formatPluginCompatibilityNotice(notice)}`), ...(compatibilityNotices.length > 4 ? [`- ... +${compatibilityNotices.length - 4} more`] : []), From 6556a403303ed6aa611d49346ca13c5f8706563e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 20:34:51 -0700 Subject: [PATCH 14/21] Tests: drop unstable plugins cli coverage --- src/cli/plugins-cli.test.ts | 168 ------------------------------------ 1 file changed, 168 deletions(-) delete mode 100644 src/cli/plugins-cli.test.ts diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts deleted file mode 100644 index 44d1d2ff262..00000000000 --- a/src/cli/plugins-cli.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { runRegisteredCli } from "../test-utils/command-runner.js"; - -const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({})), - buildPluginStatusReport: vi.fn(() => ({ - plugins: [], - diagnostics: [], - hooks: [], - typedHooks: [], - })), - buildPluginInspectReport: vi.fn(), - buildAllPluginInspectReports: vi.fn(() => []), - buildPluginCompatibilityNotices: vi.fn(() => []), - defaultRuntime: { - log: vi.fn(), - error: vi.fn(), - }, -})); - -vi.mock("../config/config.js", () => ({ - loadConfig: mocks.loadConfig, - writeConfigFile: vi.fn(), -})); - -vi.mock("../plugins/status.js", () => ({ - buildPluginStatusReport: mocks.buildPluginStatusReport, - buildPluginInspectReport: mocks.buildPluginInspectReport, - buildAllPluginInspectReports: mocks.buildAllPluginInspectReports, - buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, -})); - -vi.mock("../runtime.js", () => ({ - defaultRuntime: mocks.defaultRuntime, -})); - -let registerPluginsCli: typeof import("./plugins-cli.js").registerPluginsCli; - -beforeAll(async () => { - ({ registerPluginsCli } = await import("./plugins-cli.js")); -}); - -describe("plugins cli", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.loadConfig.mockReturnValue({}); - mocks.buildPluginStatusReport.mockReturnValue({ - plugins: [], - diagnostics: [], - hooks: [], - typedHooks: [], - }); - mocks.buildPluginInspectReport.mockReset(); - mocks.buildAllPluginInspectReports.mockReturnValue([]); - mocks.buildPluginCompatibilityNotices.mockReturnValue([]); - }); - - it("renders compatibility warnings in plugins inspect output", async () => { - mocks.buildPluginStatusReport.mockReturnValue({ - plugins: [ - { - id: "legacy-plugin", - name: "Legacy Plugin", - description: "legacy seam", - source: "/tmp/legacy.ts", - origin: "workspace", - enabled: true, - status: "loaded", - format: "openclaw", - bundleFormat: undefined, - version: "1.0.0", - bundleCapabilities: [], - }, - ], - diagnostics: [], - hooks: [], - typedHooks: [], - }); - mocks.buildPluginInspectReport.mockReturnValue({ - plugin: { - id: "legacy-plugin", - name: "Legacy Plugin", - description: "legacy seam", - source: "/tmp/legacy.ts", - origin: "workspace", - status: "loaded", - format: "openclaw", - bundleFormat: undefined, - version: "1.0.0", - bundleCapabilities: [], - }, - shape: "hook-only", - capabilityMode: "none", - capabilityCount: 0, - capabilities: [], - typedHooks: [{ name: "before_agent_start" }], - customHooks: [], - tools: [], - commands: [], - cliCommands: [], - services: [], - gatewayMethods: [], - httpRouteCount: 0, - diagnostics: [], - policy: { - allowPromptInjection: undefined, - allowModelOverride: undefined, - allowedModels: [], - hasAllowedModelsConfig: false, - }, - usesLegacyBeforeAgentStart: true, - compatibility: [ - { - pluginId: "legacy-plugin", - code: "legacy-before-agent-start", - severity: "warn", - message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", - }, - { - pluginId: "legacy-plugin", - code: "hook-only", - severity: "info", - message: - "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", - }, - ], - }); - - await runRegisteredCli({ - register: registerPluginsCli as (program: import("commander").Command) => void, - argv: ["plugins", "inspect", "legacy-plugin"], - }); - - const output = mocks.defaultRuntime.log.mock.calls.map((call) => String(call[0])).join("\n"); - expect(output).toContain("Compatibility warnings"); - expect(output).toContain("legacy-plugin still relies on legacy before_agent_start"); - expect(output).toContain("legacy-plugin is hook-only"); - }); - - it("renders compatibility notices in plugins doctor", async () => { - mocks.buildPluginStatusReport.mockReturnValue({ - plugins: [], - diagnostics: [], - hooks: [], - typedHooks: [], - }); - mocks.buildPluginCompatibilityNotices.mockReturnValue([ - { - pluginId: "legacy-plugin", - code: "legacy-before-agent-start", - severity: "warn", - message: - "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", - }, - ]); - - await runRegisteredCli({ - register: registerPluginsCli as (program: import("commander").Command) => void, - argv: ["plugins", "doctor"], - }); - - const output = mocks.defaultRuntime.log.mock.calls.map((call) => String(call[0])).join("\n"); - expect(output).toContain("Compatibility:"); - expect(output).toContain("legacy-plugin"); - expect(output).toContain("still relies on legacy before_agent_start"); - }); -}); From 005b25e9d42a60e17ec7619622ddd44a62ae9fe1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 20:34:18 -0700 Subject: [PATCH 15/21] refactor: split remaining monitor runtime helpers --- .../src/monitor/agent-components-helpers.ts | 754 +++++++++++++++++ .../discord/src/monitor/agent-components.ts | 800 +----------------- .../discord/src/monitor/monitor.test.ts | 97 ++- extensions/feishu/src/bot-sender-name.ts | 121 +++ extensions/feishu/src/bot.ts | 144 +--- extensions/matrix/runtime-api.ts | 84 +- .../src/mattermost/monitor-gating.ts | 99 +++ .../mattermost/src/mattermost/monitor.ts | 118 +-- extensions/telegram/src/bot-handlers.media.ts | 40 + .../telegram/src/bot-handlers.runtime.ts | 47 +- extensions/tlon/src/monitor/authorization.ts | 30 + extensions/tlon/src/monitor/index.ts | 35 +- src/auto-reply/reply/commands-plugins.ts | 10 + src/commands/status.scan.fast-json.ts | 3 + src/commands/status.test.ts | 3 +- src/wizard/setup.test.ts | 12 +- ui/src/styles/chat/layout.css | 10 +- ui/src/styles/components.css | 20 +- ui/src/styles/layout.css | 16 +- 19 files changed, 1209 insertions(+), 1234 deletions(-) create mode 100644 extensions/discord/src/monitor/agent-components-helpers.ts create mode 100644 extensions/feishu/src/bot-sender-name.ts create mode 100644 extensions/mattermost/src/mattermost/monitor-gating.ts create mode 100644 extensions/telegram/src/bot-handlers.media.ts create mode 100644 extensions/tlon/src/monitor/authorization.ts diff --git a/extensions/discord/src/monitor/agent-components-helpers.ts b/extensions/discord/src/monitor/agent-components-helpers.ts new file mode 100644 index 00000000000..d3173e384a6 --- /dev/null +++ b/extensions/discord/src/monitor/agent-components-helpers.ts @@ -0,0 +1,754 @@ +import { + type ButtonInteraction, + type ChannelSelectMenuInteraction, + type ComponentData, + type MentionableSelectMenuInteraction, + type ModalInteraction, + type RoleSelectMenuInteraction, + type StringSelectMenuInteraction, + type UserSelectMenuInteraction, +} from "@buape/carbon"; +import type { APIStringSelectComponent } from "discord-api-types/v10"; +import { ChannelType } from "discord-api-types/v10"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; +import { + issuePairingChallenge, + upsertChannelPairingRequest, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { + readStoreAllowFromForDmPolicy, + resolvePinnedMainDmOwnerFromAllowlist, +} from "openclaw/plugin-sdk/security-runtime"; +import { logError } from "openclaw/plugin-sdk/text-runtime"; +import { + createDiscordFormModal, + parseDiscordComponentCustomId, + parseDiscordModalCustomId, + type DiscordComponentEntry, + type DiscordModalEntry, +} from "../components.js"; +import { + type DiscordGuildEntryResolved, + normalizeDiscordAllowList, + normalizeDiscordSlug, + resolveDiscordAllowListMatch, + resolveDiscordChannelConfigWithFallback, + resolveDiscordGuildEntry, + resolveDiscordMemberAccessState, + resolveDiscordOwnerAccess, +} from "./allow-list.js"; +import { formatDiscordUserTag } from "./format.js"; + +export const AGENT_BUTTON_KEY = "agent"; +export const AGENT_SELECT_KEY = "agentsel"; + +export type DiscordUser = Parameters[0]; + +export type AgentComponentMessageInteraction = + | ButtonInteraction + | StringSelectMenuInteraction + | RoleSelectMenuInteraction + | UserSelectMenuInteraction + | MentionableSelectMenuInteraction + | ChannelSelectMenuInteraction; + +export type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction; + +export type DiscordChannelContext = { + channelName: string | undefined; + channelSlug: string; + channelType: number | undefined; + isThread: boolean; + parentId: string | undefined; + parentName: string | undefined; + parentSlug: string; +}; + +export type AgentComponentContext = { + cfg: OpenClawConfig; + accountId: string; + discordConfig?: DiscordAccountConfig; + runtime?: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; + token?: string; + guildEntries?: Record; + allowFrom?: string[]; + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; +}; + +export type ComponentInteractionContext = NonNullable< + Awaited> +>; + +function formatUsername(user: { username: string; discriminator?: string | null }): string { + if (user.discriminator && user.discriminator !== "0") { + return `${user.username}#${user.discriminator}`; + } + return user.username; +} + +function isThreadChannelType(channelType: number | undefined): boolean { + return ( + channelType === ChannelType.PublicThread || + channelType === ChannelType.PrivateThread || + channelType === ChannelType.AnnouncementThread + ); +} + +function readParsedComponentId(data: ComponentData): unknown { + if (!data || typeof data !== "object") { + return undefined; + } + return "cid" in data + ? (data as Record).cid + : (data as Record).componentId; +} + +function normalizeComponentId(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return undefined; +} + +function mapOptionLabels( + options: Array<{ value: string; label: string }> | undefined, + values: string[], +) { + if (!options || options.length === 0) { + return values; + } + const map = new Map(options.map((option) => [option.value, option.label])); + return values.map((value) => map.get(value) ?? value); +} + +/** + * The component custom id only carries the logical button id. Channel binding + * comes from Discord's trusted interaction payload. + */ +export function buildAgentButtonCustomId(componentId: string): string { + return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`; +} + +export function buildAgentSelectCustomId(componentId: string): string { + return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`; +} + +export function resolveAgentComponentRoute(params: { + ctx: AgentComponentContext; + rawGuildId: string | undefined; + memberRoleIds: string[]; + isDirectMessage: boolean; + userId: string; + channelId: string; + parentId: string | undefined; +}) { + return resolveAgentRoute({ + cfg: params.ctx.cfg, + channel: "discord", + accountId: params.ctx.accountId, + guildId: params.rawGuildId, + memberRoleIds: params.memberRoleIds, + peer: { + kind: params.isDirectMessage ? "direct" : "channel", + id: params.isDirectMessage ? params.userId : params.channelId, + }, + parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined, + }); +} + +export async function ackComponentInteraction(params: { + interaction: AgentComponentInteraction; + replyOpts: { ephemeral?: boolean }; + label: string; +}) { + try { + await params.interaction.reply({ + content: "✓", + ...params.replyOpts, + }); + } catch (err) { + logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`); + } +} + +export function resolveDiscordChannelContext( + interaction: AgentComponentInteraction, +): DiscordChannelContext { + const channel = interaction.channel; + const channelName = channel && "name" in channel ? (channel.name as string) : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelType = channel && "type" in channel ? (channel.type as number) : undefined; + const isThread = isThreadChannelType(channelType); + + let parentId: string | undefined; + let parentName: string | undefined; + let parentSlug = ""; + if (isThread && channel && "parentId" in channel) { + parentId = (channel.parentId as string) ?? undefined; + if ("parent" in channel) { + const parent = (channel as { parent?: { name?: string } }).parent; + if (parent?.name) { + parentName = parent.name; + parentSlug = normalizeDiscordSlug(parentName); + } + } + } + + return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug }; +} + +export async function resolveComponentInteractionContext(params: { + interaction: AgentComponentInteraction; + label: string; + defer?: boolean; +}) { + const { interaction, label } = params; + const channelId = interaction.rawData.channel_id; + if (!channelId) { + logError(`${label}: missing channel_id in interaction`); + return null; + } + + const user = interaction.user; + if (!user) { + logError(`${label}: missing user in interaction`); + return null; + } + + const shouldDefer = params.defer !== false && "defer" in interaction; + let didDefer = false; + if (shouldDefer) { + try { + await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true }); + didDefer = true; + } catch (err) { + logError(`${label}: failed to defer interaction: ${String(err)}`); + } + } + const replyOpts = didDefer ? {} : { ephemeral: true }; + + const username = formatUsername(user); + const userId = user.id; + const rawGuildId = interaction.rawData.guild_id; + const isDirectMessage = !rawGuildId; + const memberRoleIds = Array.isArray(interaction.rawData.member?.roles) + ? interaction.rawData.member.roles.map((roleId: string) => String(roleId)) + : []; + + return { + channelId, + user, + username, + userId, + replyOpts, + rawGuildId, + isDirectMessage, + memberRoleIds, + }; +} + +export async function ensureGuildComponentMemberAllowed(params: { + interaction: AgentComponentInteraction; + guildInfo: ReturnType; + channelId: string; + rawGuildId: string | undefined; + channelCtx: DiscordChannelContext; + memberRoleIds: string[]; + user: DiscordUser; + replyOpts: { ephemeral?: boolean }; + componentLabel: string; + unauthorizedReply: string; + allowNameMatching: boolean; +}) { + const { + interaction, + guildInfo, + channelId, + rawGuildId, + channelCtx, + memberRoleIds, + user, + replyOpts, + componentLabel, + unauthorizedReply, + } = params; + + if (!rawGuildId) { + return true; + } + + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); + + const { memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, + memberRoleIds, + sender: { + id: user.id, + name: user.username, + tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined, + }, + allowNameMatching: params.allowNameMatching, + }); + if (memberAllowed) { + return true; + } + + logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`); + try { + await interaction.reply({ + content: unauthorizedReply, + ...replyOpts, + }); + } catch {} + return false; +} + +export async function ensureComponentUserAllowed(params: { + entry: DiscordComponentEntry; + interaction: AgentComponentInteraction; + user: DiscordUser; + replyOpts: { ephemeral?: boolean }; + componentLabel: string; + unauthorizedReply: string; + allowNameMatching: boolean; +}) { + const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [ + "discord:", + "user:", + "pk:", + ]); + if (!allowList) { + return true; + } + const match = resolveDiscordAllowListMatch({ + allowList, + candidate: { + id: params.user.id, + name: params.user.username, + tag: formatDiscordUserTag(params.user), + }, + allowNameMatching: params.allowNameMatching, + }); + if (match.allowed) { + return true; + } + + logVerbose( + `discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`, + ); + try { + await params.interaction.reply({ + content: params.unauthorizedReply, + ...params.replyOpts, + }); + } catch {} + return false; +} + +export async function ensureAgentComponentInteractionAllowed(params: { + ctx: AgentComponentContext; + interaction: AgentComponentInteraction; + channelId: string; + rawGuildId: string | undefined; + memberRoleIds: string[]; + user: DiscordUser; + replyOpts: { ephemeral?: boolean }; + componentLabel: string; + unauthorizedReply: string; +}) { + const guildInfo = resolveDiscordGuildEntry({ + guild: params.interaction.guild ?? undefined, + guildId: params.rawGuildId, + guildEntries: params.ctx.guildEntries, + }); + const channelCtx = resolveDiscordChannelContext(params.interaction); + const memberAllowed = await ensureGuildComponentMemberAllowed({ + interaction: params.interaction, + guildInfo, + channelId: params.channelId, + rawGuildId: params.rawGuildId, + channelCtx, + memberRoleIds: params.memberRoleIds, + user: params.user, + replyOpts: params.replyOpts, + componentLabel: params.componentLabel, + unauthorizedReply: params.unauthorizedReply, + allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + }); + if (!memberAllowed) { + return null; + } + return { parentId: channelCtx.parentId }; +} + +export function parseAgentComponentData(data: ComponentData): { componentId: string } | null { + const raw = readParsedComponentId(data); + const decodeSafe = (value: string): string => { + if (!value.includes("%")) { + return value; + } + if (!/%[0-9A-Fa-f]{2}/.test(value)) { + return value; + } + try { + return decodeURIComponent(value); + } catch { + return value; + } + }; + const componentId = + typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null; + if (!componentId) { + return null; + } + return { componentId }; +} + +async function ensureDmComponentAuthorized(params: { + ctx: AgentComponentContext; + interaction: AgentComponentInteraction; + user: DiscordUser; + componentLabel: string; + replyOpts: { ephemeral?: boolean }; +}) { + const { ctx, interaction, user, componentLabel, replyOpts } = params; + const dmPolicy = ctx.dmPolicy ?? "pairing"; + if (dmPolicy === "disabled") { + logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`); + try { + await interaction.reply({ + content: "DM interactions are disabled.", + ...replyOpts, + }); + } catch {} + return false; + } + if (dmPolicy === "open") { + return true; + } + + const storeAllowFrom = await readStoreAllowFromForDmPolicy({ + provider: "discord", + accountId: ctx.accountId, + dmPolicy, + }); + const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom]; + const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]); + const allowMatch = allowList + ? resolveDiscordAllowListMatch({ + allowList, + candidate: { + id: user.id, + name: user.username, + tag: formatDiscordUserTag(user), + }, + allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig), + }) + : { allowed: false }; + if (allowMatch.allowed) { + return true; + } + + if (dmPolicy === "pairing") { + const pairingResult = await issuePairingChallenge({ + channel: "discord", + senderId: user.id, + senderIdLine: `Your Discord user id: ${user.id}`, + meta: { + tag: formatDiscordUserTag(user), + name: user.username, + }, + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "discord", + id, + accountId: ctx.accountId, + meta, + }), + sendPairingReply: async (text) => { + await interaction.reply({ + content: text, + ...replyOpts, + }); + }, + }); + if (!pairingResult.created) { + try { + await interaction.reply({ + content: "Pairing already requested. Ask the bot owner to approve your code.", + ...replyOpts, + }); + } catch {} + } + return false; + } + + logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`); + try { + await interaction.reply({ + content: `You are not authorized to use this ${componentLabel}.`, + ...replyOpts, + }); + } catch {} + return false; +} + +export async function resolveInteractionContextWithDmAuth(params: { + ctx: AgentComponentContext; + interaction: AgentComponentInteraction; + label: string; + componentLabel: string; + defer?: boolean; +}) { + const interactionCtx = await resolveComponentInteractionContext({ + interaction: params.interaction, + label: params.label, + defer: params.defer, + }); + if (!interactionCtx) { + return null; + } + if (interactionCtx.isDirectMessage) { + const authorized = await ensureDmComponentAuthorized({ + ctx: params.ctx, + interaction: params.interaction, + user: interactionCtx.user, + componentLabel: params.componentLabel, + replyOpts: interactionCtx.replyOpts, + }); + if (!authorized) { + return null; + } + } + return interactionCtx; +} + +export function parseDiscordComponentData( + data: ComponentData, + customId?: string, +): { componentId: string; modalId?: string } | null { + if (!data || typeof data !== "object") { + return null; + } + const rawComponentId = readParsedComponentId(data); + const rawModalId = + "mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId; + let componentId = normalizeComponentId(rawComponentId); + let modalId = normalizeComponentId(rawModalId); + if (!componentId && customId) { + const parsed = parseDiscordComponentCustomId(customId); + if (parsed) { + componentId = parsed.componentId; + modalId = parsed.modalId; + } + } + if (!componentId) { + return null; + } + return { componentId, modalId }; +} + +export function parseDiscordModalId(data: ComponentData, customId?: string): string | null { + if (data && typeof data === "object") { + const rawModalId = + "mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId; + const modalId = normalizeComponentId(rawModalId); + if (modalId) { + return modalId; + } + } + if (customId) { + return parseDiscordModalCustomId(customId); + } + return null; +} + +export function resolveInteractionCustomId( + interaction: AgentComponentInteraction, +): string | undefined { + if (!interaction?.rawData || typeof interaction.rawData !== "object") { + return undefined; + } + if (!("data" in interaction.rawData)) { + return undefined; + } + const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data; + const customId = data?.custom_id; + if (typeof customId !== "string") { + return undefined; + } + const trimmed = customId.trim(); + return trimmed ? trimmed : undefined; +} + +export function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] { + if (entry.selectType === "string") { + return mapOptionLabels(entry.options, values); + } + if (entry.selectType === "user") { + return values.map((value) => `user:${value}`); + } + if (entry.selectType === "role") { + return values.map((value) => `role:${value}`); + } + if (entry.selectType === "mentionable") { + return values.map((value) => `mentionable:${value}`); + } + if (entry.selectType === "channel") { + return values.map((value) => `channel:${value}`); + } + return values; +} + +export function resolveModalFieldValues( + field: DiscordModalEntry["fields"][number], + interaction: ModalInteraction, +): string[] { + const fields = interaction.fields; + const optionLabels = field.options?.map((option) => ({ + value: option.value, + label: option.label, + })); + const required = field.required === true; + try { + switch (field.type) { + case "text": { + const value = required ? fields.getText(field.id, true) : fields.getText(field.id); + return value ? [value] : []; + } + case "select": + case "checkbox": + case "radio": { + const values = required + ? fields.getStringSelect(field.id, true) + : (fields.getStringSelect(field.id) ?? []); + return mapOptionLabels(optionLabels, values); + } + case "role-select": { + try { + const roles = required + ? fields.getRoleSelect(field.id, true) + : (fields.getRoleSelect(field.id) ?? []); + return roles.map((role) => role.name ?? role.id); + } catch { + const values = required + ? fields.getStringSelect(field.id, true) + : (fields.getStringSelect(field.id) ?? []); + return values; + } + } + case "user-select": { + const users = required + ? fields.getUserSelect(field.id, true) + : (fields.getUserSelect(field.id) ?? []); + return users.map((user) => formatDiscordUserTag(user)); + } + default: + return []; + } + } catch (err) { + logError(`agent modal: failed to read field ${field.id}: ${String(err)}`); + return []; + } +} + +export function formatModalSubmissionText( + entry: DiscordModalEntry, + interaction: ModalInteraction, +): string { + const lines: string[] = [`Form "${entry.title}" submitted.`]; + for (const field of entry.fields) { + const values = resolveModalFieldValues(field, interaction); + if (values.length === 0) { + continue; + } + lines.push(`- ${field.label}: ${values.join(", ")}`); + } + if (lines.length === 1) { + lines.push("- (no values)"); + } + return lines.join("\n"); +} + +export function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string { + const rawId = + interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData + ? (interaction.rawData as { id?: unknown }).id + : undefined; + if (typeof rawId === "string" && rawId.trim()) { + return rawId.trim(); + } + if (typeof rawId === "number" && Number.isFinite(rawId)) { + return String(rawId); + } + return `discord-interaction:${Date.now()}`; +} + +export function resolveComponentCommandAuthorized(params: { + ctx: AgentComponentContext; + interactionCtx: ComponentInteractionContext; + channelConfig: ReturnType; + guildInfo: ReturnType; + allowNameMatching: boolean; +}) { + const { ctx, interactionCtx, channelConfig, guildInfo } = params; + if (interactionCtx.isDirectMessage) { + return true; + } + + const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({ + allowFrom: ctx.allowFrom, + sender: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }); + + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, + memberRoleIds: interactionCtx.memberRoleIds, + sender: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }); + const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false; + const authorizers = useAccessGroups + ? [ + { configured: ownerAllowList != null, allowed: ownerOk }, + { configured: hasAccessRestrictions, allowed: memberAllowed }, + ] + : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; + + return resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers, + modeWhenAccessGroupsOff: "configured", + }); +} + +export { resolveDiscordGuildEntry, resolvePinnedMainDmOwnerFromAllowlist }; diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 5ac63e76d51..78fb38b3c91 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -19,7 +19,6 @@ import { import type { APIStringSelectComponent } from "discord-api-types/v10"; import { ButtonStyle, ChannelType } from "discord-api-types/v10"; import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; @@ -27,8 +26,6 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; -import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime"; -import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPluginBindingResolvedText, parsePluginBindingApprovalCustomId, @@ -48,32 +45,51 @@ import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { - readStoreAllowFromForDmPolicy, - resolvePinnedMainDmOwnerFromAllowlist, -} from "openclaw/plugin-sdk/security-runtime"; import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import { createDiscordFormModal, formatDiscordComponentEventText, - parseDiscordComponentCustomId, parseDiscordComponentCustomIdForCarbon, - parseDiscordModalCustomId, parseDiscordModalCustomIdForCarbon, type DiscordComponentEntry, type DiscordModalEntry, } from "../components.js"; +import { + AGENT_BUTTON_KEY, + AGENT_SELECT_KEY, + ackComponentInteraction, + buildAgentButtonCustomId, + buildAgentSelectCustomId, + type AgentComponentContext, + type AgentComponentInteraction, + type AgentComponentMessageInteraction, + ensureAgentComponentInteractionAllowed, + ensureComponentUserAllowed, + ensureGuildComponentMemberAllowed, + formatModalSubmissionText, + mapSelectValues, + parseAgentComponentData, + parseDiscordComponentData, + parseDiscordModalId, + resolveAgentComponentRoute, + resolveComponentCommandAuthorized, + type ComponentInteractionContext, + resolveDiscordChannelContext, + type DiscordChannelContext, + resolveDiscordInteractionId, + resolveInteractionContextWithDmAuth, + resolveInteractionCustomId, + resolveModalFieldValues, + resolvePinnedMainDmOwnerFromAllowlist, + type DiscordUser, +} from "./agent-components-helpers.js"; import { type DiscordGuildEntryResolved, normalizeDiscordAllowList, - normalizeDiscordSlug, - resolveDiscordAllowListMatch, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, - resolveDiscordMemberAccessState, - resolveDiscordOwnerAccess, } from "./allow-list.js"; import { formatDiscordUserTag } from "./format.js"; import { @@ -84,714 +100,6 @@ import { buildDirectLabel, buildGuildLabel } from "./reply-context.js"; import { deliverDiscordReply } from "./reply-delivery.js"; import { sendTyping } from "./typing.js"; -const AGENT_BUTTON_KEY = "agent"; -const AGENT_SELECT_KEY = "agentsel"; - -type DiscordUser = Parameters[0]; - -type AgentComponentMessageInteraction = - | ButtonInteraction - | StringSelectMenuInteraction - | RoleSelectMenuInteraction - | UserSelectMenuInteraction - | MentionableSelectMenuInteraction - | ChannelSelectMenuInteraction; - -type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction; - -type ComponentInteractionContext = NonNullable< - Awaited> ->; - -type DiscordChannelContext = { - channelName: string | undefined; - channelSlug: string; - channelType: number | undefined; - isThread: boolean; - parentId: string | undefined; - parentName: string | undefined; - parentSlug: string; -}; - -function resolveAgentComponentRoute(params: { - ctx: AgentComponentContext; - rawGuildId: string | undefined; - memberRoleIds: string[]; - isDirectMessage: boolean; - userId: string; - channelId: string; - parentId: string | undefined; -}) { - return resolveAgentRoute({ - cfg: params.ctx.cfg, - channel: "discord", - accountId: params.ctx.accountId, - guildId: params.rawGuildId, - memberRoleIds: params.memberRoleIds, - peer: { - kind: params.isDirectMessage ? "direct" : "channel", - id: params.isDirectMessage ? params.userId : params.channelId, - }, - parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined, - }); -} - -async function ackComponentInteraction(params: { - interaction: AgentComponentInteraction; - replyOpts: { ephemeral?: boolean }; - label: string; -}) { - try { - await params.interaction.reply({ - content: "✓", - ...params.replyOpts, - }); - } catch (err) { - logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`); - } -} - -function resolveDiscordChannelContext( - interaction: AgentComponentInteraction, -): DiscordChannelContext { - const channel = interaction.channel; - const channelName = channel && "name" in channel ? (channel.name as string) : undefined; - const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; - const channelType = channel && "type" in channel ? (channel.type as number) : undefined; - const isThread = isThreadChannelType(channelType); - - let parentId: string | undefined; - let parentName: string | undefined; - let parentSlug = ""; - if (isThread && channel && "parentId" in channel) { - parentId = (channel.parentId as string) ?? undefined; - if ("parent" in channel) { - const parent = (channel as { parent?: { name?: string } }).parent; - if (parent?.name) { - parentName = parent.name; - parentSlug = normalizeDiscordSlug(parentName); - } - } - } - - return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug }; -} - -async function resolveComponentInteractionContext(params: { - interaction: AgentComponentInteraction; - label: string; - defer?: boolean; -}): Promise<{ - channelId: string; - user: DiscordUser; - username: string; - userId: string; - replyOpts: { ephemeral?: boolean }; - rawGuildId: string | undefined; - isDirectMessage: boolean; - memberRoleIds: string[]; -} | null> { - const { interaction, label } = params; - - // Use interaction's actual channel_id (trusted source from Discord) - // This prevents channel spoofing attacks - const channelId = interaction.rawData.channel_id; - if (!channelId) { - logError(`${label}: missing channel_id in interaction`); - return null; - } - - const user = interaction.user; - if (!user) { - logError(`${label}: missing user in interaction`); - return null; - } - - const shouldDefer = params.defer !== false && "defer" in interaction; - let didDefer = false; - // Defer immediately to satisfy Discord's 3-second interaction ACK requirement. - // We use an ephemeral deferred reply so subsequent interaction.reply() calls - // can safely edit the original deferred response. - if (shouldDefer) { - try { - await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true }); - didDefer = true; - } catch (err) { - logError(`${label}: failed to defer interaction: ${String(err)}`); - } - } - const replyOpts = didDefer ? {} : { ephemeral: true }; - - const username = formatUsername(user); - const userId = user.id; - - // P1 FIX: Use rawData.guild_id as source of truth - interaction.guild can be null - // when guild is not cached even though guild_id is present in rawData - const rawGuildId = interaction.rawData.guild_id; - const isDirectMessage = !rawGuildId; - const memberRoleIds = Array.isArray(interaction.rawData.member?.roles) - ? interaction.rawData.member.roles.map((roleId: string) => String(roleId)) - : []; - - return { - channelId, - user, - username, - userId, - replyOpts, - rawGuildId, - isDirectMessage, - memberRoleIds, - }; -} - -async function ensureGuildComponentMemberAllowed(params: { - interaction: AgentComponentInteraction; - guildInfo: ReturnType; - channelId: string; - rawGuildId: string | undefined; - channelCtx: DiscordChannelContext; - memberRoleIds: string[]; - user: DiscordUser; - replyOpts: { ephemeral?: boolean }; - componentLabel: string; - unauthorizedReply: string; - allowNameMatching: boolean; -}): Promise { - const { - interaction, - guildInfo, - channelId, - rawGuildId, - channelCtx, - memberRoleIds, - user, - replyOpts, - componentLabel, - unauthorizedReply, - } = params; - - if (!rawGuildId) { - return true; - } - - const channelConfig = resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId, - channelName: channelCtx.channelName, - channelSlug: channelCtx.channelSlug, - parentId: channelCtx.parentId, - parentName: channelCtx.parentName, - parentSlug: channelCtx.parentSlug, - scope: channelCtx.isThread ? "thread" : "channel", - }); - - const { memberAllowed } = resolveDiscordMemberAccessState({ - channelConfig, - guildInfo, - memberRoleIds, - sender: { - id: user.id, - name: user.username, - tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined, - }, - allowNameMatching: params.allowNameMatching, - }); - if (memberAllowed) { - return true; - } - - logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`); - try { - await interaction.reply({ - content: unauthorizedReply, - ...replyOpts, - }); - } catch { - // Interaction may have expired - } - return false; -} - -async function ensureComponentUserAllowed(params: { - entry: DiscordComponentEntry; - interaction: AgentComponentInteraction; - user: DiscordUser; - replyOpts: { ephemeral?: boolean }; - componentLabel: string; - unauthorizedReply: string; - allowNameMatching: boolean; -}): Promise { - const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [ - "discord:", - "user:", - "pk:", - ]); - if (!allowList) { - return true; - } - const match = resolveDiscordAllowListMatch({ - allowList, - candidate: { - id: params.user.id, - name: params.user.username, - tag: formatDiscordUserTag(params.user), - }, - allowNameMatching: params.allowNameMatching, - }); - if (match.allowed) { - return true; - } - - logVerbose( - `discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`, - ); - try { - await params.interaction.reply({ - content: params.unauthorizedReply, - ...params.replyOpts, - }); - } catch { - // Interaction may have expired - } - return false; -} - -async function ensureAgentComponentInteractionAllowed(params: { - ctx: AgentComponentContext; - interaction: AgentComponentInteraction; - channelId: string; - rawGuildId: string | undefined; - memberRoleIds: string[]; - user: DiscordUser; - replyOpts: { ephemeral?: boolean }; - componentLabel: string; - unauthorizedReply: string; -}): Promise<{ parentId: string | undefined } | null> { - const guildInfo = resolveDiscordGuildEntry({ - guild: params.interaction.guild ?? undefined, - guildId: params.rawGuildId, - guildEntries: params.ctx.guildEntries, - }); - const channelCtx = resolveDiscordChannelContext(params.interaction); - const memberAllowed = await ensureGuildComponentMemberAllowed({ - interaction: params.interaction, - guildInfo, - channelId: params.channelId, - rawGuildId: params.rawGuildId, - channelCtx, - memberRoleIds: params.memberRoleIds, - user: params.user, - replyOpts: params.replyOpts, - componentLabel: params.componentLabel, - unauthorizedReply: params.unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), - }); - if (!memberAllowed) { - return null; - } - return { parentId: channelCtx.parentId }; -} - -export type AgentComponentContext = { - cfg: OpenClawConfig; - accountId: string; - discordConfig?: DiscordAccountConfig; - runtime?: RuntimeEnv; - token?: string; - guildEntries?: Record; - /** DM allowlist (from allowFrom config; legacy: dm.allowFrom) */ - allowFrom?: string[]; - /** DM policy (default: "pairing") */ - dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; -}; - -/** - * Build agent button custom ID: agent:componentId= - * The channelId is NOT embedded in customId - we use interaction.rawData.channel_id instead - * to prevent channel spoofing attacks. - * - * Carbon's customIdParser parses "key:arg1=value1;arg2=value2" into { arg1: value1, arg2: value2 } - */ -export function buildAgentButtonCustomId(componentId: string): string { - return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`; -} - -/** - * Build agent select menu custom ID: agentsel:componentId= - */ -export function buildAgentSelectCustomId(componentId: string): string { - return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`; -} - -/** - * Parse agent component data from Carbon's parsed ComponentData - * Supports both legacy { componentId } and Components v2 { cid } payloads. - */ -function readParsedComponentId(data: ComponentData): unknown { - if (!data || typeof data !== "object") { - return undefined; - } - return "cid" in data - ? (data as Record).cid - : (data as Record).componentId; -} - -function parseAgentComponentData(data: ComponentData): { - componentId: string; -} | null { - const raw = readParsedComponentId(data); - - const decodeSafe = (value: string): string => { - // `cid` values may be raw (not URI-encoded). Guard against malformed % sequences. - // Only attempt decoding when it looks like it contains percent-encoding. - if (!value.includes("%")) { - return value; - } - // If it has a % but not a valid %XX sequence, skip decode. - if (!/%[0-9A-Fa-f]{2}/.test(value)) { - return value; - } - try { - return decodeURIComponent(value); - } catch { - return value; - } - }; - - const componentId = - typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null; - - if (!componentId) { - return null; - } - return { componentId }; -} - -function formatUsername(user: { username: string; discriminator?: string | null }): string { - if (user.discriminator && user.discriminator !== "0") { - return `${user.username}#${user.discriminator}`; - } - return user.username; -} - -/** - * Check if a channel type is a thread type - */ -function isThreadChannelType(channelType: number | undefined): boolean { - return ( - channelType === ChannelType.PublicThread || - channelType === ChannelType.PrivateThread || - channelType === ChannelType.AnnouncementThread - ); -} - -async function ensureDmComponentAuthorized(params: { - ctx: AgentComponentContext; - interaction: AgentComponentInteraction; - user: DiscordUser; - componentLabel: string; - replyOpts: { ephemeral?: boolean }; -}): Promise { - const { ctx, interaction, user, componentLabel, replyOpts } = params; - const dmPolicy = ctx.dmPolicy ?? "pairing"; - if (dmPolicy === "disabled") { - logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`); - try { - await interaction.reply({ - content: "DM interactions are disabled.", - ...replyOpts, - }); - } catch { - // Interaction may have expired - } - return false; - } - if (dmPolicy === "open") { - return true; - } - - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "discord", - accountId: ctx.accountId, - dmPolicy, - }); - const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom]; - const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]); - const allowMatch = allowList - ? resolveDiscordAllowListMatch({ - allowList, - candidate: { - id: user.id, - name: user.username, - tag: formatDiscordUserTag(user), - }, - allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig), - }) - : { allowed: false }; - if (allowMatch.allowed) { - return true; - } - - if (dmPolicy === "pairing") { - const pairingResult = await issuePairingChallenge({ - channel: "discord", - senderId: user.id, - senderIdLine: `Your Discord user id: ${user.id}`, - meta: { - tag: formatDiscordUserTag(user), - name: user.username, - }, - upsertPairingRequest: async ({ id, meta }) => - await upsertChannelPairingRequest({ - channel: "discord", - id, - accountId: ctx.accountId, - meta, - }), - sendPairingReply: async (text) => { - await interaction.reply({ - content: text, - ...replyOpts, - }); - }, - }); - if (!pairingResult.created) { - try { - await interaction.reply({ - content: "Pairing already requested. Ask the bot owner to approve your code.", - ...replyOpts, - }); - } catch { - // Interaction may have expired - } - } - return false; - } - - logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`); - try { - await interaction.reply({ - content: `You are not authorized to use this ${componentLabel}.`, - ...replyOpts, - }); - } catch { - // Interaction may have expired - } - return false; -} - -async function resolveInteractionContextWithDmAuth(params: { - ctx: AgentComponentContext; - interaction: AgentComponentInteraction; - label: string; - componentLabel: string; - defer?: boolean; -}): Promise { - const interactionCtx = await resolveComponentInteractionContext({ - interaction: params.interaction, - label: params.label, - defer: params.defer, - }); - if (!interactionCtx) { - return null; - } - if (interactionCtx.isDirectMessage) { - const authorized = await ensureDmComponentAuthorized({ - ctx: params.ctx, - interaction: params.interaction, - user: interactionCtx.user, - componentLabel: params.componentLabel, - replyOpts: interactionCtx.replyOpts, - }); - if (!authorized) { - return null; - } - } - return interactionCtx; -} - -function normalizeComponentId(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; - } - if (typeof value === "number" && Number.isFinite(value)) { - return String(value); - } - return undefined; -} - -function parseDiscordComponentData( - data: ComponentData, - customId?: string, -): { componentId: string; modalId?: string } | null { - if (!data || typeof data !== "object") { - return null; - } - const rawComponentId = readParsedComponentId(data); - const rawModalId = - "mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId; - let componentId = normalizeComponentId(rawComponentId); - let modalId = normalizeComponentId(rawModalId); - if (!componentId && customId) { - const parsed = parseDiscordComponentCustomId(customId); - if (parsed) { - componentId = parsed.componentId; - modalId = parsed.modalId; - } - } - if (!componentId) { - return null; - } - return { componentId, modalId }; -} - -function parseDiscordModalId(data: ComponentData, customId?: string): string | null { - if (data && typeof data === "object") { - const rawModalId = - "mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId; - const modalId = normalizeComponentId(rawModalId); - if (modalId) { - return modalId; - } - } - if (customId) { - return parseDiscordModalCustomId(customId); - } - return null; -} - -function resolveInteractionCustomId(interaction: AgentComponentInteraction): string | undefined { - if (!interaction?.rawData || typeof interaction.rawData !== "object") { - return undefined; - } - if (!("data" in interaction.rawData)) { - return undefined; - } - const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data; - const customId = data?.custom_id; - if (typeof customId !== "string") { - return undefined; - } - const trimmed = customId.trim(); - return trimmed ? trimmed : undefined; -} - -function mapOptionLabels( - options: Array<{ value: string; label: string }> | undefined, - values: string[], -) { - if (!options || options.length === 0) { - return values; - } - const map = new Map(options.map((option) => [option.value, option.label])); - return values.map((value) => map.get(value) ?? value); -} - -function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] { - if (entry.selectType === "string") { - return mapOptionLabels(entry.options, values); - } - if (entry.selectType === "user") { - return values.map((value) => `user:${value}`); - } - if (entry.selectType === "role") { - return values.map((value) => `role:${value}`); - } - if (entry.selectType === "mentionable") { - return values.map((value) => `mentionable:${value}`); - } - if (entry.selectType === "channel") { - return values.map((value) => `channel:${value}`); - } - return values; -} - -function resolveModalFieldValues( - field: DiscordModalEntry["fields"][number], - interaction: ModalInteraction, -): string[] { - const fields = interaction.fields; - const optionLabels = field.options?.map((option) => ({ - value: option.value, - label: option.label, - })); - const required = field.required === true; - try { - switch (field.type) { - case "text": { - const value = required ? fields.getText(field.id, true) : fields.getText(field.id); - return value ? [value] : []; - } - case "select": - case "checkbox": - case "radio": { - const values = required - ? fields.getStringSelect(field.id, true) - : (fields.getStringSelect(field.id) ?? []); - return mapOptionLabels(optionLabels, values); - } - case "role-select": { - try { - const roles = required - ? fields.getRoleSelect(field.id, true) - : (fields.getRoleSelect(field.id) ?? []); - return roles.map((role) => role.name ?? role.id); - } catch { - const values = required - ? fields.getStringSelect(field.id, true) - : (fields.getStringSelect(field.id) ?? []); - return values; - } - } - case "user-select": { - const users = required - ? fields.getUserSelect(field.id, true) - : (fields.getUserSelect(field.id) ?? []); - return users.map((user) => formatDiscordUserTag(user)); - } - default: - return []; - } - } catch (err) { - logError(`agent modal: failed to read field ${field.id}: ${String(err)}`); - return []; - } -} - -function formatModalSubmissionText( - entry: DiscordModalEntry, - interaction: ModalInteraction, -): string { - const lines: string[] = [`Form "${entry.title}" submitted.`]; - for (const field of entry.fields) { - const values = resolveModalFieldValues(field, interaction); - if (values.length === 0) { - continue; - } - lines.push(`- ${field.label}: ${values.join(", ")}`); - } - if (lines.length === 1) { - lines.push("- (no values)"); - } - return lines.join("\n"); -} - -function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string { - const rawId = - interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData - ? (interaction.rawData as { id?: unknown }).id - : undefined; - if (typeof rawId === "string" && rawId.trim()) { - return rawId.trim(); - } - if (typeof rawId === "number" && Number.isFinite(rawId)) { - return String(rawId); - } - return `discord-interaction:${Date.now()}`; -} - async function dispatchPluginDiscordInteractiveEvent(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; @@ -931,54 +239,6 @@ async function dispatchPluginDiscordInteractiveEvent(params: { return "unmatched"; } -function resolveComponentCommandAuthorized(params: { - ctx: AgentComponentContext; - interactionCtx: ComponentInteractionContext; - channelConfig: ReturnType; - guildInfo: ReturnType; - allowNameMatching: boolean; -}): boolean { - const { ctx, interactionCtx, channelConfig, guildInfo } = params; - if (interactionCtx.isDirectMessage) { - return true; - } - - const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({ - allowFrom: ctx.allowFrom, - sender: { - id: interactionCtx.user.id, - name: interactionCtx.user.username, - tag: formatDiscordUserTag(interactionCtx.user), - }, - allowNameMatching: params.allowNameMatching, - }); - - const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ - channelConfig, - guildInfo, - memberRoleIds: interactionCtx.memberRoleIds, - sender: { - id: interactionCtx.user.id, - name: interactionCtx.user.username, - tag: formatDiscordUserTag(interactionCtx.user), - }, - allowNameMatching: params.allowNameMatching, - }); - const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false; - const authorizers = useAccessGroups - ? [ - { configured: ownerAllowList != null, allowed: ownerOk }, - { configured: hasAccessRestrictions, allowed: memberAllowed }, - ] - : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; - - return resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers, - modeWhenAccessGroupsOff: "configured", - }); -} - async function dispatchDiscordComponentEvent(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; @@ -1045,7 +305,7 @@ async function dispatchDiscordComponentEvent(params: { ? resolvePinnedMainDmOwnerFromAllowlist({ dmScope: ctx.cfg.session?.dmScope, allowFrom: channelConfig?.users ?? guildInfo?.users, - normalizeEntry: (entry) => { + normalizeEntry: (entry: string) => { const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]); const candidate = normalized?.ids.values().next().value; return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined; diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index da916c4bd2b..84b36d74ec6 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -7,11 +7,11 @@ import type { import type { Client } from "@buape/carbon"; import { ChannelType } from "discord-api-types/v10"; import type { GatewayPresenceUpdate } from "discord-api-types/v10"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import { buildPluginBindingApprovalCustomId } from "openclaw/plugin-sdk/conversation-runtime"; +import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js"; -import { buildPluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js"; -import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js"; import { clearDiscordComponentEntries, registerDiscordComponentEntries, @@ -50,7 +50,6 @@ const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); const upsertPairingRequestMock = vi.hoisted(() => vi.fn()); const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); const dispatchReplyMock = vi.hoisted(() => vi.fn()); -const deliverDiscordReplyMock = vi.hoisted(() => vi.fn()); const recordInboundSessionMock = vi.hoisted(() => vi.fn()); const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn()); const resolveStorePathMock = vi.hoisted(() => vi.fn()); @@ -59,37 +58,20 @@ const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn()); const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn()); let lastDispatchCtx: Record | undefined; -vi.mock("../../../../src/pairing/pairing-store.js", () => ({ - readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), -})); - -vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, - enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), + readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args), }; }); -vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ - dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args), -})); - -vi.mock("./reply-delivery.js", () => ({ - deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args), -})); - -vi.mock("../../../../src/channels/session.js", () => ({ - recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), -})); - -vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/pairing/pairing-store.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args), - resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args), + upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), }; }); @@ -105,6 +87,42 @@ vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal }; }); +vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), + }; +}); + +vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../../src/auto-reply/reply/provider-dispatcher.js") + >(); + return { + ...actual, + dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args), + }; +}); + +vi.mock("../../../../src/channels/session.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), + }; +}); + +vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args), + resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args), + }; +}); + vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -287,12 +305,18 @@ describe("discord component interactions", () => { const createComponentButtonInteraction = (overrides: Partial = {}) => { const reply = vi.fn().mockResolvedValue(undefined); const defer = vi.fn().mockResolvedValue(undefined); + const rest = { + get: vi.fn().mockResolvedValue({ type: ChannelType.DM }), + post: vi.fn().mockResolvedValue({}), + patch: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue(undefined), + }; const interaction = { rawData: { channel_id: "dm-channel", id: "interaction-1" }, user: { id: "123456789", username: "AgentUser", discriminator: "0001" }, customId: "occomp:cid=btn_1", message: { id: "msg-1" }, - client: { rest: {} }, + client: { rest }, defer, reply, ...overrides, @@ -303,6 +327,12 @@ describe("discord component interactions", () => { const createModalInteraction = (overrides: Partial = {}) => { const reply = vi.fn().mockResolvedValue(undefined); const acknowledge = vi.fn().mockResolvedValue(undefined); + const rest = { + get: vi.fn().mockResolvedValue({ type: ChannelType.DM }), + post: vi.fn().mockResolvedValue({}), + patch: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue(undefined), + }; const fields = { getText: (key: string) => (key === "fld_1" ? "Casey" : undefined), getStringSelect: (_key: string) => undefined, @@ -316,7 +346,7 @@ describe("discord component interactions", () => { fields, acknowledge, reply, - client: { rest: {} }, + client: { rest }, ...overrides, } as unknown as ModalInteraction; return { interaction, acknowledge, reply }; @@ -363,7 +393,6 @@ describe("discord component interactions", () => { lastDispatchCtx = params.ctx; await params.dispatcherOptions.deliver({ text: "ok" }); }); - deliverDiscordReplyMock.mockClear(); recordInboundSessionMock.mockClear().mockResolvedValue(undefined); readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined); resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json"); @@ -415,8 +444,6 @@ describe("discord component interactions", () => { expect(reply).toHaveBeenCalledWith({ content: "✓" }); expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".'); expect(dispatchReplyMock).toHaveBeenCalledTimes(1); - expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1); - expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-1"); expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull(); }); @@ -482,8 +509,6 @@ describe("discord component interactions", () => { expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.'); expect(lastDispatchCtx?.BodyForAgent).toContain("- Name: Casey"); expect(dispatchReplyMock).toHaveBeenCalledTimes(1); - expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1); - expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2"); expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull(); }); diff --git a/extensions/feishu/src/bot-sender-name.ts b/extensions/feishu/src/bot-sender-name.ts new file mode 100644 index 00000000000..57b5aad3c96 --- /dev/null +++ b/extensions/feishu/src/bot-sender-name.ts @@ -0,0 +1,121 @@ +import { createFeishuClient } from "./client.js"; +import type { ResolvedFeishuAccount } from "./types.js"; + +export type FeishuPermissionError = { + code: number; + message: string; + grantUrl?: string; +}; + +type SenderNameResult = { + name?: string; + permissionError?: FeishuPermissionError; +}; + +const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"]; +const FEISHU_SCOPE_CORRECTIONS: Record = { + "contact:contact.base:readonly": "contact:user.base:readonly", +}; +const SENDER_NAME_TTL_MS = 10 * 60 * 1000; +const senderNameCache = new Map(); + +function correctFeishuScopeInUrl(url: string): string { + let corrected = url; + for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) { + corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right)); + corrected = corrected.replaceAll(wrong, right); + } + return corrected; +} + +function shouldSuppressPermissionErrorNotice(permissionError: FeishuPermissionError): boolean { + const message = permissionError.message.toLowerCase(); + return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token)); +} + +function extractPermissionError(err: unknown): FeishuPermissionError | null { + if (!err || typeof err !== "object") { + return null; + } + const axiosErr = err as { response?: { data?: unknown } }; + const data = axiosErr.response?.data; + if (!data || typeof data !== "object") { + return null; + } + const feishuErr = data as { code?: number; msg?: string }; + if (feishuErr.code !== 99991672) { + return null; + } + const msg = feishuErr.msg ?? ""; + const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/); + return { + code: feishuErr.code, + message: msg, + grantUrl: urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined, + }; +} + +function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" { + const trimmed = senderId.trim(); + if (trimmed.startsWith("ou_")) { + return "open_id"; + } + if (trimmed.startsWith("on_")) { + return "union_id"; + } + return "user_id"; +} + +export async function resolveFeishuSenderName(params: { + account: ResolvedFeishuAccount; + senderId: string; + log: (...args: any[]) => void; +}): Promise { + const { account, senderId, log } = params; + if (!account.configured) { + return {}; + } + + const normalizedSenderId = senderId.trim(); + if (!normalizedSenderId) { + return {}; + } + + const cached = senderNameCache.get(normalizedSenderId); + const now = Date.now(); + if (cached && cached.expireAt > now) { + return { name: cached.name }; + } + + try { + const client = createFeishuClient(account); + const userIdType = resolveSenderLookupIdType(normalizedSenderId); + const res: any = await client.contact.user.get({ + path: { user_id: normalizedSenderId }, + params: { user_id_type: userIdType }, + }); + const name: string | undefined = + res?.data?.user?.name || + res?.data?.user?.display_name || + res?.data?.user?.nickname || + res?.data?.user?.en_name; + + if (name && typeof name === "string") { + senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS }); + return { name }; + } + return {}; + } catch (err) { + const permErr = extractPermissionError(err); + if (permErr) { + if (shouldSuppressPermissionErrorNotice(permErr)) { + log(`feishu: ignoring stale permission scope error: ${permErr.message}`); + return {}; + } + log(`feishu: permission error resolving sender name: code=${permErr.code}`); + return { permissionError: permErr }; + } + log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`); + return {}; + } +} diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 9d8ef82a177..871ca9dfb1d 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -22,6 +22,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; +import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js"; import { createFeishuClient } from "./client.js"; import { buildFeishuConversationId } from "./conversation-id.js"; import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js"; @@ -39,150 +40,13 @@ import { parsePostContent } from "./post.js"; import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js"; -import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; +import type { FeishuMessageContext, FeishuMediaInfo } from "./types.js"; import type { DynamicAgentCreationConfig } from "./types.js"; -// --- Permission error extraction --- -// Extract permission grant URL from Feishu API error response. -type PermissionError = { - code: number; - message: string; - grantUrl?: string; -}; - -const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"]; - -// Feishu API sometimes returns incorrect scope names in permission error -// responses (e.g. "contact:contact.base:readonly" instead of the valid -// "contact:user.base:readonly"). This map corrects known mismatches. -const FEISHU_SCOPE_CORRECTIONS: Record = { - "contact:contact.base:readonly": "contact:user.base:readonly", -}; - -function correctFeishuScopeInUrl(url: string): string { - let corrected = url; - for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) { - corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right)); - corrected = corrected.replaceAll(wrong, right); - } - return corrected; -} - -function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean { - const message = permissionError.message.toLowerCase(); - return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token)); -} - -function extractPermissionError(err: unknown): PermissionError | null { - if (!err || typeof err !== "object") return null; - - // Axios error structure: err.response.data contains the Feishu error - const axiosErr = err as { response?: { data?: unknown } }; - const data = axiosErr.response?.data; - if (!data || typeof data !== "object") return null; - - const feishuErr = data as { - code?: number; - msg?: string; - error?: { permission_violations?: Array<{ uri?: string }> }; - }; - - // Feishu permission error code: 99991672 - if (feishuErr.code !== 99991672) return null; - - // Extract the grant URL from the error message (contains the direct link) - const msg = feishuErr.msg ?? ""; - const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/); - const grantUrl = urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined; - - return { - code: feishuErr.code, - message: msg, - grantUrl, - }; -} - -// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) --- -// Cache display names by sender id (open_id/user_id) to avoid an API call on every message. -const SENDER_NAME_TTL_MS = 10 * 60 * 1000; -const senderNameCache = new Map(); - // Cache permission errors to avoid spamming the user with repeated notifications. // Key: appId or "default", Value: timestamp of last notification const permissionErrorNotifiedAt = new Map(); const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes - -type SenderNameResult = { - name?: string; - permissionError?: PermissionError; -}; - -function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" { - const trimmed = senderId.trim(); - if (trimmed.startsWith("ou_")) { - return "open_id"; - } - if (trimmed.startsWith("on_")) { - return "union_id"; - } - return "user_id"; -} - -async function resolveFeishuSenderName(params: { - account: ResolvedFeishuAccount; - senderId: string; - log: (...args: any[]) => void; -}): Promise { - const { account, senderId, log } = params; - if (!account.configured) return {}; - - const normalizedSenderId = senderId.trim(); - if (!normalizedSenderId) return {}; - - const cached = senderNameCache.get(normalizedSenderId); - const now = Date.now(); - if (cached && cached.expireAt > now) return { name: cached.name }; - - try { - const client = createFeishuClient(account); - const userIdType = resolveSenderLookupIdType(normalizedSenderId); - - // contact/v3/users/:user_id?user_id_type= - const res: any = await client.contact.user.get({ - path: { user_id: normalizedSenderId }, - params: { user_id_type: userIdType }, - }); - - const name: string | undefined = - res?.data?.user?.name || - res?.data?.user?.display_name || - res?.data?.user?.nickname || - res?.data?.user?.en_name; - - if (name && typeof name === "string") { - senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS }); - return { name }; - } - - return {}; - } catch (err) { - // Check if this is a permission error - const permErr = extractPermissionError(err); - if (permErr) { - if (shouldSuppressPermissionErrorNotice(permErr)) { - log(`feishu: ignoring stale permission scope error: ${permErr.message}`); - return {}; - } - log(`feishu: permission error resolving sender name: code=${permErr.code}`); - return { permissionError: permErr }; - } - - // Best-effort. Don't fail message handling if name lookup fails. - log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`); - return {}; - } -} - export type FeishuMessageEvent = { sender: { sender_id: { @@ -848,7 +712,7 @@ export function buildFeishuAgentBody(params: { "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention" >; quotedContent?: string; - permissionErrorForAgent?: PermissionError; + permissionErrorForAgent?: FeishuPermissionError; botOpenId?: string; }): string { const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params; @@ -967,7 +831,7 @@ export async function handleFeishuMessage(params: { // Resolve sender display name (best-effort) so the agent can attribute messages correctly. // Optimization: skip if disabled to save API quota (Feishu free tier limit). - let permissionErrorForAgent: PermissionError | undefined; + let permissionErrorForAgent: FeishuPermissionError | undefined; if (feishuCfg?.resolveSenderNames ?? true) { const senderResult = await resolveFeishuSenderName({ account, diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index a35a7ab07d0..f9079d7430a 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -1,83 +1 @@ -export { - createActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringParam, -} from "../../src/agents/tools/common.js"; -export type { ReplyPayload } from "../../src/auto-reply/types.js"; -export { - compileAllowlist, - resolveCompiledAllowlistMatch, -} from "../../src/channels/allowlist-match.js"; -export { mergeAllowlist, summarizeMapping } from "../../src/channels/allowlists/resolve-utils.js"; -export { resolveControlCommandGate } from "../../src/channels/command-gating.js"; -export type { NormalizedLocation } from "../../src/channels/location.js"; -export { formatLocationText, toLocationContext } from "../../src/channels/location.js"; -export { logInboundDrop, logTypingFailure } from "../../src/channels/logging.js"; -export type { AllowlistMatch } from "../../src/channels/plugins/allowlist-match.js"; -export { formatAllowlistMatchMeta } from "../../src/channels/plugins/allowlist-match.js"; -export { - buildChannelKeyCandidates, - resolveChannelEntryMatch, -} from "../../src/channels/plugins/channel-config.js"; -export { buildChannelConfigSchema } from "../../src/channels/plugins/config-schema.js"; -export { createAccountListHelpers } from "../../src/channels/plugins/account-helpers.js"; -export type { - BaseProbeResult, - ChannelDirectoryEntry, - ChannelGroupContext, - ChannelMessageActionAdapter, - ChannelMessageActionContext, - ChannelMessageActionName, - ChannelOutboundAdapter, - ChannelResolveKind, - ChannelResolveResult, - ChannelToolSend, -} from "../../src/channels/plugins/types.js"; -export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js"; -export { createReplyPrefixOptions } from "../../src/channels/reply-prefix.js"; -export { createTypingCallbacks } from "../../src/channels/typing.js"; -export { - GROUP_POLICY_BLOCKED_LABEL, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - warnMissingProviderGroupPolicyFallbackOnce, -} from "../../src/config/runtime-group-policy.js"; -export type { - DmPolicy, - GroupPolicy, - GroupToolPolicyConfig, - MarkdownTableMode, -} from "../../src/config/types.js"; -export type { SecretInput } from "../../src/config/types.secrets.js"; -export { - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "../../src/config/types.secrets.js"; -export { ToolPolicySchema } from "../../src/config/zod-schema.agent-runtime.js"; -export { MarkdownConfigSchema } from "../../src/config/zod-schema.core.js"; -export { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js"; -export { issuePairingChallenge } from "../../src/pairing/pairing-challenge.js"; -export type { PluginRuntime, RuntimeLogger } from "../../src/plugins/runtime/types.js"; -export { DEFAULT_ACCOUNT_ID } from "../../src/routing/session-key.js"; -export type { PollInput } from "../../src/polls.js"; -export { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "../../src/security/dm-policy-shared.js"; -export { normalizeStringEntries } from "../../src/shared/string-normalization.js"; -export { - evaluateGroupRouteAccessForPolicy, - resolveSenderScopedGroupPolicy, -} from "../../src/plugin-sdk/group-access.js"; -export { createScopedPairingAccess } from "../../src/plugin-sdk/pairing-access.js"; -export { runPluginCommandWithTimeout } from "../../src/plugin-sdk/run-command.js"; -export { dispatchReplyFromConfigWithSettledDispatcher } from "../../src/plugin-sdk/inbound-reply-dispatch.js"; -export { resolveRuntimeEnv } from "../../src/plugin-sdk/runtime.js"; -export { resolveInboundSessionEnvelopeContext } from "../../src/channels/session-envelope.js"; -export { - buildProbeChannelStatusSummary, - collectStatusIssuesFromLastError, -} from "../../src/plugin-sdk/status-helpers.js"; +export * from "openclaw/plugin-sdk/matrix"; diff --git a/extensions/mattermost/src/mattermost/monitor-gating.ts b/extensions/mattermost/src/mattermost/monitor-gating.ts new file mode 100644 index 00000000000..464ee163c03 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-gating.ts @@ -0,0 +1,99 @@ +import type { ChatType, OpenClawConfig } from "../runtime-api.js"; + +export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType { + if (!channelType) { + return "channel"; + } + const normalized = channelType.trim().toUpperCase(); + if (normalized === "D") { + return "direct"; + } + if (normalized === "G" || normalized === "P") { + return "group"; + } + return "channel"; +} + +export type MattermostRequireMentionResolverInput = { + cfg: OpenClawConfig; + channel: "mattermost"; + accountId: string; + groupId: string; + requireMentionOverride?: boolean; +}; + +export type MattermostMentionGateInput = { + kind: ChatType; + cfg: OpenClawConfig; + accountId: string; + channelId: string; + threadRootId?: string; + requireMentionOverride?: boolean; + resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean; + wasMentioned: boolean; + isControlCommand: boolean; + commandAuthorized: boolean; + oncharEnabled: boolean; + oncharTriggered: boolean; + canDetectMention: boolean; +}; + +type MattermostMentionGateDecision = { + shouldRequireMention: boolean; + shouldBypassMention: boolean; + effectiveWasMentioned: boolean; + dropReason: "onchar-not-triggered" | "missing-mention" | null; +}; + +export function evaluateMattermostMentionGate( + params: MattermostMentionGateInput, +): MattermostMentionGateDecision { + const shouldRequireMention = + params.kind !== "direct" && + params.resolveRequireMention({ + cfg: params.cfg, + channel: "mattermost", + accountId: params.accountId, + groupId: params.channelId, + requireMentionOverride: params.requireMentionOverride, + }); + const shouldBypassMention = + params.isControlCommand && + shouldRequireMention && + !params.wasMentioned && + params.commandAuthorized; + const effectiveWasMentioned = + params.wasMentioned || shouldBypassMention || params.oncharTriggered; + if ( + params.oncharEnabled && + !params.oncharTriggered && + !params.wasMentioned && + !params.isControlCommand + ) { + return { + shouldRequireMention, + shouldBypassMention, + effectiveWasMentioned, + dropReason: "onchar-not-triggered", + }; + } + if ( + params.kind !== "direct" && + shouldRequireMention && + params.canDetectMention && + !effectiveWasMentioned + ) { + return { + shouldRequireMention, + shouldBypassMention, + effectiveWasMentioned, + dropReason: "missing-mention", + }; + } + return { + shouldRequireMention, + shouldBypassMention, + effectiveWasMentioned, + dropReason: null, + }; +} diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index a849cf52160..a397995bff4 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -67,6 +67,10 @@ import { isMattermostSenderAllowed, normalizeMattermostAllowList, } from "./monitor-auth.js"; +import { + evaluateMattermostMentionGate, + mapMattermostChannelTypeToChatType, +} from "./monitor-gating.js"; import { createDedupeCache, formatInboundFromLabel, @@ -96,6 +100,15 @@ import { getSlashCommandState, } from "./slash-state.js"; +export { + evaluateMattermostMentionGate, + mapMattermostChannelTypeToChatType, +} from "./monitor-gating.js"; +export type { + MattermostMentionGateInput, + MattermostRequireMentionResolverInput, +} from "./monitor-gating.js"; + export type MonitorMattermostOpts = { botToken?: string; baseUrl?: string; @@ -150,27 +163,6 @@ function isSystemPost(post: MattermostPost): boolean { return Boolean(type); } -export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType { - if (!channelType) { - return "channel"; - } - // Mattermost channel types: D=direct, G=group DM, O=public channel, P=private channel. - const normalized = channelType.trim().toUpperCase(); - if (normalized === "D") { - return "direct"; - } - if (normalized === "G") { - return "group"; - } - if (normalized === "P") { - // Private channels are invitation-restricted spaces; route as "group" so - // groupPolicy / groupAllowFrom can gate access separately from open public - // channels (type "O"), and the From prefix becomes mattermost:group:. - return "group"; - } - return "channel"; -} - function channelChatType(kind: ChatType): "direct" | "group" | "channel" { if (kind === "direct") { return "direct"; @@ -181,90 +173,6 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" { return "channel"; } -export type MattermostRequireMentionResolverInput = { - cfg: OpenClawConfig; - channel: "mattermost"; - accountId: string; - groupId: string; - requireMentionOverride?: boolean; -}; - -export type MattermostMentionGateInput = { - kind: ChatType; - cfg: OpenClawConfig; - accountId: string; - channelId: string; - threadRootId?: string; - requireMentionOverride?: boolean; - resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean; - wasMentioned: boolean; - isControlCommand: boolean; - commandAuthorized: boolean; - oncharEnabled: boolean; - oncharTriggered: boolean; - canDetectMention: boolean; -}; - -type MattermostMentionGateDecision = { - shouldRequireMention: boolean; - shouldBypassMention: boolean; - effectiveWasMentioned: boolean; - dropReason: "onchar-not-triggered" | "missing-mention" | null; -}; - -export function evaluateMattermostMentionGate( - params: MattermostMentionGateInput, -): MattermostMentionGateDecision { - const shouldRequireMention = - params.kind !== "direct" && - params.resolveRequireMention({ - cfg: params.cfg, - channel: "mattermost", - accountId: params.accountId, - groupId: params.channelId, - requireMentionOverride: params.requireMentionOverride, - }); - const shouldBypassMention = - params.isControlCommand && - shouldRequireMention && - !params.wasMentioned && - params.commandAuthorized; - const effectiveWasMentioned = - params.wasMentioned || shouldBypassMention || params.oncharTriggered; - if ( - params.oncharEnabled && - !params.oncharTriggered && - !params.wasMentioned && - !params.isControlCommand - ) { - return { - shouldRequireMention, - shouldBypassMention, - effectiveWasMentioned, - dropReason: "onchar-not-triggered", - }; - } - if ( - params.kind !== "direct" && - shouldRequireMention && - params.canDetectMention && - !effectiveWasMentioned - ) { - return { - shouldRequireMention, - shouldBypassMention, - effectiveWasMentioned, - dropReason: "missing-mention", - }; - } - return { - shouldRequireMention, - shouldBypassMention, - effectiveWasMentioned, - dropReason: null, - }; -} - export function resolveMattermostReplyRootId(params: { threadRootId?: string; replyToId?: string; diff --git a/extensions/telegram/src/bot-handlers.media.ts b/extensions/telegram/src/bot-handlers.media.ts new file mode 100644 index 00000000000..15226073d1a --- /dev/null +++ b/extensions/telegram/src/bot-handlers.media.ts @@ -0,0 +1,40 @@ +import type { Message } from "@grammyjs/types"; +import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime"; + +export const APPROVE_CALLBACK_DATA_RE = + /^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i; + +export function isMediaSizeLimitError(err: unknown): boolean { + const errMsg = String(err); + return errMsg.includes("exceeds") && errMsg.includes("MB limit"); +} + +export function isRecoverableMediaGroupError(err: unknown): boolean { + return err instanceof MediaFetchError || isMediaSizeLimitError(err); +} + +export function hasInboundMedia(msg: Message): boolean { + return ( + Boolean(msg.media_group_id) || + (Array.isArray(msg.photo) && msg.photo.length > 0) || + Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker) + ); +} + +export function hasReplyTargetMedia(msg: Message): boolean { + const externalReply = (msg as Message & { external_reply?: Message }).external_reply; + const replyTarget = msg.reply_to_message ?? externalReply; + return Boolean(replyTarget && hasInboundMedia(replyTarget)); +} + +export function resolveInboundMediaFileId(msg: Message): string | undefined { + return ( + msg.sticker?.file_id ?? + msg.photo?.[msg.photo.length - 1]?.file_id ?? + msg.video?.file_id ?? + msg.video_note?.file_id ?? + msg.document?.file_id ?? + msg.audio?.file_id ?? + msg.voice?.file_id + ); +} diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 92d584b8ea9..38a8d29dcab 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -25,7 +25,6 @@ import { resolvePluginConversationBindingApproval, } from "openclaw/plugin-sdk/conversation-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime"; import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; import { createInboundDebouncer, @@ -48,6 +47,14 @@ import { normalizeDmAllowFromWithStore, type NormalizedAllowFrom, } from "./bot-access.js"; +import { + APPROVE_CALLBACK_DATA_RE, + hasInboundMedia, + hasReplyTargetMedia, + isMediaSizeLimitError, + isRecoverableMediaGroupError, + resolveInboundMediaFileId, +} from "./bot-handlers.media.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; import { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; import { @@ -92,44 +99,6 @@ import { import { buildInlineKeyboard } from "./send.js"; import { wasSentByBot } from "./sent-message-cache.js"; -const APPROVE_CALLBACK_DATA_RE = - /^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i; - -function isMediaSizeLimitError(err: unknown): boolean { - const errMsg = String(err); - return errMsg.includes("exceeds") && errMsg.includes("MB limit"); -} - -function isRecoverableMediaGroupError(err: unknown): boolean { - return err instanceof MediaFetchError || isMediaSizeLimitError(err); -} - -function hasInboundMedia(msg: Message): boolean { - return ( - Boolean(msg.media_group_id) || - (Array.isArray(msg.photo) && msg.photo.length > 0) || - Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker) - ); -} - -function hasReplyTargetMedia(msg: Message): boolean { - const externalReply = (msg as Message & { external_reply?: Message }).external_reply; - const replyTarget = msg.reply_to_message ?? externalReply; - return Boolean(replyTarget && hasInboundMedia(replyTarget)); -} - -function resolveInboundMediaFileId(msg: Message): string | undefined { - return ( - msg.sticker?.file_id ?? - msg.photo?.[msg.photo.length - 1]?.file_id ?? - msg.video?.file_id ?? - msg.video_note?.file_id ?? - msg.document?.file_id ?? - msg.audio?.file_id ?? - msg.voice?.file_id - ); -} - export const registerTelegramHandlers = ({ cfg, accountId, diff --git a/extensions/tlon/src/monitor/authorization.ts b/extensions/tlon/src/monitor/authorization.ts new file mode 100644 index 00000000000..aef17bc5222 --- /dev/null +++ b/extensions/tlon/src/monitor/authorization.ts @@ -0,0 +1,30 @@ +import type { OpenClawConfig } from "../../api.js"; +import type { TlonSettingsStore } from "../settings.js"; + +type ChannelAuthorization = { + mode?: "restricted" | "open"; + allowedShips?: string[]; +}; + +export function resolveChannelAuthorization( + cfg: OpenClawConfig, + channelNest: string, + settings?: TlonSettingsStore, +): { mode: "restricted" | "open"; allowedShips: string[] } { + const tlonConfig = cfg.channels?.tlon as + | { + authorization?: { channelRules?: Record }; + defaultAuthorizedShips?: string[]; + } + | undefined; + + const fileRules = tlonConfig?.authorization?.channelRules ?? {}; + const settingsRules = settings?.channelRules ?? {}; + const rule = settingsRules[channelNest] ?? fileRules[channelNest]; + const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? []; + + return { + mode: rule?.mode ?? "restricted", + allowedShips: rule?.allowedShips ?? defaultShips, + }; +} diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index e7749010462..c7a99828e72 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -24,6 +24,7 @@ import { formatBlockedList, formatPendingList, } from "./approval.js"; +import { resolveChannelAuthorization } from "./authorization.js"; import { fetchAllChannels, fetchInitData } from "./discovery.js"; import { cacheMessage, getChannelHistory, fetchThreadHistory } from "./history.js"; import { downloadMessageImages } from "./media.js"; @@ -46,40 +47,6 @@ export type MonitorTlonOpts = { accountId?: string | null; }; -type ChannelAuthorization = { - mode?: "restricted" | "open"; - allowedShips?: string[]; -}; - -/** - * Resolve channel authorization by merging file config with settings store. - * Settings store takes precedence for fields it defines. - */ -function resolveChannelAuthorization( - cfg: OpenClawConfig, - channelNest: string, - settings?: TlonSettingsStore, -): { mode: "restricted" | "open"; allowedShips: string[] } { - const tlonConfig = cfg.channels?.tlon as - | { - authorization?: { channelRules?: Record }; - defaultAuthorizedShips?: string[]; - } - | undefined; - - // Merge channel rules: settings override file config - const fileRules = tlonConfig?.authorization?.channelRules ?? {}; - const settingsRules = settings?.channelRules ?? {}; - const rule = settingsRules[channelNest] ?? fileRules[channelNest]; - - // Merge default authorized ships: settings override file config - const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? []; - - const allowedShips = rule?.allowedShips ?? defaultShips; - const mode = rule?.mode ?? "restricted"; - return { mode, allowedShips }; -} - export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { const core = getTlonRuntime(); const cfg = core.config.loadConfig() as OpenClawConfig; diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 07fc7630eb0..483c64130ab 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -34,6 +34,11 @@ function buildPluginInspectJson(params: { report: PluginStatusReport; }): { inspect: NonNullable>; + compatibilityWarnings: Array<{ + code: string; + severity: string; + message: string; + }>; install: PluginInstallRecord | null; } | null { const inspect = buildPluginInspectReport({ @@ -60,6 +65,11 @@ function buildAllPluginInspectJson(params: { report: PluginStatusReport; }): Array<{ inspect: ReturnType[number]; + compatibilityWarnings: Array<{ + code: string; + severity: string; + message: string; + }>; install: PluginInstallRecord | null; }> { return buildAllPluginInspectReports({ diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 73b0b1feae6..4a7d60528b2 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -5,6 +5,7 @@ import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; import { resolveOsSummary } from "../infra/os-summary.js"; +import { buildPluginCompatibilityNotices } from "../plugins/status.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; @@ -185,6 +186,7 @@ export async function scanStatusJsonFast( : null; const memoryPlugin = resolveMemoryPluginStatus(cfg); const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); + const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); return { cfg, @@ -209,5 +211,6 @@ export async function scanStatusJsonFast( summary, memory, memoryPlugin, + pluginCompatibility, }; } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index e4a6e66d976..b6977460ee8 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1,5 +1,6 @@ import type { Mock } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { PluginCompatibilityNotice } from "../plugins/status.js"; import { captureEnv } from "../test-utils/env.js"; let envSnapshot: ReturnType; @@ -205,7 +206,7 @@ const mocks = vi.hoisted(() => ({ }, ], }), - buildPluginCompatibilityNotices: vi.fn(() => []), + buildPluginCompatibilityNotices: vi.fn((): PluginCompatibilityNotice[] => []), })); vi.mock("../memory/manager.js", () => ({ diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index ff157287902..c9765282493 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js"; +import type { PluginCompatibilityNotice } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; import { runSetupWizard } from "./setup.js"; @@ -88,7 +89,9 @@ const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: tru const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {})); const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); -const buildPluginCompatibilityNotices = vi.hoisted(() => vi.fn(() => [])); +const buildPluginCompatibilityNotices = vi.hoisted(() => + vi.fn((): PluginCompatibilityNotice[] => []), +); vi.mock("../commands/onboard-channels.js", () => ({ setupChannels, @@ -456,7 +459,12 @@ describe("runSetupWizard", () => { const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; expect(calls.some((call) => call?.[1] === "Plugin compatibility")).toBe(true); - expect(calls.some((call) => String(call?.[0] ?? "").includes("legacy-plugin"))).toBe(true); + expect( + calls.some((call) => { + const body = call?.[0]; + return typeof body === "string" && body.includes("legacy-plugin"); + }), + ).toBe(true); }); it("resolves gateway.auth.password SecretRef for local setup probe", async () => { diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index ee8cfaf2850..a6f53677c79 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -63,7 +63,7 @@ background: transparent; } -.chat-thread-inner> :first-child { +.chat-thread-inner > :first-child { margin-top: 0 !important; } @@ -320,7 +320,7 @@ } /* Hide the "Message" label - keep textarea only */ -.chat-compose__field>span { +.chat-compose__field > span { display: none; } @@ -391,7 +391,7 @@ } } -.agent-chat__input>textarea { +.agent-chat__input > textarea { width: 100%; min-height: 40px; max-height: 150px; @@ -407,7 +407,7 @@ box-sizing: border-box; } -.agent-chat__input>textarea::placeholder { +.agent-chat__input > textarea::placeholder { color: var(--muted); } @@ -549,7 +549,7 @@ scrollbar-width: thin; } -.slash-menu-group+.slash-menu-group { +.slash-menu-group + .slash-menu-group { margin-top: 4px; padding-top: 4px; border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 95fbd539f36..d844054a2b5 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1019,11 +1019,11 @@ position: relative; } -.cron-filter-dropdown__details>summary { +.cron-filter-dropdown__details > summary { list-style: none; } -.cron-filter-dropdown__details>summary::-webkit-details-marker { +.cron-filter-dropdown__details > summary::-webkit-details-marker { display: none; } @@ -1645,7 +1645,6 @@ } @media (max-width: 1100px) { - .table-head, .table-row { grid-template-columns: 1fr; @@ -1653,7 +1652,6 @@ } @container (max-width: 1100px) { - .table-head, .table-row { grid-template-columns: 1fr; @@ -2306,7 +2304,6 @@ } @keyframes chatStreamPulse { - 0%, 100% { border-color: var(--border); @@ -2341,7 +2338,7 @@ height: 12px; } -.chat-reading-indicator__dots>span { +.chat-reading-indicator__dots > span { display: inline-block; width: 6px; height: 6px; @@ -2353,16 +2350,15 @@ will-change: transform, opacity; } -.chat-reading-indicator__dots>span:nth-child(2) { +.chat-reading-indicator__dots > span:nth-child(2) { animation-delay: 0.15s; } -.chat-reading-indicator__dots>span:nth-child(3) { +.chat-reading-indicator__dots > span:nth-child(3) { animation-delay: 0.3s; } @keyframes chatReadingDot { - 0%, 80%, 100% { @@ -2377,7 +2373,7 @@ } @media (prefers-reduced-motion: reduce) { - .chat-reading-indicator__dots>span { + .chat-reading-indicator__dots > span { animation: none; opacity: 0.6; } @@ -3063,7 +3059,7 @@ min-width: 0; } -.agent-kv>div { +.agent-kv > div { min-width: 0; overflow-wrap: anywhere; word-break: break-word; @@ -3318,7 +3314,7 @@ gap: 8px; } -.agent-skills-header>span:last-child { +.agent-skills-header > span:last-child { margin-left: auto; } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 559cb919098..30ea7d05a47 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -70,7 +70,7 @@ padding-top: 0; } -.shell--chat-focus .content>*+* { +.shell--chat-focus .content > * + * { margin-top: 0; } @@ -688,9 +688,11 @@ .sidebar--collapsed .nav-item.active, .sidebar--collapsed .nav-item--active { - background: linear-gradient(180deg, - color-mix(in srgb, var(--accent) 14%, var(--bg-elevated) 86%) 0%, - color-mix(in srgb, var(--accent) 8%, var(--bg) 92%) 100%); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent) 14%, var(--bg-elevated) 86%) 0%, + color-mix(in srgb, var(--accent) 8%, var(--bg) 92%) 100% + ); border-color: color-mix(in srgb, var(--accent) 18%, var(--border) 82%); box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent), @@ -853,7 +855,7 @@ overflow-x: hidden; } -.content>*+* { +.content > * + * { margin-top: 20px; } @@ -869,7 +871,7 @@ padding-bottom: 0; } -.content--chat>*+* { +.content--chat > * + * { margin-top: 0; } @@ -928,7 +930,7 @@ padding-bottom: 0; } -.content--chat .content-header>div:first-child { +.content--chat .content-header > div:first-child { text-align: left; } From 9c12b41c525f5d29542944c9038bce7e778e6ce8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 20:35:55 -0700 Subject: [PATCH 16/21] fix: restore plugin sdk exports after rebase --- src/plugin-sdk/discord.ts | 1 + src/plugin-sdk/line-core.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 2078382a2da..0ca11b82976 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -5,6 +5,7 @@ export type { } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; +export type { DiscordConfig } from "../config/types.discord.js"; export type { DiscordPluralKitConfig } from "../../extensions/discord/api.js"; export type { InspectedDiscordAccount } from "../../extensions/discord/api.js"; export type { ResolvedDiscordAccount } from "../../extensions/discord/api.js"; diff --git a/src/plugin-sdk/line-core.ts b/src/plugin-sdk/line-core.ts index c60886bb177..eef1b3c2b76 100644 --- a/src/plugin-sdk/line-core.ts +++ b/src/plugin-sdk/line-core.ts @@ -1,6 +1,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { LineConfig } from "../line/types.js"; export { + createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, formatDocsLink, setSetupChannelEnabled, From a4b98f95c2d38a355fd53e31fe8a87d88aa64ec9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:33:33 +0000 Subject: [PATCH 17/21] Changelog: attribute message discovery break --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03cee239520..271a3521ec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,7 +140,7 @@ Docs: https://docs.openclaw.ai - Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. - Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. - Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. -- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. +- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras. ## 2026.3.13 From c29458d40765afe86da0c5c632114e19a4556237 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:37:11 +0000 Subject: [PATCH 18/21] WhatsApp: move group policy behind plugin boundary --- extensions/whatsapp/src/group-policy.test.ts | 36 ++++++++++++++++++ extensions/whatsapp/src/group-policy.ts | 40 ++++++++++++++++++++ extensions/whatsapp/src/shared.ts | 6 ++- src/plugin-sdk/whatsapp-core.ts | 2 +- src/plugin-sdk/whatsapp.ts | 2 +- 5 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 extensions/whatsapp/src/group-policy.test.ts create mode 100644 extensions/whatsapp/src/group-policy.ts diff --git a/extensions/whatsapp/src/group-policy.test.ts b/extensions/whatsapp/src/group-policy.test.ts new file mode 100644 index 00000000000..bd1aecdeaa5 --- /dev/null +++ b/extensions/whatsapp/src/group-policy.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, +} from "./group-policy.js"; + +describe("whatsapp group policy", () => { + it("uses generic channel group policy helpers", () => { + const cfg = { + channels: { + whatsapp: { + groups: { + "1203630@g.us": { + requireMention: false, + tools: { deny: ["exec"] }, + }, + "*": { + requireMention: true, + tools: { allow: ["message.send"] }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect(resolveWhatsAppGroupRequireMention({ cfg, groupId: "1203630@g.us" })).toBe(false); + expect(resolveWhatsAppGroupRequireMention({ cfg, groupId: "other@g.us" })).toBe(true); + expect(resolveWhatsAppGroupToolPolicy({ cfg, groupId: "1203630@g.us" })).toEqual({ + deny: ["exec"], + }); + expect(resolveWhatsAppGroupToolPolicy({ cfg, groupId: "other@g.us" })).toEqual({ + allow: ["message.send"], + }); + }); +}); diff --git a/extensions/whatsapp/src/group-policy.ts b/extensions/whatsapp/src/group-policy.ts new file mode 100644 index 00000000000..dd1d04b7868 --- /dev/null +++ b/extensions/whatsapp/src/group-policy.ts @@ -0,0 +1,40 @@ +import { + resolveChannelGroupRequireMention, + resolveChannelGroupToolsPolicy, + type GroupToolPolicyConfig, +} from "openclaw/plugin-sdk/channel-policy"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/whatsapp-core"; + +type WhatsAppGroupContext = { + cfg: OpenClawConfig; + accountId?: string | null; + groupId?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; +}; + +export function resolveWhatsAppGroupRequireMention(params: WhatsAppGroupContext): boolean { + return resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel: "whatsapp", + groupId: params.groupId, + accountId: params.accountId, + }); +} + +export function resolveWhatsAppGroupToolPolicy( + params: WhatsAppGroupContext, +): GroupToolPolicyConfig | undefined { + return resolveChannelGroupToolsPolicy({ + cfg: params.cfg, + channel: "whatsapp", + groupId: params.groupId, + accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); +} diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 6f8952687e2..11177f41f66 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -13,8 +13,6 @@ import { resolveWhatsAppConfigAllowFrom, resolveWhatsAppConfigDefaultTo, resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, WhatsAppConfigSchema, type ChannelPlugin, } from "openclaw/plugin-sdk/whatsapp-core"; @@ -24,6 +22,10 @@ import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount, } from "./accounts.js"; +import { + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, +} from "./group-policy.js"; export const WHATSAPP_CHANNEL = "whatsapp" as const; diff --git a/src/plugin-sdk/whatsapp-core.ts b/src/plugin-sdk/whatsapp-core.ts index 03a396e38f4..1bfcf7e5471 100644 --- a/src/plugin-sdk/whatsapp-core.ts +++ b/src/plugin-sdk/whatsapp-core.ts @@ -13,7 +13,7 @@ export { export { resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, -} from "../channels/plugins/group-mentions.js"; +} from "../../extensions/whatsapp/src/group-policy.js"; export { resolveWhatsAppGroupIntroHint } from "../channels/plugins/whatsapp-shared.js"; export { ToolAuthorizationError, diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 74ab27dac2f..405118818b5 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -52,7 +52,7 @@ export { export { resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, -} from "../channels/plugins/group-mentions.js"; +} from "../../extensions/whatsapp/src/group-policy.js"; export { createWhatsAppOutboundBase, resolveWhatsAppGroupIntroHint, From 1f5f3fc2ef52cba703661412e88b15af8c4e98b4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:37:56 +0000 Subject: [PATCH 19/21] iMessage: move group policy behind plugin boundary --- extensions/imessage/src/channel.ts | 6 ++- extensions/imessage/src/group-policy.test.ts | 36 ++++++++++++++++++ extensions/imessage/src/group-policy.ts | 40 ++++++++++++++++++++ src/plugin-sdk/imessage.ts | 2 +- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 extensions/imessage/src/group-policy.test.ts create mode 100644 extensions/imessage/src/group-policy.ts diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 2eadc5a8a90..5178f1f883c 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -11,14 +11,16 @@ import { formatTrimmedAllowFromEntries, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, - resolveIMessageGroupRequireMention, - resolveIMessageGroupToolPolicy, type ChannelPlugin, } from "openclaw/plugin-sdk/imessage"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; +import { + resolveIMessageGroupRequireMention, + resolveIMessageGroupToolPolicy, +} from "./group-policy.js"; import { getIMessageRuntime } from "./runtime.js"; import { imessageSetupAdapter } from "./setup-core.js"; import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; diff --git a/extensions/imessage/src/group-policy.test.ts b/extensions/imessage/src/group-policy.test.ts new file mode 100644 index 00000000000..c94d76bfd27 --- /dev/null +++ b/extensions/imessage/src/group-policy.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + resolveIMessageGroupRequireMention, + resolveIMessageGroupToolPolicy, +} from "./group-policy.js"; + +describe("imessage group policy", () => { + it("uses generic channel group policy helpers", () => { + const cfg = { + channels: { + imessage: { + groups: { + "chat:family": { + requireMention: false, + tools: { deny: ["exec"] }, + }, + "*": { + requireMention: true, + tools: { allow: ["message.send"] }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:family" })).toBe(false); + expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true); + expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:family" })).toEqual({ + deny: ["exec"], + }); + expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({ + allow: ["message.send"], + }); + }); +}); diff --git a/extensions/imessage/src/group-policy.ts b/extensions/imessage/src/group-policy.ts new file mode 100644 index 00000000000..bfb25e77241 --- /dev/null +++ b/extensions/imessage/src/group-policy.ts @@ -0,0 +1,40 @@ +import { + resolveChannelGroupRequireMention, + resolveChannelGroupToolsPolicy, + type GroupToolPolicyConfig, +} from "openclaw/plugin-sdk/channel-policy"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/imessage"; + +type IMessageGroupContext = { + cfg: OpenClawConfig; + accountId?: string | null; + groupId?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; +}; + +export function resolveIMessageGroupRequireMention(params: IMessageGroupContext): boolean { + return resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel: "imessage", + groupId: params.groupId, + accountId: params.accountId, + }); +} + +export function resolveIMessageGroupToolPolicy( + params: IMessageGroupContext, +): GroupToolPolicyConfig | undefined { + return resolveChannelGroupToolsPolicy({ + cfg: params.cfg, + channel: "imessage", + groupId: params.groupId, + accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); +} diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index adad1403eb6..d3007be1eef 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -37,7 +37,7 @@ export { export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, -} from "../channels/plugins/group-mentions.js"; +} from "../../extensions/imessage/src/group-policy.js"; export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; From 4e706da898470419bc68afc752b77f7d4bd22805 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:39:21 +0000 Subject: [PATCH 20/21] iMessage: fix group policy config import --- extensions/imessage/src/group-policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/imessage/src/group-policy.ts b/extensions/imessage/src/group-policy.ts index bfb25e77241..71efac603ed 100644 --- a/extensions/imessage/src/group-policy.ts +++ b/extensions/imessage/src/group-policy.ts @@ -3,7 +3,7 @@ import { resolveChannelGroupToolsPolicy, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/imessage"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; type IMessageGroupContext = { cfg: OpenClawConfig; From bf8702973fc0fd5c0570f8918761b72852336448 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:39:25 +0000 Subject: [PATCH 21/21] Google Chat: move group policy behind plugin boundary --- extensions/googlechat/src/channel.ts | 2 +- .../googlechat/src/group-policy.test.ts | 25 +++++++++++++++++++ extensions/googlechat/src/group-policy.ts | 17 +++++++++++++ src/plugin-sdk/googlechat.ts | 2 +- 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 extensions/googlechat/src/group-policy.test.ts create mode 100644 extensions/googlechat/src/group-policy.ts diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index c4ee5364643..6c401293003 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -19,7 +19,6 @@ import { missingTargetError, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - resolveGoogleChatGroupRequireMention, runPassiveAccountLifecycle, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -36,6 +35,7 @@ import { type ResolvedGoogleChatAccount, } from "./accounts.js"; import { googlechatMessageActions } from "./actions.js"; +import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; import { getGoogleChatRuntime } from "./runtime.js"; import { googlechatSetupAdapter } from "./setup-core.js"; import { googlechatSetupWizard } from "./setup-surface.js"; diff --git a/extensions/googlechat/src/group-policy.test.ts b/extensions/googlechat/src/group-policy.test.ts new file mode 100644 index 00000000000..5f907695aad --- /dev/null +++ b/extensions/googlechat/src/group-policy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; + +describe("googlechat group policy", () => { + it("uses generic channel group policy helpers", () => { + const cfg = { + channels: { + googlechat: { + groups: { + "spaces/AAA": { + requireMention: false, + }, + "*": { + requireMention: true, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false); + expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true); + }); +}); diff --git a/extensions/googlechat/src/group-policy.ts b/extensions/googlechat/src/group-policy.ts new file mode 100644 index 00000000000..3518ce6e13c --- /dev/null +++ b/extensions/googlechat/src/group-policy.ts @@ -0,0 +1,17 @@ +import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat"; + +type GoogleChatGroupContext = { + cfg: OpenClawConfig; + accountId?: string | null; + groupId?: string | null; +}; + +export function resolveGoogleChatGroupRequireMention(params: GoogleChatGroupContext): boolean { + return resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel: "googlechat", + groupId: params.groupId, + accountId: params.accountId, + }); +} diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index ce6d5f44511..ade38097fad 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -20,7 +20,7 @@ export { export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { createAccountStatusSink, runPassiveAccountLifecycle } from "./channel-lifecycle.js"; -export { resolveGoogleChatGroupRequireMention } from "../channels/plugins/group-mentions.js"; +export { resolveGoogleChatGroupRequireMention } from "../../extensions/googlechat/src/group-policy.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export {