From 823a09acbefc893f4ca143d90898b41a482c7a36 Mon Sep 17 00:00:00 2001 From: Chris Kimpton Date: Wed, 18 Mar 2026 16:21:46 +0000 Subject: [PATCH 01/34] docs: clarify that CI test-fix-only PRs are handled by maintainers (#49679) Co-authored-by: Shadow --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e487f254cd..7d43d661161 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,8 @@ Welcome to the lobster tank! 🦞 1. **Bugs & small fixes** → Open a PR! 2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) +3. **Test/CI-only PRs for known `main` failures** → Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a *new* regression not yet shown in main CI, report it as an issue first. +4. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR @@ -96,6 +97,7 @@ Welcome to the lobster tank! 🦞 - For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins` - If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review - If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs. +- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a *new* regression not yet shown in main CI, report it as an issue first. - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Describe what & why From b64f4e313dabfe120865cc6cb7a822e6075cc01e Mon Sep 17 00:00:00 2001 From: liyuan97 <33855278+liyuan97@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:24:37 +0800 Subject: [PATCH 02/34] MiniMax: add M2.7 models and update default to M2.7 (#49691) * MiniMax: add M2.7 models and update default to M2.7 - Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to provider catalog and model definitions - Update default model from MiniMax-M2.5 to MiniMax-M2.7 across onboard, portal, and provider configs - Update isModernMiniMaxModel to recognize M2.7 prefix - Update all test fixtures to reflect M2.7 as default Made-with: Cursor * MiniMax: add extension test for model definitions * update 2.7 * feat: add MiniMax M2.7 models and update default (#49691) (thanks @liyuan97) --------- Co-authored-by: George Zhang --- CHANGELOG.md | 1 + extensions/minimax/index.ts | 17 +++++--- extensions/minimax/model-definitions.test.ts | 42 +++++++++++++++++++ extensions/minimax/model-definitions.ts | 4 +- extensions/minimax/onboard.ts | 8 ++-- extensions/minimax/openclaw.plugin.json | 8 ++-- extensions/minimax/provider-catalog.ts | 12 +++++- src/agents/live-model-errors.test.ts | 2 +- src/agents/minimax.live.test.ts | 2 +- src/agents/model-compat.test.ts | 6 +-- ...ssing-provider-apikey-from-env-var.test.ts | 6 +-- ...serves-explicit-reasoning-override.test.ts | 14 +++---- .../models-config.providers.minimax.test.ts | 4 ++ ...s-writing-models-json-no-env-token.test.ts | 2 +- ...ols.subagents.sessions-spawn.model.test.ts | 8 ++-- .../pi-embedded-runner-extraparams.test.ts | 4 +- src/agents/tools/image-tool.test.ts | 14 +++---- ...nk-low-reasoning-capable-models-no.test.ts | 11 +++-- ...tches-fuzzy-selection-is-ambiguous.test.ts | 12 ++++-- ....triggers.trigger-handling.test-harness.ts | 2 +- src/auto-reply/reply/session.test.ts | 4 +- src/commands/auth-choice.test.ts | 2 +- ...re.gateway-auth.prompt-auth-config.test.ts | 4 +- src/commands/onboard-auth.test.ts | 16 +++---- ...oard-non-interactive.provider-auth.test.ts | 4 +- src/config/config.identity-defaults.test.ts | 4 +- src/gateway/session-utils.test.ts | 2 +- .../contracts/discovery.contract.test.ts | 4 +- src/tui/tui-session-actions.test.ts | 4 +- 29 files changed, 148 insertions(+), 75 deletions(-) create mode 100644 extensions/minimax/model-definitions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aa76166bf0d..04aa378d28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev. - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. - Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. +- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97. ### Fixes diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index d1a97cb43dc..5cb40be22b2 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -23,7 +23,7 @@ import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-cat const API_PROVIDER_ID = "minimax"; const PORTAL_PROVIDER_ID = "minimax-portal"; const PROVIDER_LABEL = "MiniMax"; -const DEFAULT_MODEL = "MiniMax-M2.5"; +const DEFAULT_MODEL = "MiniMax-M2.7"; const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; @@ -40,7 +40,8 @@ function portalModelRef(modelId: string): string { } function isModernMiniMaxModel(modelId: string): boolean { - return modelId.trim().toLowerCase().startsWith("minimax-m2.5"); + const lower = modelId.trim().toLowerCase(); + return lower.startsWith("minimax-m2.7") || lower.startsWith("minimax-m2.5"); } function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) { @@ -129,6 +130,10 @@ function createOAuthHandler(region: MiniMaxRegion) { agents: { defaults: { models: { + [portalModelRef("MiniMax-M2.7")]: { alias: "minimax-m2.7" }, + [portalModelRef("MiniMax-M2.7-highspeed")]: { + alias: "minimax-m2.7-highspeed", + }, [portalModelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" }, [portalModelRef("MiniMax-M2.5-highspeed")]: { alias: "minimax-m2.5-highspeed", @@ -190,7 +195,7 @@ export default definePluginEntry({ choiceHint: "Global endpoint - api.minimax.io", groupId: "minimax", groupLabel: "MiniMax", - groupHint: "M2.5 (recommended)", + groupHint: "M2.7 (recommended)", }, }), createProviderApiKeyAuthMethod({ @@ -214,7 +219,7 @@ export default definePluginEntry({ choiceHint: "CN endpoint - api.minimaxi.com", groupId: "minimax", groupLabel: "MiniMax", - groupHint: "M2.5 (recommended)", + groupHint: "M2.7 (recommended)", }, }), ], @@ -253,7 +258,7 @@ export default definePluginEntry({ choiceHint: "Global endpoint - api.minimax.io", groupId: "minimax", groupLabel: "MiniMax", - groupHint: "M2.5 (recommended)", + groupHint: "M2.7 (recommended)", }, run: createOAuthHandler("global"), }, @@ -268,7 +273,7 @@ export default definePluginEntry({ choiceHint: "CN endpoint - api.minimaxi.com", groupId: "minimax", groupLabel: "MiniMax", - groupHint: "M2.5 (recommended)", + groupHint: "M2.7 (recommended)", }, run: createOAuthHandler("cn"), }, diff --git a/extensions/minimax/model-definitions.test.ts b/extensions/minimax/model-definitions.test.ts new file mode 100644 index 00000000000..e92bc512a0c --- /dev/null +++ b/extensions/minimax/model-definitions.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, + DEFAULT_MINIMAX_CONTEXT_WINDOW, + DEFAULT_MINIMAX_MAX_TOKENS, + MINIMAX_API_COST, + MINIMAX_HOSTED_MODEL_ID, +} from "./model-definitions.js"; + +describe("minimax model definitions", () => { + it("uses M2.7 as default hosted model", () => { + expect(MINIMAX_HOSTED_MODEL_ID).toBe("MiniMax-M2.7"); + }); + + it("builds catalog model with name and reasoning from catalog", () => { + const model = buildMinimaxModelDefinition({ + id: "MiniMax-M2.7", + cost: MINIMAX_API_COST, + contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW, + maxTokens: DEFAULT_MINIMAX_MAX_TOKENS, + }); + expect(model).toMatchObject({ + id: "MiniMax-M2.7", + name: "MiniMax M2.7", + reasoning: true, + }); + }); + + it("builds API model definition with standard cost", () => { + const model = buildMinimaxApiModelDefinition("MiniMax-M2.7"); + expect(model.cost).toEqual(MINIMAX_API_COST); + expect(model.contextWindow).toBe(DEFAULT_MINIMAX_CONTEXT_WINDOW); + expect(model.maxTokens).toBe(DEFAULT_MINIMAX_MAX_TOKENS); + }); + + it("falls back to generated name for unknown model id", () => { + const model = buildMinimaxApiModelDefinition("MiniMax-Future"); + expect(model.name).toBe("MiniMax MiniMax-Future"); + expect(model.reasoning).toBe(false); + }); +}); diff --git a/extensions/minimax/model-definitions.ts b/extensions/minimax/model-definitions.ts index 48396f21240..1de1c6aee5b 100644 --- a/extensions/minimax/model-definitions.ts +++ b/extensions/minimax/model-definitions.ts @@ -3,7 +3,7 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models" export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic"; -export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.5"; +export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.7"; export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; export const DEFAULT_MINIMAX_MAX_TOKENS = 8192; @@ -28,6 +28,8 @@ export const MINIMAX_LM_STUDIO_COST = { }; const MINIMAX_MODEL_CATALOG = { + "MiniMax-M2.7": { name: "MiniMax M2.7", reasoning: true }, + "MiniMax-M2.7-highspeed": { name: "MiniMax M2.7 Highspeed", reasoning: true }, "MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true }, "MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true }, } as const; diff --git a/extensions/minimax/onboard.ts b/extensions/minimax/onboard.ts index 2edcf9637e4..ee0066b563d 100644 --- a/extensions/minimax/onboard.ts +++ b/extensions/minimax/onboard.ts @@ -61,7 +61,7 @@ function applyMinimaxApiConfigWithBaseUrl( export function applyMinimaxApiProviderConfig( cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", + modelId: string = "MiniMax-M2.7", ): OpenClawConfig { return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { providerId: "minimax", @@ -72,7 +72,7 @@ export function applyMinimaxApiProviderConfig( export function applyMinimaxApiConfig( cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", + modelId: string = "MiniMax-M2.7", ): OpenClawConfig { return applyMinimaxApiConfigWithBaseUrl(cfg, { providerId: "minimax", @@ -83,7 +83,7 @@ export function applyMinimaxApiConfig( export function applyMinimaxApiProviderConfigCn( cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", + modelId: string = "MiniMax-M2.7", ): OpenClawConfig { return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { providerId: "minimax", @@ -94,7 +94,7 @@ export function applyMinimaxApiProviderConfigCn( export function applyMinimaxApiConfigCn( cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", + modelId: string = "MiniMax-M2.7", ): OpenClawConfig { return applyMinimaxApiConfigWithBaseUrl(cfg, { providerId: "minimax", diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 848ce80699a..60a77127713 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -14,7 +14,7 @@ "choiceHint": "Global endpoint - api.minimax.io", "groupId": "minimax", "groupLabel": "MiniMax", - "groupHint": "M2.5 (recommended)" + "groupHint": "M2.7 (recommended)" }, { "provider": "minimax", @@ -24,7 +24,7 @@ "choiceHint": "Global endpoint - api.minimax.io", "groupId": "minimax", "groupLabel": "MiniMax", - "groupHint": "M2.5 (recommended)", + "groupHint": "M2.7 (recommended)", "optionKey": "minimaxApiKey", "cliFlag": "--minimax-api-key", "cliOption": "--minimax-api-key ", @@ -38,7 +38,7 @@ "choiceHint": "CN endpoint - api.minimaxi.com", "groupId": "minimax", "groupLabel": "MiniMax", - "groupHint": "M2.5 (recommended)" + "groupHint": "M2.7 (recommended)" }, { "provider": "minimax", @@ -48,7 +48,7 @@ "choiceHint": "CN endpoint - api.minimaxi.com", "groupId": "minimax", "groupLabel": "MiniMax", - "groupHint": "M2.5 (recommended)", + "groupHint": "M2.7 (recommended)", "optionKey": "minimaxApiKey", "cliFlag": "--minimax-api-key", "cliOption": "--minimax-api-key ", diff --git a/extensions/minimax/provider-catalog.ts b/extensions/minimax/provider-catalog.ts index ab8cceb9c53..61549e8a883 100644 --- a/extensions/minimax/provider-catalog.ts +++ b/extensions/minimax/provider-catalog.ts @@ -4,7 +4,7 @@ import type { } from "openclaw/plugin-sdk/provider-models"; const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic"; -export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; +export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.7"; const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; const MINIMAX_DEFAULT_MAX_TOKENS = 8192; @@ -50,6 +50,16 @@ function buildMinimaxCatalog(): ModelDefinitionConfig[] { }), buildMinimaxTextModel({ id: MINIMAX_DEFAULT_MODEL_ID, + name: "MiniMax M2.7", + reasoning: true, + }), + buildMinimaxTextModel({ + id: "MiniMax-M2.7-highspeed", + name: "MiniMax M2.7 Highspeed", + reasoning: true, + }), + buildMinimaxTextModel({ + id: "MiniMax-M2.5", name: "MiniMax M2.5", reasoning: true, }), diff --git a/src/agents/live-model-errors.test.ts b/src/agents/live-model-errors.test.ts index a0db57799ed..ec9440fbe57 100644 --- a/src/agents/live-model-errors.test.ts +++ b/src/agents/live-model-errors.test.ts @@ -7,7 +7,7 @@ import { describe("live model error helpers", () => { it("detects generic model-not-found messages", () => { expect(isModelNotFoundErrorMessage('{"code":404,"message":"model not found"}')).toBe(true); - expect(isModelNotFoundErrorMessage("model: MiniMax-M2.5-highspeed not found")).toBe(true); + expect(isModelNotFoundErrorMessage("model: MiniMax-M2.7-highspeed not found")).toBe(true); expect(isModelNotFoundErrorMessage("request ended without sending any chunks")).toBe(false); }); diff --git a/src/agents/minimax.live.test.ts b/src/agents/minimax.live.test.ts index 0d618725a8c..9ad1d18cf4e 100644 --- a/src/agents/minimax.live.test.ts +++ b/src/agents/minimax.live.test.ts @@ -4,7 +4,7 @@ import { isTruthyEnvValue } from "../infra/env.js"; const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? ""; const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic"; -const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.5"; +const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.7"; const LIVE = isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip; diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index e576bc621b3..c1e79f4757a 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -368,14 +368,14 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); expect(isModernModelRef({ provider: "opencode-go", id: "kimi-k2.5" })).toBe(true); expect(isModernModelRef({ provider: "opencode-go", id: "glm-5" })).toBe(true); - expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.5" })).toBe(true); + expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.7" })).toBe(true); }); it("excludes provider-declined modern models", () => { providerRuntimeMocks.resolveProviderModernModelRef.mockImplementation(({ provider, context }) => - provider === "opencode" && context.modelId === "minimax-m2.5" ? false : undefined, + provider === "opencode" && context.modelId === "minimax-m2.7" ? false : undefined, ); - expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.5" })).toBe(false); + expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.7" })).toBe(false); }); }); diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 036f4d00824..5e0f870e476 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -308,8 +308,8 @@ describe("models-config", () => { api: "anthropic-messages", models: [ { - id: "MiniMax-M2.5", - name: "MiniMax M2.5", + id: "MiniMax-M2.7", + name: "MiniMax M2.7", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -454,7 +454,7 @@ describe("models-config", () => { baseUrl: "https://api.minimax.io/anthropic", apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret api: "anthropic-messages", - models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5", input: ["text"] }], + models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", input: ["text"] }], }, }, }); diff --git a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts index b1dd8ca49f0..ed35a9a14b0 100644 --- a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts +++ b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts @@ -21,7 +21,7 @@ type ModelsJson = { }; const MINIMAX_ENV_KEY = "MINIMAX_API_KEY"; -const MINIMAX_MODEL_ID = "MiniMax-M2.5"; +const MINIMAX_MODEL_ID = "MiniMax-M2.7"; const MINIMAX_TEST_KEY = "sk-minimax-test"; const baseMinimaxProvider = { @@ -50,8 +50,8 @@ async function generateAndReadMinimaxModel(cfg: OpenClawConfig): Promise { - it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.5)", async () => { - // MiniMax-M2.5 has reasoning:true in the built-in catalog. + it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.7)", async () => { + // MiniMax-M2.7 has reasoning:true in the built-in catalog. // User explicitly sets reasoning:false to avoid message-ordering conflicts. await withTempHome(async () => { await withMinimaxApiKey(async () => { @@ -63,7 +63,7 @@ describe("models-config: explicit reasoning override", () => { models: [ { id: MINIMAX_MODEL_ID, - name: "MiniMax M2.5", + name: "MiniMax M2.7", reasoning: false, // explicit override: user wants to disable reasoning input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -84,15 +84,15 @@ describe("models-config: explicit reasoning override", () => { }); }); - it("falls back to built-in reasoning:true when user omits the field (MiniMax-M2.5)", async () => { + it("falls back to built-in reasoning:true when user omits the field (MiniMax-M2.7)", async () => { // When the user does not set reasoning at all, the built-in catalog value - // (true for MiniMax-M2.5) should be used so the model works out of the box. + // (true for MiniMax-M2.7) should be used so the model works out of the box. await withTempHome(async () => { await withMinimaxApiKey(async () => { // Omit 'reasoning' to simulate a user config that doesn't set it. const modelWithoutReasoning = { id: MINIMAX_MODEL_ID, - name: "MiniMax M2.5", + name: "MiniMax M2.7", input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1_000_000, diff --git a/src/agents/models-config.providers.minimax.test.ts b/src/agents/models-config.providers.minimax.test.ts index 80718d28fbe..b3e3ea1e5c2 100644 --- a/src/agents/models-config.providers.minimax.test.ts +++ b/src/agents/models-config.providers.minimax.test.ts @@ -37,11 +37,15 @@ describe("minimax provider catalog", () => { const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.minimax?.models?.map((model) => model.id)).toEqual([ "MiniMax-VL-01", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", ]); expect(providers?.["minimax-portal"]?.models?.map((model) => model.id)).toEqual([ "MiniMax-VL-01", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", ]); diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index ff38fe5e64a..4895a43c8d6 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -98,7 +98,7 @@ describe("models-config", () => { providerKey: "minimax", expectedBaseUrl: "https://api.minimax.io/anthropic", expectedApiKeyRef: "MINIMAX_API_KEY", // pragma: allowlist secret - expectedModelIds: ["MiniMax-M2.5", "MiniMax-VL-01"], + expectedModelIds: ["MiniMax-M2.7", "MiniMax-VL-01"], }); }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts index 042f479d5e4..69cf44409ff 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts @@ -199,11 +199,11 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { await expectSpawnUsesConfiguredModel({ config: { session: { mainKey: "main", scope: "per-sender" }, - agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.5" } } }, + agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.7" } } }, }, runId: "run-default-model", callId: "call-default-model", - expectedModel: "minimax/MiniMax-M2.5", + expectedModel: "minimax/MiniMax-M2.7", }); }); @@ -220,7 +220,7 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { config: { session: { mainKey: "main", scope: "per-sender" }, agents: { - defaults: { subagents: { model: "minimax/MiniMax-M2.5" } }, + defaults: { subagents: { model: "minimax/MiniMax-M2.7" } }, list: [{ id: "research", subagents: { model: "opencode/claude" } }], }, }, @@ -235,7 +235,7 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { config: { session: { mainKey: "main", scope: "per-sender" }, agents: { - defaults: { model: { primary: "minimax/MiniMax-M2.5" } }, + defaults: { model: { primary: "minimax/MiniMax-M2.7" } }, list: [{ id: "research", model: { primary: "opencode/claude" } }], }, }, diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index dbd95e64d34..685976bf63d 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -685,7 +685,7 @@ describe("applyExtraParamsToAgent", () => { agent, undefined, "siliconflow", - "Pro/MiniMaxAI/MiniMax-M2.5", + "Pro/MiniMaxAI/MiniMax-M2.7", undefined, "off", ); @@ -693,7 +693,7 @@ describe("applyExtraParamsToAgent", () => { const model = { api: "openai-completions", provider: "siliconflow", - id: "Pro/MiniMaxAI/MiniMax-M2.5", + id: "Pro/MiniMaxAI/MiniMax-M2.7", } as Model<"openai-completions">; const context: Context = { messages: [] }; void agent.streamFn?.(model, context, {}); diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index c58a7f9aa1a..c48a705dc01 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -142,7 +142,7 @@ function createMinimaxImageConfig(): OpenClawConfig { return { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, imageModel: { primary: "minimax/MiniMax-VL-01" }, }, }, @@ -272,7 +272,7 @@ describe("image tool implicit imageModel config", () => { vi.stubEnv("OPENAI_API_KEY", "openai-test"); vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, + agents: { defaults: { model: { primary: "minimax/MiniMax-M2.7" } } }, }; expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"), @@ -298,7 +298,7 @@ describe("image tool implicit imageModel config", () => { vi.stubEnv("OPENAI_API_KEY", "openai-test"); vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "minimax-portal/MiniMax-M2.5" } } }, + agents: { defaults: { model: { primary: "minimax-portal/MiniMax-M2.7" } } }, }; expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( createDefaultImageFallbackExpectation("minimax-portal/MiniMax-VL-01"), @@ -356,7 +356,7 @@ describe("image tool implicit imageModel config", () => { const cfg: OpenClawConfig = { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, imageModel: { primary: "openai/gpt-5-mini" }, }, }, @@ -584,7 +584,7 @@ describe("image tool implicit imageModel config", () => { vi.stubEnv("OPENAI_API_KEY", "openai-test"); const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, + agents: { defaults: { model: { primary: "minimax/MiniMax-M2.7" } } }, }; const tool = createRequiredImageTool({ config: cfg, agentDir, sandbox }); @@ -651,7 +651,7 @@ describe("image tool implicit imageModel config", () => { const cfg: OpenClawConfig = { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, imageModel: { primary: "minimax/MiniMax-VL-01" }, }, }, @@ -704,7 +704,7 @@ describe("image tool MiniMax VLM routing", () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-minimax-vlm-")); vi.stubEnv("MINIMAX_API_KEY", "minimax-test"); const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, + agents: { defaults: { model: { primary: "minimax/MiniMax-M2.7" } } }, }; const tool = createRequiredImageTool({ config: cfg, agentDir }); return { fetch, tool }; diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index 0a93f5f69a6..6ad08b1d6c5 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -183,7 +183,7 @@ describe("directive behavior", () => { primary: "anthropic/claude-opus-4-5", fallbacks: ["openai/gpt-4.1-mini"], }, - imageModel: { primary: "minimax/MiniMax-M2.5" }, + imageModel: { primary: "minimax/MiniMax-M2.7" }, models: undefined, }, }); @@ -206,7 +206,7 @@ describe("directive behavior", () => { models: { "anthropic/claude-opus-4-5": {}, "openai/gpt-4.1-mini": {}, - "minimax/MiniMax-M2.5": { alias: "minimax" }, + "minimax/MiniMax-M2.7": { alias: "minimax" }, }, }, extra: { @@ -216,14 +216,17 @@ describe("directive behavior", () => { minimax: { baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", - models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5" }], + models: [ + { id: "MiniMax-M2.7", name: "MiniMax M2.7" }, + { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + ], }, }, }, }, }); expect(configOnlyProviderText).toContain("Models (minimax"); - expect(configOnlyProviderText).toContain("minimax/MiniMax-M2.5"); + expect(configOnlyProviderText).toContain("minimax/MiniMax-M2.7"); const missingAuthText = await runModelDirectiveText(home, "/model list", { defaults: { diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index 9cca0fad783..dd98000d165 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -119,9 +119,10 @@ describe("directive behavior", () => { config: { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, workspace: path.join(home, "openclaw"), models: { + "minimax/MiniMax-M2.7": {}, "minimax/MiniMax-M2.5": {}, "minimax/MiniMax-M2.5-highspeed": {}, "lmstudio/minimax-m2.5-gs32": {}, @@ -135,7 +136,10 @@ describe("directive behavior", () => { baseUrl: "https://api.minimax.io/anthropic", apiKey: "sk-test", // pragma: allowlist secret api: "anthropic-messages", - models: [makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5")], + models: [ + makeModelDefinition("MiniMax-M2.7", "MiniMax M2.7"), + makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"), + ], }, lmstudio: { baseUrl: "http://127.0.0.1:1234/v1", @@ -153,9 +157,10 @@ describe("directive behavior", () => { config: { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, workspace: path.join(home, "openclaw"), models: { + "minimax/MiniMax-M2.7": {}, "minimax/MiniMax-M2.5": {}, "minimax/MiniMax-M2.5-highspeed": {}, }, @@ -169,6 +174,7 @@ describe("directive behavior", () => { apiKey: "sk-test", // pragma: allowlist secret api: "anthropic-messages", models: [ + makeModelDefinition("MiniMax-M2.7", "MiniMax M2.7"), makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"), makeModelDefinition("MiniMax-M2.5-highspeed", "MiniMax M2.5 Highspeed"), ], diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 9a831dde795..626683601d7 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -80,7 +80,7 @@ const modelCatalogMocks = vi.hoisted(() => ({ { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, { provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" }, - { provider: "minimax", id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + { provider: "minimax", id: "MiniMax-M2.7", name: "MiniMax M2.7" }, ]), resetModelCatalogCacheForTest: vi.fn(), })); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index fb43946a6b4..2dac5c15f6a 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -24,7 +24,7 @@ vi.mock("../../agents/session-write-lock.js", () => ({ vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ - { provider: "minimax", id: "m2.5", name: "M2.5" }, + { provider: "minimax", id: "m2.7", name: "M2.7" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, ]), })); @@ -1288,7 +1288,7 @@ describe("applyResetModelOverride", () => { }); expect(sessionEntry.providerOverride).toBe("minimax"); - expect(sessionEntry.modelOverride).toBe("m2.5"); + expect(sessionEntry.modelOverride).toBe("m2.7"); expect(sessionCtx.BodyStripped).toBe("summarize"); }); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index dd270a6d3d2..84fda1e43fb 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1423,7 +1423,7 @@ describe("applyAuthChoice", () => { profileId: "minimax-portal:default", baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", - defaultModel: "minimax-portal/MiniMax-M2.5", + defaultModel: "minimax-portal/MiniMax-M2.7", apiKey: "minimax-oauth", // pragma: allowlist secret }, ]; diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index b6ba81a432e..971429bb2bf 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -88,7 +88,7 @@ function createApplyAuthChoiceConfig(includeMinimaxProvider = false) { minimax: { baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", - models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5" }], + models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7" }], }, } : {}), @@ -127,7 +127,7 @@ describe("promptAuthConfig", () => { "anthropic/claude-sonnet-4", ]); expect(result.models?.providers?.minimax?.models?.map((model) => model.id)).toEqual([ - "MiniMax-M2.5", + "MiniMax-M2.7", ]); }); diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index d245d64f703..58f7f94b484 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -386,8 +386,8 @@ describe("applyMinimaxApiConfig", () => { }); }); - it("keeps reasoning enabled for MiniMax-M2.5", () => { - const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.5"); + it("keeps reasoning enabled for MiniMax-M2.7", () => { + const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.7"); expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(true); }); @@ -397,7 +397,7 @@ describe("applyMinimaxApiConfig", () => { agents: { defaults: { models: { - "minimax/MiniMax-M2.5": { + "minimax/MiniMax-M2.7": { alias: "MiniMax", params: { custom: "value" }, }, @@ -405,9 +405,9 @@ describe("applyMinimaxApiConfig", () => { }, }, }, - "MiniMax-M2.5", + "MiniMax-M2.7", ); - expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.5"]).toMatchObject({ + expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.7"]).toMatchObject({ alias: "Minimax", params: { custom: "value" }, }); @@ -426,7 +426,7 @@ describe("applyMinimaxApiConfig", () => { expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key"); expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([ "old-model", - "MiniMax-M2.5", + "MiniMax-M2.7", ]); }); @@ -669,8 +669,8 @@ describe("provider alias defaults", () => { it("adds expected alias for provider defaults", () => { const aliasCases = [ { - applyConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.5"), - modelRef: "minimax/MiniMax-M2.5", + applyConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.7"), + modelRef: "minimax/MiniMax-M2.7", alias: "Minimax", }, { diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 329314d1efd..9f281e26cbc 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -236,7 +236,7 @@ describe("onboard (non-interactive): provider auth", () => { expect(cfg.auth?.profiles?.["minimax:global"]?.provider).toBe("minimax"); expect(cfg.auth?.profiles?.["minimax:global"]?.mode).toBe("api_key"); expect(cfg.models?.providers?.minimax?.baseUrl).toBe(MINIMAX_API_BASE_URL); - expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5"); + expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.7"); await expectApiKeyProfile({ profileId: "minimax:global", provider: "minimax", @@ -255,7 +255,7 @@ describe("onboard (non-interactive): provider auth", () => { expect(cfg.auth?.profiles?.["minimax:cn"]?.provider).toBe("minimax"); expect(cfg.auth?.profiles?.["minimax:cn"]?.mode).toBe("api_key"); expect(cfg.models?.providers?.minimax?.baseUrl).toBe(MINIMAX_CN_API_BASE_URL); - expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5"); + expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.7"); await expectApiKeyProfile({ profileId: "minimax:cn", provider: "minimax", diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index 92a4769c1fd..42f721edd6b 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -131,8 +131,8 @@ describe("config identity defaults", () => { api: "anthropic-messages", models: [ { - id: "MiniMax-M2.5", - name: "MiniMax M2.5", + id: "MiniMax-M2.7", + name: "MiniMax M2.7", reasoning: false, input: ["text"], cost: { diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 3c69ce1bcd7..e965d10b5db 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -415,7 +415,7 @@ describe("resolveSessionModelRef", () => { test("preserves openrouter provider when model contains vendor prefix", () => { const cfg = createModelDefaultsConfig({ - primary: "openrouter/minimax/minimax-m2.5", + primary: "openrouter/minimax/minimax-m2.7", }); const resolved = resolveSessionModelRef(cfg, { diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 123933e194c..77606c8dcf9 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -458,7 +458,7 @@ describe("provider discovery contract", () => { authHeader: true, apiKey: "minimax-key", models: expect.arrayContaining([ - expect.objectContaining({ id: "MiniMax-M2.5" }), + expect.objectContaining({ id: "MiniMax-M2.7" }), expect.objectContaining({ id: "MiniMax-VL-01" }), ]), }, @@ -499,7 +499,7 @@ describe("provider discovery contract", () => { api: "anthropic-messages", authHeader: true, apiKey: "minimax-oauth", - models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.5" })]), + models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.7" })]), }, }); }); diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 67f5e4d8798..68065a25607 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -104,7 +104,7 @@ describe("tui session actions", () => { sessions: [ { key: "agent:main:main", - model: "Minimax-M2.5", + model: "Minimax-M2.7", modelProvider: "minimax", }, ], @@ -112,7 +112,7 @@ describe("tui session actions", () => { await second; - expect(state.sessionInfo.model).toBe("Minimax-M2.5"); + expect(state.sessionInfo.model).toBe("Minimax-M2.7"); expect(updateAutocompleteProvider).toHaveBeenCalledTimes(2); expect(updateFooter).toHaveBeenCalledTimes(2); expect(requestRender).toHaveBeenCalledTimes(2); From 3d8afb96bd903d308e4e6132b77f8f33a994ba22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 16:24:37 +0000 Subject: [PATCH 03/34] fix: use transpiled jiti for source plugin shims --- src/plugin-sdk/root-alias.cjs | 19 ++++---- src/plugin-sdk/root-alias.test.ts | 22 +++++++-- src/plugins/loader.test.ts | 77 +++++++++++++++++++++++++++++++ src/plugins/loader.ts | 39 ++++++++++++---- 4 files changed, 134 insertions(+), 23 deletions(-) diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 0013b32d21f..d9d742c3070 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -4,7 +4,7 @@ const path = require("node:path"); const fs = require("node:fs"); let monolithicSdk = null; -let jitiLoader = null; +const jitiLoaders = new Map(); function emptyPluginConfigSchema() { function error(message) { @@ -61,19 +61,20 @@ function resolveControlCommandGate(params) { return { commandAuthorized, shouldBlock }; } -function getJiti() { - if (jitiLoader) { - return jitiLoader; +function getJiti(tryNative) { + if (jitiLoaders.has(tryNative)) { + return jitiLoaders.get(tryNative); } const { createJiti } = require("jiti"); - jitiLoader = createJiti(__filename, { + const jitiLoader = createJiti(__filename, { interopDefault: true, // Prefer Node's native sync ESM loader for built dist/plugin-sdk/*.js files // so local plugins do not create a second transpiled OpenClaw core graph. - tryNative: true, + tryNative, extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], }); + jitiLoaders.set(tryNative, jitiLoader); return jitiLoader; } @@ -82,19 +83,17 @@ function loadMonolithicSdk() { return monolithicSdk; } - const jiti = getJiti(); - const distCandidate = path.resolve(__dirname, "..", "..", "dist", "plugin-sdk", "compat.js"); if (fs.existsSync(distCandidate)) { try { - monolithicSdk = jiti(distCandidate); + monolithicSdk = getJiti(true)(distCandidate); return monolithicSdk; } catch { // Fall through to source alias if dist is unavailable or stale. } } - monolithicSdk = jiti(path.join(__dirname, "compat.ts")); + monolithicSdk = getJiti(false)(path.join(__dirname, "compat.ts")); return monolithicSdk; } diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 6767ca773e3..95565cab89a 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -25,7 +25,7 @@ function loadRootAliasWithStubs(options?: { }) { let createJitiCalls = 0; let jitiLoadCalls = 0; - let lastJitiOptions: Record | undefined; + const createJitiOptions: Record[] = []; const loadedSpecifiers: string[] = []; const monolithicExports = options?.monolithicExports ?? { slowHelper: () => "loaded", @@ -55,7 +55,7 @@ function loadRootAliasWithStubs(options?: { return { createJiti(_filename: string, jitiOptions?: Record) { createJitiCalls += 1; - lastJitiOptions = jitiOptions; + createJitiOptions.push(jitiOptions ?? {}); return (specifier: string) => { jitiLoadCalls += 1; loadedSpecifiers.push(specifier); @@ -75,8 +75,8 @@ function loadRootAliasWithStubs(options?: { get jitiLoadCalls() { return jitiLoadCalls; }, - get lastJitiOptions() { - return lastJitiOptions; + get createJitiOptions() { + return createJitiOptions; }, loadedSpecifiers, }; @@ -121,12 +121,24 @@ describe("plugin-sdk root alias", () => { expect("slowHelper" in lazyRootSdk).toBe(true); expect(lazyModule.createJitiCalls).toBe(1); expect(lazyModule.jitiLoadCalls).toBe(1); - expect(lazyModule.lastJitiOptions?.tryNative).toBe(true); + expect(lazyModule.createJitiOptions.at(-1)?.tryNative).toBe(false); expect((lazyRootSdk.slowHelper as () => string)()).toBe("loaded"); expect(Object.keys(lazyRootSdk)).toContain("slowHelper"); expect(Object.getOwnPropertyDescriptor(lazyRootSdk, "slowHelper")).toBeDefined(); }); + it("prefers native loading when compat resolves to dist", () => { + const lazyModule = loadRootAliasWithStubs({ + distExists: true, + monolithicExports: { + slowHelper: () => "loaded", + }, + }); + + expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded"); + expect(lazyModule.createJitiOptions.at(-1)?.tryNative).toBe(true); + }); + it("forwards delegateCompactionToRuntime through the compat-backed root alias", () => { const delegateCompactionToRuntime = () => "delegated"; const lazyModule = loadRootAliasWithStubs({ diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 60673ffa67f..194fcdae1d1 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { createJiti } from "jiti"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; async function importFreshPluginTestModules() { @@ -3341,6 +3342,82 @@ module.exports = { expect("alias" in options).toBe(false); }); + it("uses transpiled Jiti loads for source TypeScript plugin entries", () => { + expect(__testing.shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true); + expect( + __testing.shouldPreferNativeJiti("/repo/extensions/discord/src/channel.runtime.ts"), + ).toBe(false); + }); + + it("loads source runtime shims through the non-native Jiti boundary", async () => { + const jiti = createJiti(import.meta.url, { + ...__testing.buildPluginLoaderJitiOptions({}), + tryNative: false, + }); + const discordChannelRuntime = path.join( + process.cwd(), + "extensions", + "discord", + "src", + "channel.runtime.ts", + ); + const discordVoiceRuntime = path.join( + process.cwd(), + "extensions", + "discord", + "src", + "voice", + "manager.runtime.ts", + ); + + await expect(jiti.import(discordChannelRuntime)).resolves.toMatchObject({ + discordSetupWizard: expect.any(Object), + }); + await expect(jiti.import(discordVoiceRuntime)).resolves.toMatchObject({ + DiscordVoiceManager: expect.any(Function), + DiscordVoiceReadyListener: expect.any(Function), + }); + }); + + it("loads source TypeScript plugins that route through local runtime shims", () => { + const plugin = writePlugin({ + id: "source-runtime-shim", + filename: "source-runtime-shim.ts", + body: `import "./runtime-shim.ts"; + +export default { + id: "source-runtime-shim", + register() {}, +};`, + }); + fs.writeFileSync( + path.join(plugin.dir, "runtime-shim.ts"), + `import { helperValue } from "./helper.js"; + +export const runtimeValue = helperValue;`, + "utf-8", + ); + fs.writeFileSync( + path.join(plugin.dir, "helper.ts"), + `export const helperValue = "ok";`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["source-runtime-shim"], + }, + }, + }); + + const record = registry.plugins.find((entry) => entry.id === "source-runtime-shim"); + expect(record?.status).toBe("loaded"); + }); + it.each([ { name: "prefers dist plugin runtime module when loader runs from dist", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index c39a64e5f30..7be252d68e6 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -288,6 +288,18 @@ const resolvePluginSdkScopedAliasMap = (): Record => { return aliasMap; }; +function shouldPreferNativeJiti(modulePath: string): boolean { + switch (path.extname(modulePath).toLowerCase()) { + case ".js": + case ".mjs": + case ".cjs": + case ".json": + return true; + default: + return false; + } +} + export const __testing = { buildPluginLoaderJitiOptions, listPluginSdkAliasCandidates, @@ -295,6 +307,7 @@ export const __testing = { resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, resolvePluginRuntimeModulePath, + shouldPreferNativeJiti, maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES, }; @@ -849,18 +862,28 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). - let jitiLoader: ReturnType | null = null; - const getJiti = () => { - if (jitiLoader) { - return jitiLoader; + const jitiLoaders = new Map>(); + const getJiti = (modulePath: string) => { + const tryNative = shouldPreferNativeJiti(modulePath); + const cached = jitiLoaders.get(tryNative); + if (cached) { + return cached; } const pluginSdkAlias = resolvePluginSdkAlias(); const aliasMap = { ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), ...resolvePluginSdkScopedAliasMap(), }; - jitiLoader = createJiti(import.meta.url, buildPluginLoaderJitiOptions(aliasMap)); - return jitiLoader; + const loader = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + // Source .ts runtime shims import sibling ".js" specifiers that only exist + // after build. Disable native loading for source entries so Jiti rewrites + // those imports against the source graph, while keeping native dist/*.js + // loading for the canonical built module graph. + tryNative, + }); + jitiLoaders.set(tryNative, loader); + return loader; }; let createPluginRuntimeFactory: ((options?: CreatePluginRuntimeOptions) => PluginRuntime) | null = @@ -875,7 +898,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (!runtimeModulePath) { throw new Error("Unable to resolve plugin runtime module"); } - const runtimeModule = getJiti()(runtimeModulePath) as { + const runtimeModule = getJiti(runtimeModulePath)(runtimeModulePath) as { createPluginRuntime?: (options?: CreatePluginRuntimeOptions) => PluginRuntime; }; if (typeof runtimeModule.createPluginRuntime !== "function") { @@ -1208,7 +1231,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi let mod: OpenClawPluginModule | null = null; try { - mod = getJiti()(safeSource) as OpenClawPluginModule; + mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule; } catch (err) { recordPluginError({ logger, From d8008a9a678c4fcfe6bf5e7763d0ac7510996693 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 09:22:45 -0700 Subject: [PATCH 04/34] Tools: classify optional bundled clusters --- scripts/audit-plugin-sdk-seams.mjs | 153 ++++++++++++++++++++++ scripts/lib/optional-bundled-clusters.mjs | 16 +++ 2 files changed, 169 insertions(+) create mode 100644 scripts/lib/optional-bundled-clusters.mjs diff --git a/scripts/audit-plugin-sdk-seams.mjs b/scripts/audit-plugin-sdk-seams.mjs index 90250cfaaa1..67e27c036f4 100644 --- a/scripts/audit-plugin-sdk-seams.mjs +++ b/scripts/audit-plugin-sdk-seams.mjs @@ -4,6 +4,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; +import { optionalBundledClusterSet } from "./lib/optional-bundled-clusters.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const srcRoot = path.join(repoRoot, "src"); @@ -78,6 +79,18 @@ function normalizePluginSdkFamily(resolvedPath) { return relative.replace(/\.(m|c)?[jt]sx?$/, ""); } +function resolveOptionalClusterFromPath(resolvedPath) { + if (resolvedPath.startsWith("extensions/")) { + const cluster = resolvedPath.split("/")[1]; + return optionalBundledClusterSet.has(cluster) ? cluster : null; + } + if (resolvedPath.startsWith("src/plugin-sdk/")) { + const cluster = normalizePluginSdkFamily(resolvedPath).split("/")[0]; + return optionalBundledClusterSet.has(cluster) ? cluster : null; + } + return null; +} + function compareImports(left, right) { return ( left.family.localeCompare(right.family) || @@ -152,6 +165,79 @@ async function collectCorePluginSdkImports() { return inventory.toSorted(compareImports); } +function collectOptionalClusterStaticImports(filePath, sourceFile) { + const entries = []; + + function push(kind, specifierNode, specifier) { + if (!specifier.startsWith(".")) { + return; + } + const resolvedPath = resolveRelativeSpecifier(specifier, filePath); + if (!resolvedPath) { + return; + } + const cluster = resolveOptionalClusterFromPath(resolvedPath); + if (!cluster) { + return; + } + entries.push({ + cluster, + file: normalizePath(filePath), + kind, + line: toLine(sourceFile, specifierNode), + resolvedPath, + specifier, + }); + } + + function visit(node) { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + push("import", node.moduleSpecifier, node.moduleSpecifier.text); + } else if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + push("export", node.moduleSpecifier, node.moduleSpecifier.text); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return entries; +} + +async function collectOptionalClusterStaticLeaks() { + const files = await walkCodeFiles(srcRoot); + const inventory = []; + for (const filePath of files) { + const relativePath = normalizePath(filePath); + if (relativePath.startsWith("src/plugin-sdk/")) { + continue; + } + const source = await fs.readFile(filePath, "utf8"); + const scriptKind = + filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + scriptKind, + ); + inventory.push(...collectOptionalClusterStaticImports(filePath, sourceFile)); + } + return inventory.toSorted((left, right) => { + return ( + left.cluster.localeCompare(right.cluster) || + left.file.localeCompare(right.file) || + left.line - right.line || + left.kind.localeCompare(right.kind) || + left.specifier.localeCompare(right.specifier) + ); + }); +} + function buildDuplicatedSeamFamilies(inventory) { const grouped = new Map(); for (const entry of inventory) { @@ -207,6 +293,30 @@ function buildOverlapFiles(inventory) { }); } +function buildOptionalClusterStaticLeaks(inventory) { + const grouped = new Map(); + for (const entry of inventory) { + const bucket = grouped.get(entry.cluster) ?? []; + bucket.push(entry); + grouped.set(entry.cluster, bucket); + } + + return Object.fromEntries( + [...grouped.entries()] + .map(([cluster, entries]) => [ + cluster, + { + count: entries.length, + files: [...new Set(entries.map((entry) => entry.file))].toSorted(compareStrings), + imports: entries, + }, + ]) + .toSorted((left, right) => { + return right[1].count - left[1].count || left[0].localeCompare(right[0]); + }), + ); +} + function packageClusterMeta(relativePackagePath) { if (relativePackagePath === "ui/package.json") { return { @@ -227,6 +337,35 @@ function packageClusterMeta(relativePackagePath) { }; } +function classifyMissingPackageCluster(params) { + if (optionalBundledClusterSet.has(params.cluster)) { + if (params.cluster === "ui") { + return { + decision: "optional", + reason: + "Private UI workspace. Repo-wide CLI/plugin CI should not require UI-only packages.", + }; + } + if (params.pluginSdkEntries.length > 0) { + return { + decision: "optional", + reason: + "Public plugin-sdk entry exists, but repo-wide default check/build should isolate this optional cluster from the static graph.", + }; + } + return { + decision: "optional", + reason: + "Workspace package is intentionally not mirrored into the root dependency set by default CI policy.", + }; + } + return { + decision: "required", + reason: + "Cluster is statically visible to repo-wide check/build and has not been classified optional.", + }; +} + async function buildMissingPackages() { const rootPackage = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8")); const rootDeps = new Set([ @@ -264,15 +403,27 @@ async function buildMissingPackages() { continue; } const meta = packageClusterMeta(relativePackagePath); + const rootDependencyMirrorAllowlist = ( + pkg.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? [] + ).toSorted(compareStrings); const pluginSdkEntries = [...(pluginSdkReachability.get(meta.cluster) ?? new Set())].toSorted( compareStrings, ); + const classification = classifyMissingPackageCluster({ + cluster: meta.cluster, + pluginSdkEntries, + }); output.push({ cluster: meta.cluster, + decision: classification.decision, + decisionReason: classification.reason, packageName: pkg.name ?? meta.packageName, packagePath: relativePackagePath, npmSpec: pkg.openclaw?.install?.npmSpec ?? null, private: pkg.private === true, + rootDependencyMirrorAllowlist, + mirrorAllowlistMatchesMissing: + missing.join("\n") === rootDependencyMirrorAllowlist.join("\n"), pluginSdkReachability: pluginSdkEntries.length > 0 ? { staticEntryPoints: pluginSdkEntries } : undefined, missing, @@ -286,9 +437,11 @@ async function buildMissingPackages() { await collectWorkspacePackagePaths(); const inventory = await collectCorePluginSdkImports(); +const optionalClusterStaticLeaks = await collectOptionalClusterStaticLeaks(); const result = { duplicatedSeamFamilies: buildDuplicatedSeamFamilies(inventory), overlapFiles: buildOverlapFiles(inventory), + optionalClusterStaticLeaks: buildOptionalClusterStaticLeaks(optionalClusterStaticLeaks), missingPackages: await buildMissingPackages(), }; diff --git a/scripts/lib/optional-bundled-clusters.mjs b/scripts/lib/optional-bundled-clusters.mjs new file mode 100644 index 00000000000..c3c442d4ae7 --- /dev/null +++ b/scripts/lib/optional-bundled-clusters.mjs @@ -0,0 +1,16 @@ +export const optionalBundledClusters = [ + "acpx", + "diagnostics-otel", + "diffs", + "googlechat", + "matrix", + "memory-lancedb", + "msteams", + "nostr", + "tlon", + "twitch", + "ui", + "zalouser", +]; + +export const optionalBundledClusterSet = new Set(optionalBundledClusters); From 382640e67492bc3e5a94f1c04fba986ca763ded3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 09:30:54 -0700 Subject: [PATCH 05/34] Channels: trim optional bundled plugin defaults --- src/channels/plugins/bundled.ts | 13 ----------- src/channels/plugins/contracts/registry.ts | 27 ---------------------- 2 files changed, 40 deletions(-) diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 5579ddfdf65..86f4c0083b7 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -2,17 +2,13 @@ import { bluebubblesPlugin } from "../../../extensions/bluebubbles/index.js"; import { discordPlugin, setDiscordRuntime } from "../../../extensions/discord/index.js"; import { discordSetupPlugin } from "../../../extensions/discord/setup-entry.js"; import { feishuPlugin } from "../../../extensions/feishu/index.js"; -import { googlechatPlugin } from "../../../extensions/googlechat/index.js"; import { imessagePlugin } from "../../../extensions/imessage/index.js"; import { imessageSetupPlugin } from "../../../extensions/imessage/setup-entry.js"; import { ircPlugin } from "../../../extensions/irc/index.js"; import { linePlugin, setLineRuntime } from "../../../extensions/line/index.js"; import { lineSetupPlugin } from "../../../extensions/line/setup-entry.js"; -import { matrixPlugin } from "../../../extensions/matrix/index.js"; import { mattermostPlugin } from "../../../extensions/mattermost/index.js"; -import { msteamsPlugin } from "../../../extensions/msteams/index.js"; import { nextcloudTalkPlugin } from "../../../extensions/nextcloud-talk/index.js"; -import { nostrPlugin } from "../../../extensions/nostr/index.js"; import { signalPlugin } from "../../../extensions/signal/index.js"; import { signalSetupPlugin } from "../../../extensions/signal/setup-entry.js"; import { slackPlugin } from "../../../extensions/slack/index.js"; @@ -20,34 +16,26 @@ import { slackSetupPlugin } from "../../../extensions/slack/setup-entry.js"; import { synologyChatPlugin } from "../../../extensions/synology-chat/index.js"; import { telegramPlugin, setTelegramRuntime } from "../../../extensions/telegram/index.js"; import { telegramSetupPlugin } from "../../../extensions/telegram/setup-entry.js"; -import { tlonPlugin } from "../../../extensions/tlon/index.js"; import { whatsappPlugin } from "../../../extensions/whatsapp/index.js"; import { whatsappSetupPlugin } from "../../../extensions/whatsapp/setup-entry.js"; import { zaloPlugin } from "../../../extensions/zalo/index.js"; -import { zalouserPlugin } from "../../../extensions/zalouser/index.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; export const bundledChannelPlugins = [ bluebubblesPlugin, discordPlugin, feishuPlugin, - googlechatPlugin, imessagePlugin, ircPlugin, linePlugin, - matrixPlugin, mattermostPlugin, - msteamsPlugin, nextcloudTalkPlugin, - nostrPlugin, signalPlugin, slackPlugin, synologyChatPlugin, telegramPlugin, - tlonPlugin, whatsappPlugin, zaloPlugin, - zalouserPlugin, ] as ChannelPlugin[]; export const bundledChannelSetupPlugins = [ @@ -55,7 +43,6 @@ export const bundledChannelSetupPlugins = [ whatsappSetupPlugin, discordSetupPlugin, ircPlugin, - googlechatPlugin, slackSetupPlugin, signalSetupPlugin, imessageSetupPlugin, diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 134d8dddfb1..94892151c7b 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -4,7 +4,6 @@ import { createThreadBindingManager as createDiscordThreadBindingManager, } from "../../../../extensions/discord/runtime-api.js"; import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; -import { setMatrixRuntime } from "../../../../extensions/matrix/index.js"; import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { @@ -208,12 +207,6 @@ bundledChannelRuntimeSetters.setLineRuntime({ }, } as never); -setMatrixRuntime({ - state: { - resolveStateDir: (_env: unknown, homeDir?: () => string) => (homeDir ?? (() => "/tmp"))(), - }, -} as never); - export const pluginContractRegistry: PluginContractEntry[] = bundledChannelPlugins.map( (plugin) => ({ id: plugin.id, @@ -583,25 +576,6 @@ export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContra })); const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]); -const matrixDirectoryCfg = { - channels: { - matrix: { - enabled: true, - homeserver: "https://matrix.example.com", - userId: "@lobster:example.com", - accessToken: "matrix-access-token", - dm: { - allowFrom: ["matrix:@alice:example.com"], - }, - groupAllowFrom: ["matrix:@team:example.com"], - groups: { - "!room:example.com": { - users: ["matrix:@alice:example.com"], - }, - }, - }, - }, -} as OpenClawConfig; export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContractRegistry .filter((entry) => entry.surfaces.includes("directory")) @@ -609,7 +583,6 @@ export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContra id: entry.id, plugin: entry.plugin, coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups", - ...(entry.id === "matrix" ? { cfg: matrixDirectoryCfg } : {}), })); const baseSessionBindingCfg = { From 3e02635df386c4d3ddf7741ffbf0f11764839e59 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 09:33:21 -0700 Subject: [PATCH 06/34] Plugin SDK: use public telegram subpath --- src/agents/pi-embedded-runner/compact.ts | 8 ++++---- src/agents/pi-embedded-runner/run/attempt.ts | 8 ++++---- src/auto-reply/reply/commands-approve.ts | 6 +++--- src/auto-reply/reply/commands-models.ts | 14 +++++++------- src/auto-reply/reply/directive-handling.model.ts | 2 +- src/auto-reply/templating.ts | 2 +- .../read-only-account-inspect.telegram.runtime.ts | 6 +++--- src/cli/send-runtime/telegram.ts | 4 ++-- src/commands/doctor-config-flow.ts | 14 +++++++------- src/infra/state-migrations.ts | 2 +- src/security/audit-channel.runtime.ts | 4 ++-- 11 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 587a0e9214d..0dfc727dee1 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -7,6 +7,10 @@ import { estimateTokens, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { + resolveTelegramInlineButtonsScope, + resolveTelegramReactionLevel, +} from "openclaw/plugin-sdk/telegram"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; @@ -20,10 +24,6 @@ import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getMemorySearchManager } from "../../memory/index.js"; import { resolveSignalReactionLevel } from "../../plugin-sdk/signal.js"; -import { - resolveTelegramInlineButtonsScope, - resolveTelegramReactionLevel, -} from "../../plugin-sdk/telegram.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 3c77d877e28..f89759606de 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -7,6 +7,10 @@ import { DefaultResourceLoader, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { + resolveTelegramInlineButtonsScope, + resolveTelegramReactionLevel, +} from "openclaw/plugin-sdk/telegram"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../../config/config.js"; @@ -17,10 +21,6 @@ import { } from "../../../infra/net/undici-global-dispatcher.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import { resolveSignalReactionLevel } from "../../../plugin-sdk/signal.js"; -import { - resolveTelegramInlineButtonsScope, - resolveTelegramReactionLevel, -} from "../../../plugin-sdk/telegram.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { PluginHookAgentContext, diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 630ea988c05..05d7fe0139a 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,9 +1,9 @@ -import { callGateway } from "../../gateway/call.js"; -import { logVerbose } from "../../globals.js"; import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, -} from "../../plugin-sdk/telegram.js"; +} from "openclaw/plugin-sdk/telegram"; +import { callGateway } from "../../gateway/call.js"; +import { logVerbose } from "../../globals.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 25f309361d2..b1a1fcba8da 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -1,3 +1,10 @@ +import { + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + type ProviderInfo, +} from "openclaw/plugin-sdk/telegram"; import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; @@ -10,13 +17,6 @@ import { } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { - buildModelsKeyboard, - buildProviderKeyboard, - calculateTotalPages, - getModelsPageSize, - type ProviderInfo, -} from "../../plugin-sdk/telegram.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 986f632bcb5..5d8d871f9ec 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -1,3 +1,4 @@ +import { buildBrowseProvidersButton } from "openclaw/plugin-sdk/telegram"; import { ensureAuthProfileStore, resolveAuthStorePathForDisplay, @@ -12,7 +13,6 @@ import { } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { buildBrowseProvidersButton } from "../../plugin-sdk/telegram.js"; import { shortenHomePath } from "../../utils.js"; import { resolveSelectedAndActiveModel } from "../model-runtime.js"; import type { ReplyPayload } from "../types.js"; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index a32fdc3ba87..4485e2c22ee 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,9 +1,9 @@ +import type { StickerMetadata } from "openclaw/plugin-sdk/telegram"; import type { ChannelId } from "../channels/plugins/types.js"; import type { MediaUnderstandingDecision, MediaUnderstandingOutput, } from "../media-understanding/types.js"; -import type { StickerMetadata } from "../plugin-sdk/telegram.js"; import type { InputProvenance } from "../sessions/input-provenance.js"; import type { InternalMessageChannel } from "../utils/message-channel.js"; import type { CommandArgs } from "./commands-registry.types.js"; diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts index 01c492dfffd..12158022b2b 100644 --- a/src/channels/read-only-account-inspect.telegram.runtime.ts +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -1,8 +1,8 @@ -import { inspectTelegramAccount as inspectTelegramAccountImpl } from "../plugin-sdk/telegram.js"; +import { inspectTelegramAccount as inspectTelegramAccountImpl } from "openclaw/plugin-sdk/telegram"; -export type { InspectedTelegramAccount } from "../plugin-sdk/telegram.js"; +export type { InspectedTelegramAccount } from "openclaw/plugin-sdk/telegram"; -type InspectTelegramAccount = typeof import("../plugin-sdk/telegram.js").inspectTelegramAccount; +type InspectTelegramAccount = typeof import("openclaw/plugin-sdk/telegram").inspectTelegramAccount; export function inspectTelegramAccount( ...args: Parameters diff --git a/src/cli/send-runtime/telegram.ts b/src/cli/send-runtime/telegram.ts index 09d5e3e9b19..bfa22643976 100644 --- a/src/cli/send-runtime/telegram.ts +++ b/src/cli/send-runtime/telegram.ts @@ -1,7 +1,7 @@ -import { sendMessageTelegram as sendMessageTelegramImpl } from "../../plugin-sdk/telegram.js"; +import { sendMessageTelegram as sendMessageTelegramImpl } from "openclaw/plugin-sdk/telegram"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/telegram.js").sendMessageTelegram; + sendMessage: typeof import("openclaw/plugin-sdk/telegram").sendMessageTelegram; }; export const runtimeSend = { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index ae755423987..10721412927 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,5 +1,12 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + fetchTelegramChatId, + inspectTelegramAccount, + isNumericTelegramUserId, + listTelegramAccountIds, + normalizeTelegramAllowFromEntry, +} from "openclaw/plugin-sdk/telegram"; import { normalizeChatChannelId } from "../channels/registry.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; @@ -23,13 +30,6 @@ import { } from "../infra/exec-safe-bin-trust.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js"; -import { - fetchTelegramChatId, - inspectTelegramAccount, - isNumericTelegramUserId, - listTelegramAccountIds, - normalizeTelegramAllowFromEntry, -} from "../plugin-sdk/telegram.js"; import { formatChannelAccountsDefaultPath, formatSetExplicitDefaultInstruction, diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index b429365a4a4..8c8dd821df6 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { listTelegramAccountIds } from "openclaw/plugin-sdk/telegram"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -15,7 +16,6 @@ import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js import type { SessionScope } from "../config/sessions/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveChannelAllowFromPath } from "../pairing/pairing-store.js"; -import { listTelegramAccountIds } from "../plugin-sdk/telegram.js"; import { buildAgentMainSessionKey, DEFAULT_ACCOUNT_ID, diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts index de2d666cb87..e53c1c19391 100644 --- a/src/security/audit-channel.runtime.ts +++ b/src/security/audit-channel.runtime.ts @@ -1,8 +1,8 @@ -import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, -} from "../plugin-sdk/telegram.js"; +} from "openclaw/plugin-sdk/telegram"; +import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { isDiscordMutableAllowEntry, isZalouserMutableGroupEntry, From 27f655ed113637b07d2dabf6d5b837aca25187da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 16:36:09 +0000 Subject: [PATCH 07/34] refactor: deduplicate channel runtime helpers --- extensions/bluebubbles/src/channel.ts | 46 ++-- extensions/discord/src/channel.ts | 220 +++++++--------- extensions/discord/src/directory-config.ts | 53 ++-- extensions/feishu/src/channel.ts | 109 +++++--- .../googlechat/src/channel.directory.test.ts | 58 ++++ extensions/googlechat/src/channel.ts | 113 ++++---- extensions/imessage/src/channel.ts | 32 +-- extensions/imessage/src/shared.ts | 15 +- extensions/irc/src/channel.ts | 174 ++++++------ extensions/line/src/channel.ts | 52 ++-- extensions/matrix/src/channel.ts | 226 +++++++--------- extensions/mattermost/src/channel.ts | 42 +-- extensions/msteams/src/channel.ts | 161 ++++++------ extensions/nextcloud-talk/src/channel.ts | 69 +++-- extensions/signal/src/channel.ts | 50 ++-- extensions/signal/src/shared.ts | 15 +- extensions/slack/src/channel.ts | 190 ++++++-------- extensions/slack/src/directory-config.ts | 49 ++-- extensions/synology-chat/src/channel.test.ts | 14 +- extensions/synology-chat/src/channel.ts | 81 +++--- extensions/telegram/src/channel.ts | 148 +++++------ extensions/telegram/src/directory-config.ts | 43 ++- extensions/tlon/src/channel.ts | 19 +- .../whatsapp/src/channel.directory.test.ts | 62 +++++ extensions/whatsapp/src/channel.ts | 31 +-- extensions/whatsapp/src/directory-config.ts | 22 +- extensions/whatsapp/src/shared.ts | 55 ++-- extensions/zalo/src/channel.ts | 94 +++---- extensions/zalouser/src/channel.ts | 15 +- .../plugins/directory-adapters.test.ts | 35 +++ src/channels/plugins/directory-adapters.ts | 28 ++ .../plugins/directory-config-helpers.test.ts | 97 +++++++ .../plugins/directory-config-helpers.ts | 90 +++++++ .../plugins/group-policy-warnings.test.ts | 240 +++++++++++++++++ src/channels/plugins/group-policy-warnings.ts | 171 ++++++++++++ src/channels/plugins/pairing-adapters.test.ts | 37 +++ src/channels/plugins/pairing-adapters.ts | 34 +++ .../plugins/runtime-forwarders.test.ts | 54 ++++ src/channels/plugins/runtime-forwarders.ts | 117 +++++++++ src/channels/plugins/target-resolvers.test.ts | 40 +++ src/channels/plugins/target-resolvers.ts | 30 +++ src/plugin-sdk/allowlist-config-edit.test.ts | 247 ++++++++++++++++++ src/plugin-sdk/allowlist-config-edit.ts | 214 ++++++++++++++- src/plugin-sdk/channel-policy.ts | 10 + src/plugin-sdk/channel-runtime.ts | 4 + src/plugin-sdk/directory-runtime.ts | 5 + src/plugin-sdk/subpaths.test.ts | 35 +++ 47 files changed, 2595 insertions(+), 1151 deletions(-) create mode 100644 extensions/googlechat/src/channel.directory.test.ts create mode 100644 extensions/whatsapp/src/channel.directory.test.ts create mode 100644 src/channels/plugins/directory-adapters.test.ts create mode 100644 src/channels/plugins/directory-adapters.ts create mode 100644 src/channels/plugins/pairing-adapters.test.ts create mode 100644 src/channels/plugins/pairing-adapters.ts create mode 100644 src/channels/plugins/runtime-forwarders.test.ts create mode 100644 src/channels/plugins/runtime-forwarders.ts create mode 100644 src/channels/plugins/target-resolvers.test.ts create mode 100644 src/channels/plugins/target-resolvers.ts create mode 100644 src/plugin-sdk/allowlist-config-edit.test.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 33249fcfa9e..b13d21f71fd 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -4,7 +4,14 @@ import { 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 { + createOpenGroupPolicyRestrictSendersWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, @@ -68,6 +75,17 @@ const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), }); +const collectBlueBubblesSecurityWarnings = + createOpenGroupPolicyRestrictSendersWarningCollector({ + resolveGroupPolicy: (account) => account.config.groupPolicy, + defaultGroupPolicy: "allowlist", + surface: "BlueBubbles groups", + openScope: "any member", + groupPolicyPath: "channels.bluebubbles.groupPolicy", + groupAllowFromPath: "channels.bluebubbles.groupAllowFrom", + mentionGated: false, + }); + const meta = { id: "bluebubbles", label: "BlueBubbles", @@ -123,17 +141,10 @@ export const bluebubblesPlugin: ChannelPlugin = { actions: bluebubblesMessageActions, security: { resolveDmPolicy: resolveBlueBubblesDmPolicy, - collectWarnings: ({ account }) => { - const groupPolicy = account.config.groupPolicy ?? "allowlist"; - return collectOpenGroupPolicyRestrictSendersWarnings({ - groupPolicy, - surface: "BlueBubbles groups", - openScope: "any member", - groupPolicyPath: "channels.bluebubbles.groupPolicy", - groupAllowFromPath: "channels.bluebubbles.groupAllowFrom", - mentionGated: false, - }); - }, + collectWarnings: projectWarningCollector( + ({ account }: { account: ResolvedBlueBubblesAccount }) => account, + collectBlueBubblesSecurityWarnings, + ), }, messaging: { normalizeTarget: normalizeBlueBubblesMessagingTarget, @@ -226,17 +237,18 @@ export const bluebubblesPlugin: ChannelPlugin = { }, }, setup: blueBubblesSetupAdapter, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "bluebubblesSenderId", - normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^bluebubbles:/i, normalizeBlueBubblesHandle), + notify: async ({ cfg, id, message }) => { await ( await loadBlueBubblesChannelRuntime() - ).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { + ).sendMessageBlueBubbles(id, message, { cfg: cfg, }); }, - }, + }), outbound: { deliveryMode: "direct", textChunkLimit: 4000, diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 1224fc7b37a..24a8577af3a 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,15 +1,20 @@ import { Separator, TextDisplay } from "@buape/carbon"; import { - buildAccountScopedAllowlistConfigEditor, - resolveLegacyDmAllowlistConfigPaths, + buildLegacyDmAccountAllowlistAdapter, + createAccountScopedAllowlistNameResolver, + createNestedAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - collectOpenGroupPolicyConfiguredRouteWarnings, - collectOpenProviderGroupPolicyWarnings, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createTextPairingAdapter, + normalizeMessageChannel, + resolveOutboundSendDep, + resolveTargetsWithOptionalToken, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { @@ -131,42 +136,40 @@ function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean { }); } -function readDiscordAllowlistConfig(account: ResolvedDiscordAccount) { - const groupOverrides: Array<{ label: string; entries: string[] }> = []; - for (const [guildKey, guildCfg] of Object.entries(account.config.guilds ?? {})) { - const entries = (guildCfg?.users ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: `guild ${guildKey}`, entries }); - } - for (const [channelKey, channelCfg] of Object.entries(guildCfg?.channels ?? {})) { - const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean); - if (channelEntries.length > 0) { - groupOverrides.push({ - label: `guild ${guildKey} / channel ${channelKey}`, - entries: channelEntries, - }); - } - } - } - return { - dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), - groupPolicy: account.config.groupPolicy, - groupOverrides, - }; -} +const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({ + resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds, + outerLabel: (guildKey) => `guild ${guildKey}`, + resolveOuterEntries: (guildCfg) => guildCfg?.users, + resolveChildren: (guildCfg) => guildCfg?.channels, + innerLabel: (guildKey, channelKey) => `guild ${guildKey} / channel ${channelKey}`, + resolveInnerEntries: (channelCfg) => channelCfg?.users, +}); -async function resolveDiscordAllowlistNames(params: { - cfg: Parameters[0]["cfg"]; - accountId?: string | null; - entries: string[]; -}) { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); - const token = account.token?.trim(); - if (!token) { - return []; - } - return await resolveDiscordUserAllowlist({ token, entries: params.entries }); -} +const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), + resolveToken: (account: ResolvedDiscordAccount) => account.token, + resolveNames: ({ token, entries }) => resolveDiscordUserAllowlist({ token, entries }), +}); + +const collectDiscordSecurityWarnings = + createOpenProviderConfiguredRouteWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.discord !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Object.keys(account.config.guilds ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Discord guilds", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.discord.groupPolicy", + routeAllowlistPath: "channels.discord.guilds..channels", + }, + missingRouteAllowlist: { + surface: "Discord guilds", + openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels', + }, + }); function normalizeDiscordAcpConversationId(conversationId: string) { const normalized = conversationId.trim(); @@ -288,60 +291,29 @@ export const discordPlugin: ChannelPlugin = { ...createDiscordPluginBase({ setup: discordSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "discordUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), - notifyApproval: async ({ id }) => { - await getDiscordRuntime().channel.discord.sendMessageDiscord( - `user:${id}`, - PAIRING_APPROVED_MESSAGE, - ); + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(discord|user):/i), + notify: async ({ id, message }) => { + await getDiscordRuntime().channel.discord.sendMessageDiscord(`user:${id}`, message); }, - }, + }), allowlist: { - supportsScope: ({ scope }) => scope === "dm", - readConfig: ({ cfg, accountId }) => - readDiscordAllowlistConfig(resolveDiscordAccount({ cfg, accountId })), - resolveNames: async ({ cfg, accountId, entries }) => - await resolveDiscordAllowlistNames({ cfg, accountId, entries }), - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + ...buildLegacyDmAccountAllowlistAdapter({ channelId: "discord", + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), normalize: ({ cfg, accountId, values }) => discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: resolveLegacyDmAllowlistConfigPaths, + resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveGroupOverrides: resolveDiscordAllowlistGroupOverrides, }), + resolveNames: resolveDiscordAllowlistNames, }, security: { resolveDmPolicy: resolveDiscordDmPolicy, - collectWarnings: ({ account, cfg }) => { - const guildEntries = account.config.guilds ?? {}; - const guildsConfigured = Object.keys(guildEntries).length > 0; - const channelAllowlistConfigured = guildsConfigured; - - return collectOpenProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.discord !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyConfiguredRouteWarnings({ - groupPolicy, - routeAllowlistConfigured: channelAllowlistConfigured, - configureRouteAllowlist: { - surface: "Discord guilds", - openScope: "any channel not explicitly denied", - groupPolicyPath: "channels.discord.groupPolicy", - routeAllowlistPath: "channels.discord.guilds..channels", - }, - missingRouteAllowlist: { - surface: "Discord guilds", - openBehavior: - "with no guild/channel allowlist; any channel can trigger (mention-gated)", - remediation: - 'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels', - }, - }), - }); - }, + collectWarnings: collectDiscordSecurityWarnings, }, groups: { resolveRequireMention: resolveDiscordGroupRequireMention, @@ -387,53 +359,57 @@ export const discordPlugin: ChannelPlugin = { (normalizeMessageChannel(target.channel) ?? target.channel) === "discord" && isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }), }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => - getDiscordRuntime().channel.discord.listDirectoryPeersLive(params), - listGroupsLive: async (params) => - getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: () => getDiscordRuntime().channel.discord, + listPeersLive: (runtime) => runtime.listDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { const account = resolveDiscordAccount({ cfg, accountId }); - const token = account.token?.trim(); - if (!token) { - return inputs.map((input) => ({ - input, - resolved: false, - note: "missing Discord token", - })); - } if (kind === "group") { - const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({ - token, - entries: inputs, + return resolveTargetsWithOptionalToken({ + token: account.token, + inputs, + missingTokenNote: "missing Discord token", + resolveWithToken: ({ token, inputs }) => + getDiscordRuntime().channel.discord.resolveChannelAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.channelId ?? entry.guildId, + name: + entry.channelName ?? + entry.guildName ?? + (entry.guildId && !entry.channelId ? entry.guildId : undefined), + note: entry.note, + }), }); - return resolved.map((entry) => ({ + } + return resolveTargetsWithOptionalToken({ + token: account.token, + inputs, + missingTokenNote: "missing Discord token", + resolveWithToken: ({ token, inputs }) => + getDiscordRuntime().channel.discord.resolveUserAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => ({ input: entry.input, resolved: entry.resolved, - id: entry.channelId ?? entry.guildId, - name: - entry.channelName ?? - entry.guildName ?? - (entry.guildId && !entry.channelId ? entry.guildId : undefined), + id: entry.id, + name: entry.name, note: entry.note, - })); - } - const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({ - token, - entries: inputs, + }), }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.note, - })); }, }, actions: discordMessageActions, diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 69b39d4f9a5..19ec9ce18b5 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -1,54 +1,43 @@ import { - applyDirectoryQueryAndLimit, - collectNormalizedDirectoryIds, - toDirectoryEntries, + listInspectedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { inspectDiscordAccount, type InspectedDiscordAccount } from "../api.js"; export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account: InspectedDiscordAccount = inspectDiscordAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? []; - const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [ - ...(guild.users ?? []), - ...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []), - ]); - const ids = collectNormalizedDirectoryIds({ - sources: [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers], + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "user", + inspectAccount: (cfg, accountId) => + inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null, + resolveSources: (account) => { + const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? []; + const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [ + ...(guild.users ?? []), + ...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []), + ]); + return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers]; + }, normalizeId: (raw) => { const mention = raw.match(/^<@!?(\d+)>$/); const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim(); return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null; }, }); - return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account: InspectedDiscordAccount = inspectDiscordAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const ids = collectNormalizedDirectoryIds({ - sources: Object.values(account.config.guilds ?? {}).map((guild) => - Object.keys(guild.channels ?? {}), - ), + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "group", + inspectAccount: (cfg, accountId) => + inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null, + resolveSources: (account) => + Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})), normalizeId: (raw) => { const mention = raw.match(/^<#(\d+)>$/); const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim(); return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null; }, }); - return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params)); } diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 0aa071e7abd..97fd5dd068d 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,7 +1,17 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAllowlistProviderGroupPolicyWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createMessageToolCardSchema, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, @@ -53,6 +63,24 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport( "feishuChannelRuntime", ); +const collectFeishuSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ + cfg: ClawdbotConfig; + accountId?: string | null; +}>({ + providerConfigPresent: (cfg) => cfg.channels?.feishu !== undefined, + resolveGroupPolicy: ({ cfg, accountId }) => + resolveFeishuAccount({ cfg, accountId }).config?.groupPolicy, + collect: ({ cfg, accountId, groupPolicy }) => { + if (groupPolicy !== "open") { + return []; + } + const account = resolveFeishuAccount({ cfg, accountId }); + return [ + `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, + ]; + }, +}); + function describeFeishuMessageTool({ cfg, }: Parameters< @@ -355,18 +383,19 @@ export const feishuPlugin: ChannelPlugin = { meta: { ...meta, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "feishuUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(feishu|user|open_id):/i), + notify: async ({ cfg, id, message }) => { const { sendMessageFeishu } = await loadFeishuChannelRuntime(); await sendMessageFeishu({ cfg, to: id, - text: PAIRING_APPROVED_MESSAGE, + text: message, }); }, - }, + }), capabilities: { chatTypes: ["direct", "channel"], polls: false, @@ -839,19 +868,13 @@ export const feishuPlugin: ChannelPlugin = { }, }, security: { - collectWarnings: ({ cfg, accountId }) => { - const account = resolveFeishuAccount({ cfg, accountId }); - const feishuCfg = account.config; - return collectAllowlistProviderRestrictSendersWarnings({ + collectWarnings: projectWarningCollector( + ({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string | null }) => ({ cfg, - providerConfigPresent: cfg.channels?.feishu !== undefined, - configuredGroupPolicy: feishuCfg?.groupPolicy, - surface: `Feishu[${account.accountId}] groups`, - openScope: "any member", - groupPolicyPath: "channels.feishu.groupPolicy", - groupAllowFromPath: "channels.feishu.groupAllowFrom", - }); - }, + accountId, + }), + collectFeishuSecurityWarnings, + ), }, bindings: { compileConfiguredBinding: ({ conversationId }) => @@ -873,8 +896,7 @@ export const feishuPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async ({ cfg, query, limit, accountId }) => listFeishuDirectoryPeers({ cfg, @@ -889,29 +911,38 @@ export const feishuPlugin: ChannelPlugin = { limit: limit ?? undefined, accountId: accountId ?? undefined, }), - listPeersLive: async ({ cfg, query, limit, accountId }) => - (await loadFeishuChannelRuntime()).listFeishuDirectoryPeersLive({ - cfg, - query: query ?? undefined, - limit: limit ?? undefined, - accountId: accountId ?? undefined, - }), - listGroupsLive: async ({ cfg, query, limit, accountId }) => - (await loadFeishuChannelRuntime()).listFeishuDirectoryGroupsLive({ - cfg, - query: query ?? undefined, - limit: limit ?? undefined, - accountId: accountId ?? undefined, - }), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: loadFeishuChannelRuntime, + listPeersLive: + (runtime) => + async ({ cfg, query, limit, accountId }) => + await runtime.listFeishuDirectoryPeersLive({ + cfg, + query: query ?? undefined, + limit: limit ?? undefined, + accountId: accountId ?? undefined, + }), + listGroupsLive: + (runtime) => + async ({ cfg, query, limit, accountId }) => + await runtime.listFeishuDirectoryGroupsLive({ + cfg, + query: query ?? undefined, + limit: limit ?? undefined, + accountId: accountId ?? undefined, + }), + }), + }), outbound: { deliveryMode: "direct", chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async (params) => (await loadFeishuChannelRuntime()).feishuOutbound.sendText!(params), - sendMedia: async (params) => - (await loadFeishuChannelRuntime()).feishuOutbound.sendMedia!(params), + ...createRuntimeOutboundDelegates({ + getRuntime: loadFeishuChannelRuntime, + sendText: { resolve: (runtime) => runtime.feishuOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.feishuOutbound.sendMedia }, + }), }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), diff --git a/extensions/googlechat/src/channel.directory.test.ts b/extensions/googlechat/src/channel.directory.test.ts new file mode 100644 index 00000000000..7dbf68a0934 --- /dev/null +++ b/extensions/googlechat/src/channel.directory.test.ts @@ -0,0 +1,58 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat"; +import { describe, expect, it } from "vitest"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.ts"; +import { googlechatPlugin } from "./channel.js"; + +describe("googlechat directory", () => { + const runtimeEnv = createDirectoryTestRuntime() as never; + + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + googlechat: { + serviceAccount: { client_email: "bot@example.com" }, + dm: { allowFrom: ["users/alice", "googlechat:bob"] }, + groups: { + "spaces/AAA": {}, + "spaces/BBB": {}, + }, + }, + }, + } as unknown as OpenClawConfig; + + const directory = expectDirectorySurface(googlechatPlugin.directory); + + await expect( + directory.listPeers({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "users/alice" }, + { kind: "user", id: "bob" }, + ]), + ); + + await expect( + directory.listGroups({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "spaces/AAA" }, + { kind: "group", id: "spaces/BBB" }, + ]), + ); + }); +}); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 7cc86e81cda..856891cfb48 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -4,9 +4,19 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildOpenGroupPolicyConfigureRouteAllowlistWarning, - collectAllowlistProviderGroupPolicyWarnings, + composeWarningCollectors, + createAllowlistProviderGroupPolicyWarningCollector, + createConditionalWarningCollector, + createAllowlistProviderOpenWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; +import { + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, +} from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { @@ -15,8 +25,6 @@ import { DEFAULT_ACCOUNT_ID, createAccountStatusSink, getChatChannelMeta, - listDirectoryGroupEntriesFromMapKeys, - listDirectoryUserEntriesFromAllowFrom, missingTargetError, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, @@ -103,15 +111,40 @@ const googlechatActions: ChannelMessageActionAdapter = { }, }; +const collectGoogleChatGroupPolicyWarnings = + createAllowlistProviderOpenWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.googlechat !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + buildOpenWarning: { + surface: "Google Chat spaces", + openBehavior: "allows any space to trigger (mention-gated)", + remediation: + 'Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups', + }, + }); + +const collectGoogleChatSecurityWarnings = composeWarningCollectors<{ + cfg: OpenClawConfig; + account: ResolvedGoogleChatAccount; +}>( + collectGoogleChatGroupPolicyWarnings, + createConditionalWarningCollector( + ({ account }) => + account.config.dm?.policy === "open" && + '- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".', + ), +); + export const googlechatPlugin: ChannelPlugin = { id: "googlechat", meta: { ...meta }, setup: googlechatSetupAdapter, setupWizard: googlechatSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "googlechatUserId", + message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), - notifyApproval: async ({ cfg, id }) => { + notify: async ({ cfg, id, message }) => { const account = resolveGoogleChatAccount({ cfg: cfg }); if (account.credentialSource === "none") { return; @@ -123,10 +156,10 @@ export const googlechatPlugin: ChannelPlugin = { await sendGoogleChatMessage({ account, space, - text: PAIRING_APPROVED_MESSAGE, + text: message, }); }, - }, + }), capabilities: { chatTypes: ["direct", "group", "thread"], reactions: true, @@ -153,30 +186,7 @@ export const googlechatPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveGoogleChatDmPolicy, - collectWarnings: ({ account, cfg }) => { - const warnings = collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.googlechat !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyConfigureRouteAllowlistWarning({ - surface: "Google Chat spaces", - openScope: "any space", - groupPolicyPath: "channels.googlechat.groupPolicy", - routeAllowlistPath: "channels.googlechat.groups", - }), - ] - : [], - }); - if (account.config.dm?.policy === "open") { - warnings.push( - `- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".`, - ); - } - return warnings; - }, + collectWarnings: collectGoogleChatSecurityWarnings, }, groups: { resolveRequireMention: resolveGoogleChatGroupRequireMention, @@ -194,32 +204,21 @@ export const googlechatPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveGoogleChatAccount({ - cfg: cfg, - accountId, - }); - return listDirectoryUserEntriesFromAllowFrom({ - allowFrom: account.config.dm?.allowFrom, - query, - limit, + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.config.dm?.allowFrom, normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry, - }); - }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveGoogleChatAccount({ - cfg: cfg, - accountId, - }); - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.config.groups, - query, - limit, - }); - }, - }, + }), + listGroups: async (params) => + listResolvedDirectoryGroupEntriesFromMapKeys({ + ...params, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + resolveGroups: (account) => account.config.groups, + }), + }), resolver: { resolveTargets: async ({ inputs, kind }) => { const resolved = inputs.map((input) => { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 27a26a9db88..bd7df04e249 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,4 +1,4 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; @@ -21,6 +21,7 @@ import { imessageSetupAdapter } from "./setup-core.js"; import { collectIMessageSecurityWarnings, createIMessagePluginBase, + imessageConfigAdapter, imessageResolveDmPolicy, imessageSetupWizard, } from "./shared.js"; @@ -113,26 +114,15 @@ export const imessagePlugin: ChannelPlugin = { notifyApproval: async ({ id }) => await (await loadIMessageChannelRuntime()).notifyIMessageApproval(id), }, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => { - const account = resolveIMessageAccount({ cfg, accountId }); - return { - dmAllowFrom: (account.config.allowFrom ?? []).map(String), - groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), - dmPolicy: account.config.dmPolicy, - groupPolicy: account.config.groupPolicy, - }; - }, - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "imessage", - normalize: ({ values }) => formatTrimmedAllowFromEntries(values), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "imessage", + resolveAccount: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }), + normalize: ({ values }) => formatTrimmedAllowFromEntries(values), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + }), security: { resolveDmPolicy: imessageResolveDmPolicy, collectWarnings: collectIMessageSecurityWarnings, diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index cf3e7b173cf..41275715c36 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -1,9 +1,9 @@ import { - collectAllowlistProviderRestrictSendersWarnings, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, formatTrimmedAllowFromEntries, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, @@ -47,21 +47,16 @@ export const imessageResolveDmPolicy = createScopedDmSecurityResolver[0]["cfg"]; -}) { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg: params.cfg, - providerConfigPresent: params.cfg.channels?.imessage !== undefined, - configuredGroupPolicy: params.account.config.groupPolicy, +export const collectIMessageSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.imessage !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, surface: "iMessage groups", openScope: "any member", groupPolicyPath: "channels.imessage.groupPolicy", groupAllowFromPath: "channels.imessage.groupAllowFrom", mentionGated: false, }); -} export function createIMessagePluginBase(params: { setupWizard?: NonNullable["setupWizard"]>; diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index a0f6c9a5bc8..216ce997d16 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -4,9 +4,15 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildOpenGroupPolicyWarning, - collectAllowlistProviderGroupPolicyWarnings, + composeWarningCollectors, + createAllowlistProviderOpenWarningCollector, + createConditionalWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createTextPairingAdapter, + listResolvedDirectoryEntriesFromSources, +} from "openclaw/plugin-sdk/channel-runtime"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { listIrcAccountIds, @@ -88,6 +94,36 @@ const resolveIrcDmPolicy = createScopedDmSecurityResolver({ normalizeEntry: (raw) => normalizeIrcAllowEntry(raw), }); +const collectIrcGroupPolicyWarnings = + createAllowlistProviderOpenWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.irc !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + buildOpenWarning: { + surface: "IRC channels", + openBehavior: "allows all channels and senders (mention-gated)", + remediation: 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups', + }, + }); + +const collectIrcSecurityWarnings = composeWarningCollectors<{ + account: ResolvedIrcAccount; + cfg: CoreConfig; +}>( + collectIrcGroupPolicyWarnings, + createConditionalWarningCollector( + ({ account }) => + !account.config.tls && + "- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.", + ({ account }) => + account.config.nickserv?.register && + '- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.', + ({ account }) => + account.config.nickserv?.register && + !account.config.nickserv.password?.trim() && + "- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.", + ), +); + export const ircPlugin: ChannelPlugin = { id: "irc", meta: { @@ -96,17 +132,18 @@ export const ircPlugin: ChannelPlugin = { }, setup: ircSetupAdapter, setupWizard: ircSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "ircUser", + message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry), - notifyApproval: async ({ id }) => { + notify: async ({ id, message }) => { const target = normalizePairingTarget(id); if (!target) { throw new Error(`invalid IRC pairing id: ${id}`); } - await sendMessageIrc(target, PAIRING_APPROVED_MESSAGE); + await sendMessageIrc(target, message); }, - }, + }), capabilities: { chatTypes: ["direct", "group"], media: true, @@ -131,40 +168,7 @@ export const ircPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveIrcDmPolicy, - collectWarnings: ({ account, cfg }) => { - const warnings = collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.irc !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyWarning({ - surface: "IRC channels", - openBehavior: "allows all channels and senders (mention-gated)", - remediation: - 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups', - }), - ] - : [], - }); - if (!account.config.tls) { - warnings.push( - "- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.", - ); - } - if (account.config.nickserv?.register) { - warnings.push( - '- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.', - ); - if (!account.config.nickserv.password?.trim()) { - warnings.push( - "- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.", - ); - } - } - return warnings; - }, + collectWarnings: collectIrcSecurityWarnings, }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { @@ -230,66 +234,38 @@ export const ircPlugin: ChannelPlugin = { }); }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() ?? ""; - const ids = new Set(); - - for (const entry of account.config.allowFrom ?? []) { - const normalized = normalizePairingTarget(String(entry)); - if (normalized && normalized !== "*") { - ids.add(normalized); - } - } - for (const entry of account.config.groupAllowFrom ?? []) { - const normalized = normalizePairingTarget(String(entry)); - if (normalized && normalized !== "*") { - ids.add(normalized); - } - } - for (const group of Object.values(account.config.groups ?? {})) { - for (const entry of group.allowFrom ?? []) { - const normalized = normalizePairingTarget(String(entry)); - if (normalized && normalized !== "*") { - ids.add(normalized); - } - } - } - - return Array.from(ids) - .filter((id) => (q ? id.includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "user", id })); + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "user", + resolveAccount: (cfg, accountId) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + account.config.allowFrom ?? [], + account.config.groupAllowFrom ?? [], + ...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []), + ], + normalizeId: (entry) => normalizePairingTarget(entry) || null, + }), + listGroups: async (params) => { + const entries = listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "group", + resolveAccount: (cfg, accountId) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + account.config.channels ?? [], + Object.keys(account.config.groups ?? {}), + ], + normalizeId: (entry) => { + const normalized = normalizeIrcMessagingTarget(entry); + return normalized && isChannelTarget(normalized) ? normalized : null; + }, + }); + return entries.map((entry) => ({ ...entry, name: entry.id })); }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() ?? ""; - const groupIds = new Set(); - - for (const channel of account.config.channels ?? []) { - const normalized = normalizeIrcMessagingTarget(channel); - if (normalized && isChannelTarget(normalized)) { - groupIds.add(normalized); - } - } - for (const group of Object.keys(account.config.groups ?? {})) { - if (group === "*") { - continue; - } - const normalized = normalizeIrcMessagingTarget(group); - if (normalized && isChannelTarget(normalized)) { - groupIds.add(normalized); - } - } - - return Array.from(groupIds) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id, name: id })); - }, - }, + }), outbound: { deliveryMode: "direct", chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 33f2b7aa247..edc9f861d28 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,5 +1,10 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + createEmptyChannelDirectoryAdapter, + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildChannelConfigSchema, buildComputedAccountStatusSnapshot, @@ -42,29 +47,39 @@ const resolveLineDmPolicy = createScopedDmSecurityResolver( normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), }); +const collectLineSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.line !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + surface: "LINE groups", + openScope: "any member in groups", + groupPolicyPath: "channels.line.groupPolicy", + groupAllowFromPath: "channels.line.groupAllowFrom", + mentionGated: false, + }); + export const linePlugin: ChannelPlugin = { id: "line", meta: { ...meta, quickstartAllowFrom: true, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "lineUserId", - normalizeAllowEntry: (entry) => { - // LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:). - return entry.replace(/^line:(?:user:)?/i, ""); - }, - notifyApproval: async ({ cfg, id }) => { + message: "OpenClaw: your access has been approved.", + // LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:). + normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i), + notify: async ({ cfg, id, message }) => { const line = getLineRuntime().channel.line; const account = line.resolveLineAccount({ cfg }); if (!account.channelAccessToken) { throw new Error("LINE channel access token not configured"); } - await line.pushMessageLine(id, "OpenClaw: your access has been approved.", { + await line.pushMessageLine(id, message, { channelAccessToken: account.channelAccessToken, }); }, - }, + }), capabilities: { chatTypes: ["direct", "group"], reactions: false, @@ -90,18 +105,7 @@ export const linePlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveLineDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.line !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "LINE groups", - openScope: "any member in groups", - groupPolicyPath: "channels.line.groupPolicy", - groupAllowFromPath: "channels.line.groupAllowFrom", - mentionGated: false, - }); - }, + collectWarnings: collectLineSecurityWarnings, }, groups: { resolveRequireMention: resolveLineGroupRequireMention, @@ -128,11 +132,7 @@ export const linePlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async () => [], - listGroups: async () => [], - }, + directory: createEmptyChannelDirectoryAdapter(), setup: lineSetupAdapter, outbound: { deliveryMode: "direct", diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index aaf18e3f94b..2334476c224 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -3,9 +3,17 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildOpenGroupPolicyWarning, - collectAllowlistProviderGroupPolicyWarnings, + createAllowlistProviderOpenWarningCollector, + projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, + createTextPairingAdapter, + listResolvedDirectoryEntriesFromSources, +} from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { @@ -100,18 +108,31 @@ const resolveMatrixDmPolicy = createScopedDmSecurityResolver normalizeMatrixUserId(raw), }); +const collectMatrixSecurityWarnings = + createAllowlistProviderOpenWarningCollector({ + providerConfigPresent: (cfg) => (cfg as CoreConfig).channels?.matrix !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + buildOpenWarning: { + surface: "Matrix rooms", + openBehavior: "allows any room to trigger (mention-gated)", + remediation: + 'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms', + }, + }); + export const matrixPlugin: ChannelPlugin = { id: "matrix", meta, setupWizard: matrixSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "matrixUserId", - normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), - notifyApproval: async ({ id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^matrix:/i), + notify: async ({ id, message }) => { const { sendMessageMatrix } = await loadMatrixChannelRuntime(); - await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE); + await sendMessageMatrix(`user:${id}`, message); }, - }, + }), capabilities: { chatTypes: ["direct", "group", "thread"], polls: true, @@ -134,24 +155,13 @@ export const matrixPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveMatrixDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderGroupPolicyWarnings({ + collectWarnings: projectWarningCollector( + ({ account, cfg }: { account: ResolvedMatrixAccount; cfg: unknown }) => ({ + account, cfg: cfg as CoreConfig, - providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyWarning({ - surface: "Matrix rooms", - openBehavior: "allows any room to trigger (mention-gated)", - remediation: - 'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms', - }), - ] - : [], - }); - }, + }), + collectMatrixSecurityWarnings, + ), }, groups: { resolveRequireMention: resolveMatrixGroupRequireMention, @@ -187,101 +197,63 @@ export const matrixPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - - for (const entry of account.config.dm?.allowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw.replace(/^matrix:/i, "")); - } - - for (const entry of account.config.groupAllowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw.replace(/^matrix:/i, "")); - } - - const groups = account.config.groups ?? account.config.rooms ?? {}; - for (const room of Object.values(groups)) { - for (const entry of room.users ?? []) { - const raw = String(entry).trim(); + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => { + const entries = listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "user", + resolveAccount: (cfg, accountId) => + resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + account.config.dm?.allowFrom ?? [], + account.config.groupAllowFrom ?? [], + ...Object.values(account.config.groups ?? account.config.rooms ?? {}).map( + (room) => room.users ?? [], + ), + ], + normalizeId: (entry) => { + const raw = entry.replace(/^matrix:/i, "").trim(); if (!raw || raw === "*") { - continue; + return null; } - ids.add(raw.replace(/^matrix:/i, "")); - } - } - - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => { const lowered = raw.toLowerCase(); const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw; - if (cleaned.startsWith("@")) { - return `user:${cleaned}`; - } - return cleaned; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => { - const raw = id.startsWith("user:") ? id.slice("user:".length) : id; - const incomplete = !raw.startsWith("@") || !raw.includes(":"); - return { - kind: "user", - id, - ...(incomplete ? { name: "incomplete id; expected @user:server" } : {}), - }; - }); + return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned; + }, + }); + return entries.map((entry) => { + const raw = entry.id.startsWith("user:") ? entry.id.slice("user:".length) : entry.id; + const incomplete = !raw.startsWith("@") || !raw.includes(":"); + return incomplete ? { ...entry, name: "incomplete id; expected @user:server" } : entry; + }); }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() || ""; - const groups = account.config.groups ?? account.config.rooms ?? {}; - const ids = Object.keys(groups) - .map((raw) => raw.trim()) - .filter((raw) => Boolean(raw) && raw !== "*") - .map((raw) => raw.replace(/^matrix:/i, "")) - .map((raw) => { + listGroups: async (params) => + listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "group", + resolveAccount: (cfg, accountId) => + resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + Object.keys(account.config.groups ?? account.config.rooms ?? {}), + ], + normalizeId: (entry) => { + const raw = entry.replace(/^matrix:/i, "").trim(); + if (!raw || raw === "*") { + return null; + } const lowered = raw.toLowerCase(); if (lowered.startsWith("room:") || lowered.startsWith("channel:")) { return raw; } - if (raw.startsWith("!")) { - return `room:${raw}`; - } - return raw; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id }) as const); - return ids; - }, - listPeersLive: async ({ cfg, accountId, query, limit }) => - (await loadMatrixChannelRuntime()).listMatrixDirectoryPeersLive({ - cfg, - accountId, - query, - limit, + return raw.startsWith("!") ? `room:${raw}` : raw; + }, }), - listGroupsLive: async ({ cfg, accountId, query, limit }) => - (await loadMatrixChannelRuntime()).listMatrixDirectoryGroupsLive({ - cfg, - accountId, - query, - limit, - }), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: loadMatrixChannelRuntime, + listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listMatrixDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, inputs, kind, runtime }) => (await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }), @@ -293,27 +265,21 @@ export const matrixPlugin: ChannelPlugin = { chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async (params) => { - const outbound = (await loadMatrixChannelRuntime()).matrixOutbound; - if (!outbound.sendText) { - throw new Error("Matrix outbound text delivery is unavailable"); - } - return await outbound.sendText(params); - }, - sendMedia: async (params) => { - const outbound = (await loadMatrixChannelRuntime()).matrixOutbound; - if (!outbound.sendMedia) { - throw new Error("Matrix outbound media delivery is unavailable"); - } - return await outbound.sendMedia(params); - }, - sendPoll: async (params) => { - const outbound = (await loadMatrixChannelRuntime()).matrixOutbound; - if (!outbound.sendPoll) { - throw new Error("Matrix outbound poll delivery is unavailable"); - } - return await outbound.sendPoll(params); - }, + ...createRuntimeOutboundDelegates({ + getRuntime: loadMatrixChannelRuntime, + sendText: { + resolve: (runtime) => runtime.matrixOutbound.sendText, + unavailableMessage: "Matrix outbound text delivery is unavailable", + }, + sendMedia: { + resolve: (runtime) => runtime.matrixOutbound.sendMedia, + unavailableMessage: "Matrix outbound media delivery is unavailable", + }, + sendPoll: { + resolve: (runtime) => runtime.matrixOutbound.sendPoll, + unavailableMessage: "Matrix outbound poll delivery is unavailable", + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 8c32e068165..511d46b76e6 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -3,9 +3,13 @@ import { createScopedChannelConfigAdapter, 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 { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createLoggedPairingApprovalNotifier, + createMessageToolButtonsSchema, + type ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; @@ -42,6 +46,16 @@ import { resolveMattermostOutboundSessionRoute } from "./session-route.js"; import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; +const collectMattermostSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.mattermost !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + surface: "Mattermost channels", + openScope: "any member", + groupPolicyPath: "channels.mattermost.groupPolicy", + groupAllowFromPath: "channels.mattermost.groupAllowFrom", + }); + function describeMattermostMessageTool({ cfg, }: Parameters< @@ -279,9 +293,9 @@ export const mattermostPlugin: ChannelPlugin = { pairing: { idLabel: "mattermostUserId", normalizeAllowEntry: (entry) => normalizeAllowEntry(entry), - notifyApproval: async ({ id }) => { - console.log(`[mattermost] User ${id} approved for pairing`); - }, + notifyApproval: createLoggedPairingApprovalNotifier( + ({ id }) => `[mattermost] User ${id} approved for pairing`, + ), }, capabilities: { chatTypes: ["direct", "channel", "group", "thread"], @@ -319,28 +333,18 @@ export const mattermostPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveMattermostDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.mattermost !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "Mattermost channels", - openScope: "any member", - groupPolicyPath: "channels.mattermost.groupPolicy", - groupAllowFromPath: "channels.mattermost.groupAllowFrom", - }); - }, + collectWarnings: collectMattermostSecurityWarnings, }, groups: { resolveRequireMention: resolveMattermostGroupRequireMention, }, actions: mattermostMessageActions, - directory: { + directory: createChannelDirectoryAdapter({ listGroups: async (params) => listMattermostDirectoryGroups(params), listGroupsLive: async (params) => listMattermostDirectoryGroups(params), listPeers: async (params) => listMattermostDirectoryPeers(params), listPeersLive: async (params) => listMattermostDirectoryPeers(params), - }, + }), messaging: { normalizeTarget: normalizeMattermostMessagingTarget, resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params), diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index b1379e311df..9d59b042167 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,11 +1,22 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAllowlistProviderGroupPolicyWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createMessageToolCardSchema, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-runtime"; +import { listDirectoryEntriesFromSources } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "../runtime-api.js"; import { @@ -60,6 +71,19 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record = { "Files.Read.All": "files (OneDrive)", }; +const collectMSTeamsSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ + cfg: OpenClawConfig; +}>({ + providerConfigPresent: (cfg) => cfg.channels?.msteams !== undefined, + resolveGroupPolicy: ({ cfg }) => cfg.channels?.msteams?.groupPolicy, + collect: ({ groupPolicy }) => + groupPolicy === "open" + ? [ + '- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.', + ] + : [], +}); + const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), "msTeamsChannelRuntime", @@ -117,18 +141,19 @@ export const msteamsPlugin: ChannelPlugin = { aliases: [...meta.aliases], }, setupWizard: msteamsSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "msteamsUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(msteams|user):/i), + notify: async ({ cfg, id, message }) => { const { sendMessageMSTeams } = await loadMSTeamsChannelRuntime(); await sendMessageMSTeams({ cfg, to: id, - text: PAIRING_APPROVED_MESSAGE, + text: message, }); }, - }, + }), capabilities: { chatTypes: ["direct", "channel", "thread"], polls: true, @@ -163,17 +188,10 @@ export const msteamsPlugin: ChannelPlugin = { }), }, security: { - collectWarnings: ({ cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.msteams !== undefined, - configuredGroupPolicy: cfg.channels?.msteams?.groupPolicy, - surface: "MS Teams groups", - openScope: "any member", - groupPolicyPath: "channels.msteams.groupPolicy", - groupAllowFromPath: "channels.msteams.groupAllowFrom", - }); - }, + collectWarnings: projectWarningCollector( + ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg }), + collectMSTeamsSecurityWarnings, + ), }, setup: msteamsSetupAdapter, messaging: { @@ -198,66 +216,43 @@ export const msteamsPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, query, limit }) => { - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - for (const entry of cfg.channels?.msteams?.allowFrom ?? []) { - const trimmed = String(entry).trim(); - if (trimmed && trimmed !== "*") { - ids.add(trimmed); - } - } - for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) { - const trimmed = userId.trim(); - if (trimmed) { - ids.add(trimmed); - } - } - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw) - .map((raw) => { - const lowered = raw.toLowerCase(); - if (lowered.startsWith("user:")) { - return raw; + directory: createChannelDirectoryAdapter({ + listPeers: async ({ cfg, query, limit }) => + listDirectoryEntriesFromSources({ + kind: "user", + sources: [ + cfg.channels?.msteams?.allowFrom ?? [], + Object.keys(cfg.channels?.msteams?.dms ?? {}), + ], + query, + limit, + normalizeId: (raw) => { + const normalized = normalizeMSTeamsMessagingTarget(raw) ?? raw; + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("user:") || lowered.startsWith("conversation:")) { + return normalized; } - if (lowered.startsWith("conversation:")) { - return raw; - } - return `user:${raw}`; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "user", id }) as const); - }, - listGroups: async ({ cfg, query, limit }) => { - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) { - for (const channelId of Object.keys(team.channels ?? {})) { - const trimmed = channelId.trim(); - if (trimmed && trimmed !== "*") { - ids.add(trimmed); - } - } - } - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => raw.replace(/^conversation:/i, "").trim()) - .map((id) => `conversation:${id}`) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id }) as const); - }, - listPeersLive: async ({ cfg, query, limit }) => - (await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryPeersLive({ cfg, query, limit }), - listGroupsLive: async ({ cfg, query, limit }) => - (await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryGroupsLive({ cfg, query, limit }), - }, + return `user:${normalized}`; + }, + }), + listGroups: async ({ cfg, query, limit }) => + listDirectoryEntriesFromSources({ + kind: "group", + sources: [ + Object.values(cfg.channels?.msteams?.teams ?? {}).flatMap((team) => + Object.keys(team.channels ?? {}), + ), + ], + query, + limit, + normalizeId: (raw) => `conversation:${raw.replace(/^conversation:/i, "").trim()}`, + }), + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: loadMSTeamsChannelRuntime, + listPeersLive: (runtime) => runtime.listMSTeamsDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listMSTeamsDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, inputs, kind, runtime }) => { const results = inputs.map((input) => ({ @@ -436,12 +431,12 @@ export const msteamsPlugin: ChannelPlugin = { chunkerMode: "markdown", textChunkLimit: 4000, pollMaxOptions: 12, - sendText: async (params) => - (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendText!(params), - sendMedia: async (params) => - (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendMedia!(params), - sendPoll: async (params) => - (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendPoll!(params), + ...createRuntimeOutboundDelegates({ + getRuntime: loadMSTeamsChannelRuntime, + sendText: { resolve: (runtime) => runtime.msteamsOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.msteamsOutbound.sendMedia }, + sendPoll: { resolve: (runtime) => runtime.msteamsOutbound.sendPoll }, + }), }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index ce2f281a3e6..5416a71f9af 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -4,10 +4,11 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, -} from "openclaw/plugin-sdk/channel-policy"; + createLoggedPairingApprovalNotifier, + createPairingPrefixStripper, +} from "openclaw/plugin-sdk/channel-runtime"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { buildBaseChannelStatusSummary, @@ -76,17 +77,40 @@ const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), }); +const collectNextcloudTalkSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => + (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.rooms) && Object.keys(account.config.rooms ?? {}).length > 0, + restrictSenders: { + surface: "Nextcloud Talk rooms", + openScope: "any member in allowed rooms", + groupPolicyPath: "channels.nextcloud-talk.groupPolicy", + groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Nextcloud Talk rooms", + routeAllowlistPath: "channels.nextcloud-talk.rooms", + routeScope: "room", + groupPolicyPath: "channels.nextcloud-talk.groupPolicy", + groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", + }, + }); + export const nextcloudTalkPlugin: ChannelPlugin = { id: "nextcloud-talk", meta, setupWizard: nextcloudTalkSetupWizard, pairing: { idLabel: "nextcloudUserId", - normalizeAllowEntry: (entry) => - entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), - notifyApproval: async ({ id }) => { - console.log(`[nextcloud-talk] User ${id} approved for pairing`); - }, + normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) => + entry.toLowerCase(), + ), + notifyApproval: createLoggedPairingApprovalNotifier( + ({ id }) => `[nextcloud-talk] User ${id} approved for pairing`, + ), }, capabilities: { chatTypes: ["direct", "group"], @@ -112,34 +136,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = }, security: { resolveDmPolicy: resolveNextcloudTalkDmPolicy, - collectWarnings: ({ account, cfg }) => { - const roomAllowlistConfigured = - account.config.rooms && Object.keys(account.config.rooms).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: - (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: Boolean(roomAllowlistConfigured), - restrictSenders: { - surface: "Nextcloud Talk rooms", - openScope: "any member in allowed rooms", - groupPolicyPath: "channels.nextcloud-talk.groupPolicy", - groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "Nextcloud Talk rooms", - routeAllowlistPath: "channels.nextcloud-talk.rooms", - routeScope: "room", - groupPolicyPath: "channels.nextcloud-talk.groupPolicy", - groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", - }, - }), - }); - }, + collectWarnings: collectNextcloudTalkSecurityWarnings, }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 1879c85a7b0..e5f8f392202 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,5 +1,9 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; @@ -268,35 +272,25 @@ export const signalPlugin: ChannelPlugin = { setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "signalNumber", - normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), - notifyApproval: async ({ id }) => { - await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^signal:/i), + notify: async ({ id, message }) => { + await getSignalRuntime().channel.signal.sendMessageSignal(id, message); }, - }, + }), actions: signalMessageActions, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => { - const account = resolveSignalAccount({ cfg, accountId }); - return { - dmAllowFrom: (account.config.allowFrom ?? []).map(String), - groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), - dmPolicy: account.config.dmPolicy, - groupPolicy: account.config.groupPolicy, - }; - }, - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "signal", - normalize: ({ cfg, accountId, values }) => - signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "signal", + resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), + normalize: ({ cfg, accountId, values }) => + signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + }), security: { resolveDmPolicy: signalResolveDmPolicy, collectWarnings: collectSignalSecurityWarnings, diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 1622dc207e4..c1c0e8055dc 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -1,8 +1,8 @@ import { - collectAllowlistProviderRestrictSendersWarnings, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { listSignalAccountIds, @@ -53,21 +53,16 @@ export const signalResolveDmPolicy = createScopedDmSecurityResolver normalizeE164(raw.replace(/^signal:/i, "").trim()), }); -export function collectSignalSecurityWarnings(params: { - account: ResolvedSignalAccount; - cfg: Parameters[0]["cfg"]; -}) { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg: params.cfg, - providerConfigPresent: params.cfg.channels?.signal !== undefined, - configuredGroupPolicy: params.account.config.groupPolicy, +export const collectSignalSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.signal !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, surface: "Signal groups", openScope: "any member", groupPolicyPath: "channels.signal.groupPolicy", groupAllowFromPath: "channels.signal.groupAllowFrom", mentionGated: false, }); -} export function createSignalPluginBase(params: { setupWizard?: NonNullable["setupWizard"]>; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index cbb86a1dff1..dca51eb1fc7 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,13 +1,18 @@ import { - buildAccountScopedAllowlistConfigEditor, - resolveLegacyDmAllowlistConfigPaths, + buildLegacyDmAccountAllowlistAdapter, + createAccountScopedAllowlistNameResolver, + createFlatAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - createScopedDmSecurityResolver, - collectOpenGroupPolicyConfiguredRouteWarnings, - collectOpenProviderGroupPolicyWarnings, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createTextPairingAdapter, + resolveOutboundSendDep, + resolveTargetsWithOptionalToken, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -286,41 +291,49 @@ function formatSlackScopeDiagnostic(params: { } as const; } -function readSlackAllowlistConfig(account: ResolvedSlackAccount) { - return { - dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), - groupPolicy: account.groupPolicy, - groupOverrides: Object.entries(account.channels ?? {}) - .map(([key, value]) => { - const entries = (value?.users ?? []).map(String).filter(Boolean); - return entries.length > 0 ? { label: key, entries } : null; - }) - .filter(Boolean) as Array<{ label: string; entries: string[] }>, - }; -} +const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver({ + resolveRecord: (account: ResolvedSlackAccount) => account.channels, + label: (key) => key, + resolveEntries: (value) => value?.users, +}); -async function resolveSlackAllowlistNames(params: { - cfg: Parameters[0]["cfg"]; - accountId?: string | null; - entries: string[]; -}) { - const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const token = account.config.userToken?.trim() || account.botToken?.trim(); - if (!token) { - return []; - } - return await resolveSlackUserAllowlist({ token, entries: params.entries }); -} +const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), + resolveToken: (account: ResolvedSlackAccount) => + account.config.userToken?.trim() || account.botToken?.trim(), + resolveNames: ({ token, entries }) => resolveSlackUserAllowlist({ token, entries }), +}); + +const collectSlackSecurityWarnings = + createOpenProviderConfiguredRouteWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Slack channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.slack.groupPolicy", + routeAllowlistPath: "channels.slack.channels", + }, + missingRouteAllowlist: { + surface: "Slack channels", + openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels', + }, + }); export const slackPlugin: ChannelPlugin = { ...createSlackPluginBase({ setupWizard: slackSetupWizard, setup: slackSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "slackUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), - notifyApproval: async ({ id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(slack|user):/i), + notify: async ({ id, message }) => { const cfg = getSlackRuntime().config.loadConfig(); const account = resolveSlackAccount({ cfg, @@ -330,63 +343,29 @@ export const slackPlugin: ChannelPlugin = { const botToken = account.botToken?.trim(); const tokenOverride = token && token !== botToken ? token : undefined; if (tokenOverride) { - await getSlackRuntime().channel.slack.sendMessageSlack( - `user:${id}`, - PAIRING_APPROVED_MESSAGE, - { - token: tokenOverride, - }, - ); + await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, message, { + token: tokenOverride, + }); } else { - await getSlackRuntime().channel.slack.sendMessageSlack( - `user:${id}`, - PAIRING_APPROVED_MESSAGE, - ); + await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, message); } }, - }, + }), allowlist: { - supportsScope: ({ scope }) => scope === "dm", - readConfig: ({ cfg, accountId }) => - readSlackAllowlistConfig(resolveSlackAccount({ cfg, accountId })), - resolveNames: async ({ cfg, accountId, entries }) => - await resolveSlackAllowlistNames({ cfg, accountId, entries }), - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + ...buildLegacyDmAccountAllowlistAdapter({ channelId: "slack", + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), normalize: ({ cfg, accountId, values }) => slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: resolveLegacyDmAllowlistConfigPaths, + resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: resolveSlackAllowlistGroupOverrides, }), + resolveNames: resolveSlackAllowlistNames, }, security: { resolveDmPolicy: resolveSlackDmPolicy, - collectWarnings: ({ account, cfg }) => { - const channelAllowlistConfigured = - Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; - - return collectOpenProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.slack !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyConfiguredRouteWarnings({ - groupPolicy, - routeAllowlistConfigured: channelAllowlistConfigured, - configureRouteAllowlist: { - surface: "Slack channels", - openScope: "any channel not explicitly denied", - groupPolicyPath: "channels.slack.groupPolicy", - routeAllowlistPath: "channels.slack.channels", - }, - missingRouteAllowlist: { - surface: "Slack channels", - openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)", - remediation: - 'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels', - }, - }), - }); - }, + collectWarnings: collectSlackSecurityWarnings, }, groups: { resolveRequireMention: resolveSlackGroupRequireMention, @@ -435,14 +414,15 @@ export const slackPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async (params) => listSlackDirectoryPeersFromConfig(params), listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => getSlackRuntime().channel.slack.listDirectoryPeersLive(params), - listGroupsLive: async (params) => - getSlackRuntime().channel.slack.listDirectoryGroupsLive(params), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: () => getSlackRuntime().channel.slack, + listPeersLive: (runtime) => runtime.listDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { const toResolvedTarget = < @@ -458,28 +438,30 @@ export const slackPlugin: ChannelPlugin = { note, }); const account = resolveSlackAccount({ cfg, accountId }); - const token = account.config.userToken?.trim() || account.botToken?.trim(); - if (!token) { - return inputs.map((input) => ({ - input, - resolved: false, - note: "missing Slack token", - })); - } if (kind === "group") { - const resolved = await getSlackRuntime().channel.slack.resolveChannelAllowlist({ - token, - entries: inputs, + return resolveTargetsWithOptionalToken({ + token: account.config.userToken?.trim() || account.botToken?.trim(), + inputs, + missingTokenNote: "missing Slack token", + resolveWithToken: ({ token, inputs }) => + getSlackRuntime().channel.slack.resolveChannelAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => toResolvedTarget(entry, entry.archived ? "archived" : undefined), }); - return resolved.map((entry) => - toResolvedTarget(entry, entry.archived ? "archived" : undefined), - ); } - const resolved = await getSlackRuntime().channel.slack.resolveUserAllowlist({ - token, - entries: inputs, + return resolveTargetsWithOptionalToken({ + token: account.config.userToken?.trim() || account.botToken?.trim(), + inputs, + missingTokenNote: "missing Slack token", + resolveWithToken: ({ token, inputs }) => + getSlackRuntime().channel.slack.resolveUserAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => toResolvedTarget(entry, entry.note), }); - return resolved.map((entry) => toResolvedTarget(entry, entry.note)); }, }, actions: createSlackActions(SLACK_CHANNEL, { diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index ec125727454..9cc8330820e 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,28 +1,23 @@ import { - applyDirectoryQueryAndLimit, - collectNormalizedDirectoryIds, - listDirectoryGroupEntriesFromMapKeys, - toDirectoryEntries, + listInspectedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { inspectSlackAccount, type InspectedSlackAccount } from "../api.js"; import { parseSlackTarget } from "./targets.js"; export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account: InspectedSlackAccount = inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? []; - const channelUsers = Object.values(account.config.channels ?? {}).flatMap( - (channel) => channel.users ?? [], - ); - const ids = collectNormalizedDirectoryIds({ - sources: [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers], + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "user", + inspectAccount: (cfg, accountId) => + inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null, + resolveSources: (account) => { + const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? []; + const channelUsers = Object.values(account.config.channels ?? {}).flatMap( + (channel) => channel.users ?? [], + ); + return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers]; + }, normalizeId: (raw) => { const mention = raw.match(/^<@([A-Z0-9]+)>$/i); const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim(); @@ -34,21 +29,15 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null; }, }); - return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account: InspectedSlackAccount = inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.config.channels, - query: params.query, - limit: params.limit, + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "group", + inspectAccount: (cfg, accountId) => + inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null, + resolveSources: (account) => [Object.keys(account.config.channels ?? {})], normalizeId: (raw) => { const normalized = parseSlackTarget(raw, { defaultKind: "channel" }); return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null; diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 3c453d0613a..4d9ed53a14e 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -97,8 +97,11 @@ describe("createSynologyChatPlugin", () => { it("has notifyApproval and normalizeAllowEntry", () => { const plugin = createSynologyChatPlugin(); expect(plugin.pairing.idLabel).toBe("synologyChatUserId"); - expect(typeof plugin.pairing.normalizeAllowEntry).toBe("function"); - expect(plugin.pairing.normalizeAllowEntry(" USER1 ")).toBe("user1"); + const normalize = plugin.pairing.normalizeAllowEntry; + expect(typeof normalize).toBe("function"); + if (normalize) { + expect(normalize(" USER1 ")).toBe("user1"); + } expect(typeof plugin.pairing.notifyApproval).toBe("function"); }); }); @@ -160,9 +163,10 @@ describe("createSynologyChatPlugin", () => { describe("directory", () => { it("returns empty stubs", async () => { const plugin = createSynologyChatPlugin(); - expect(await plugin.directory.self()).toBeNull(); - expect(await plugin.directory.listPeers()).toEqual([]); - expect(await plugin.directory.listGroups()).toEqual([]); + const params = { cfg: {}, runtime: {} as never }; + expect(await plugin.directory.self?.(params)).toBeNull(); + expect(await plugin.directory.listPeers?.(params)).toEqual([]); + expect(await plugin.directory.listGroups?.(params)).toEqual([]); }); }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 496b5563857..1b53185cb0f 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -8,6 +8,14 @@ import { createHybridChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createConditionalWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createEmptyChannelDirectoryAdapter, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { z } from "zod"; import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js"; import { listAccountIds, resolveAccount } from "./accounts.js"; @@ -53,6 +61,26 @@ const synologyChatConfigAdapter = createHybridChannelConfigAdapter String(entry).trim().toLowerCase()).filter(Boolean), }); +const collectSynologyChatSecurityWarnings = + createConditionalWarningCollector( + (account) => + !account.token && + "- Synology Chat: token is not configured. The webhook will reject all requests.", + (account) => + !account.incomingUrl && + "- Synology Chat: incomingUrl is not configured. The bot cannot send replies.", + (account) => + account.allowInsecureSsl && + "- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.", + (account) => + account.dmPolicy === "open" && + '- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.', + (account) => + account.dmPolicy === "allowlist" && + account.allowedUserIds.length === 0 && + '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', + ); + function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise { return new Promise((resolve) => { const complete = () => { @@ -106,52 +134,23 @@ export function createSynologyChatPlugin() { ...synologyChatConfigAdapter, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "synologyChatUserId", + message: "OpenClaw: your access has been approved.", normalizeAllowEntry: (entry: string) => entry.toLowerCase().trim(), - notifyApproval: async ({ cfg, id }: { cfg: any; id: string }) => { + notify: async ({ cfg, id, message }) => { const account = resolveAccount(cfg); if (!account.incomingUrl) return; - await sendMessage( - account.incomingUrl, - "OpenClaw: your access has been approved.", - id, - account.allowInsecureSsl, - ); + await sendMessage(account.incomingUrl, message, id, account.allowInsecureSsl); }, - }, + }), security: { resolveDmPolicy: resolveSynologyChatDmPolicy, - collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => { - const warnings: string[] = []; - if (!account.token) { - warnings.push( - "- Synology Chat: token is not configured. The webhook will reject all requests.", - ); - } - if (!account.incomingUrl) { - warnings.push( - "- Synology Chat: incomingUrl is not configured. The bot cannot send replies.", - ); - } - if (account.allowInsecureSsl) { - warnings.push( - "- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.", - ); - } - if (account.dmPolicy === "open") { - warnings.push( - '- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.', - ); - } - if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { - warnings.push( - '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', - ); - } - return warnings; - }, + collectWarnings: projectWarningCollector( + ({ account }: { account: ResolvedSynologyChatAccount }) => account, + collectSynologyChatSecurityWarnings, + ), }, messaging: { @@ -172,11 +171,7 @@ export function createSynologyChatPlugin() { }, }, - directory: { - self: async () => null, - listPeers: async () => [], - listGroups: async () => [], - }, + directory: createEmptyChannelDirectoryAdapter(), outbound: { deliveryMode: "gateway" as const, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 073ca5bd03a..d37b65fc447 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,11 +1,17 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, - createScopedDmSecurityResolver, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { type OutboundSendDeps, resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; + buildDmGroupAccountAllowlistAdapter, + createNestedAllowlistOverrideResolver, +} from "openclaw/plugin-sdk/allowlist-config-edit"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createTextPairingAdapter, + normalizeMessageChannel, + type OutboundSendDeps, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; @@ -273,65 +279,66 @@ const resolveTelegramDmPolicy = createScopedDmSecurityResolver raw.replace(/^(telegram|tg):/i, ""), }); -function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { - const groupOverrides: Array<{ label: string; entries: string[] }> = []; - for (const [groupId, groupCfg] of Object.entries(account.config.groups ?? {})) { - const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: groupId, entries }); - } - for (const [topicId, topicCfg] of Object.entries(groupCfg?.topics ?? {})) { - const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (topicEntries.length > 0) { - groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries }); - } - } - } - return { - dmAllowFrom: (account.config.allowFrom ?? []).map(String), - groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), - dmPolicy: account.config.dmPolicy, - groupPolicy: account.config.groupPolicy, - groupOverrides, - }; -} +const resolveTelegramAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({ + resolveRecord: (account: ResolvedTelegramAccount) => account.config.groups, + outerLabel: (groupId) => groupId, + resolveOuterEntries: (groupCfg) => groupCfg?.allowFrom, + resolveChildren: (groupCfg) => groupCfg?.topics, + innerLabel: (groupId, topicId) => `${groupId} topic ${topicId}`, + resolveInnerEntries: (topicCfg) => topicCfg?.allowFrom, +}); + +const collectTelegramSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.telegram !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.groups) && Object.keys(account.config.groups ?? {}).length > 0, + restrictSenders: { + surface: "Telegram groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.telegram.groupPolicy", + groupAllowFromPath: "channels.telegram.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Telegram groups", + routeAllowlistPath: "channels.telegram.groups", + routeScope: "group", + groupPolicyPath: "channels.telegram.groupPolicy", + groupAllowFromPath: "channels.telegram.groupAllowFrom", + }, + }); export const telegramPlugin: ChannelPlugin = { ...createTelegramPluginBase({ setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "telegramUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(telegram|tg):/i), + notify: async ({ cfg, id, message }) => { const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg); if (!token) { throw new Error("telegram token not configured"); } - await getTelegramRuntime().channel.telegram.sendMessageTelegram( - id, - PAIRING_APPROVED_MESSAGE, - { - token, - }, - ); + await getTelegramRuntime().channel.telegram.sendMessageTelegram(id, message, { + token, + }); }, - }, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => - readTelegramAllowlistConfig(resolveTelegramAccount({ cfg, accountId })), - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "telegram", - normalize: ({ cfg, accountId, values }) => - telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + }), + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "telegram", + resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), + normalize: ({ cfg, accountId, values }) => + telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveGroupOverrides: resolveTelegramAllowlistGroupOverrides, + }), bindings: { compileConfiguredBinding: ({ conversationId }) => normalizeTelegramAcpConversationId(conversationId), @@ -344,33 +351,7 @@ export const telegramPlugin: ChannelPlugin { - const groupAllowlistConfigured = - account.config.groups && Object.keys(account.config.groups).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.telegram !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: Boolean(groupAllowlistConfigured), - restrictSenders: { - surface: "Telegram groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.telegram.groupPolicy", - groupAllowFromPath: "channels.telegram.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "Telegram groups", - routeAllowlistPath: "channels.telegram.groups", - routeScope: "group", - groupPolicyPath: "channels.telegram.groupPolicy", - groupAllowFromPath: "channels.telegram.groupAllowFrom", - }, - }), - }); - }, + collectWarnings: collectTelegramSecurityWarnings, }, groups: { resolveRequireMention: resolveTelegramGroupRequireMention, @@ -471,11 +452,10 @@ export const telegramPlugin: ChannelPlugin {}); }, }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params), listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params), - }, + }), actions: telegramMessageActions, setup: telegramSetupAdapter, outbound: { diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index af515a29379..6cb51ab686e 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -1,24 +1,20 @@ import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { - applyDirectoryQueryAndLimit, - collectNormalizedDirectoryIds, - listDirectoryGroupEntriesFromMapKeys, - toDirectoryEntries, + listInspectedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { inspectTelegramAccount, type InspectedTelegramAccount } from "../api.js"; export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account: InspectedTelegramAccount = inspectTelegramAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const ids = collectNormalizedDirectoryIds({ - sources: [mapAllowFromEntries(account.config.allowFrom), Object.keys(account.config.dms ?? {})], + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "user", + inspectAccount: (cfg, accountId) => + inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveSources: (account) => [ + mapAllowFromEntries(account.config.allowFrom), + Object.keys(account.config.dms ?? {}), + ], normalizeId: (entry) => { const trimmed = entry.replace(/^(telegram|tg):/i, "").trim(); if (!trimmed) { @@ -30,20 +26,15 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; }, }); - return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account: InspectedTelegramAccount = inspectTelegramAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.config.groups, - query: params.query, - limit: params.limit, + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "group", + inspectAccount: (cfg, accountId) => + inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveSources: (account) => [Object.keys(account.config.groups ?? {})], + normalizeId: (entry) => entry.trim() || null, }); } diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 865ead9ab46..89e4a235b60 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,5 +1,9 @@ import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/channel-runtime"; +import { + createRuntimeOutboundDelegates, + type ChannelAccountSnapshot, + type ChannelPlugin, +} from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { tlonChannelConfigSchema } from "./config-schema.js"; @@ -107,14 +111,11 @@ export const tlonPlugin: ChannelPlugin = { deliveryMode: "direct", textChunkLimit: 10000, resolveTarget: ({ to }) => resolveTlonOutboundTarget(to), - sendText: async (params) => - await ( - await loadTlonChannelRuntime() - ).tlonRuntimeOutbound.sendText!(params), - sendMedia: async (params) => - await ( - await loadTlonChannelRuntime() - ).tlonRuntimeOutbound.sendMedia!(params), + ...createRuntimeOutboundDelegates({ + getRuntime: loadTlonChannelRuntime, + sendText: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendMedia }, + }), }, status: { defaultRuntime: { diff --git a/extensions/whatsapp/src/channel.directory.test.ts b/extensions/whatsapp/src/channel.directory.test.ts new file mode 100644 index 00000000000..3fd58b31d4d --- /dev/null +++ b/extensions/whatsapp/src/channel.directory.test.ts @@ -0,0 +1,62 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/whatsapp"; +import { describe, expect, it } from "vitest"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.ts"; +import { whatsappPlugin } from "./channel.js"; + +describe("whatsapp directory", () => { + const runtimeEnv = createDirectoryTestRuntime() as never; + + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + whatsapp: { + authDir: "/tmp/wa-auth", + allowFrom: [ + "whatsapp:+15551230001", + "15551230002@s.whatsapp.net", + "120363999999999999@g.us", + ], + groups: { + "120363111111111111@g.us": {}, + "120363222222222222@g.us": {}, + }, + }, + }, + } as unknown as OpenClawConfig; + + const directory = expectDirectorySurface(whatsappPlugin.directory); + + await expect( + directory.listPeers({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "+15551230001" }, + { kind: "user", id: "+15551230002" }, + ]), + ); + + await expect( + directory.listGroups({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "120363111111111111@g.us" }, + { kind: "group", id: "120363222222222222@g.us" }, + ]), + ); + }); +}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 04780f81eda..151cfc60b40 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,4 +1,4 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; import { @@ -67,26 +67,15 @@ export const whatsappPlugin: ChannelPlugin = { pairing: { idLabel: "whatsappSenderId", }, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => { - const account = resolveWhatsAppAccount({ cfg, accountId }); - return { - dmAllowFrom: (account.allowFrom ?? []).map(String), - groupAllowFrom: (account.groupAllowFrom ?? []).map(String), - dmPolicy: account.dmPolicy, - groupPolicy: account.groupPolicy, - }; - }, - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "whatsapp", - normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "whatsapp", + resolveAccount: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }), + normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values), + resolveDmAllowFrom: (account) => account.allowFrom, + resolveGroupAllowFrom: (account) => account.groupAllowFrom, + resolveDmPolicy: (account) => account.dmPolicy, + resolveGroupPolicy: (account) => account.groupPolicy, + }), mentions: { stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), }, diff --git a/extensions/whatsapp/src/directory-config.ts b/extensions/whatsapp/src/directory-config.ts index 1a5fbbff9b0..1915b6fd4da 100644 --- a/extensions/whatsapp/src/directory-config.ts +++ b/extensions/whatsapp/src/directory-config.ts @@ -1,17 +1,16 @@ import { - listDirectoryGroupEntriesFromMapKeys, - listDirectoryUserEntriesFromAllowFrom, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { resolveWhatsAppAccount } from "./accounts.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); - return listDirectoryUserEntriesFromAllowFrom({ - allowFrom: account.allowFrom, - query: params.query, - limit: params.limit, + return listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.allowFrom, normalizeId: (entry) => { const normalized = normalizeWhatsAppTarget(entry); if (!normalized || isWhatsAppGroupJid(normalized)) { @@ -23,10 +22,9 @@ export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConf } export async function listWhatsAppDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.groups, - query: params.query, - limit: params.limit, + return listResolvedDirectoryGroupEntriesFromMapKeys({ + ...params, + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + resolveGroups: (account) => account.groups, }); } diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index b9b86161b3d..5fa27f42030 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -1,9 +1,8 @@ import { - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup"; import { @@ -107,7 +106,27 @@ export function createWhatsAppPluginBase(params: { | "setup" | "groups" > { - return { + const collectWhatsAppSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }); + return createChannelPluginBase({ id: WHATSAPP_CHANNEL, meta: { ...getChatChannelMeta(WHATSAPP_CHANNEL), @@ -144,35 +163,9 @@ export function createWhatsAppPluginBase(params: { }, security: { resolveDmPolicy: whatsappResolveDmPolicy, - collectWarnings: ({ account, cfg }) => { - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.whatsapp !== undefined, - configuredGroupPolicy: account.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - restrictSenders: { - surface: "WhatsApp groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "WhatsApp groups", - routeAllowlistPath: "channels.whatsapp.groups", - routeScope: "group", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - }), - }); - }, + collectWarnings: collectWhatsAppSecurityWarnings, }, setup: params.setup, groups: params.groups, - }; + }); } diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 5434b3e144e..8bd6be02612 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -6,8 +6,10 @@ import { import { buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, - collectOpenProviderGroupPolicyWarnings, + createOpenProviderGroupPolicyWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { listZaloAccountIds, @@ -78,6 +80,41 @@ const resolveZaloDmPolicy = createScopedDmSecurityResolver( normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), }); +const collectZaloSecurityWarnings = createOpenProviderGroupPolicyWarningCollector<{ + cfg: OpenClawConfig; + account: ResolvedZaloAccount; +}>({ + providerConfigPresent: (cfg) => cfg.channels?.zalo !== undefined, + resolveGroupPolicy: ({ account }) => account.config.groupPolicy, + collect: ({ account, groupPolicy }) => { + if (groupPolicy !== "open") { + return []; + } + const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom); + const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom); + const effectiveAllowFrom = + explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; + if (effectiveAllowFrom.length > 0) { + return [ + buildOpenGroupPolicyRestrictSendersWarning({ + surface: "Zalo groups", + openScope: "any member", + groupPolicyPath: "channels.zalo.groupPolicy", + groupAllowFromPath: "channels.zalo.groupAllowFrom", + }), + ]; + } + return [ + buildOpenGroupPolicyWarning({ + surface: "Zalo groups", + openBehavior: + "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)", + remediation: 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom', + }), + ]; + }, +}); + export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, @@ -107,41 +144,7 @@ export const zaloPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveZaloDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectOpenProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.zalo !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => { - if (groupPolicy !== "open") { - return []; - } - const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom); - const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom); - const effectiveAllowFrom = - explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; - if (effectiveAllowFrom.length > 0) { - return [ - buildOpenGroupPolicyRestrictSendersWarning({ - surface: "Zalo groups", - openScope: "any member", - groupPolicyPath: "channels.zalo.groupPolicy", - groupAllowFromPath: "channels.zalo.groupAllowFrom", - }), - ]; - } - return [ - buildOpenGroupPolicyWarning({ - surface: "Zalo groups", - openBehavior: - "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)", - remediation: - 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom', - }), - ]; - }, - }); - }, + collectWarnings: collectZaloSecurityWarnings, }, groups: { resolveRequireMention: () => true, @@ -158,19 +161,16 @@ export const zaloPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveZaloAccount({ cfg: cfg, accountId }); - return listDirectoryUserEntriesFromAllowFrom({ - allowFrom: account.config.allowFrom, - query, - limit, + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.config.allowFrom, normalizeId: (entry) => entry.replace(/^(zalo|zl):/i, ""), - }); - }, + }), listGroups: async () => [], - }, + }), pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index c1c90affe9c..629125fb120 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,5 +1,9 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import type { ChannelAccountSnapshot, @@ -431,20 +435,21 @@ export const zalouserPlugin: ChannelPlugin = { return results; }, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "zalouserUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: "Your pairing request has been approved.", + normalizeAllowEntry: createPairingPrefixStripper(/^(zalouser|zlu):/i), + notify: async ({ cfg, id, message }) => { const account = resolveZalouserAccountSync({ cfg: cfg }); const authenticated = await checkZcaAuthenticated(account.profile); if (!authenticated) { throw new Error("Zalouser not authenticated"); } - await sendMessageZalouser(id, "Your pairing request has been approved.", { + await sendMessageZalouser(id, message, { profile: account.profile, }); }, - }, + }), auth: { login: async ({ cfg, accountId, runtime }) => { const account = resolveZalouserAccountSync({ diff --git a/src/channels/plugins/directory-adapters.test.ts b/src/channels/plugins/directory-adapters.test.ts new file mode 100644 index 00000000000..8d9a6bfea6b --- /dev/null +++ b/src/channels/plugins/directory-adapters.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + createChannelDirectoryAdapter, + createEmptyChannelDirectoryAdapter, + emptyChannelDirectoryList, + nullChannelDirectorySelf, +} from "./directory-adapters.js"; + +describe("directory adapters", () => { + it("defaults self to null", async () => { + const adapter = createChannelDirectoryAdapter(); + await expect(adapter.self?.({ cfg: {}, runtime: {} as never })).resolves.toBeNull(); + }); + + it("preserves provided resolvers", async () => { + const adapter = createChannelDirectoryAdapter({ + listPeers: async () => [{ kind: "user", id: "u-1" }], + }); + await expect(adapter.listPeers?.({ cfg: {}, runtime: {} as never })).resolves.toEqual([ + { kind: "user", id: "u-1" }, + ]); + }); + + it("builds empty directory adapters", async () => { + const adapter = createEmptyChannelDirectoryAdapter(); + await expect(adapter.self?.({ cfg: {}, runtime: {} as never })).resolves.toBeNull(); + await expect(adapter.listPeers?.({ cfg: {}, runtime: {} as never })).resolves.toEqual([]); + await expect(adapter.listGroups?.({ cfg: {}, runtime: {} as never })).resolves.toEqual([]); + }); + + it("exports standalone null/empty helpers", async () => { + await expect(nullChannelDirectorySelf({ cfg: {}, runtime: {} as never })).resolves.toBeNull(); + await expect(emptyChannelDirectoryList({ cfg: {}, runtime: {} as never })).resolves.toEqual([]); + }); +}); diff --git a/src/channels/plugins/directory-adapters.ts b/src/channels/plugins/directory-adapters.ts new file mode 100644 index 00000000000..5462f977d0b --- /dev/null +++ b/src/channels/plugins/directory-adapters.ts @@ -0,0 +1,28 @@ +import type { ChannelDirectoryAdapter } from "./types.adapters.js"; + +export const nullChannelDirectorySelf: NonNullable = async () => + null; + +export const emptyChannelDirectoryList: NonNullable< + ChannelDirectoryAdapter["listPeers"] +> = async () => []; + +/** Build a channel directory adapter with a null self resolver by default. */ +export function createChannelDirectoryAdapter( + params: Omit & { + self?: ChannelDirectoryAdapter["self"]; + } = {}, +): ChannelDirectoryAdapter { + return { + self: params.self ?? nullChannelDirectorySelf, + ...params, + }; +} + +/** Build the common empty directory surface for channels without directory support. */ +export function createEmptyChannelDirectoryAdapter(): ChannelDirectoryAdapter { + return createChannelDirectoryAdapter({ + listPeers: emptyChannelDirectoryList, + listGroups: emptyChannelDirectoryList, + }); +} diff --git a/src/channels/plugins/directory-config-helpers.test.ts b/src/channels/plugins/directory-config-helpers.test.ts index 15aa8f0d298..5fadc922328 100644 --- a/src/channels/plugins/directory-config-helpers.test.ts +++ b/src/channels/plugins/directory-config-helpers.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { + listDirectoryEntriesFromSources, + listInspectedDirectoryEntriesFromSources, listDirectoryGroupEntriesFromMapKeysAndAllowFrom, listDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryEntriesFromSources, + listResolvedDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFromAndMapKeys, listDirectoryUserEntriesFromAllowFrom, } from "./directory-config-helpers.js"; @@ -78,3 +83,95 @@ describe("listDirectoryGroupEntriesFromMapKeysAndAllowFrom", () => { ]); }); }); + +describe("listDirectoryEntriesFromSources", () => { + it("merges source iterables with dedupe/query/limit", () => { + const entries = listDirectoryEntriesFromSources({ + kind: "user", + sources: [ + ["user:alice", "user:bob"], + ["user:carla", "user:alice"], + ], + normalizeId: (entry) => entry.replace(/^user:/i, ""), + query: "a", + limit: 2, + }); + + expectUserDirectoryEntries(entries); + }); +}); + +describe("listInspectedDirectoryEntriesFromSources", () => { + it("returns empty when the inspected account is missing", () => { + const entries = listInspectedDirectoryEntriesFromSources({ + cfg: {} as never, + kind: "user", + inspectAccount: () => null, + resolveSources: () => [["user:alice"]], + normalizeId: (entry) => entry.replace(/^user:/i, ""), + }); + + expect(entries).toEqual([]); + }); + + it("lists entries from inspected account sources", () => { + const entries = listInspectedDirectoryEntriesFromSources({ + cfg: {} as never, + kind: "group", + inspectAccount: () => ({ ids: [["room:a"], ["room:b", "room:a"]] }), + resolveSources: (account) => account.ids, + normalizeId: (entry) => entry.replace(/^room:/i, ""), + query: "a", + }); + + expect(entries).toEqual([{ kind: "group", id: "a" }]); + }); +}); + +describe("resolved account directory helpers", () => { + const cfg = {} as never; + const resolveAccount = () => ({ + allowFrom: ["user:alice", "user:bob"], + groups: { "room:a": {}, "room:b": {} }, + }); + + it("lists user entries from resolved account allowFrom", () => { + const entries = listResolvedDirectoryUserEntriesFromAllowFrom({ + cfg, + resolveAccount, + resolveAllowFrom: (account) => account.allowFrom, + normalizeId: (entry) => entry.replace(/^user:/i, ""), + query: "a", + }); + + expect(entries).toEqual([{ kind: "user", id: "alice" }]); + }); + + it("lists group entries from resolved account map keys", () => { + const entries = listResolvedDirectoryGroupEntriesFromMapKeys({ + cfg, + resolveAccount, + resolveGroups: (account) => account.groups, + normalizeId: (entry) => entry.replace(/^room:/i, ""), + }); + + expect(entries).toEqual([ + { kind: "group", id: "a" }, + { kind: "group", id: "b" }, + ]); + }); + + it("lists entries from resolved account sources", () => { + const entries = listResolvedDirectoryEntriesFromSources({ + cfg, + kind: "user", + resolveAccount, + resolveSources: (account) => [account.allowFrom, ["user:carla", "user:alice"]], + normalizeId: (entry) => entry.replace(/^user:/i, ""), + query: "a", + limit: 2, + }); + + expectUserDirectoryEntries(entries); + }); +}); diff --git a/src/channels/plugins/directory-config-helpers.ts b/src/channels/plugins/directory-config-helpers.ts index 94dc5c3324c..6ee329e578a 100644 --- a/src/channels/plugins/directory-config-helpers.ts +++ b/src/channels/plugins/directory-config-helpers.ts @@ -1,3 +1,5 @@ +import type { OpenClawConfig } from "../../config/types.js"; +import type { DirectoryConfigParams } from "./directory-types.js"; import type { ChannelDirectoryEntry } from "./types.js"; function resolveDirectoryQuery(query?: string | null): string { @@ -81,6 +83,62 @@ export function collectNormalizedDirectoryIds(params: { return Array.from(ids); } +export function listDirectoryEntriesFromSources(params: { + kind: "user" | "group"; + sources: Iterable[]; + query?: string | null; + limit?: number | null; + normalizeId: (entry: string) => string | null | undefined; +}): ChannelDirectoryEntry[] { + const ids = collectNormalizedDirectoryIds({ + sources: params.sources, + normalizeId: params.normalizeId, + }); + return toDirectoryEntries(params.kind, applyDirectoryQueryAndLimit(ids, params)); +} + +export function listInspectedDirectoryEntriesFromSources( + params: DirectoryConfigParams & { + kind: "user" | "group"; + inspectAccount: ( + cfg: OpenClawConfig, + accountId?: string | null, + ) => InspectedAccount | null | undefined; + resolveSources: (account: InspectedAccount) => Iterable[]; + normalizeId: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.inspectAccount(params.cfg, params.accountId); + if (!account) { + return []; + } + return listDirectoryEntriesFromSources({ + kind: params.kind, + sources: params.resolveSources(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} + +export function listResolvedDirectoryEntriesFromSources( + params: DirectoryConfigParams & { + kind: "user" | "group"; + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; + resolveSources: (account: ResolvedAccount) => Iterable[]; + normalizeId: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.resolveAccount(params.cfg, params.accountId); + return listDirectoryEntriesFromSources({ + kind: params.kind, + sources: params.resolveSources(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} + export function listDirectoryUserEntriesFromAllowFrom(params: { allowFrom?: readonly unknown[]; query?: string | null; @@ -152,3 +210,35 @@ export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: { ]); return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params)); } + +export function listResolvedDirectoryUserEntriesFromAllowFrom( + params: DirectoryConfigParams & { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; + resolveAllowFrom: (account: ResolvedAccount) => readonly unknown[] | undefined; + normalizeId?: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.resolveAccount(params.cfg, params.accountId); + return listDirectoryUserEntriesFromAllowFrom({ + allowFrom: params.resolveAllowFrom(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} + +export function listResolvedDirectoryGroupEntriesFromMapKeys( + params: DirectoryConfigParams & { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; + resolveGroups: (account: ResolvedAccount) => Record | undefined; + normalizeId?: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.resolveAccount(params.cfg, params.accountId); + return listDirectoryGroupEntriesFromMapKeys({ + groups: params.resolveGroups(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} diff --git a/src/channels/plugins/group-policy-warnings.test.ts b/src/channels/plugins/group-policy-warnings.test.ts index 51a77d992f1..c70e089a288 100644 --- a/src/channels/plugins/group-policy-warnings.test.ts +++ b/src/channels/plugins/group-policy-warnings.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it } from "vitest"; import { collectAllowlistProviderGroupPolicyWarnings, collectAllowlistProviderRestrictSendersWarnings, + composeWarningCollectors, + createAllowlistProviderGroupPolicyWarningCollector, + createConditionalWarningCollector, + createAllowlistProviderOpenWarningCollector, + createAllowlistProviderRestrictSendersWarningCollector, + createAllowlistProviderRouteAllowlistWarningCollector, + createOpenGroupPolicyRestrictSendersWarningCollector, + createOpenProviderGroupPolicyWarningCollector, + createOpenProviderConfiguredRouteWarningCollector, + projectWarningCollector, collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyRestrictSendersWarnings, @@ -13,6 +23,35 @@ import { } from "./group-policy-warnings.js"; describe("group policy warning builders", () => { + it("composes warning collectors", () => { + const collect = composeWarningCollectors<{ enabled: boolean }>( + () => ["a"], + ({ enabled }) => (enabled ? ["b"] : []), + ); + + expect(collect({ enabled: true })).toEqual(["a", "b"]); + expect(collect({ enabled: false })).toEqual(["a"]); + }); + + it("projects warning collector inputs", () => { + const collect = projectWarningCollector( + ({ value }: { value: string }) => value, + (value: string) => [value.toUpperCase()], + ); + + expect(collect({ value: "abc" })).toEqual(["ABC"]); + }); + + it("builds conditional warning collectors", () => { + const collect = createConditionalWarningCollector<{ open: boolean; token?: string }>( + ({ open }) => (open ? "open" : undefined), + ({ token }) => (token ? undefined : ["missing token", "cannot send replies"]), + ); + + expect(collect({ open: true })).toEqual(["open", "missing token", "cannot send replies"]); + expect(collect({ open: false, token: "x" })).toEqual([]); + }); + it("builds base open-policy warning", () => { expect( buildOpenGroupPolicyWarning({ @@ -253,4 +292,205 @@ describe("group policy warning builders", () => { }), ).toEqual([buildOpenGroupPolicyWarning(params.missingRouteAllowlist)]); }); + + it("builds account-aware allowlist-provider restrict-senders collectors", () => { + const collectWarnings = createAllowlistProviderRestrictSendersWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open" }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyRestrictSendersWarning({ + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }), + ]); + }); + + it("builds config-aware allowlist-provider collectors", () => { + const collectWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ + cfg: { + channels?: { + defaults?: { groupPolicy?: "open" | "allowlist" | "disabled" }; + example?: unknown; + }; + }; + channelLabel: string; + configuredGroupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: ({ configuredGroupPolicy }) => configuredGroupPolicy, + collect: ({ channelLabel, groupPolicy }) => + groupPolicy === "open" ? [`warn:${channelLabel}`] : [], + }); + + expect( + collectWarnings({ + cfg: { channels: { example: {} } }, + channelLabel: "example", + configuredGroupPolicy: "open", + }), + ).toEqual(["warn:example"]); + }); + + it("builds account-aware route-allowlist collectors", () => { + const collectWarnings = createAllowlistProviderRouteAllowlistWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + groups?: Record; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveRouteAllowlistConfigured: (account) => Object.keys(account.groups ?? {}).length > 0, + restrictSenders: { + surface: "Example groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Example groups", + routeAllowlistPath: "channels.example.groups", + routeScope: "group", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }, + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open", groups: {} }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyNoRouteAllowlistWarning({ + surface: "Example groups", + routeAllowlistPath: "channels.example.groups", + routeScope: "group", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }), + ]); + }); + + it("builds account-aware configured-route collectors", () => { + const collectWarnings = createOpenProviderConfiguredRouteWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + channels?: Record; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveRouteAllowlistConfigured: (account) => Object.keys(account.channels ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Example channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.example.groupPolicy", + routeAllowlistPath: "channels.example.channels", + }, + missingRouteAllowlist: { + surface: "Example channels", + openBehavior: "with no route allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.example.groupPolicy="allowlist" and configure channels.example.channels', + }, + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open", channels: { general: true } }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyConfigureRouteAllowlistWarning({ + surface: "Example channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.example.groupPolicy", + routeAllowlistPath: "channels.example.channels", + }), + ]); + }); + + it("builds config-aware open-provider collectors", () => { + const collectWarnings = createOpenProviderGroupPolicyWarningCollector<{ + cfg: { channels?: { example?: unknown } }; + configuredGroupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: ({ configuredGroupPolicy }) => configuredGroupPolicy, + collect: ({ groupPolicy }) => [groupPolicy], + }); + + expect( + collectWarnings({ + cfg: { channels: { example: {} } }, + configuredGroupPolicy: "open", + }), + ).toEqual(["open"]); + }); + + it("builds account-aware simple open warning collectors", () => { + const collectWarnings = createAllowlistProviderOpenWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + buildOpenWarning: { + surface: "Example channels", + openBehavior: "allows any channel to trigger (mention-gated)", + remediation: + 'Set channels.example.groupPolicy="allowlist" and configure channels.example.channels', + }, + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open" }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyWarning({ + surface: "Example channels", + openBehavior: "allows any channel to trigger (mention-gated)", + remediation: + 'Set channels.example.groupPolicy="allowlist" and configure channels.example.channels', + }), + ]); + }); + + it("builds direct account-aware open-policy restrict-senders collectors", () => { + const collectWarnings = createOpenGroupPolicyRestrictSendersWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + resolveGroupPolicy: (account) => account.groupPolicy, + defaultGroupPolicy: "allowlist", + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + mentionGated: false, + }); + + expect(collectWarnings({ groupPolicy: "allowlist" })).toEqual([]); + expect(collectWarnings({ groupPolicy: "open" })).toEqual([ + buildOpenGroupPolicyRestrictSendersWarning({ + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + mentionGated: false, + }), + ]); + }); }); diff --git a/src/channels/plugins/group-policy-warnings.ts b/src/channels/plugins/group-policy-warnings.ts index 67d8c952b02..776ac6ddba4 100644 --- a/src/channels/plugins/group-policy-warnings.ts +++ b/src/channels/plugins/group-policy-warnings.ts @@ -7,6 +7,40 @@ import { import type { GroupPolicy } from "../../config/types.base.js"; type GroupPolicyWarningCollector = (groupPolicy: GroupPolicy) => string[]; +type AccountGroupPolicyWarningCollector = (params: { + account: ResolvedAccount; + cfg: OpenClawConfig; +}) => string[]; +type ConfigGroupPolicyWarningCollector = ( + params: Params, +) => string[]; +type WarningCollector = (params: Params) => string[]; + +export function composeWarningCollectors( + ...collectors: Array | null | undefined> +): WarningCollector { + return (params) => collectors.flatMap((collector) => collector?.(params) ?? []); +} + +export function projectWarningCollector( + project: (params: Params) => Projected, + collector: WarningCollector, +): WarningCollector { + return (params) => collector(project(params)); +} + +export function createConditionalWarningCollector( + ...collectors: Array<(params: Params) => string | string[] | null | undefined | false> +): WarningCollector { + return (params) => + collectors.flatMap((collector) => { + const next = collector(params); + if (!next) { + return []; + } + return Array.isArray(next) ? next : [next]; + }); +} export function buildOpenGroupPolicyWarning(params: { surface: string; @@ -96,6 +130,50 @@ export function collectAllowlistProviderRestrictSendersWarnings( }); } +/** Build an account-aware allowlist-provider warning collector for sender-restricted groups. */ +export function createAllowlistProviderRestrictSendersWarningCollector( + params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + } & Omit< + Parameters[0], + "cfg" | "providerConfigPresent" | "configuredGroupPolicy" + >, +): AccountGroupPolicyWarningCollector { + return createAllowlistProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ groupPolicy }) => + collectOpenGroupPolicyRestrictSendersWarnings({ + groupPolicy, + surface: params.surface, + openScope: params.openScope, + groupPolicyPath: params.groupPolicyPath, + groupAllowFromPath: params.groupAllowFromPath, + mentionGated: params.mentionGated, + }), + }); +} + +/** Build a direct account-aware warning collector when the policy already lives on the account. */ +export function createOpenGroupPolicyRestrictSendersWarningCollector( + params: { + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + defaultGroupPolicy?: GroupPolicy; + } & Omit[0], "groupPolicy">, +): (account: ResolvedAccount) => string[] { + return (account) => + collectOpenGroupPolicyRestrictSendersWarnings({ + groupPolicy: params.resolveGroupPolicy(account) ?? params.defaultGroupPolicy ?? "allowlist", + surface: params.surface, + openScope: params.openScope, + groupPolicyPath: params.groupPolicyPath, + groupAllowFromPath: params.groupAllowFromPath, + mentionGated: params.mentionGated, + }); +} + export function collectAllowlistProviderGroupPolicyWarnings(params: { cfg: OpenClawConfig; providerConfigPresent: boolean; @@ -111,6 +189,23 @@ export function collectAllowlistProviderGroupPolicyWarnings(params: { return params.collect(groupPolicy); } +/** Build a config-aware allowlist-provider warning collector from an arbitrary policy resolver. */ +export function createAllowlistProviderGroupPolicyWarningCollector< + Params extends { cfg: OpenClawConfig }, +>(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (params: Params) => GroupPolicy | null | undefined; + collect: (params: Params & { groupPolicy: GroupPolicy }) => string[]; +}): ConfigGroupPolicyWarningCollector { + return (runtime) => + collectAllowlistProviderGroupPolicyWarnings({ + cfg: runtime.cfg, + providerConfigPresent: params.providerConfigPresent(runtime.cfg), + configuredGroupPolicy: params.resolveGroupPolicy(runtime), + collect: (groupPolicy) => params.collect({ ...runtime, groupPolicy }), + }); +} + export function collectOpenProviderGroupPolicyWarnings(params: { cfg: OpenClawConfig; providerConfigPresent: boolean; @@ -126,6 +221,38 @@ export function collectOpenProviderGroupPolicyWarnings(params: { return params.collect(groupPolicy); } +/** Build a config-aware open-provider warning collector from an arbitrary policy resolver. */ +export function createOpenProviderGroupPolicyWarningCollector< + Params extends { cfg: OpenClawConfig }, +>(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (params: Params) => GroupPolicy | null | undefined; + collect: (params: Params & { groupPolicy: GroupPolicy }) => string[]; +}): ConfigGroupPolicyWarningCollector { + return (runtime) => + collectOpenProviderGroupPolicyWarnings({ + cfg: runtime.cfg, + providerConfigPresent: params.providerConfigPresent(runtime.cfg), + configuredGroupPolicy: params.resolveGroupPolicy(runtime), + collect: (groupPolicy) => params.collect({ ...runtime, groupPolicy }), + }); +} + +/** Build an account-aware allowlist-provider warning collector for simple open-policy warnings. */ +export function createAllowlistProviderOpenWarningCollector(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + buildOpenWarning: Parameters[0]; +}): AccountGroupPolicyWarningCollector { + return createAllowlistProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ groupPolicy }) => + groupPolicy === "open" ? [buildOpenGroupPolicyWarning(params.buildOpenWarning)] : [], + }); +} + export function collectOpenGroupPolicyRouteAllowlistWarnings(params: { groupPolicy: "open" | "allowlist" | "disabled"; routeAllowlistConfigured: boolean; @@ -141,6 +268,28 @@ export function collectOpenGroupPolicyRouteAllowlistWarnings(params: { return [buildOpenGroupPolicyNoRouteAllowlistWarning(params.noRouteAllowlist)]; } +/** Build an account-aware allowlist-provider warning collector for route-allowlisted groups. */ +export function createAllowlistProviderRouteAllowlistWarningCollector(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + resolveRouteAllowlistConfigured: (account: ResolvedAccount) => boolean; + restrictSenders: Parameters[0]; + noRouteAllowlist: Parameters[0]; +}): AccountGroupPolicyWarningCollector { + return createAllowlistProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ account, groupPolicy }) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: params.resolveRouteAllowlistConfigured(account), + restrictSenders: params.restrictSenders, + noRouteAllowlist: params.noRouteAllowlist, + }), + }); +} + export function collectOpenGroupPolicyConfiguredRouteWarnings(params: { groupPolicy: "open" | "allowlist" | "disabled"; routeAllowlistConfigured: boolean; @@ -155,3 +304,25 @@ export function collectOpenGroupPolicyConfiguredRouteWarnings(params: { } return [buildOpenGroupPolicyWarning(params.missingRouteAllowlist)]; } + +/** Build an account-aware open-provider warning collector for configured-route channels. */ +export function createOpenProviderConfiguredRouteWarningCollector(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + resolveRouteAllowlistConfigured: (account: ResolvedAccount) => boolean; + configureRouteAllowlist: Parameters[0]; + missingRouteAllowlist: Parameters[0]; +}): AccountGroupPolicyWarningCollector { + return createOpenProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ account, groupPolicy }) => + collectOpenGroupPolicyConfiguredRouteWarnings({ + groupPolicy, + routeAllowlistConfigured: params.resolveRouteAllowlistConfigured(account), + configureRouteAllowlist: params.configureRouteAllowlist, + missingRouteAllowlist: params.missingRouteAllowlist, + }), + }); +} diff --git a/src/channels/plugins/pairing-adapters.test.ts b/src/channels/plugins/pairing-adapters.test.ts new file mode 100644 index 00000000000..7fee2155414 --- /dev/null +++ b/src/channels/plugins/pairing-adapters.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createLoggedPairingApprovalNotifier, + createPairingPrefixStripper, + createTextPairingAdapter, +} from "./pairing-adapters.js"; + +describe("pairing adapters", () => { + it("strips prefixes and applies optional mapping", () => { + const strip = createPairingPrefixStripper(/^(telegram|tg):/i); + const lower = createPairingPrefixStripper(/^nextcloud:/i, (entry) => entry.toLowerCase()); + expect(strip("telegram:123")).toBe("123"); + expect(strip("tg:123")).toBe("123"); + expect(lower("nextcloud:USER")).toBe("user"); + }); + + it("builds text pairing adapters", async () => { + const notify = vi.fn(async () => {}); + const pairing = createTextPairingAdapter({ + idLabel: "telegramUserId", + message: "approved", + normalizeAllowEntry: createPairingPrefixStripper(/^telegram:/i), + notify, + }); + expect(pairing.idLabel).toBe("telegramUserId"); + expect(pairing.normalizeAllowEntry?.("telegram:123")).toBe("123"); + await pairing.notifyApproval?.({ cfg: {}, id: "123" }); + expect(notify).toHaveBeenCalledWith({ cfg: {}, id: "123", message: "approved" }); + }); + + it("builds logger-backed approval notifiers", async () => { + const log = vi.fn(); + const notify = createLoggedPairingApprovalNotifier(({ id }) => `approved ${id}`, log); + await notify({ cfg: {}, id: "u-1" }); + expect(log).toHaveBeenCalledWith("approved u-1"); + }); +}); diff --git a/src/channels/plugins/pairing-adapters.ts b/src/channels/plugins/pairing-adapters.ts new file mode 100644 index 00000000000..583fe44a448 --- /dev/null +++ b/src/channels/plugins/pairing-adapters.ts @@ -0,0 +1,34 @@ +import type { ChannelPairingAdapter } from "./types.adapters.js"; + +type PairingNotifyParams = Parameters>[0]; + +export function createPairingPrefixStripper( + prefixRe: RegExp, + map: (entry: string) => string = (entry) => entry, +): NonNullable { + return (entry) => map(entry.replace(prefixRe, "")); +} + +export function createLoggedPairingApprovalNotifier( + format: string | ((params: PairingNotifyParams) => string), + log: (message: string) => void = console.log, +): NonNullable { + return async (params) => { + log(typeof format === "function" ? format(params) : format); + }; +} + +export function createTextPairingAdapter(params: { + idLabel: string; + message: string; + normalizeAllowEntry?: ChannelPairingAdapter["normalizeAllowEntry"]; + notify: (params: PairingNotifyParams & { message: string }) => Promise | void; +}): ChannelPairingAdapter { + return { + idLabel: params.idLabel, + normalizeAllowEntry: params.normalizeAllowEntry, + notifyApproval: async (ctx) => { + await params.notify({ ...ctx, message: params.message }); + }, + }; +} diff --git a/src/channels/plugins/runtime-forwarders.test.ts b/src/channels/plugins/runtime-forwarders.test.ts new file mode 100644 index 00000000000..8b927a319f3 --- /dev/null +++ b/src/channels/plugins/runtime-forwarders.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, +} from "./runtime-forwarders.js"; + +describe("createRuntimeDirectoryLiveAdapter", () => { + it("forwards live directory calls through the runtime getter", async () => { + const listPeersLive = vi.fn(async (_ctx: unknown) => [{ kind: "user" as const, id: "alice" }]); + const adapter = createRuntimeDirectoryLiveAdapter({ + getRuntime: async () => ({ listPeersLive }), + listPeersLive: (runtime) => runtime.listPeersLive, + }); + + await expect( + adapter.listPeersLive?.({ cfg: {} as never, runtime: {} as never, query: "a", limit: 1 }), + ).resolves.toEqual([{ kind: "user", id: "alice" }]); + expect(listPeersLive).toHaveBeenCalled(); + }); +}); + +describe("createRuntimeOutboundDelegates", () => { + it("forwards outbound methods through the runtime getter", async () => { + const sendText = vi.fn(async () => ({ channel: "x", messageId: "1" })); + const outbound = createRuntimeOutboundDelegates({ + getRuntime: async () => ({ outbound: { sendText } }), + sendText: { resolve: (runtime) => runtime.outbound.sendText }, + }); + + await expect(outbound.sendText?.({ cfg: {} as never, to: "a", text: "hi" })).resolves.toEqual({ + channel: "x", + messageId: "1", + }); + expect(sendText).toHaveBeenCalled(); + }); + + it("throws the configured unavailable message", async () => { + const outbound = createRuntimeOutboundDelegates({ + getRuntime: async () => ({ outbound: {} }), + sendPoll: { + resolve: () => undefined, + unavailableMessage: "poll unavailable", + }, + }); + + await expect( + outbound.sendPoll?.({ + cfg: {} as never, + to: "a", + poll: { question: "q", options: ["a"] }, + }), + ).rejects.toThrow("poll unavailable"); + }); +}); diff --git a/src/channels/plugins/runtime-forwarders.ts b/src/channels/plugins/runtime-forwarders.ts new file mode 100644 index 00000000000..9730e4a94e8 --- /dev/null +++ b/src/channels/plugins/runtime-forwarders.ts @@ -0,0 +1,117 @@ +import type { ChannelDirectoryAdapter, ChannelOutboundAdapter } from "./types.adapters.js"; + +type MaybePromise = T | Promise; + +type DirectoryListMethod = "listPeersLive" | "listGroupsLive" | "listGroupMembers"; +type OutboundMethod = "sendText" | "sendMedia" | "sendPoll"; + +type DirectoryListParams = Parameters>[0]; +type DirectoryGroupMembersParams = Parameters< + NonNullable +>[0]; +type SendTextParams = Parameters>[0]; +type SendMediaParams = Parameters>[0]; +type SendPollParams = Parameters>[0]; + +async function resolveForwardedMethod(params: { + getRuntime: () => MaybePromise; + resolve: (runtime: Runtime) => Fn | null | undefined; + unavailableMessage?: string; +}): Promise { + const runtime = await params.getRuntime(); + const method = params.resolve(runtime); + if (method) { + return method; + } + throw new Error(params.unavailableMessage ?? "Runtime method is unavailable"); +} + +export function createRuntimeDirectoryLiveAdapter(params: { + getRuntime: () => MaybePromise; + listPeersLive?: (runtime: Runtime) => ChannelDirectoryAdapter["listPeersLive"] | null | undefined; + listGroupsLive?: ( + runtime: Runtime, + ) => ChannelDirectoryAdapter["listGroupsLive"] | null | undefined; + listGroupMembers?: ( + runtime: Runtime, + ) => ChannelDirectoryAdapter["listGroupMembers"] | null | undefined; +}): Pick { + return { + listPeersLive: params.listPeersLive + ? async (ctx: DirectoryListParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.listPeersLive!, + }) + )(ctx) + : undefined, + listGroupsLive: params.listGroupsLive + ? async (ctx: DirectoryListParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.listGroupsLive!, + }) + )(ctx) + : undefined, + listGroupMembers: params.listGroupMembers + ? async (ctx: DirectoryGroupMembersParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.listGroupMembers!, + }) + )(ctx) + : undefined, + }; +} + +export function createRuntimeOutboundDelegates(params: { + getRuntime: () => MaybePromise; + sendText?: { + resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendText"] | null | undefined; + unavailableMessage?: string; + }; + sendMedia?: { + resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendMedia"] | null | undefined; + unavailableMessage?: string; + }; + sendPoll?: { + resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendPoll"] | null | undefined; + unavailableMessage?: string; + }; +}): Pick { + return { + sendText: params.sendText + ? async (ctx: SendTextParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.sendText!.resolve, + unavailableMessage: params.sendText!.unavailableMessage, + }) + )(ctx) + : undefined, + sendMedia: params.sendMedia + ? async (ctx: SendMediaParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.sendMedia!.resolve, + unavailableMessage: params.sendMedia!.unavailableMessage, + }) + )(ctx) + : undefined, + sendPoll: params.sendPoll + ? async (ctx: SendPollParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.sendPoll!.resolve, + unavailableMessage: params.sendPoll!.unavailableMessage, + }) + )(ctx) + : undefined, + }; +} diff --git a/src/channels/plugins/target-resolvers.test.ts b/src/channels/plugins/target-resolvers.test.ts new file mode 100644 index 00000000000..161b94a8fb2 --- /dev/null +++ b/src/channels/plugins/target-resolvers.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + buildUnresolvedTargetResults, + resolveTargetsWithOptionalToken, +} from "./target-resolvers.js"; + +describe("buildUnresolvedTargetResults", () => { + it("marks each input unresolved with the same note", () => { + expect(buildUnresolvedTargetResults(["a", "b"], "missing token")).toEqual([ + { input: "a", resolved: false, note: "missing token" }, + { input: "b", resolved: false, note: "missing token" }, + ]); + }); +}); + +describe("resolveTargetsWithOptionalToken", () => { + it("returns unresolved entries when the token is missing", async () => { + const resolved = await resolveTargetsWithOptionalToken({ + inputs: ["alice"], + missingTokenNote: "missing token", + resolveWithToken: async () => [{ input: "alice", id: "1" }], + mapResolved: (entry) => ({ input: entry.input, resolved: true, id: entry.id }), + }); + + expect(resolved).toEqual([{ input: "alice", resolved: false, note: "missing token" }]); + }); + + it("resolves and maps entries when a token is present", async () => { + const resolved = await resolveTargetsWithOptionalToken({ + token: " x ", + inputs: ["alice"], + missingTokenNote: "missing token", + resolveWithToken: async ({ token, inputs }) => + inputs.map((input) => ({ input, id: `${token}:${input}` })), + mapResolved: (entry) => ({ input: entry.input, resolved: true, id: entry.id }), + }); + + expect(resolved).toEqual([{ input: "alice", resolved: true, id: "x:alice" }]); + }); +}); diff --git a/src/channels/plugins/target-resolvers.ts b/src/channels/plugins/target-resolvers.ts new file mode 100644 index 00000000000..81bdd82fd6c --- /dev/null +++ b/src/channels/plugins/target-resolvers.ts @@ -0,0 +1,30 @@ +import type { ChannelResolveResult } from "./types.adapters.js"; + +export function buildUnresolvedTargetResults( + inputs: string[], + note: string, +): ChannelResolveResult[] { + return inputs.map((input) => ({ + input, + resolved: false, + note, + })); +} + +export async function resolveTargetsWithOptionalToken(params: { + token?: string | null; + inputs: string[]; + missingTokenNote: string; + resolveWithToken: (params: { token: string; inputs: string[] }) => Promise; + mapResolved: (entry: TResult) => ChannelResolveResult; +}): Promise { + const token = params.token?.trim(); + if (!token) { + return buildUnresolvedTargetResults(params.inputs, params.missingTokenNote); + } + const resolved = await params.resolveWithToken({ + token, + inputs: params.inputs, + }); + return resolved.map(params.mapResolved); +} diff --git a/src/plugin-sdk/allowlist-config-edit.test.ts b/src/plugin-sdk/allowlist-config-edit.test.ts new file mode 100644 index 00000000000..45305fcc0ed --- /dev/null +++ b/src/plugin-sdk/allowlist-config-edit.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import { + buildDmGroupAccountAllowlistAdapter, + buildLegacyDmAccountAllowlistAdapter, + collectAllowlistOverridesFromRecord, + collectNestedAllowlistOverridesFromRecord, + createAccountScopedAllowlistNameResolver, + createFlatAllowlistOverrideResolver, + createNestedAllowlistOverrideResolver, + readConfiguredAllowlistEntries, +} from "./allowlist-config-edit.js"; + +describe("readConfiguredAllowlistEntries", () => { + it("coerces mixed entries to non-empty strings", () => { + expect(readConfiguredAllowlistEntries(["owner", 42, ""])).toEqual(["owner", "42"]); + }); +}); + +describe("collectAllowlistOverridesFromRecord", () => { + it("collects only non-empty overrides from a flat record", () => { + expect( + collectAllowlistOverridesFromRecord({ + record: { + room1: { users: ["a", "b"] }, + room2: { users: [] }, + }, + label: (key) => key, + resolveEntries: (value) => value.users, + }), + ).toEqual([{ label: "room1", entries: ["a", "b"] }]); + }); +}); + +describe("collectNestedAllowlistOverridesFromRecord", () => { + it("collects outer and nested overrides from a hierarchical record", () => { + expect( + collectNestedAllowlistOverridesFromRecord({ + record: { + guild1: { + users: ["owner"], + channels: { + chan1: { users: ["member"] }, + }, + }, + }, + outerLabel: (key) => `guild ${key}`, + resolveOuterEntries: (value) => value.users, + resolveChildren: (value) => value.channels, + innerLabel: (outerKey, innerKey) => `guild ${outerKey} / channel ${innerKey}`, + resolveInnerEntries: (value) => value.users, + }), + ).toEqual([ + { label: "guild guild1", entries: ["owner"] }, + { label: "guild guild1 / channel chan1", entries: ["member"] }, + ]); + }); +}); + +describe("createFlatAllowlistOverrideResolver", () => { + it("builds an account-scoped flat override resolver", () => { + const resolveOverrides = createFlatAllowlistOverrideResolver({ + resolveRecord: (account: { channels?: Record }) => + account.channels, + label: (key) => key, + resolveEntries: (value) => value.users, + }); + + expect(resolveOverrides({ channels: { room1: { users: ["a"] } } })).toEqual([ + { label: "room1", entries: ["a"] }, + ]); + }); +}); + +describe("createNestedAllowlistOverrideResolver", () => { + it("builds an account-scoped nested override resolver", () => { + const resolveOverrides = createNestedAllowlistOverrideResolver({ + resolveRecord: (account: { + groups?: Record< + string, + { allowFrom?: string[]; topics?: Record } + >; + }) => account.groups, + outerLabel: (groupId) => groupId, + resolveOuterEntries: (group) => group.allowFrom, + resolveChildren: (group) => group.topics, + innerLabel: (groupId, topicId) => `${groupId} topic ${topicId}`, + resolveInnerEntries: (topic) => topic.allowFrom, + }); + + expect( + resolveOverrides({ + groups: { + g1: { allowFrom: ["owner"], topics: { t1: { allowFrom: ["member"] } } }, + }, + }), + ).toEqual([ + { label: "g1", entries: ["owner"] }, + { label: "g1 topic t1", entries: ["member"] }, + ]); + }); +}); + +describe("createAccountScopedAllowlistNameResolver", () => { + it("returns empty results when the resolved account has no token", async () => { + const resolveNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: () => ({ token: "" }), + resolveToken: (account) => account.token, + resolveNames: async ({ token, entries }) => + entries.map((entry) => ({ input: `${token}:${entry}`, resolved: true })), + }); + + expect(await resolveNames({ cfg: {}, accountId: "alt", scope: "dm", entries: ["a"] })).toEqual( + [], + ); + }); + + it("delegates to the resolver when a token is present", async () => { + const resolveNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: () => ({ token: " secret " }), + resolveToken: (account) => account.token, + resolveNames: async ({ token, entries }) => + entries.map((entry) => ({ input: entry, resolved: true, name: `${token}:${entry}` })), + }); + + expect(await resolveNames({ cfg: {}, accountId: "alt", scope: "dm", entries: ["a"] })).toEqual([ + { input: "a", resolved: true, name: "secret:a" }, + ]); + }); +}); + +describe("buildDmGroupAccountAllowlistAdapter", () => { + const adapter = buildDmGroupAccountAllowlistAdapter({ + channelId: "demo", + resolveAccount: ({ accountId }) => ({ + accountId: accountId ?? "default", + dmAllowFrom: ["dm-owner"], + groupAllowFrom: ["group-owner"], + dmPolicy: "allowlist", + groupPolicy: "allowlist", + groupOverrides: [{ label: "room-1", entries: ["member-1"] }], + }), + normalize: ({ values }) => values.map((entry) => String(entry).trim().toLowerCase()), + resolveDmAllowFrom: (account) => account.dmAllowFrom, + resolveGroupAllowFrom: (account) => account.groupAllowFrom, + resolveDmPolicy: (account) => account.dmPolicy, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: (account) => account.groupOverrides, + }); + + it("supports dm, group, and all scopes", () => { + expect(adapter.supportsScope?.({ scope: "dm" })).toBe(true); + expect(adapter.supportsScope?.({ scope: "group" })).toBe(true); + expect(adapter.supportsScope?.({ scope: "all" })).toBe(true); + }); + + it("reads dm/group config from the resolved account", () => { + expect(adapter.readConfig?.({ cfg: {}, accountId: "alt" })).toEqual({ + dmAllowFrom: ["dm-owner"], + groupAllowFrom: ["group-owner"], + dmPolicy: "allowlist", + groupPolicy: "allowlist", + groupOverrides: [{ label: "room-1", entries: ["member-1"] }], + }); + }); + + it("writes group allowlist entries to groupAllowFrom", () => { + expect( + adapter.applyConfigEdit?.({ + cfg: {}, + parsedConfig: {}, + accountId: "alt", + scope: "group", + action: "add", + entry: " Member-2 ", + }), + ).toEqual({ + kind: "ok", + changed: true, + pathLabel: "channels.demo.accounts.alt.groupAllowFrom", + writeTarget: { + kind: "account", + scope: { channelId: "demo", accountId: "alt" }, + }, + }); + }); +}); + +describe("buildLegacyDmAccountAllowlistAdapter", () => { + const adapter = buildLegacyDmAccountAllowlistAdapter({ + channelId: "demo", + resolveAccount: ({ accountId }) => ({ + accountId: accountId ?? "default", + dmAllowFrom: ["owner"], + groupPolicy: "allowlist", + groupOverrides: [{ label: "group-1", entries: ["member-1"] }], + }), + normalize: ({ values }) => values.map((entry) => String(entry).trim().toLowerCase()), + resolveDmAllowFrom: (account) => account.dmAllowFrom, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: (account) => account.groupOverrides, + }); + + it("supports only dm scope", () => { + expect(adapter.supportsScope?.({ scope: "dm" })).toBe(true); + expect(adapter.supportsScope?.({ scope: "group" })).toBe(false); + expect(adapter.supportsScope?.({ scope: "all" })).toBe(false); + }); + + it("reads legacy dm config from the resolved account", () => { + expect(adapter.readConfig?.({ cfg: {}, accountId: "alt" })).toEqual({ + dmAllowFrom: ["owner"], + groupPolicy: "allowlist", + groupOverrides: [{ label: "group-1", entries: ["member-1"] }], + }); + }); + + it("writes dm allowlist entries and keeps legacy cleanup behavior", () => { + expect( + adapter.applyConfigEdit?.({ + cfg: {}, + parsedConfig: { + channels: { + demo: { + accounts: { + alt: { + dm: { allowFrom: ["owner"] }, + }, + }, + }, + }, + }, + accountId: "alt", + scope: "dm", + action: "add", + entry: "admin", + }), + ).toEqual({ + kind: "ok", + changed: true, + pathLabel: "channels.demo.accounts.alt.allowFrom", + writeTarget: { + kind: "account", + scope: { channelId: "demo", accountId: "alt" }, + }, + }); + }); +}); diff --git a/src/plugin-sdk/allowlist-config-edit.ts b/src/plugin-sdk/allowlist-config-edit.ts index e92e4cb8551..4891bb5075a 100644 --- a/src/plugin-sdk/allowlist-config-edit.ts +++ b/src/plugin-sdk/allowlist-config-edit.ts @@ -11,16 +11,152 @@ type AllowlistConfigPaths = { cleanupPaths?: string[][]; }; +export type AllowlistGroupOverride = { label: string; entries: string[] }; +export type AllowlistNameResolution = Array<{ + input: string; + resolved: boolean; + name?: string | null; +}>; +type AllowlistNormalizer = (params: { + cfg: OpenClawConfig; + accountId?: string | null; + values: Array; +}) => string[]; +type AllowlistAccountResolver = (params: { + cfg: OpenClawConfig; + accountId?: string | null; +}) => ResolvedAccount; + +const DM_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { + readPaths: [["allowFrom"]], + writePath: ["allowFrom"], +}; + +const GROUP_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { + readPaths: [["groupAllowFrom"]], + writePath: ["groupAllowFrom"], +}; + const LEGACY_DM_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { readPaths: [["allowFrom"], ["dm", "allowFrom"]], writePath: ["allowFrom"], cleanupPaths: [["dm", "allowFrom"]], }; +export function resolveDmGroupAllowlistConfigPaths(scope: "dm" | "group") { + return scope === "dm" ? DM_ALLOWLIST_CONFIG_PATHS : GROUP_ALLOWLIST_CONFIG_PATHS; +} + export function resolveLegacyDmAllowlistConfigPaths(scope: "dm" | "group") { return scope === "dm" ? LEGACY_DM_ALLOWLIST_CONFIG_PATHS : null; } +/** Coerce stored allowlist entries into presentable non-empty strings. */ +export function readConfiguredAllowlistEntries( + entries: Array | null | undefined, +): string[] { + return (entries ?? []).map(String).filter(Boolean); +} + +/** Collect labeled allowlist overrides from a flat keyed record. */ +export function collectAllowlistOverridesFromRecord(params: { + record: Record | null | undefined; + label: (key: string, value: T) => string; + resolveEntries: (value: T) => Array | null | undefined; +}): AllowlistGroupOverride[] { + const overrides: AllowlistGroupOverride[] = []; + for (const [key, value] of Object.entries(params.record ?? {})) { + if (!value) { + continue; + } + const entries = readConfiguredAllowlistEntries(params.resolveEntries(value)); + if (entries.length === 0) { + continue; + } + overrides.push({ label: params.label(key, value), entries }); + } + return overrides; +} + +/** Collect labeled allowlist overrides from an outer record with nested child records. */ +export function collectNestedAllowlistOverridesFromRecord(params: { + record: Record | null | undefined; + outerLabel: (key: string, value: Outer) => string; + resolveOuterEntries: (value: Outer) => Array | null | undefined; + resolveChildren: (value: Outer) => Record | null | undefined; + innerLabel: (outerKey: string, innerKey: string, inner: Inner) => string; + resolveInnerEntries: (value: Inner) => Array | null | undefined; +}): AllowlistGroupOverride[] { + const overrides: AllowlistGroupOverride[] = []; + for (const [outerKey, outerValue] of Object.entries(params.record ?? {})) { + if (!outerValue) { + continue; + } + const outerEntries = readConfiguredAllowlistEntries(params.resolveOuterEntries(outerValue)); + if (outerEntries.length > 0) { + overrides.push({ label: params.outerLabel(outerKey, outerValue), entries: outerEntries }); + } + overrides.push( + ...collectAllowlistOverridesFromRecord({ + record: params.resolveChildren(outerValue), + label: (innerKey, innerValue) => params.innerLabel(outerKey, innerKey, innerValue), + resolveEntries: params.resolveInnerEntries, + }), + ); + } + return overrides; +} + +/** Build an account-scoped flat override resolver from a keyed allowlist record. */ +export function createFlatAllowlistOverrideResolver(params: { + resolveRecord: (account: ResolvedAccount) => Record | null | undefined; + label: (key: string, value: Entry) => string; + resolveEntries: (value: Entry) => Array | null | undefined; +}): (account: ResolvedAccount) => AllowlistGroupOverride[] { + return (account) => + collectAllowlistOverridesFromRecord({ + record: params.resolveRecord(account), + label: params.label, + resolveEntries: params.resolveEntries, + }); +} + +/** Build an account-scoped nested override resolver from hierarchical allowlist records. */ +export function createNestedAllowlistOverrideResolver(params: { + resolveRecord: (account: ResolvedAccount) => Record | null | undefined; + outerLabel: (key: string, value: Outer) => string; + resolveOuterEntries: (value: Outer) => Array | null | undefined; + resolveChildren: (value: Outer) => Record | null | undefined; + innerLabel: (outerKey: string, innerKey: string, inner: Inner) => string; + resolveInnerEntries: (value: Inner) => Array | null | undefined; +}): (account: ResolvedAccount) => AllowlistGroupOverride[] { + return (account) => + collectNestedAllowlistOverridesFromRecord({ + record: params.resolveRecord(account), + outerLabel: params.outerLabel, + resolveOuterEntries: params.resolveOuterEntries, + resolveChildren: params.resolveChildren, + innerLabel: params.innerLabel, + resolveInnerEntries: params.resolveInnerEntries, + }); +} + +/** Build the common account-scoped token-gated allowlist name resolver. */ +export function createAccountScopedAllowlistNameResolver(params: { + resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => ResolvedAccount; + resolveToken: (account: ResolvedAccount) => string | null | undefined; + resolveNames: (params: { token: string; entries: string[] }) => Promise; +}): NonNullable { + return async ({ cfg, accountId, entries }) => { + const account = params.resolveAccount({ cfg, accountId }); + const token = params.resolveToken(account)?.trim(); + if (!token) { + return []; + } + return await params.resolveNames({ token, entries }); + }; +} + function resolveAccountScopedWriteTarget( parsed: Record, channelId: ChannelId, @@ -196,11 +332,7 @@ function applyAccountScopedAllowlistConfigEdit(params: { /** Build the default account-scoped allowlist editor used by channel plugins with config-backed lists. */ export function buildAccountScopedAllowlistConfigEditor(params: { channelId: ChannelId; - normalize: (params: { - cfg: OpenClawConfig; - accountId?: string | null; - values: Array; - }) => string[]; + normalize: AllowlistNormalizer; resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null; }): NonNullable { return ({ cfg, parsedConfig, accountId, scope, action, entry }) => { @@ -219,3 +351,75 @@ export function buildAccountScopedAllowlistConfigEditor(params: { }); }; } + +function buildAccountAllowlistAdapter(params: { + channelId: ChannelId; + resolveAccount: AllowlistAccountResolver; + normalize: AllowlistNormalizer; + supportsScope: NonNullable; + resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null; + readConfig: ( + account: ResolvedAccount, + ) => Awaited>>; +}): Pick { + return { + supportsScope: params.supportsScope, + readConfig: ({ cfg, accountId }) => + params.readConfig(params.resolveAccount({ cfg, accountId })), + applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + channelId: params.channelId, + normalize: params.normalize, + resolvePaths: params.resolvePaths, + }), + }; +} + +/** Build the common DM/group allowlist adapter used by channels that store both lists in config. */ +export function buildDmGroupAccountAllowlistAdapter(params: { + channelId: ChannelId; + resolveAccount: AllowlistAccountResolver; + normalize: AllowlistNormalizer; + resolveDmAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveGroupAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveDmPolicy?: (account: ResolvedAccount) => string | null | undefined; + resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined; + resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined; +}): Pick { + return buildAccountAllowlistAdapter({ + channelId: params.channelId, + resolveAccount: params.resolveAccount, + normalize: params.normalize, + supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", + resolvePaths: resolveDmGroupAllowlistConfigPaths, + readConfig: (account) => ({ + dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account)), + groupAllowFrom: readConfiguredAllowlistEntries(params.resolveGroupAllowFrom(account)), + dmPolicy: params.resolveDmPolicy?.(account) ?? undefined, + groupPolicy: params.resolveGroupPolicy?.(account) ?? undefined, + groupOverrides: params.resolveGroupOverrides?.(account), + }), + }); +} + +/** Build the common DM-only allowlist adapter for channels with legacy dm.allowFrom fallback paths. */ +export function buildLegacyDmAccountAllowlistAdapter(params: { + channelId: ChannelId; + resolveAccount: AllowlistAccountResolver; + normalize: AllowlistNormalizer; + resolveDmAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined; + resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined; +}): Pick { + return buildAccountAllowlistAdapter({ + channelId: params.channelId, + resolveAccount: params.resolveAccount, + normalize: params.normalize, + supportsScope: ({ scope }) => scope === "dm", + resolvePaths: resolveLegacyDmAllowlistConfigPaths, + readConfig: (account) => ({ + dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account)), + groupPolicy: params.resolveGroupPolicy?.(account) ?? undefined, + groupOverrides: params.resolveGroupOverrides?.(account), + }), + }); +} diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index c59643a4e4b..06dc117b9b2 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -5,6 +5,15 @@ export type { } from "../config/types.tools.js"; export { buildOpenGroupPolicyConfigureRouteAllowlistWarning, + composeWarningCollectors, + createAllowlistProviderGroupPolicyWarningCollector, + createConditionalWarningCollector, + createAllowlistProviderOpenWarningCollector, + createAllowlistProviderRestrictSendersWarningCollector, + createAllowlistProviderRouteAllowlistWarningCollector, + createOpenGroupPolicyRestrictSendersWarningCollector, + createOpenProviderGroupPolicyWarningCollector, + createOpenProviderConfiguredRouteWarningCollector, buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, @@ -12,6 +21,7 @@ export { collectOpenGroupPolicyRestrictSendersWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, collectOpenProviderGroupPolicyWarnings, + projectWarningCollector, } from "../channels/plugins/group-policy-warnings.js"; export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; export { diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 59832d70f80..a7630924997 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -32,12 +32,16 @@ export * from "../channels/plugins/actions/reaction-message-id.js"; export * from "../channels/plugins/actions/shared.js"; export type * from "../channels/plugins/types.js"; export * from "../channels/plugins/config-writes.js"; +export * from "../channels/plugins/directory-adapters.js"; export * from "../channels/plugins/media-payload.js"; export * from "../channels/plugins/message-tool-schema.js"; export * from "../channels/plugins/normalize/signal.js"; export * from "../channels/plugins/normalize/whatsapp.js"; export * from "../channels/plugins/outbound/direct-text-media.js"; export * from "../channels/plugins/outbound/interactive.js"; +export * from "../channels/plugins/pairing-adapters.js"; +export * from "../channels/plugins/runtime-forwarders.js"; +export * from "../channels/plugins/target-resolvers.js"; export * from "../channels/plugins/status-issues/shared.js"; export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../infra/outbound/send-deps.js"; diff --git a/src/plugin-sdk/directory-runtime.ts b/src/plugin-sdk/directory-runtime.ts index a13a368abd4..caa21657810 100644 --- a/src/plugin-sdk/directory-runtime.ts +++ b/src/plugin-sdk/directory-runtime.ts @@ -4,8 +4,13 @@ export type { ReadOnlyInspectedAccount } from "../channels/read-only-account-ins export { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, + listDirectoryEntriesFromSources, listDirectoryGroupEntriesFromMapKeys, listDirectoryGroupEntriesFromMapKeysAndAllowFrom, + listInspectedDirectoryEntriesFromSources, + listResolvedDirectoryEntriesFromSources, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFromAndMapKeys, toDirectoryEntries, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 0e5da56d274..079fa8b3a01 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,3 +1,4 @@ +import * as channelRuntimeSdk from "openclaw/plugin-sdk/channel-runtime"; import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as coreSdk from "openclaw/plugin-sdk/core"; import type { @@ -5,6 +6,7 @@ import type { OpenClawPluginApi as CoreOpenClawPluginApi, PluginRuntime as CorePluginRuntime, } from "openclaw/plugin-sdk/core"; +import * as directoryRuntimeSdk from "openclaw/plugin-sdk/directory-runtime"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; @@ -58,6 +60,7 @@ const mattermostSdk = await import("openclaw/plugin-sdk/mattermost"); const nextcloudTalkSdk = await import("openclaw/plugin-sdk/nextcloud-talk"); const twitchSdk = await import("openclaw/plugin-sdk/twitch"); const accountHelpersSdk = await import("openclaw/plugin-sdk/account-helpers"); +const allowlistEditSdk = await import("openclaw/plugin-sdk/allowlist-config-edit"); const lobsterSdk = await import("openclaw/plugin-sdk/lobster"); describe("plugin-sdk subpath exports", () => { @@ -94,10 +97,42 @@ describe("plugin-sdk subpath exports", () => { expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function"); }); + it("exports allowlist edit helpers from the dedicated subpath", () => { + expect(typeof allowlistEditSdk.buildDmGroupAccountAllowlistAdapter).toBe("function"); + expect(typeof allowlistEditSdk.buildLegacyDmAccountAllowlistAdapter).toBe("function"); + expect(typeof allowlistEditSdk.createAccountScopedAllowlistNameResolver).toBe("function"); + expect(typeof allowlistEditSdk.createFlatAllowlistOverrideResolver).toBe("function"); + expect(typeof allowlistEditSdk.createNestedAllowlistOverrideResolver).toBe("function"); + }); + it("exports runtime helpers from the dedicated subpath", () => { expect(typeof runtimeSdk.createLoggerBackedRuntime).toBe("function"); }); + it("exports directory runtime helpers from the dedicated subpath", () => { + expect(typeof directoryRuntimeSdk.listDirectoryEntriesFromSources).toBe("function"); + expect(typeof directoryRuntimeSdk.listInspectedDirectoryEntriesFromSources).toBe("function"); + expect(typeof directoryRuntimeSdk.listResolvedDirectoryEntriesFromSources).toBe("function"); + expect(typeof directoryRuntimeSdk.listResolvedDirectoryGroupEntriesFromMapKeys).toBe( + "function", + ); + expect(typeof directoryRuntimeSdk.listResolvedDirectoryUserEntriesFromAllowFrom).toBe( + "function", + ); + }); + + it("exports channel runtime helpers from the dedicated subpath", () => { + expect(typeof channelRuntimeSdk.buildUnresolvedTargetResults).toBe("function"); + expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createEmptyChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createLoggedPairingApprovalNotifier).toBe("function"); + expect(typeof channelRuntimeSdk.createPairingPrefixStripper).toBe("function"); + expect(typeof channelRuntimeSdk.createRuntimeDirectoryLiveAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); + expect(typeof channelRuntimeSdk.resolveTargetsWithOptionalToken).toBe("function"); + expect(typeof channelRuntimeSdk.createTextPairingAdapter).toBe("function"); + }); + it("exports provider setup helpers from the dedicated subpath", () => { expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); From b3ca855283990ba7725b92cabc426e7548a8cef7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 09:37:42 -0700 Subject: [PATCH 08/34] Plugin SDK: use public whatsapp subpath --- src/channel-web.ts | 14 +++++++++----- src/cli/deps.ts | 2 +- src/cli/send-runtime/whatsapp.ts | 4 ++-- src/config/plugin-auto-enable.ts | 2 +- src/cron/isolated-agent/delivery-target.ts | 2 +- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/channel-web.ts b/src/channel-web.ts index 38d5a3c02cb..3566cee4790 100644 --- a/src/channel-web.ts +++ b/src/channel-web.ts @@ -7,11 +7,15 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, -} from "./plugin-sdk/whatsapp.js"; -export { extractMediaPlaceholder, extractText, monitorWebInbox } from "./plugin-sdk/whatsapp.js"; -export { loginWeb } from "./plugin-sdk/whatsapp.js"; +} from "openclaw/plugin-sdk/whatsapp"; +export { + extractMediaPlaceholder, + extractText, + monitorWebInbox, +} from "openclaw/plugin-sdk/whatsapp"; +export { loginWeb } from "openclaw/plugin-sdk/whatsapp"; export { loadWebMedia, optimizeImageToJpeg } from "./media/web-media.js"; -export { sendMessageWhatsApp } from "./plugin-sdk/whatsapp.js"; +export { sendMessageWhatsApp } from "openclaw/plugin-sdk/whatsapp"; export { createWaSocket, formatError, @@ -22,4 +26,4 @@ export { WA_WEB_AUTH_DIR, waitForWaConnection, webAuthExists, -} from "./plugin-sdk/whatsapp.js"; +} from "openclaw/plugin-sdk/whatsapp"; diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 1d9d6885fe2..23d2d9af399 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -70,4 +70,4 @@ export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); } -export { logWebSelfId } from "../plugin-sdk/whatsapp.js"; +export { logWebSelfId } from "openclaw/plugin-sdk/whatsapp"; diff --git a/src/cli/send-runtime/whatsapp.ts b/src/cli/send-runtime/whatsapp.ts index 49f0e50baa6..b1e731e7c44 100644 --- a/src/cli/send-runtime/whatsapp.ts +++ b/src/cli/send-runtime/whatsapp.ts @@ -1,7 +1,7 @@ -import { sendMessageWhatsApp as sendMessageWhatsAppImpl } from "../../plugin-sdk/whatsapp.js"; +import { sendMessageWhatsApp as sendMessageWhatsAppImpl } from "openclaw/plugin-sdk/whatsapp"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/whatsapp.js").sendMessageWhatsApp; + sendMessage: typeof import("openclaw/plugin-sdk/whatsapp").sendMessageWhatsApp; }; export const runtimeSend = { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 1deaad96d6f..54fd24b5880 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,3 +1,4 @@ +import { hasAnyWhatsAppAuth } from "openclaw/plugin-sdk/whatsapp"; import { normalizeProviderId } from "../agents/model-selection.js"; import { hasMeaningfulChannelConfig } from "../channels/config-presence.js"; import { @@ -9,7 +10,6 @@ import { listChatChannels, normalizeChatChannelId, } from "../channels/registry.js"; -import { hasAnyWhatsAppAuth } from "../plugin-sdk/whatsapp.js"; import { loadPluginManifestRegistry, type PluginManifestRegistry, diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index e903cd15cab..85966c3e07c 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -1,3 +1,4 @@ +import { resolveWhatsAppAccount } from "openclaw/plugin-sdk/whatsapp"; import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -13,7 +14,6 @@ import { resolveSessionDeliveryTarget, } from "../../infra/outbound/targets.js"; import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; -import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js"; import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; From e64cc1983f686a4dfeb1ca8dbdd9117bdbc1d57b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 09:39:12 -0700 Subject: [PATCH 09/34] Plugin SDK: use public discord subpath --- src/channels/read-only-account-inspect.discord.runtime.ts | 6 +++--- src/cli/send-runtime/discord.ts | 4 ++-- src/config/schema.help.ts | 2 +- src/config/types.discord.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts index 28db6fd4c1e..d52f56ad316 100644 --- a/src/channels/read-only-account-inspect.discord.runtime.ts +++ b/src/channels/read-only-account-inspect.discord.runtime.ts @@ -1,8 +1,8 @@ -import { inspectDiscordAccount as inspectDiscordAccountImpl } from "../plugin-sdk/discord.js"; +import { inspectDiscordAccount as inspectDiscordAccountImpl } from "openclaw/plugin-sdk/discord"; -export type { InspectedDiscordAccount } from "../plugin-sdk/discord.js"; +export type { InspectedDiscordAccount } from "openclaw/plugin-sdk/discord"; -type InspectDiscordAccount = typeof import("../plugin-sdk/discord.js").inspectDiscordAccount; +type InspectDiscordAccount = typeof import("openclaw/plugin-sdk/discord").inspectDiscordAccount; export function inspectDiscordAccount( ...args: Parameters diff --git a/src/cli/send-runtime/discord.ts b/src/cli/send-runtime/discord.ts index 768653752b6..3c6527a8175 100644 --- a/src/cli/send-runtime/discord.ts +++ b/src/cli/send-runtime/discord.ts @@ -1,7 +1,7 @@ -import { sendMessageDiscord as sendMessageDiscordImpl } from "../../plugin-sdk/discord.js"; +import { sendMessageDiscord as sendMessageDiscordImpl } from "openclaw/plugin-sdk/discord"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/discord.js").sendMessageDiscord; + sendMessage: typeof import("openclaw/plugin-sdk/discord").sendMessageDiscord; }; export const runtimeSend = { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index b83c1cfeda2..684246b9ddc 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1,7 +1,7 @@ import { DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "../plugin-sdk/discord.js"; +} from "openclaw/plugin-sdk/discord"; import { MEDIA_AUDIO_FIELD_HELP } from "./media-audio-field-metadata.js"; import { IRC_FIELD_HELP } from "./schema.irc.js"; import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js"; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index c9269c6b8fd..2b115ec67b6 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -1,4 +1,4 @@ -import type { DiscordPluralKitConfig } from "../plugin-sdk/discord.js"; +import type { DiscordPluralKitConfig } from "openclaw/plugin-sdk/discord"; import type { BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, From f187e8bac438eda6fd832f04fd6ef49b594cd874 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 09:40:57 -0700 Subject: [PATCH 10/34] Plugin SDK: use public slack subpath --- src/channels/read-only-account-inspect.slack.runtime.ts | 6 +++--- src/cli/send-runtime/slack.ts | 4 ++-- src/gateway/server-http.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts index f2a9260b63e..0d3e2c878c1 100644 --- a/src/channels/read-only-account-inspect.slack.runtime.ts +++ b/src/channels/read-only-account-inspect.slack.runtime.ts @@ -1,8 +1,8 @@ -import { inspectSlackAccount as inspectSlackAccountImpl } from "../plugin-sdk/slack.js"; +import { inspectSlackAccount as inspectSlackAccountImpl } from "openclaw/plugin-sdk/slack"; -export type { InspectedSlackAccount } from "../plugin-sdk/slack.js"; +export type { InspectedSlackAccount } from "openclaw/plugin-sdk/slack"; -type InspectSlackAccount = typeof import("../plugin-sdk/slack.js").inspectSlackAccount; +type InspectSlackAccount = typeof import("openclaw/plugin-sdk/slack").inspectSlackAccount; export function inspectSlackAccount( ...args: Parameters diff --git a/src/cli/send-runtime/slack.ts b/src/cli/send-runtime/slack.ts index 354186cd128..beec4f55906 100644 --- a/src/cli/send-runtime/slack.ts +++ b/src/cli/send-runtime/slack.ts @@ -1,7 +1,7 @@ -import { sendMessageSlack as sendMessageSlackImpl } from "../../plugin-sdk/slack.js"; +import { sendMessageSlack as sendMessageSlackImpl } from "openclaw/plugin-sdk/slack"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/slack.js").sendMessageSlack; + sendMessage: typeof import("openclaw/plugin-sdk/slack").sendMessageSlack; }; export const runtimeSend = { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 0ad655f4990..9366a917059 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -7,13 +7,13 @@ import { } from "node:http"; import { createServer as createHttpsServer } from "node:https"; import type { TlsOptions } from "node:tls"; +import { handleSlackHttpRequest } from "openclaw/plugin-sdk/slack"; import type { WebSocketServer } from "ws"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; -import { handleSlackHttpRequest } from "../plugin-sdk/slack.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, From a02bfd30c58929aede9ba592c00efc879b65ce47 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 09:43:46 -0700 Subject: [PATCH 11/34] Plugin SDK: use public utility subpaths --- src/acp/control-plane/session-actor-queue.ts | 2 +- src/agents/cli-runner/helpers.ts | 2 +- src/agents/pi-embedded-runner/compact.ts | 2 +- src/agents/pi-embedded-runner/run/attempt.ts | 2 +- src/channels/allowlists/resolve-utils.ts | 2 +- src/cli/send-runtime/signal.ts | 4 ++-- src/infra/outbound/targets.ts | 2 +- src/infra/system-run-normalize.ts | 2 +- src/line/bot-handlers.ts | 2 +- src/security/dm-policy-shared.ts | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/acp/control-plane/session-actor-queue.ts b/src/acp/control-plane/session-actor-queue.ts index 7112d7421e3..54a8d33e54b 100644 --- a/src/acp/control-plane/session-actor-queue.ts +++ b/src/acp/control-plane/session-actor-queue.ts @@ -1,4 +1,4 @@ -import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; +import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; export class SessionActorQueue { private readonly queue = new KeyedAsyncQueue(); diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 96ec35540be..98289396112 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -4,10 +4,10 @@ import os from "node:os"; import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; +import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { CliBackendConfig } from "../../config/types.js"; -import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; import { isRecord } from "../../utils.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 0dfc727dee1..37198c71cda 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -7,6 +7,7 @@ import { estimateTokens, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { resolveSignalReactionLevel } from "openclaw/plugin-sdk/signal"; import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, @@ -23,7 +24,6 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getMemorySearchManager } from "../../memory/index.js"; -import { resolveSignalReactionLevel } from "../../plugin-sdk/signal.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f89759606de..fdf92569c0b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -7,6 +7,7 @@ import { DefaultResourceLoader, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { resolveSignalReactionLevel } from "openclaw/plugin-sdk/signal"; import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, @@ -20,7 +21,6 @@ import { ensureGlobalUndiciStreamTimeouts, } from "../../../infra/net/undici-global-dispatcher.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; -import { resolveSignalReactionLevel } from "../../../plugin-sdk/signal.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { PluginHookAgentContext, diff --git a/src/channels/allowlists/resolve-utils.ts b/src/channels/allowlists/resolve-utils.ts index 2199eaf4ecf..84a3da97b5e 100644 --- a/src/channels/allowlists/resolve-utils.ts +++ b/src/channels/allowlists/resolve-utils.ts @@ -1,4 +1,4 @@ -import { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js"; +import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import type { RuntimeEnv } from "../../runtime.js"; import { summarizeStringEntries } from "../../shared/string-sample.js"; diff --git a/src/cli/send-runtime/signal.ts b/src/cli/send-runtime/signal.ts index 151f13cc351..967fde0bc35 100644 --- a/src/cli/send-runtime/signal.ts +++ b/src/cli/send-runtime/signal.ts @@ -1,7 +1,7 @@ -import { sendMessageSignal as sendMessageSignalImpl } from "../../plugin-sdk/signal.js"; +import { sendMessageSignal as sendMessageSignalImpl } from "openclaw/plugin-sdk/signal"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/signal.js").sendMessageSignal; + sendMessage: typeof import("openclaw/plugin-sdk/signal").sendMessageSignal; }; export const runtimeSend = { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index b15dfb881b2..2d294efbef9 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,10 +1,10 @@ +import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; -import { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.js"; import type { diff --git a/src/infra/system-run-normalize.ts b/src/infra/system-run-normalize.ts index 850685e033b..cbf37809356 100644 --- a/src/infra/system-run-normalize.ts +++ b/src/infra/system-run-normalize.ts @@ -1,4 +1,4 @@ -import { mapAllowFromEntries } from "../plugin-sdk/channel-config-helpers.js"; +import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; export function normalizeNonEmptyString(value: unknown): string | null { if (typeof value !== "string") { diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index 96d82afd33c..0a0d91bf19f 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -7,6 +7,7 @@ import type { LeaveEvent, PostbackEvent, } from "@line/bot-sdk"; +import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { clearHistoryEntriesIfEnabled, @@ -30,7 +31,6 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../pairing/pairing-store.js"; -import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { diff --git a/src/security/dm-policy-shared.ts b/src/security/dm-policy-shared.ts index 7f42f02519e..fdab6636009 100644 --- a/src/security/dm-policy-shared.ts +++ b/src/security/dm-policy-shared.ts @@ -1,9 +1,9 @@ +import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js"; import { resolveControlCommandGate } from "../channels/command-gating.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { GroupPolicy } from "../config/types.base.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; -import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; export function resolvePinnedMainDmOwnerFromAllowlist(params: { From b4f16bad327c8bb03be390ddcd194d7fdab2fa24 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 09:46:24 -0700 Subject: [PATCH 12/34] Plugin SDK: export windows spawn and temp path --- package.json | 8 ++++++++ scripts/lib/plugin-sdk-entrypoints.json | 2 ++ src/acp/client.ts | 6 +++--- src/agents/sandbox/docker.ts | 4 ++-- src/line/download.ts | 2 +- src/media-understanding/attachments.cache.ts | 2 +- src/memory/qmd-process.ts | 2 +- 7 files changed, 18 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index ab3c95330e0..f752857492f 100644 --- a/package.json +++ b/package.json @@ -410,6 +410,10 @@ "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" }, + "./plugin-sdk/windows-spawn": { + "types": "./dist/plugin-sdk/windows-spawn.d.ts", + "default": "./dist/plugin-sdk/windows-spawn.js" + }, "./plugin-sdk/provider-auth": { "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" @@ -486,6 +490,10 @@ "types": "./dist/plugin-sdk/state-paths.d.ts", "default": "./dist/plugin-sdk/state-paths.js" }, + "./plugin-sdk/temp-path": { + "types": "./dist/plugin-sdk/temp-path.d.ts", + "default": "./dist/plugin-sdk/temp-path.js" + }, "./plugin-sdk/tool-send": { "types": "./dist/plugin-sdk/tool-send.d.ts", "default": "./dist/plugin-sdk/tool-send.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index ac54dabe731..555c9e54bb7 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -92,6 +92,7 @@ "directory-runtime", "json-store", "keyed-async-queue", + "windows-spawn", "provider-auth", "provider-auth-api-key", "provider-auth-login", @@ -111,6 +112,7 @@ "web-media", "speech", "state-paths", + "temp-path", "tool-send", "secret-input-schema" ] diff --git a/src/acp/client.ts b/src/acp/client.ts index 1d25281cce5..f3a04371c55 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -13,12 +13,12 @@ import { type RequestPermissionResponse, type SessionNotification, } from "@agentclientprotocol/sdk"; -import { isKnownCoreToolId } from "../agents/tool-catalog.js"; -import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, -} from "../plugin-sdk/windows-spawn.js"; +} from "openclaw/plugin-sdk/windows-spawn"; +import { isKnownCoreToolId } from "../agents/tool-catalog.js"; +import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { listKnownProviderAuthEnvVarNames, omitEnvKeysCaseInsensitive, diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 80a2921cb6b..dff86ea6756 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -1,9 +1,9 @@ import { spawn } from "node:child_process"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, -} from "../../plugin-sdk/windows-spawn.js"; +} from "openclaw/plugin-sdk/windows-spawn"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; import { sanitizeEnvVars } from "./sanitize-env-vars.js"; import type { EnvSanitizationOptions } from "./sanitize-env-vars.js"; diff --git a/src/line/download.ts b/src/line/download.ts index 8ec7ad45c32..6067fcc01f4 100644 --- a/src/line/download.ts +++ b/src/line/download.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import { messagingApi } from "@line/bot-sdk"; +import { buildRandomTempFilePath } from "openclaw/plugin-sdk/temp-path"; import { logVerbose } from "../globals.js"; -import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js"; interface DownloadResult { path: string; diff --git a/src/media-understanding/attachments.cache.ts b/src/media-understanding/attachments.cache.ts index f8e61265022..ce4f966d56d 100644 --- a/src/media-understanding/attachments.cache.ts +++ b/src/media-understanding/attachments.cache.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { buildRandomTempFilePath } from "openclaw/plugin-sdk/temp-path"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { isAbortError } from "../infra/unhandled-rejections.js"; import { fetchRemoteMedia, MediaFetchError } from "../media/fetch.js"; @@ -10,7 +11,6 @@ import { } from "../media/inbound-path-policy.js"; import { getDefaultMediaLocalRoots } from "../media/local-roots.js"; import { detectMime } from "../media/mime.js"; -import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js"; import { normalizeAttachmentPath } from "./attachments.normalize.js"; import { MediaUnderstandingSkipError } from "./errors.js"; import { fetchWithTimeout } from "./providers/shared.js"; diff --git a/src/memory/qmd-process.ts b/src/memory/qmd-process.ts index 5a70cd3c361..60d1efd41ed 100644 --- a/src/memory/qmd-process.ts +++ b/src/memory/qmd-process.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, -} from "../plugin-sdk/windows-spawn.js"; +} from "openclaw/plugin-sdk/windows-spawn"; export type CliSpawnInvocation = { command: string; From 891e2a3da8c674f284cdc2cd71acd86d34782d7b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 09:54:22 -0700 Subject: [PATCH 13/34] Build: isolate optional bundled plugin-sdk clusters --- scripts/lib/optional-bundled-clusters.mjs | 14 ++++++ src/plugin-sdk/googlechat.ts | 38 +++++++++++++-- src/plugin-sdk/matrix.ts | 21 ++++++++- src/plugin-sdk/msteams.ts | 21 ++++++++- src/plugin-sdk/nostr.ts | 20 +++++++- src/plugin-sdk/optional-channel-setup.ts | 56 +++++++++++++++++++++++ src/plugin-sdk/tlon.ts | 20 +++++++- src/plugin-sdk/twitch.ts | 21 +++++++-- src/plugin-sdk/zalouser.ts | 21 ++++++++- tsdown.config.ts | 4 ++ 10 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 src/plugin-sdk/optional-channel-setup.ts diff --git a/scripts/lib/optional-bundled-clusters.mjs b/scripts/lib/optional-bundled-clusters.mjs index c3c442d4ae7..153dfee4ad6 100644 --- a/scripts/lib/optional-bundled-clusters.mjs +++ b/scripts/lib/optional-bundled-clusters.mjs @@ -14,3 +14,17 @@ export const optionalBundledClusters = [ ]; export const optionalBundledClusterSet = new Set(optionalBundledClusters); + +export const OPTIONAL_BUNDLED_BUILD_ENV = "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; + +export function isOptionalBundledCluster(cluster) { + return optionalBundledClusterSet.has(cluster); +} + +export function shouldIncludeOptionalBundledClusters(env = process.env) { + return env[OPTIONAL_BUNDLED_BUILD_ENV] === "1"; +} + +export function shouldBuildBundledCluster(cluster, env = process.env) { + return shouldIncludeOptionalBundledClusters(env) || !isOptionalBundledCluster(cluster); +} diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index ade38097fad..bbb818b78b8 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -1,6 +1,12 @@ // Narrow plugin-sdk surface for the bundled googlechat plugin. // Keep this list additive and scoped to symbols used under extensions/googlechat. +import { resolveChannelGroupRequireMention } from "./channel-policy.js"; +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export { createActionGate, jsonResult, @@ -20,7 +26,6 @@ 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 "../../extensions/googlechat/src/group-policy.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { @@ -65,8 +70,6 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { googlechatSetupAdapter } from "../../extensions/googlechat/api.js"; -export { googlechatSetupWizard } from "../../extensions/googlechat/api.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; @@ -88,3 +91,32 @@ export { resolveWebhookTargetWithAuthOrReject, withResolvedWebhookRequestPipeline, } from "./webhook-targets.js"; + +type GoogleChatGroupContext = { + cfg: import("../config/config.js").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, + }); +} + +export const googlechatSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "googlechat", + label: "Google Chat", + npmSpec: "@openclaw/googlechat", + docsPath: "/channels/googlechat", +}); + +export const googlechatSetupWizard = createOptionalChannelSetupWizard({ + channel: "googlechat", + label: "Google Chat", + npmSpec: "@openclaw/googlechat", + docsPath: "/channels/googlechat", +}); diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 099b53792da..5bbaac2ce48 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled matrix plugin. // Keep this list additive and scoped to symbols used under extensions/matrix. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export { createActionGate, jsonResult, @@ -108,5 +113,17 @@ export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; -export { matrixSetupWizard } from "../../extensions/matrix/api.js"; -export { matrixSetupAdapter } from "../../extensions/matrix/api.js"; + +export const matrixSetupWizard = createOptionalChannelSetupWizard({ + channel: "matrix", + label: "Matrix", + npmSpec: "@openclaw/matrix", + docsPath: "/channels/matrix", +}); + +export const matrixSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "matrix", + label: "Matrix", + npmSpec: "@openclaw/matrix", + docsPath: "/channels/matrix", +}); diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 1185558de79..803dd999a62 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled msteams plugin. // Keep this list additive and scoped to symbols used under extensions/msteams. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export type { ChunkMode } from "../auto-reply/chunk.js"; export type { HistoryEntry } from "../auto-reply/reply/history.js"; export { @@ -117,5 +122,17 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; -export { msteamsSetupWizard } from "../../extensions/msteams/api.js"; -export { msteamsSetupAdapter } from "../../extensions/msteams/api.js"; + +export const msteamsSetupWizard = createOptionalChannelSetupWizard({ + channel: "msteams", + label: "Microsoft Teams", + npmSpec: "@openclaw/msteams", + docsPath: "/channels/msteams", +}); + +export const msteamsSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "msteams", + label: "Microsoft Teams", + npmSpec: "@openclaw/msteams", + docsPath: "/channels/msteams", +}); diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index 4c8abc0f15a..a3bd64e34fc 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled nostr plugin. // Keep this list additive and scoped to symbols used under extensions/nostr. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; @@ -19,4 +24,17 @@ export { } from "./status-helpers.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js"; -export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/setup-api.js"; + +export const nostrSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "nostr", + label: "Nostr", + npmSpec: "@openclaw/nostr", + docsPath: "/channels/nostr", +}); + +export const nostrSetupWizard = createOptionalChannelSetupWizard({ + channel: "nostr", + label: "Nostr", + npmSpec: "@openclaw/nostr", + docsPath: "/channels/nostr", +}); diff --git a/src/plugin-sdk/optional-channel-setup.ts b/src/plugin-sdk/optional-channel-setup.ts new file mode 100644 index 00000000000..42f62e2efcd --- /dev/null +++ b/src/plugin-sdk/optional-channel-setup.ts @@ -0,0 +1,56 @@ +import type { ChannelSetupWizard } from "../channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { formatDocsLink } from "../terminal/links.js"; + +type OptionalChannelSetupParams = { + channel: string; + label: string; + npmSpec?: string; + docsPath?: string; +}; + +function buildOptionalChannelSetupMessage(params: OptionalChannelSetupParams): string { + const installTarget = params.npmSpec ?? `the ${params.label} plugin`; + const message = [`${params.label} setup requires ${installTarget} to be installed.`]; + if (params.docsPath) { + message.push(`Docs: ${formatDocsLink(params.docsPath, params.docsPath.replace(/^\/+/u, ""))}`); + } + return message.join(" "); +} + +export function createOptionalChannelSetupAdapter( + params: OptionalChannelSetupParams, +): ChannelSetupAdapter { + const message = buildOptionalChannelSetupMessage(params); + return { + resolveAccountId: ({ accountId }) => accountId ?? DEFAULT_ACCOUNT_ID, + applyAccountConfig: () => { + throw new Error(message); + }, + validateInput: () => message, + }; +} + +export function createOptionalChannelSetupWizard( + params: OptionalChannelSetupParams, +): ChannelSetupWizard { + const message = buildOptionalChannelSetupMessage(params); + return { + channel: params.channel, + status: { + configuredLabel: `${params.label} plugin installed`, + unconfiguredLabel: `install ${params.label} plugin`, + configuredHint: message, + unconfiguredHint: message, + unconfiguredScore: 0, + resolveConfigured: () => false, + resolveStatusLines: () => [message], + resolveSelectionHint: () => message, + }, + credentials: [], + finalize: async () => { + throw new Error(message); + }, + }; +} diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 1bcd9078292..cd11ca66545 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled tlon plugin. // Keep this list additive and scoped to symbols used under extensions/tlon. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export type { ReplyPayload } from "../auto-reply/types.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { @@ -27,4 +32,17 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; -export { tlonSetupAdapter, tlonSetupWizard } from "../../extensions/tlon/setup-api.js"; + +export const tlonSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "tlon", + label: "Tlon", + npmSpec: "@openclaw/tlon", + docsPath: "/channels/tlon", +}); + +export const tlonSetupWizard = createOptionalChannelSetupWizard({ + channel: "tlon", + label: "Tlon", + npmSpec: "@openclaw/tlon", + docsPath: "/channels/tlon", +}); diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index 907cdd171fa..77bba58209e 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled twitch plugin. // Keep this list additive and scoped to symbols used under extensions/twitch. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export type { ReplyPayload } from "../auto-reply/types.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export type { @@ -33,7 +38,15 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - twitchSetupAdapter, - twitchSetupWizard, -} from "../../extensions/twitch/src/setup-surface.js"; + +export const twitchSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "twitch", + label: "Twitch", + npmSpec: "@openclaw/twitch", +}); + +export const twitchSetupWizard = createOptionalChannelSetupWizard({ + channel: "twitch", + label: "Twitch", + npmSpec: "@openclaw/twitch", +}); diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index ed66e31754e..e2ab63e0e7a 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled zalouser plugin. // Keep this list additive and scoped to symbols used under extensions/zalouser. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export type { ReplyPayload } from "../auto-reply/types.js"; export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; @@ -53,8 +58,6 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; -export { zalouserSetupAdapter } from "../../extensions/zalouser/api.js"; -export { zalouserSetupWizard } from "../../extensions/zalouser/api.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, @@ -73,3 +76,17 @@ export { export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { buildBaseAccountStatusSnapshot } from "./status-helpers.js"; export { chunkTextForOutbound } from "./text-chunking.js"; + +export const zalouserSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "zalouser", + label: "Zalo Personal", + npmSpec: "@openclaw/zalouser", + docsPath: "/channels/zalouser", +}); + +export const zalouserSetupWizard = createOptionalChannelSetupWizard({ + channel: "zalouser", + label: "Zalo Personal", + npmSpec: "@openclaw/zalouser", + docsPath: "/channels/zalouser", +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 0d643b046ac..aafa874a041 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { defineConfig, type UserConfig } from "tsdown"; +import { shouldBuildBundledCluster } from "./scripts/lib/optional-bundled-clusters.mjs"; import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs"; type InputOptionsFactory = Extract, Function>; @@ -81,6 +82,9 @@ function listBundledPluginBuildEntries(): Record { if (!dirent.isDirectory()) { continue; } + if (!shouldBuildBundledCluster(dirent.name, process.env)) { + continue; + } const pluginDir = path.join(extensionsRoot, dirent.name); const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); From 05b1cdec3c88e5164522f35d0498ca19cdddb6f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 16:57:27 +0000 Subject: [PATCH 14/34] test: make runner scheduling timing-driven --- docs/help/testing.md | 4 + docs/reference/test.md | 3 +- package.json | 1 + scripts/test-parallel.mjs | 429 ++++++++++------------ scripts/test-runner-manifest.mjs | 129 +++++++ scripts/test-update-timings.mjs | 109 ++++++ test/fixtures/test-parallel.behavior.json | 60 +++ test/fixtures/test-timings.unit.json | 135 +++++++ 8 files changed, 639 insertions(+), 231 deletions(-) create mode 100644 scripts/test-runner-manifest.mjs create mode 100644 scripts/test-update-timings.mjs create mode 100644 test/fixtures/test-parallel.behavior.json create mode 100644 test/fixtures/test-timings.unit.json diff --git a/docs/help/testing.md b/docs/help/testing.md index 2d7e9664176..6fb91982f1d 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -52,6 +52,10 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Runs in CI - No real keys required - Should be fast and stable +- Scheduler note: + - `pnpm test` now keeps a small checked-in behavioral manifest for true pool/isolation overrides and a separate timing snapshot for the slowest unit files. + - Shared unit coverage stays on, but the wrapper peels the heaviest measured files into dedicated lanes instead of relying on a growing hand-maintained exclusion list. + - Refresh the timing snapshot with `pnpm test:perf:update-timings` after major suite shape changes. - Embedded runner note: - When you change message-tool discovery inputs or compaction runtime context, keep both levels of coverage. diff --git a/docs/reference/test.md b/docs/reference/test.md index 378789f6d6e..e337e963e1d 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -12,9 +12,10 @@ title: "Tests" - `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied. - `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic. - `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for faster startup. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`. -- `pnpm test`: runs the fast core unit lane by default for quick local feedback. +- `pnpm test`: runs the full wrapper. It keeps only a small behavioral override manifest in git, then uses a checked-in timing snapshot to peel the heaviest measured unit files into dedicated lanes. - `pnpm test:channels`: runs channel-heavy suites. - `pnpm test:extensions`: runs extension/plugin suites. +- `pnpm test:perf:update-timings`: refreshes the checked-in slow-file timing snapshot used by `scripts/test-parallel.mjs`. - Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`. - `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `vmForks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs. - `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip. diff --git a/package.json b/package.json index f752857492f..413fee96094 100644 --- a/package.json +++ b/package.json @@ -642,6 +642,7 @@ "test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh", "test:perf:budget": "node scripts/test-perf-budget.mjs", "test:perf:hotspots": "node scripts/test-hotspots.mjs", + "test:perf:update-timings": "node scripts/test-update-timings.mjs", "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", "test:startup:memory": "node scripts/check-cli-startup-memory.mjs", "test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index dc7158a4cb7..68361a6b094 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -3,127 +3,30 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { channelTestPrefixes } from "../vitest.channel-paths.mjs"; +import { + loadTestRunnerBehavior, + loadUnitTimingManifest, + packFilesByDuration, + selectTimedHeavyFiles, +} from "./test-runner-manifest.mjs"; // On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell // (especially under GitHub Actions + Git Bash). Use `shell: true` and let the shell resolve pnpm. const pnpm = "pnpm"; - -const unitIsolatedFilesRaw = [ - "src/plugins/loader.test.ts", - "src/plugins/tools.optional.test.ts", - "src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts", - "src/security/fix.test.ts", - // Runtime source guard scans are sensitive to filesystem contention. - "src/security/temp-path-guard.test.ts", - "src/security/audit.test.ts", - "src/utils.test.ts", - "src/auto-reply/tool-meta.test.ts", - "src/auto-reply/envelope.test.ts", - "src/commands/auth-choice.test.ts", - // Provider runtime contract imports plugin runtimes plus async ESM mocks; - // keep it off the shared fast lane to avoid teardown stalls on this host. - "src/plugins/contracts/runtime.contract.test.ts", - // Process supervision + docker setup suites are stable but setup-heavy. - "src/process/supervisor/supervisor.test.ts", - "src/docker-setup.test.ts", - // Filesystem-heavy skills sync suite. - "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts", - // Real git hook integration test; keep signal, move off unit-fast critical path. - "test/git-hooks-pre-commit.test.ts", - // Setup-heavy doctor command suites; keep them off the unit-fast critical path. - "src/commands/doctor.warns-state-directory-is-missing.test.ts", - "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts", - "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts", - // Setup-heavy CLI update flow suite; move off unit-fast critical path. - "src/cli/update-cli.test.ts", - // Uses temp repos + module cache resets; keep it off vmForks to avoid ref-resolution flakes. - "src/infra/git-commit.test.ts", - // Expensive schema build/bootstrap checks; keep coverage but run in isolated lane. - "src/config/schema.test.ts", - "src/config/schema.tags.test.ts", - // CLI smoke/agent flows are stable but setup-heavy. - "src/cli/program.smoke.test.ts", - "src/commands/agent.test.ts", - "src/media/store.test.ts", - "src/media/store.header-ext.test.ts", - "extensions/whatsapp/src/media.test.ts", - "extensions/whatsapp/src/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts", - "src/browser/server.covers-additional-endpoint-branches.test.ts", - "src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts", - "src/browser/server.agent-contract-snapshot-endpoints.test.ts", - "src/browser/server.agent-contract-form-layout-act-commands.test.ts", - "src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts", - "src/browser/server.auth-token-gates-http.test.ts", - // Keep this high-variance heavy file off the unit-fast critical path. - "src/auto-reply/reply.block-streaming.test.ts", - // Archive extraction/fixture-heavy suite; keep off unit-fast critical path. - "src/hooks/install.test.ts", - // Download/extraction safety cases can spike under unit-fast contention. - "src/agents/skills-install.download.test.ts", - // Skills discovery/snapshot suites are filesystem-heavy and high-variance in vmForks lanes. - "src/agents/skills.test.ts", - "src/agents/skills.buildworkspaceskillsnapshot.test.ts", - "extensions/acpx/src/runtime.test.ts", - // Shell-heavy script harness can contend under vmForks startup bursts. - "test/scripts/ios-team-id.test.ts", - // Heavy runner/exec/archive suites are stable but contend on shared resources under vmForks. - "src/agents/pi-embedded-runner.test.ts", - "src/agents/bash-tools.test.ts", - "src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts", - "src/agents/bash-tools.exec.background-abort.test.ts", - "src/agents/subagent-announce.format.test.ts", - "src/infra/archive.test.ts", - "src/cli/daemon-cli.coverage.test.ts", - // Model normalization test imports config/model discovery stack; keep off unit-fast critical path. - "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", - // Auth profile rotation suite is retry-heavy and high-variance under vmForks contention. - "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts", - // Heavy trigger command scenarios; keep off unit-fast critical path to reduce contention noise. - "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts", - "src/auto-reply/reply.triggers.group-intro-prompts.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts", - "extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts", - // Setup-heavy bot bootstrap suite. - "extensions/telegram/src/bot.create-telegram-bot.test.ts", - // Medium-heavy bot behavior suite; move off unit-fast critical path. - "extensions/telegram/src/bot.test.ts", - // Slack slash registration tests are setup-heavy and can bottleneck unit-fast. - "extensions/slack/src/monitor/slash.test.ts", - // Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage. - "extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts", - // Mutates process.cwd() and mocks core module loaders; isolate from the shared fast lane. - "src/infra/git-commit.test.ts", -]; -const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file)); -const unitSingletonIsolatedFilesRaw = [ - // These pass clean in isolation but can hang on fork shutdown after sharing - // the broad unit-fast lane on this host; keep them in dedicated processes. - "src/cli/command-secret-gateway.test.ts", -]; -const unitSingletonIsolatedFiles = unitSingletonIsolatedFilesRaw.filter((file) => - fs.existsSync(file), -); -const unitThreadSingletonFilesRaw = [ - // These suites terminate cleanly under the threads pool but can hang during - // forks worker shutdown on this host. - "src/channels/plugins/actions/actions.test.ts", - "src/infra/outbound/deliver.test.ts", - "src/infra/outbound/deliver.lifecycle.test.ts", - "src/infra/outbound/message.channels.test.ts", - "src/infra/outbound/message-action-runner.poll.test.ts", - "src/tts/tts.test.ts", -]; -const unitThreadSingletonFiles = unitThreadSingletonFilesRaw.filter((file) => fs.existsSync(file)); -const unitVmForkSingletonFilesRaw = [ - "src/channels/plugins/contracts/inbound.telegram.contract.test.ts", -]; -const unitVmForkSingletonFiles = unitVmForkSingletonFilesRaw.filter((file) => fs.existsSync(file)); -const groupedUnitIsolatedFiles = unitIsolatedFiles.filter( - (file) => !unitSingletonIsolatedFiles.includes(file) && !unitThreadSingletonFiles.includes(file), -); -const channelSingletonFilesRaw = []; -const channelSingletonFiles = channelSingletonFilesRaw.filter((file) => fs.existsSync(file)); +const behaviorManifest = loadTestRunnerBehavior(); +const existingFiles = (entries) => + entries.map((entry) => entry.file).filter((file) => fs.existsSync(file)); +const unitBehaviorIsolatedFiles = existingFiles(behaviorManifest.unit.isolated); +const unitSingletonIsolatedFiles = existingFiles(behaviorManifest.unit.singletonIsolated); +const unitThreadSingletonFiles = existingFiles(behaviorManifest.unit.threadSingleton); +const unitVmForkSingletonFiles = existingFiles(behaviorManifest.unit.vmForkSingleton); +const unitBehaviorOverrideSet = new Set([ + ...unitBehaviorIsolatedFiles, + ...unitSingletonIsolatedFiles, + ...unitThreadSingletonFiles, + ...unitVmForkSingletonFiles, +]); +const channelSingletonFiles = []; const children = new Set(); const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; @@ -158,117 +61,7 @@ const testProfile = // Even on low-memory hosts, keep the isolated lane split so files like // git-commit.test.ts still get the worker/process isolation they require. const shouldSplitUnitRuns = testProfile !== "serial"; -const runs = [ - ...(shouldSplitUnitRuns - ? [ - { - name: "unit-fast", - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - `--pool=${useVmForks ? "vmForks" : "forks"}`, - ...(disableIsolation ? ["--isolate=false"] : []), - ...[ - ...unitIsolatedFiles, - ...unitSingletonIsolatedFiles, - ...unitThreadSingletonFiles, - ...unitVmForkSingletonFiles, - ].flatMap((file) => ["--exclude", file]), - ], - }, - ...(groupedUnitIsolatedFiles.length > 0 - ? [ - { - name: "unit-isolated", - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - "--pool=forks", - ...groupedUnitIsolatedFiles, - ], - }, - ] - : []), - ...unitSingletonIsolatedFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-isolated`, - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - `--pool=${useVmForks ? "vmForks" : "forks"}`, - file, - ], - })), - ...unitThreadSingletonFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-threads`, - args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file], - })), - ...unitVmForkSingletonFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-vmforks`, - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - `--pool=${useVmForks ? "vmForks" : "forks"}`, - ...(disableIsolation ? ["--isolate=false"] : []), - file, - ], - })), - ...channelSingletonFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-channels-isolated`, - args: ["vitest", "run", "--config", "vitest.channels.config.ts", "--pool=forks", file], - })), - ] - : [ - { - name: "unit", - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - `--pool=${useVmForks ? "vmForks" : "forks"}`, - ...(disableIsolation ? ["--isolate=false"] : []), - ], - }, - ]), - ...(includeExtensionsSuite - ? [ - { - name: "extensions", - args: [ - "vitest", - "run", - "--config", - "vitest.extensions.config.ts", - ...(useVmForks ? ["--pool=vmForks"] : []), - ], - }, - ] - : []), - ...(includeGatewaySuite - ? [ - { - name: "gateway", - args: [ - "vitest", - "run", - "--config", - "vitest.gateway.config.ts", - // Gateway tests are sensitive to vmForks behavior (global state + env stubs). - // Keep them on process forks for determinism even when other suites use vmForks. - "--pool=forks", - ], - }, - ] - : []), -]; +let runs = []; const shardOverride = Number.parseInt(process.env.OPENCLAW_TEST_SHARDS ?? "", 10); const configuredShardCount = Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : null; @@ -414,7 +207,7 @@ const allKnownTestFiles = [ ]), ]; const inferTarget = (fileFilter) => { - const isolated = unitIsolatedFiles.includes(fileFilter); + const isolated = unitBehaviorIsolatedFiles.includes(fileFilter); if (fileFilter.endsWith(".live.test.ts")) { return { owner: "live", isolated }; } @@ -438,6 +231,155 @@ const inferTarget = (fileFilter) => { } return { owner: "base", isolated }; }; +const unitTimingManifest = loadUnitTimingManifest(); +const parseEnvNumber = (name, fallback) => { + const parsed = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +}; +const allKnownUnitFiles = allKnownTestFiles.filter((file) => inferTarget(file).owner === "unit"); +const defaultHeavyUnitFileLimit = + testProfile === "serial" ? 0 : testProfile === "low" ? 8 : highMemLocalHost ? 24 : 16; +const defaultHeavyUnitLaneCount = + testProfile === "serial" ? 0 : testProfile === "low" ? 1 : highMemLocalHost ? 3 : 2; +const heavyUnitFileLimit = parseEnvNumber( + "OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT", + defaultHeavyUnitFileLimit, +); +const heavyUnitLaneCount = parseEnvNumber( + "OPENCLAW_TEST_HEAVY_UNIT_LANES", + defaultHeavyUnitLaneCount, +); +const heavyUnitMinDurationMs = parseEnvNumber("OPENCLAW_TEST_HEAVY_UNIT_MIN_MS", 1200); +const timedHeavyUnitFiles = + shouldSplitUnitRuns && heavyUnitFileLimit > 0 + ? selectTimedHeavyFiles({ + candidates: allKnownUnitFiles, + limit: heavyUnitFileLimit, + minDurationMs: heavyUnitMinDurationMs, + exclude: unitBehaviorOverrideSet, + timings: unitTimingManifest, + }) + : []; +const unitFastExcludedFiles = [ + ...new Set([...unitBehaviorOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]), +]; +const estimateUnitDurationMs = (file) => + unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs; +const heavyUnitBuckets = packFilesByDuration( + timedHeavyUnitFiles, + heavyUnitLaneCount, + estimateUnitDurationMs, +); +const unitHeavyEntries = heavyUnitBuckets.map((files, index) => ({ + name: `unit-heavy-${String(index + 1)}`, + args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files], +})); +const baseRuns = [ + ...(shouldSplitUnitRuns + ? [ + { + name: "unit-fast", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + ...unitFastExcludedFiles.flatMap((file) => ["--exclude", file]), + ], + }, + ...(unitBehaviorIsolatedFiles.length > 0 + ? [ + { + name: "unit-isolated", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + "--pool=forks", + ...unitBehaviorIsolatedFiles, + ], + }, + ] + : []), + ...unitHeavyEntries, + ...unitSingletonIsolatedFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-isolated`, + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + file, + ], + })), + ...unitThreadSingletonFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-threads`, + args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file], + })), + ...unitVmForkSingletonFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-vmforks`, + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + file, + ], + })), + ...channelSingletonFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-channels-isolated`, + args: ["vitest", "run", "--config", "vitest.channels.config.ts", "--pool=forks", file], + })), + ] + : [ + { + name: "unit", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + ], + }, + ]), + ...(includeExtensionsSuite + ? [ + { + name: "extensions", + args: [ + "vitest", + "run", + "--config", + "vitest.extensions.config.ts", + ...(useVmForks ? ["--pool=vmForks"] : []), + ], + }, + ] + : []), + ...(includeGatewaySuite + ? [ + { + name: "gateway", + args: ["vitest", "run", "--config", "vitest.gateway.config.ts", "--pool=forks"], + }, + ] + : []), +]; +runs = baseRuns; +const formatEntrySummary = (entry) => { + const explicitFilters = countExplicitEntryFilters(entry.args) ?? 0; + return `${entry.name} filters=${String(explicitFilters || "all")} maxWorkers=${String( + maxWorkersForRun(entry.name) ?? "default", + )}`; +}; const resolveFilterMatches = (fileFilter) => { const normalizedFilter = normalizeRepoPath(fileFilter); if (fs.existsSync(fileFilter)) { @@ -674,7 +616,13 @@ const maxWorkersForRun = (name) => { if (isCI && isMacOS) { return 1; } - if (name === "unit-isolated" || name.endsWith("-isolated")) { + if (name.endsWith("-threads") || name.endsWith("-vmforks")) { + return 1; + } + if (name.endsWith("-isolated") && name !== "unit-isolated") { + return 1; + } + if (name === "unit-isolated" || name.startsWith("unit-heavy-")) { return defaultWorkerBudget.unitIsolated; } if (name === "extensions") { @@ -706,9 +654,12 @@ const maxOldSpaceSizeMb = (() => { } return null; })(); +const formatElapsedMs = (elapsedMs) => + elapsedMs >= 1000 ? `${(elapsedMs / 1000).toFixed(1)}s` : `${Math.round(elapsedMs)}ms`; const runOnce = (entry, extraArgs = []) => new Promise((resolve) => { + const startedAt = Date.now(); const maxWorkers = maxWorkersForRun(entry.name); // vmForks with a single worker has shown cross-file leakage in extension suites. // Fall back to process forks when we intentionally clamp that lane to one worker. @@ -726,6 +677,11 @@ const runOnce = (entry, extraArgs = []) => ...extraArgs, ] : [...entryArgs, ...silentArgs, ...windowsCiArgs, ...extraArgs]; + console.log( + `[test-parallel] start ${entry.name} workers=${maxWorkers ?? "default"} filters=${String( + countExplicitEntryFilters(entryArgs) ?? "all", + )}`, + ); const nodeOptions = process.env.NODE_OPTIONS ?? ""; const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce( (acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()), @@ -756,6 +712,11 @@ const runOnce = (entry, extraArgs = []) => }); child.on("exit", (code, signal) => { children.delete(child); + console.log( + `[test-parallel] done ${entry.name} code=${String(code ?? (signal ? 1 : 0))} elapsed=${formatElapsedMs( + Date.now() - startedAt, + )}`, + ); resolve(code ?? (signal ? 1 : 0)); }); }); @@ -823,6 +784,14 @@ const shutdown = (signal) => { process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); +if (process.env.OPENCLAW_TEST_LIST_LANES === "1") { + const entriesToPrint = targetedEntries.length > 0 ? targetedEntries : runs; + for (const entry of entriesToPrint) { + console.log(formatEntrySummary(entry)); + } + process.exit(0); +} + if (targetedEntries.length > 0) { if (passthroughRequiresSingleRun && targetedEntries.length > 1) { console.error( diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs new file mode 100644 index 00000000000..30b4414acc7 --- /dev/null +++ b/scripts/test-runner-manifest.mjs @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const behaviorManifestPath = "test/fixtures/test-parallel.behavior.json"; +export const unitTimingManifestPath = "test/fixtures/test-timings.unit.json"; + +const defaultTimingManifest = { + config: "vitest.unit.config.ts", + defaultDurationMs: 250, + files: {}, +}; + +const readJson = (filePath, fallback) => { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return fallback; + } +}; + +const normalizeRepoPath = (value) => value.split(path.sep).join("/"); + +const normalizeManifestEntries = (entries) => + entries + .map((entry) => + typeof entry === "string" + ? { file: normalizeRepoPath(entry), reason: "" } + : { + file: normalizeRepoPath(String(entry?.file ?? "")), + reason: typeof entry?.reason === "string" ? entry.reason : "", + }, + ) + .filter((entry) => entry.file.length > 0); + +export function loadTestRunnerBehavior() { + const raw = readJson(behaviorManifestPath, {}); + const unit = raw.unit ?? {}; + return { + unit: { + isolated: normalizeManifestEntries(unit.isolated ?? []), + singletonIsolated: normalizeManifestEntries(unit.singletonIsolated ?? []), + threadSingleton: normalizeManifestEntries(unit.threadSingleton ?? []), + vmForkSingleton: normalizeManifestEntries(unit.vmForkSingleton ?? []), + }, + }; +} + +export function loadUnitTimingManifest() { + const raw = readJson(unitTimingManifestPath, defaultTimingManifest); + const defaultDurationMs = + Number.isFinite(raw.defaultDurationMs) && raw.defaultDurationMs > 0 + ? raw.defaultDurationMs + : defaultTimingManifest.defaultDurationMs; + const files = Object.fromEntries( + Object.entries(raw.files ?? {}) + .map(([file, value]) => { + const normalizedFile = normalizeRepoPath(file); + const durationMs = + Number.isFinite(value?.durationMs) && value.durationMs >= 0 ? value.durationMs : null; + const testCount = + Number.isFinite(value?.testCount) && value.testCount >= 0 ? value.testCount : null; + if (!durationMs) { + return [normalizedFile, null]; + } + return [ + normalizedFile, + { + durationMs, + ...(testCount !== null ? { testCount } : {}), + }, + ]; + }) + .filter(([, value]) => value !== null), + ); + + return { + config: + typeof raw.config === "string" && raw.config ? raw.config : defaultTimingManifest.config, + generatedAt: typeof raw.generatedAt === "string" ? raw.generatedAt : "", + defaultDurationMs, + files, + }; +} + +export function selectTimedHeavyFiles({ + candidates, + limit, + minDurationMs, + exclude = new Set(), + timings, +}) { + return candidates + .filter((file) => !exclude.has(file)) + .map((file) => ({ + file, + durationMs: timings.files[file]?.durationMs ?? timings.defaultDurationMs, + known: Boolean(timings.files[file]), + })) + .filter((entry) => entry.known && entry.durationMs >= minDurationMs) + .toSorted((a, b) => b.durationMs - a.durationMs) + .slice(0, limit) + .map((entry) => entry.file); +} + +export function packFilesByDuration(files, bucketCount, estimateDurationMs) { + const normalizedBucketCount = Math.max(0, Math.floor(bucketCount)); + if (normalizedBucketCount <= 0 || files.length === 0) { + return []; + } + + const buckets = Array.from({ length: Math.min(normalizedBucketCount, files.length) }, () => ({ + totalMs: 0, + files: [], + })); + + const sortedFiles = [...files].toSorted((left, right) => { + return estimateDurationMs(right) - estimateDurationMs(left); + }); + + for (const file of sortedFiles) { + const bucket = buckets.reduce((lightest, current) => + current.totalMs < lightest.totalMs ? current : lightest, + ); + bucket.files.push(file); + bucket.totalMs += estimateDurationMs(file); + } + + return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0); +} diff --git a/scripts/test-update-timings.mjs b/scripts/test-update-timings.mjs new file mode 100644 index 00000000000..722d3539f7a --- /dev/null +++ b/scripts/test-update-timings.mjs @@ -0,0 +1,109 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { unitTimingManifestPath } from "./test-runner-manifest.mjs"; + +function parseArgs(argv) { + const args = { + config: "vitest.unit.config.ts", + out: unitTimingManifestPath, + reportPath: "", + limit: 128, + defaultDurationMs: 250, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--config") { + args.config = argv[i + 1] ?? args.config; + i += 1; + continue; + } + if (arg === "--out") { + args.out = argv[i + 1] ?? args.out; + i += 1; + continue; + } + if (arg === "--report") { + args.reportPath = argv[i + 1] ?? ""; + i += 1; + continue; + } + if (arg === "--limit") { + const parsed = Number.parseInt(argv[i + 1] ?? "", 10); + if (Number.isFinite(parsed) && parsed > 0) { + args.limit = parsed; + } + i += 1; + continue; + } + if (arg === "--default-duration-ms") { + const parsed = Number.parseInt(argv[i + 1] ?? "", 10); + if (Number.isFinite(parsed) && parsed > 0) { + args.defaultDurationMs = parsed; + } + i += 1; + continue; + } + } + return args; +} + +const normalizeRepoPath = (value) => value.split(path.sep).join("/"); + +const opts = parseArgs(process.argv.slice(2)); +const reportPath = + opts.reportPath || path.join(os.tmpdir(), `openclaw-vitest-timings-${Date.now()}.json`); + +if (!(opts.reportPath && fs.existsSync(reportPath))) { + const run = spawnSync( + "pnpm", + ["vitest", "run", "--config", opts.config, "--reporter=json", "--outputFile", reportPath], + { + stdio: "inherit", + env: process.env, + }, + ); + + if (run.status !== 0) { + process.exit(run.status ?? 1); + } +} + +const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); +const files = Object.fromEntries( + (report.testResults ?? []) + .map((result) => { + const file = typeof result.name === "string" ? normalizeRepoPath(result.name) : ""; + const start = typeof result.startTime === "number" ? result.startTime : 0; + const end = typeof result.endTime === "number" ? result.endTime : 0; + const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0; + return { + file, + durationMs: Math.max(0, end - start), + testCount, + }; + }) + .filter((entry) => entry.file.length > 0 && entry.durationMs > 0) + .toSorted((a, b) => b.durationMs - a.durationMs) + .slice(0, opts.limit) + .map((entry) => [ + entry.file, + { + durationMs: entry.durationMs, + testCount: entry.testCount, + }, + ]), +); + +const output = { + config: opts.config, + generatedAt: new Date().toISOString(), + defaultDurationMs: opts.defaultDurationMs, + files, +}; + +fs.writeFileSync(opts.out, `${JSON.stringify(output, null, 2)}\n`); +console.log( + `[test-update-timings] wrote ${String(Object.keys(files).length)} timings to ${opts.out}`, +); diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json new file mode 100644 index 00000000000..b1ed463612e --- /dev/null +++ b/test/fixtures/test-parallel.behavior.json @@ -0,0 +1,60 @@ +{ + "unit": { + "isolated": [ + { + "file": "src/plugins/contracts/runtime.contract.test.ts", + "reason": "Async runtime imports + provider refresh seams; keep out of the shared lane." + }, + { + "file": "src/security/temp-path-guard.test.ts", + "reason": "Filesystem guard scans are sensitive to contention." + }, + { + "file": "src/infra/git-commit.test.ts", + "reason": "Mutates process.cwd() and core loader seams." + }, + { + "file": "extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts", + "reason": "Touches process-level unhandledRejection listeners." + } + ], + "singletonIsolated": [ + { + "file": "src/cli/command-secret-gateway.test.ts", + "reason": "Clean in isolation, but can hang after sharing the broad lane." + } + ], + "threadSingleton": [ + { + "file": "src/channels/plugins/actions/actions.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/infra/outbound/deliver.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/infra/outbound/deliver.lifecycle.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/infra/outbound/message.channels.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/infra/outbound/message-action-runner.poll.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/tts/tts.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + } + ], + "vmForkSingleton": [ + { + "file": "src/channels/plugins/contracts/inbound.telegram.contract.test.ts", + "reason": "Needs the vmForks lane when targeted." + } + ] + } +} diff --git a/test/fixtures/test-timings.unit.json b/test/fixtures/test-timings.unit.json new file mode 100644 index 00000000000..2199276bc5b --- /dev/null +++ b/test/fixtures/test-timings.unit.json @@ -0,0 +1,135 @@ +{ + "config": "vitest.unit.config.ts", + "generatedAt": "2026-03-18T17:10:00.000Z", + "defaultDurationMs": 250, + "files": { + "src/security/audit.test.ts": { + "durationMs": 6200, + "testCount": 380 + }, + "src/plugins/loader.test.ts": { + "durationMs": 6100, + "testCount": 260 + }, + "src/cli/update-cli.test.ts": { + "durationMs": 5400, + "testCount": 210 + }, + "src/agents/pi-embedded-runner.test.ts": { + "durationMs": 5200, + "testCount": 140 + }, + "src/process/supervisor/supervisor.test.ts": { + "durationMs": 5000, + "testCount": 120 + }, + "src/agents/bash-tools.test.ts": { + "durationMs": 4700, + "testCount": 150 + }, + "src/cli/program.smoke.test.ts": { + "durationMs": 4500, + "testCount": 95 + }, + "src/hooks/install.test.ts": { + "durationMs": 4300, + "testCount": 95 + }, + "src/agents/skills.test.ts": { + "durationMs": 4200, + "testCount": 135 + }, + "src/config/schema.test.ts": { + "durationMs": 4000, + "testCount": 110 + }, + "src/media/store.test.ts": { + "durationMs": 3900, + "testCount": 120 + }, + "src/commands/agent.test.ts": { + "durationMs": 3700, + "testCount": 110 + }, + "extensions/telegram/src/bot.create-telegram-bot.test.ts": { + "durationMs": 3600, + "testCount": 80 + }, + "extensions/telegram/src/bot.test.ts": { + "durationMs": 3400, + "testCount": 95 + }, + "src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts": { + "durationMs": 3300, + "testCount": 85 + }, + "src/infra/archive.test.ts": { + "durationMs": 3200, + "testCount": 75 + }, + "src/auto-reply/reply.block-streaming.test.ts": { + "durationMs": 3100, + "testCount": 60 + }, + "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts": { + "durationMs": 3000, + "testCount": 55 + }, + "src/agents/skills.buildworkspaceskillsnapshot.test.ts": { + "durationMs": 2900, + "testCount": 70 + }, + "src/docker-setup.test.ts": { + "durationMs": 2800, + "testCount": 65 + }, + "src/agents/skills-install.download.test.ts": { + "durationMs": 2700, + "testCount": 60 + }, + "src/config/schema.tags.test.ts": { + "durationMs": 2600, + "testCount": 70 + }, + "src/cli/daemon-cli.coverage.test.ts": { + "durationMs": 2500, + "testCount": 50 + }, + "extensions/slack/src/monitor/slash.test.ts": { + "durationMs": 2400, + "testCount": 55 + }, + "test/git-hooks-pre-commit.test.ts": { + "durationMs": 2300, + "testCount": 20 + }, + "src/commands/doctor.warns-state-directory-is-missing.test.ts": { + "durationMs": 2200, + "testCount": 35 + }, + "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts": { + "durationMs": 2100, + "testCount": 30 + }, + "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts": { + "durationMs": 2000, + "testCount": 28 + }, + "src/browser/server.agent-contract-snapshot-endpoints.test.ts": { + "durationMs": 1900, + "testCount": 45 + }, + "src/browser/server.agent-contract-form-layout-act-commands.test.ts": { + "durationMs": 1800, + "testCount": 40 + }, + "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts": { + "durationMs": 1700, + "testCount": 25 + }, + "src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts": { + "durationMs": 1600, + "testCount": 22 + } + } +} From 467ec4d5f30a1786e2601c68212235a599709f14 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 10:02:21 -0700 Subject: [PATCH 15/34] Types: fix optional cluster check follow-ups --- CONTRIBUTING.md | 4 ++-- extensions/nostr/api.ts | 1 - extensions/tlon/api.ts | 1 - extensions/whatsapp/src/shared.ts | 15 ++++++++++++++- scripts/lib/optional-bundled-clusters.d.mts | 6 ++++++ 5 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 scripts/lib/optional-bundled-clusters.d.mts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d43d661161..8914ffc1f31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,7 @@ Welcome to the lobster tank! 🦞 1. **Bugs & small fixes** → Open a PR! 2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Test/CI-only PRs for known `main` failures** → Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a *new* regression not yet shown in main CI, report it as an issue first. +3. **Test/CI-only PRs for known `main` failures** → Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a _new_ regression not yet shown in main CI, report it as an issue first. 4. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR @@ -97,7 +97,7 @@ Welcome to the lobster tank! 🦞 - For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins` - If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review - If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs. -- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a *new* regression not yet shown in main CI, report it as an issue first. +- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first. - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Describe what & why diff --git a/extensions/nostr/api.ts b/extensions/nostr/api.ts index 2de81f11142..3f3d64cc3bf 100644 --- a/extensions/nostr/api.ts +++ b/extensions/nostr/api.ts @@ -1,2 +1 @@ export * from "openclaw/plugin-sdk/nostr"; -export * from "./setup-api.js"; diff --git a/extensions/tlon/api.ts b/extensions/tlon/api.ts index bccfa85fbac..5364c68f07d 100644 --- a/extensions/tlon/api.ts +++ b/extensions/tlon/api.ts @@ -1,2 +1 @@ export * from "openclaw/plugin-sdk/tlon"; -export * from "./setup-api.js"; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 5fa27f42030..3888cdc36d3 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -167,5 +167,18 @@ export function createWhatsAppPluginBase(params: { }, setup: params.setup, groups: params.groups, - }); + }) as Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "reload" + | "gatewayMethods" + | "configSchema" + | "config" + | "security" + | "setup" + | "groups" + >; } diff --git a/scripts/lib/optional-bundled-clusters.d.mts b/scripts/lib/optional-bundled-clusters.d.mts new file mode 100644 index 00000000000..42640bd1772 --- /dev/null +++ b/scripts/lib/optional-bundled-clusters.d.mts @@ -0,0 +1,6 @@ +export const optionalBundledClusters: string[]; +export const optionalBundledClusterSet: Set; +export const OPTIONAL_BUNDLED_BUILD_ENV: string; +export function isOptionalBundledCluster(cluster: string): boolean; +export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; +export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean; From ff326e90c33f72bb1b96684dabe594e2c75eb599 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 10:14:53 -0700 Subject: [PATCH 16/34] Build: use hoisted pnpm linker --- .npmrc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.npmrc b/.npmrc index 05620061611..bdf24a6c276 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,4 @@ # pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies. +# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker. +# Keep the workspace on a hoisted layout so pnpm check/build stay stable. +node-linker=hoisted From b49946a67e053f02c92c0f1bc9079a920f011995 Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:24:17 -0700 Subject: [PATCH 17/34] Slack: import directory helpers (#49930) import the config-backed Slack directory helpers into the Slack channel plugin so directory.listPeers and directory.listGroups no longer throw at runtime, and add a regression test covering configured DM peer listing --- extensions/slack/src/channel.test.ts | 22 ++++++++++++++++++++++ extensions/slack/src/channel.ts | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 4f22cd91263..e8d03f88b45 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -171,6 +171,28 @@ describe("slackPlugin outbound", () => { }); }); +describe("slackPlugin directory", () => { + it("lists configured peers without throwing a ReferenceError", async () => { + const listPeers = slackPlugin.directory?.listPeers; + expect(listPeers).toBeDefined(); + + await expect( + listPeers!({ + cfg: { + channels: { + slack: { + dms: { + U123: {}, + }, + }, + }, + }, + runtime: undefined, + }), + ).resolves.toEqual([{ id: "user:u123", kind: "user" }]); + }); +}); + describe("slackPlugin agentPrompt", () => { it("tells agents interactive replies are disabled by default", () => { const hints = slackPlugin.agentPrompt?.messageToolHints?.({ diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index dca51eb1fc7..5dc8876f15f 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -26,6 +26,10 @@ import type { SlackActionContext } from "./action-runtime.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackActions } from "./channel-actions.js"; import { createSlackWebClient } from "./client.js"; +import { + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, +} from "./directory-config.js"; import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; From 656679e6e09168a67e12b44589801792499ca22f Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:28:59 -0700 Subject: [PATCH 18/34] Slack: remove duplicate directory imports (#49935) --- extensions/slack/src/channel.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 5dc8876f15f..1942d3674ed 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,8 +38,6 @@ import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, From 8d73bc77fa5d4eb733891efd8bbca5a5d14d9d58 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 17:29:54 +0000 Subject: [PATCH 19/34] refactor: deduplicate reply payload helpers --- extensions/bluebubbles/src/channel.ts | 79 +++---- .../bluebubbles/src/monitor-processing.ts | 95 ++++---- extensions/discord/src/channel.ts | 87 +++---- .../discord/src/monitor/native-command.ts | 41 ++-- .../src/monitor/reply-delivery.test.ts | 14 +- .../discord/src/monitor/reply-delivery.ts | 115 +++++---- .../discord/src/outbound-adapter.test.ts | 61 +++++ extensions/discord/src/outbound-adapter.ts | 138 ++++++----- extensions/discord/src/send.shared.ts | 6 +- extensions/feishu/src/outbound.ts | 223 +++++++++--------- extensions/googlechat/src/channel.ts | 176 +++++++------- extensions/googlechat/src/monitor.ts | 91 ++++--- extensions/imessage/src/channel.ts | 60 ++--- extensions/imessage/src/monitor/deliver.ts | 33 +-- extensions/irc/src/channel.ts | 33 ++- extensions/irc/src/inbound.ts | 33 ++- extensions/line/src/channel.ts | 82 +++---- extensions/matrix/src/channel.ts | 8 +- .../matrix/src/matrix/monitor/replies.ts | 56 ++--- extensions/mattermost/src/channel.ts | 68 +++--- .../src/mattermost/reply-delivery.ts | 57 ++--- extensions/msteams/src/messenger.ts | 3 +- extensions/msteams/src/outbound.ts | 106 +++++---- extensions/nextcloud-talk/src/channel.ts | 33 ++- extensions/nextcloud-talk/src/inbound.ts | 21 +- extensions/nostr/src/channel.ts | 6 +- extensions/signal/src/channel.ts | 52 ++-- extensions/signal/src/monitor.ts | 31 ++- extensions/signal/src/outbound-adapter.ts | 68 +++--- extensions/slack/src/channel.test.ts | 74 ++++++ extensions/slack/src/channel.ts | 93 ++++---- extensions/slack/src/monitor/replies.ts | 42 +++- extensions/slack/src/outbound-adapter.ts | 140 ++++++----- extensions/slack/src/send.ts | 9 +- extensions/synology-chat/src/channel.ts | 5 +- extensions/telegram/src/channel.ts | 80 ++++--- extensions/telegram/src/outbound-adapter.ts | 99 ++++---- .../whatsapp/src/auto-reply/deliver-reply.ts | 49 ++-- .../src/outbound-adapter.poll.test.ts | 8 +- extensions/whatsapp/src/outbound-adapter.ts | 82 ++++--- extensions/zalo/src/channel.ts | 57 ++--- extensions/zalo/src/monitor.ts | 50 ++-- extensions/zalouser/src/channel.ts | 61 ++--- extensions/zalouser/src/monitor.ts | 46 ++-- scripts/lib/plugin-sdk-entrypoints.json | 2 + .../outbound/direct-text-media.test.ts | 82 +++++++ .../plugins/outbound/direct-text-media.ts | 35 +++ .../plugins/threading-helpers.test.ts | 73 ++++++ src/channels/plugins/threading-helpers.ts | 32 +++ src/channels/plugins/whatsapp-shared.ts | 80 ++++--- src/gateway/server-methods/send.ts | 5 +- src/infra/outbound/deliver.ts | 37 +-- src/infra/outbound/message.ts | 5 +- src/infra/outbound/payloads.ts | 6 +- src/line/auto-reply-delivery.ts | 3 +- src/plugin-sdk/channel-runtime.ts | 2 + src/plugin-sdk/channel-send-result.test.ts | 120 ++++++++++ src/plugin-sdk/channel-send-result.ts | 65 +++++ src/plugin-sdk/discord-send.ts | 3 +- src/plugin-sdk/irc.ts | 1 + src/plugin-sdk/msteams.ts | 1 + src/plugin-sdk/nextcloud-talk.ts | 1 + src/plugin-sdk/reply-payload.test.ts | 164 ++++++++++++- src/plugin-sdk/reply-payload.ts | 91 ++++++- src/plugin-sdk/subpaths.test.ts | 31 +++ src/plugin-sdk/zalo.ts | 1 + src/plugin-sdk/zalouser.ts | 1 + 67 files changed, 2246 insertions(+), 1366 deletions(-) create mode 100644 src/channels/plugins/outbound/direct-text-media.test.ts create mode 100644 src/channels/plugins/threading-helpers.test.ts create mode 100644 src/channels/plugins/threading-helpers.ts create mode 100644 src/plugin-sdk/channel-send-result.test.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index b13d21f71fd..4d4b411a639 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -9,6 +9,7 @@ import { projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createPairingPrefixStripper, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; @@ -262,46 +263,44 @@ export const bluebubblesPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const runtime = await loadBlueBubblesChannelRuntime(); - const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; - // Resolve short ID (e.g., "5") to full UUID - const replyToMessageGuid = rawReplyToId - ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) - : ""; - const result = await runtime.sendMessageBlueBubbles(to, text, { - cfg: cfg, - accountId: accountId ?? undefined, - replyToMessageGuid: replyToMessageGuid || undefined, - }); - return { channel: "bluebubbles", ...result }; - }, - sendMedia: async (ctx) => { - const runtime = await loadBlueBubblesChannelRuntime(); - const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; - const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { - mediaPath?: string; - mediaBuffer?: Uint8Array; - contentType?: string; - filename?: string; - caption?: string; - }; - const resolvedCaption = caption ?? text; - const result = await runtime.sendBlueBubblesMedia({ - cfg: cfg, - to, - mediaUrl, - mediaPath, - mediaBuffer, - contentType, - filename, - caption: resolvedCaption ?? undefined, - replyToId: replyToId ?? null, - accountId: accountId ?? undefined, - }); - - return { channel: "bluebubbles", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "bluebubbles", + sendText: async ({ cfg, to, text, accountId, replyToId }) => { + const runtime = await loadBlueBubblesChannelRuntime(); + const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; + const replyToMessageGuid = rawReplyToId + ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) + : ""; + return await runtime.sendMessageBlueBubbles(to, text, { + cfg: cfg, + accountId: accountId ?? undefined, + replyToMessageGuid: replyToMessageGuid || undefined, + }); + }, + sendMedia: async (ctx) => { + const runtime = await loadBlueBubblesChannelRuntime(); + const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; + const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { + mediaPath?: string; + mediaBuffer?: Uint8Array; + contentType?: string; + filename?: string; + caption?: string; + }; + return await runtime.sendBlueBubblesMedia({ + cfg: cfg, + to, + mediaUrl, + mediaPath, + mediaBuffer, + contentType, + filename, + caption: caption ?? text ?? undefined, + replyToId: replyToId ?? null, + accountId: accountId ?? undefined, + }); + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 958c629f766..ef01150487b 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -1,3 +1,8 @@ +import { + resolveOutboundMediaUrls, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { downloadBlueBubblesAttachment } from "./attachments.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { fetchBlueBubblesHistory } from "./history.js"; @@ -1243,11 +1248,7 @@ export async function processMessage( const replyToMessageGuid = rawReplyToId ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) : ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const mediaList = resolveOutboundMediaUrls(payload); if (mediaList.length > 0) { const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg: config, @@ -1257,43 +1258,44 @@ export async function processMessage( const text = sanitizeReplyDirectiveText( core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), ); - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - const cachedBody = (caption ?? "").trim() || ""; - const pendingId = rememberPendingOutboundMessageId({ - accountId: account.accountId, - sessionKey: route.sessionKey, - outboundTarget, - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - chatId, - snippet: cachedBody, - }); - let result: Awaited>; - try { - result = await sendBlueBubblesMedia({ - cfg: config, - to: outboundTarget, - mediaUrl, - caption: caption ?? undefined, - replyToId: replyToMessageGuid || null, + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList, + caption: text, + send: async ({ mediaUrl, caption }) => { + const cachedBody = (caption ?? "").trim() || ""; + const pendingId = rememberPendingOutboundMessageId({ accountId: account.accountId, + sessionKey: route.sessionKey, + outboundTarget, + chatGuid: chatGuidForActions ?? chatGuid, + chatIdentifier, + chatId, + snippet: cachedBody, }); - } catch (err) { - forgetPendingOutboundMessageId(pendingId); - throw err; - } - if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) { - forgetPendingOutboundMessageId(pendingId); - } - sentMessage = true; - statusSink?.({ lastOutboundAt: Date.now() }); - if (info.kind === "block") { - restartTypingSoon(); - } - } + let result: Awaited>; + try { + result = await sendBlueBubblesMedia({ + cfg: config, + to: outboundTarget, + mediaUrl, + caption: caption ?? undefined, + replyToId: replyToMessageGuid || null, + accountId: account.accountId, + }); + } catch (err) { + forgetPendingOutboundMessageId(pendingId); + throw err; + } + if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) { + forgetPendingOutboundMessageId(pendingId); + } + sentMessage = true; + statusSink?.({ lastOutboundAt: Date.now() }); + if (info.kind === "block") { + restartTypingSoon(); + } + }, + }); return; } @@ -1312,11 +1314,14 @@ export async function processMessage( ); const chunks = chunkMode === "newline" - ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode) - : core.channel.text.chunkMarkdownText(text, textLimit); - if (!chunks.length && text) { - chunks.push(text); - } + ? resolveTextChunksWithFallback( + text, + core.channel.text.chunkTextWithMode(text, textLimit, chunkMode), + ) + : resolveTextChunksWithFallback( + text, + core.channel.text.chunkMarkdownText(text, textLimit), + ); if (!chunks.length) { return; } diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 24a8577af3a..0ddb5c9e19f 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -7,8 +7,10 @@ import { import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createPairingPrefixStripper, + createTopLevelChannelReplyToModeResolver, createRuntimeDirectoryLiveAdapter, createTextPairingAdapter, normalizeMessageChannel, @@ -323,7 +325,7 @@ export const discordPlugin: ChannelPlugin = { stripPatterns: () => ["<@!?\\d+>"], }, threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("discord"), }, agentPrompt: { messageToolHints: () => [ @@ -420,50 +422,51 @@ export const discordPlugin: ChannelPlugin = { textChunkLimit: 2000, pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), - sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { - const send = - resolveOutboundSendDep(deps, "discord") ?? - getDiscordRuntime().channel.discord.sendMessageDiscord; - const result = await send(to, text, { - verbose: false, - cfg, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }); - return { channel: "discord", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - silent, - }) => { - const send = - resolveOutboundSendDep(deps, "discord") ?? - getDiscordRuntime().channel.discord.sendMessageDiscord; - const result = await send(to, text, { - verbose: false, + ...createAttachedChannelResultAdapter({ + channel: "discord", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; + return await send(to, text, { + verbose: false, + cfg, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }); + }, + sendMedia: async ({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }); - return { channel: "discord", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId, silent }) => - await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { - cfg, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }), + accountId, + deps, + replyToId, + silent, + }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; + return await send(to, text, { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId, silent }) => + await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { + cfg, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }), + }), }, bindings: { compileConfiguredBinding: ({ conversationId }) => diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 58e6083eef0..61e225d4f32 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -25,6 +25,10 @@ import { import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; +import { + resolveOutboundMediaUrls, + resolveTextChunksWithFallback, +} from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import type { ChatCommandDefinition, @@ -887,7 +891,7 @@ async function deliverDiscordInteractionReply(params: { chunkMode: "length" | "newline"; }) { const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaList = resolveOutboundMediaUrls(payload); const text = payload.text ?? ""; const discordData = payload.channelData?.discord as | { components?: TopLevelComponents[] } @@ -945,14 +949,14 @@ async function deliverDiscordInteractionReply(params: { }; }), ); - const chunks = chunkDiscordTextWithMode(text, { - maxChars: textLimit, - maxLines: maxLinesPerMessage, - chunkMode, - }); - if (!chunks.length && text) { - chunks.push(text); - } + const chunks = resolveTextChunksWithFallback( + text, + chunkDiscordTextWithMode(text, { + maxChars: textLimit, + maxLines: maxLinesPerMessage, + chunkMode, + }), + ); const caption = chunks[0] ?? ""; await sendMessage(caption, media, firstMessageComponents); for (const chunk of chunks.slice(1)) { @@ -967,14 +971,17 @@ async function deliverDiscordInteractionReply(params: { if (!text.trim() && !firstMessageComponents) { return; } - const chunks = chunkDiscordTextWithMode(text, { - maxChars: textLimit, - maxLines: maxLinesPerMessage, - chunkMode, - }); - if (!chunks.length && (text || firstMessageComponents)) { - chunks.push(text); - } + const chunks = + text || firstMessageComponents + ? resolveTextChunksWithFallback( + text, + chunkDiscordTextWithMode(text, { + maxChars: textLimit, + maxLines: maxLinesPerMessage, + chunkMode, + }), + ) + : []; for (const chunk of chunks) { if (!chunk.trim() && !firstMessageComponents) { continue; diff --git a/extensions/discord/src/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts index bd4d0e91dfd..bbfbe6eeae8 100644 --- a/extensions/discord/src/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -12,11 +12,15 @@ const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn()); const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn()); const sendDiscordTextMock = vi.hoisted(() => vi.fn()); -vi.mock("../send.js", () => ({ - sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args), - sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args), - sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args), -})); +vi.mock("../send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args), + sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args), + sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args), + }; +}); vi.mock("../send.shared.js", () => ({ sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args), diff --git a/extensions/discord/src/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts index 6e495d420ce..84efdb24237 100644 --- a/extensions/discord/src/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -8,6 +8,11 @@ import { retryAsync, type RetryConfig, } from "openclaw/plugin-sdk/infra-runtime"; +import { + resolveOutboundMediaUrls, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -209,35 +214,6 @@ async function sendDiscordChunkWithFallback(params: { ); } -async function sendAdditionalDiscordMedia(params: { - cfg: OpenClawConfig; - target: string; - token: string; - rest?: RequestClient; - accountId?: string; - mediaUrls: string[]; - mediaLocalRoots?: readonly string[]; - resolveReplyTo: () => string | undefined; - retryConfig: ResolvedRetryConfig; -}) { - for (const mediaUrl of params.mediaUrls) { - const replyTo = params.resolveReplyTo(); - await sendWithRetry( - () => - sendMessageDiscord(params.target, "", { - cfg: params.cfg, - token: params.token, - rest: params.rest, - mediaUrl, - accountId: params.accountId, - mediaLocalRoots: params.mediaLocalRoots, - replyTo, - }), - params.retryConfig, - ); - } -} - export async function deliverDiscordReply(params: { cfg: OpenClawConfig; replies: ReplyPayload[]; @@ -292,7 +268,7 @@ export async function deliverDiscordReply(params: { : undefined; let deliveredAny = false; for (const payload of params.replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaList = resolveOutboundMediaUrls(payload); const rawText = payload.text ?? ""; const tableMode = params.tableMode ?? "code"; const text = convertMarkdownTables(rawText, tableMode); @@ -301,14 +277,14 @@ export async function deliverDiscordReply(params: { } if (mediaList.length === 0) { const mode = params.chunkMode ?? "length"; - const chunks = chunkDiscordTextWithMode(text, { - maxChars: chunkLimit, - maxLines: params.maxLinesPerMessage, - chunkMode: mode, - }); - if (!chunks.length && text) { - chunks.push(text); - } + const chunks = resolveTextChunksWithFallback( + text, + chunkDiscordTextWithMode(text, { + maxChars: chunkLimit, + maxLines: params.maxLinesPerMessage, + chunkMode: mode, + }), + ); for (const chunk of chunks) { if (!chunk.trim()) { continue; @@ -340,19 +316,6 @@ export async function deliverDiscordReply(params: { if (!firstMedia) { continue; } - const sendRemainingMedia = () => - sendAdditionalDiscordMedia({ - cfg: params.cfg, - target: params.target, - token: params.token, - rest: params.rest, - accountId: params.accountId, - mediaUrls: mediaList.slice(1), - mediaLocalRoots: params.mediaLocalRoots, - resolveReplyTo, - retryConfig, - }); - // Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord. if (payload.audioAsVoice) { const replyTo = resolveReplyTo(); @@ -383,22 +346,50 @@ export async function deliverDiscordReply(params: { retryConfig, }); // Additional media items are sent as regular attachments (voice is single-file only). - await sendRemainingMedia(); + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList.slice(1), + caption: "", + send: async ({ mediaUrl }) => { + const replyTo = resolveReplyTo(); + await sendWithRetry( + () => + sendMessageDiscord(params.target, "", { + cfg: params.cfg, + token: params.token, + rest: params.rest, + mediaUrl, + accountId: params.accountId, + mediaLocalRoots: params.mediaLocalRoots, + replyTo, + }), + retryConfig, + ); + }, + }); continue; } - const replyTo = resolveReplyTo(); - await sendMessageDiscord(params.target, text, { - cfg: params.cfg, - token: params.token, - rest: params.rest, - mediaUrl: firstMedia, - accountId: params.accountId, - mediaLocalRoots: params.mediaLocalRoots, - replyTo, + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList, + caption: text, + send: async ({ mediaUrl, caption }) => { + const replyTo = resolveReplyTo(); + await sendWithRetry( + () => + sendMessageDiscord(params.target, caption ?? "", { + cfg: params.cfg, + token: params.token, + rest: params.rest, + mediaUrl, + accountId: params.accountId, + mediaLocalRoots: params.mediaLocalRoots, + replyTo, + }), + retryConfig, + ); + }, }); deliveredAny = true; - await sendRemainingMedia(); } if (binding && deliveredAny) { diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 3321a9cb59b..c3833972f44 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -3,11 +3,13 @@ import { normalizeDiscordOutboundTarget } from "./normalize.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscordMock = vi.fn(); + const sendDiscordComponentMessageMock = vi.fn(); const sendPollDiscordMock = vi.fn(); const sendWebhookMessageDiscordMock = vi.fn(); const getThreadBindingManagerMock = vi.fn(); return { sendMessageDiscordMock, + sendDiscordComponentMessageMock, sendPollDiscordMock, sendWebhookMessageDiscordMock, getThreadBindingManagerMock, @@ -19,6 +21,8 @@ vi.mock("./send.js", async (importOriginal) => { return { ...actual, sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args), + sendDiscordComponentMessage: (...args: unknown[]) => + hoisted.sendDiscordComponentMessageMock(...args), sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args), sendWebhookMessageDiscord: (...args: unknown[]) => hoisted.sendWebhookMessageDiscordMock(...args), @@ -114,6 +118,10 @@ describe("discordOutbound", () => { messageId: "msg-1", channelId: "ch-1", }); + hoisted.sendDiscordComponentMessageMock.mockClear().mockResolvedValue({ + messageId: "component-1", + channelId: "ch-1", + }); hoisted.sendPollDiscordMock.mockClear().mockResolvedValue({ messageId: "poll-1", channelId: "ch-1", @@ -249,8 +257,61 @@ describe("discordOutbound", () => { }), ); expect(result).toEqual({ + channel: "discord", messageId: "poll-1", channelId: "ch-1", }); }); + + it("sends component payload media sequences with the component message first", async () => { + hoisted.sendDiscordComponentMessageMock.mockResolvedValueOnce({ + messageId: "component-1", + channelId: "ch-1", + }); + hoisted.sendMessageDiscordMock.mockResolvedValueOnce({ + messageId: "msg-2", + channelId: "ch-1", + }); + + const result = await discordOutbound.sendPayload?.({ + cfg: {}, + to: "channel:123456", + text: "", + payload: { + text: "hello", + mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"], + channelData: { + discord: { + components: { text: "hello", components: [] }, + }, + }, + }, + accountId: "default", + mediaLocalRoots: ["/tmp/media"], + }); + + expect(hoisted.sendDiscordComponentMessageMock).toHaveBeenCalledWith( + "channel:123456", + expect.objectContaining({ text: "hello" }), + expect.objectContaining({ + mediaUrl: "https://example.com/1.png", + mediaLocalRoots: ["/tmp/media"], + accountId: "default", + }), + ); + expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith( + "channel:123456", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/2.png", + mediaLocalRoots: ["/tmp/media"], + accountId: "default", + }), + ); + expect(result).toEqual({ + channel: "discord", + messageId: "msg-2", + channelId: "ch-1", + }); + }); }); diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 93fd1cb8bfb..8b18fffec90 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -1,10 +1,14 @@ import { resolvePayloadMediaUrls, - sendPayloadMediaSequence, + sendPayloadMediaSequenceOrFallback, sendTextMediaPayload, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import type { DiscordComponentMessageSpec } from "./components.js"; @@ -123,18 +127,17 @@ export const discordOutbound: ChannelOutboundAdapter = { resolveOutboundSendDep(ctx.deps, "discord") ?? sendMessageDiscord; const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId }); const mediaUrls = resolvePayloadMediaUrls(payload); - if (mediaUrls.length === 0) { - const result = await sendDiscordComponentMessage(target, componentSpec, { - replyTo: ctx.replyToId ?? undefined, - accountId: ctx.accountId ?? undefined, - silent: ctx.silent ?? undefined, - cfg: ctx.cfg, - }); - return { channel: "discord", ...result }; - } - const lastResult = await sendPayloadMediaSequence({ + const result = await sendPayloadMediaSequenceOrFallback({ text: payload.text ?? "", mediaUrls, + fallbackResult: { messageId: "", channelId: target }, + sendNoMedia: async () => + await sendDiscordComponentMessage(target, componentSpec, { + replyTo: ctx.replyToId ?? undefined, + accountId: ctx.accountId ?? undefined, + silent: ctx.silent ?? undefined, + cfg: ctx.cfg, + }), send: async ({ text, mediaUrl, isFirst }) => { if (isFirst) { return await sendDiscordComponentMessage(target, componentSpec, { @@ -157,68 +160,63 @@ export const discordOutbound: ChannelOutboundAdapter = { }); }, }); - return lastResult - ? { channel: "discord", ...lastResult } - : { channel: "discord", messageId: "" }; + return attachChannelToResult("discord", result); }, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { - if (!silent) { - const webhookResult = await maybeSendDiscordWebhookText({ - cfg, - text, - threadId, - accountId, - identity, - replyToId, - }).catch(() => null); - if (webhookResult) { - return { channel: "discord", ...webhookResult }; + ...createAttachedChannelResultAdapter({ + channel: "discord", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { + if (!silent) { + const webhookResult = await maybeSendDiscordWebhookText({ + cfg, + text, + threadId, + accountId, + identity, + replyToId, + }).catch(() => null); + if (webhookResult) { + return webhookResult; + } } - } - const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; - const target = resolveDiscordOutboundTarget({ to, threadId }); - const result = await send(target, text, { - verbose: false, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { + verbose: false, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }); + }, + sendMedia: async ({ cfg, - }); - return { channel: "discord", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - silent, - }) => { - const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; - const target = resolveDiscordOutboundTarget({ to, threadId }); - const result = await send(target, text, { - verbose: false, + to, + text, mediaUrl, mediaLocalRoots, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - cfg, - }); - return { channel: "discord", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => { - const target = resolveDiscordOutboundTarget({ to, threadId }); - return await sendPollDiscord(target, poll, { - accountId: accountId ?? undefined, - silent: silent ?? undefined, - cfg, - }); - }, + accountId, + deps, + replyToId, + threadId, + silent, + }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { + verbose: false, + mediaUrl, + mediaLocalRoots, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => + await sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, { + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }), + }), }; diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index d3b248a3c6f..8cdc8ce2805 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -17,6 +17,7 @@ import { normalizePollInput, type PollInput, } from "openclaw/plugin-sdk/media-runtime"; +import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; @@ -276,10 +277,7 @@ export function buildDiscordTextChunks( maxLines: opts.maxLinesPerMessage, chunkMode: opts.chunkMode, }); - if (!chunks.length && text) { - chunks.push(text); - } - return chunks; + return resolveTextChunksWithFallback(text, chunks); } function hasV2Components(components?: TopLevelComponents[]): boolean { diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index fd79bff869f..0c449f82bd2 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { sendMediaFeishu } from "./media.js"; @@ -81,128 +82,124 @@ export const feishuOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ - cfg, - to, - text, - accountId, - replyToId, - threadId, - mediaLocalRoots, - identity, - }) => { - const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); - // Scheme A compatibility shim: - // when upstream accidentally returns a local image path as plain text, - // auto-upload and send as Feishu image message instead of leaking path text. - const localImagePath = normalizePossibleLocalImagePath(text); - if (localImagePath) { - try { - const result = await sendMediaFeishu({ - cfg, - to, - mediaUrl: localImagePath, - accountId: accountId ?? undefined, - replyToMessageId, - mediaLocalRoots, - }); - return { channel: "feishu", ...result }; - } catch (err) { - console.error(`[feishu] local image path auto-send failed:`, err); - // fall through to plain text as last resort - } - } - - const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); - const renderMode = account.config?.renderMode ?? "auto"; - const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); - if (useCard) { - const header = identity - ? { - title: identity.emoji - ? `${identity.emoji} ${identity.name ?? ""}`.trim() - : (identity.name ?? ""), - template: "blue" as const, - } - : undefined; - const result = await sendStructuredCardFeishu({ - cfg, - to, - text, - replyToMessageId, - replyInThread: threadId != null && !replyToId, - accountId: accountId ?? undefined, - header: header?.title ? header : undefined, - }); - return { channel: "feishu", ...result }; - } - const result = await sendOutboundText({ + ...createAttachedChannelResultAdapter({ + channel: "feishu", + sendText: async ({ cfg, to, text, - accountId: accountId ?? undefined, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - accountId, - mediaLocalRoots, - replyToId, - threadId, - }) => { - const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); - // Send text first if provided - if (text?.trim()) { - await sendOutboundText({ + accountId, + replyToId, + threadId, + mediaLocalRoots, + identity, + }) => { + const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); + // Scheme A compatibility shim: + // when upstream accidentally returns a local image path as plain text, + // auto-upload and send as Feishu image message instead of leaking path text. + const localImagePath = normalizePossibleLocalImagePath(text); + if (localImagePath) { + try { + return await sendMediaFeishu({ + cfg, + to, + mediaUrl: localImagePath, + accountId: accountId ?? undefined, + replyToMessageId, + mediaLocalRoots, + }); + } catch (err) { + console.error(`[feishu] local image path auto-send failed:`, err); + // fall through to plain text as last resort + } + } + + const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); + const renderMode = account.config?.renderMode ?? "auto"; + const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); + if (useCard) { + const header = identity + ? { + title: identity.emoji + ? `${identity.emoji} ${identity.name ?? ""}`.trim() + : (identity.name ?? ""), + template: "blue" as const, + } + : undefined; + return await sendStructuredCardFeishu({ + cfg, + to, + text, + replyToMessageId, + replyInThread: threadId != null && !replyToId, + accountId: accountId ?? undefined, + header: header?.title ? header : undefined, + }); + } + return await sendOutboundText({ cfg, to, text, accountId: accountId ?? undefined, replyToMessageId, }); - } - - // Upload and send media if URL or local path provided - if (mediaUrl) { - try { - const result = await sendMediaFeishu({ - cfg, - to, - mediaUrl, - accountId: accountId ?? undefined, - mediaLocalRoots, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - } catch (err) { - // Log the error for debugging - console.error(`[feishu] sendMediaFeishu failed:`, err); - // Fallback to URL link if upload fails - const fallbackText = `📎 ${mediaUrl}`; - const result = await sendOutboundText({ - cfg, - to, - text: fallbackText, - accountId: accountId ?? undefined, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - } - } - - // No media URL, just return text result - const result = await sendOutboundText({ + }, + sendMedia: async ({ cfg, to, - text: text ?? "", - accountId: accountId ?? undefined, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - }, + text, + mediaUrl, + accountId, + mediaLocalRoots, + replyToId, + threadId, + }) => { + const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); + // Send text first if provided + if (text?.trim()) { + await sendOutboundText({ + cfg, + to, + text, + accountId: accountId ?? undefined, + replyToMessageId, + }); + } + + // Upload and send media if URL or local path provided + if (mediaUrl) { + try { + return await sendMediaFeishu({ + cfg, + to, + mediaUrl, + accountId: accountId ?? undefined, + mediaLocalRoots, + replyToMessageId, + }); + } catch (err) { + // Log the error for debugging + console.error(`[feishu] sendMediaFeishu failed:`, err); + // Fallback to URL link if upload fails + return await sendOutboundText({ + cfg, + to, + text: `📎 ${mediaUrl}`, + accountId: accountId ?? undefined, + replyToMessageId, + }); + } + } + + // No media URL, just return text result + return await sendOutboundText({ + cfg, + to, + text: text ?? "", + accountId: accountId ?? undefined, + replyToMessageId, + }); + }, + }), }; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 856891cfb48..29dfeae6ac0 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -10,7 +10,9 @@ import { createAllowlistProviderOpenWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, + createTopLevelChannelReplyToModeResolver, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; import { @@ -192,7 +194,7 @@ export const googlechatPlugin: ChannelPlugin = { resolveRequireMention: resolveGoogleChatGroupRequireMention, }, threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("googlechat"), }, messaging: { normalizeTarget: normalizeGoogleChatTarget, @@ -266,91 +268,97 @@ export const googlechatPlugin: ChannelPlugin = { error: missingTargetError("Google Chat", ""), }; }, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { - const account = resolveGoogleChatAccount({ - cfg: cfg, - accountId, - }); - const space = await resolveGoogleChatOutboundSpace({ account, target: to }); - const thread = (threadId ?? replyToId ?? undefined) as string | undefined; - const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); - const result = await sendGoogleChatMessage({ - account, - space, + ...createAttachedChannelResultAdapter({ + channel: "googlechat", + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); + const result = await sendGoogleChatMessage({ + account, + space, + text, + thread, + }); + return { + messageId: result?.messageName ?? "", + chatId: space, + }; + }, + sendMedia: async ({ + cfg, + to, text, - thread, - }); - return { - channel: "googlechat", - messageId: result?.messageName ?? "", - chatId: space, - }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - replyToId, - threadId, - }) => { - if (!mediaUrl) { - throw new Error("Google Chat mediaUrl is required."); - } - const account = resolveGoogleChatAccount({ - cfg: cfg, + mediaUrl, + mediaLocalRoots, accountId, - }); - const space = await resolveGoogleChatOutboundSpace({ account, target: to }); - const thread = (threadId ?? replyToId ?? undefined) as string | undefined; - const runtime = getGoogleChatRuntime(); - const maxBytes = resolveChannelMediaMaxBytes({ - cfg: cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - ( - cfg.channels?.["googlechat"] as - | { accounts?: Record; mediaMaxMb?: number } - | undefined - )?.accounts?.[accountId]?.mediaMaxMb ?? - (cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, - accountId, - }); - const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024; - const loaded = /^https?:\/\//i.test(mediaUrl) - ? await runtime.channel.media.fetchRemoteMedia({ - url: mediaUrl, - maxBytes: effectiveMaxBytes, - }) - : await runtime.media.loadWebMedia(mediaUrl, { - maxBytes: effectiveMaxBytes, - localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, - }); - const { sendGoogleChatMessage, uploadGoogleChatAttachment } = - await loadGoogleChatChannelRuntime(); - const upload = await uploadGoogleChatAttachment({ - account, - space, - filename: loaded.fileName ?? "attachment", - buffer: loaded.buffer, - contentType: loaded.contentType, - }); - const result = await sendGoogleChatMessage({ - account, - space, - text, - thread, - attachments: upload.attachmentUploadToken - ? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName }] - : undefined, - }); - return { - channel: "googlechat", - messageId: result?.messageName ?? "", - chatId: space, - }; - }, + replyToId, + threadId, + }) => { + if (!mediaUrl) { + throw new Error("Google Chat mediaUrl is required."); + } + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const runtime = getGoogleChatRuntime(); + const maxBytes = resolveChannelMediaMaxBytes({ + cfg: cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + ( + cfg.channels?.["googlechat"] as + | { accounts?: Record; mediaMaxMb?: number } + | undefined + )?.accounts?.[accountId]?.mediaMaxMb ?? + (cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, + accountId, + }); + const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024; + const loaded = /^https?:\/\//i.test(mediaUrl) + ? await runtime.channel.media.fetchRemoteMedia({ + url: mediaUrl, + maxBytes: effectiveMaxBytes, + }) + : await runtime.media.loadWebMedia(mediaUrl, { + maxBytes: effectiveMaxBytes, + localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, + }); + const { sendGoogleChatMessage, uploadGoogleChatAttachment } = + await loadGoogleChatChannelRuntime(); + const upload = await uploadGoogleChatAttachment({ + account, + space, + filename: loaded.fileName ?? "attachment", + buffer: loaded.buffer, + contentType: loaded.contentType, + }); + const result = await sendGoogleChatMessage({ + account, + space, + text, + thread, + attachments: upload.attachmentUploadToken + ? [ + { + attachmentUploadToken: upload.attachmentUploadToken, + contentName: loaded.fileName, + }, + ] + : undefined, + }); + return { + messageId: result?.messageName ?? "", + chatId: space, + }; + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 80ba9ff3939..e6eeecb5138 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig } from "../runtime-api.js"; import { createWebhookInFlightLimiter, @@ -375,14 +376,12 @@ async function deliverGoogleChatReply(params: { }): Promise { const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const hasMedia = Boolean(payload.mediaUrls?.length) || Boolean(payload.mediaUrl); + const text = payload.text ?? ""; + let firstTextChunk = true; + let suppressCaption = false; - if (mediaList.length > 0) { - let suppressCaption = false; + if (hasMedia) { if (typingMessageName) { try { await deleteGoogleChatMessage({ @@ -391,9 +390,10 @@ async function deliverGoogleChatReply(params: { }); } catch (err) { runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); - const fallbackText = payload.text?.trim() - ? payload.text - : mediaList.length > 1 + const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0); + const fallbackText = text.trim() + ? text + : mediaCount > 1 ? "Sent attachments." : "Sent attachment."; try { @@ -402,16 +402,43 @@ async function deliverGoogleChatReply(params: { messageName: typingMessageName, text: fallbackText, }); - suppressCaption = Boolean(payload.text?.trim()); + suppressCaption = Boolean(text.trim()); } catch (updateErr) { runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`); } } } - let first = true; - for (const mediaUrl of mediaList) { - const caption = first && !suppressCaption ? payload.text : undefined; - first = false; + } + + const chunkLimit = account.config.textChunkLimit ?? 4000; + const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); + await deliverTextOrMediaReply({ + payload, + text: suppressCaption ? "" : text, + chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode), + sendText: async (chunk) => { + try { + if (firstTextChunk && typingMessageName) { + await updateGoogleChatMessage({ + account, + messageName: typingMessageName, + text: chunk, + }); + } else { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: chunk, + thread: payload.replyToId, + }); + } + firstTextChunk = false; + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`Google Chat message send failed: ${String(err)}`); + } + }, + sendMedia: async ({ mediaUrl, caption }) => { try { const loaded = await core.channel.media.fetchRemoteMedia({ url: mediaUrl, @@ -440,38 +467,8 @@ async function deliverGoogleChatReply(params: { } catch (err) { runtime.error?.(`Google Chat attachment send failed: ${String(err)}`); } - } - return; - } - - if (payload.text) { - const chunkLimit = account.config.textChunkLimit ?? 4000; - const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); - const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode); - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - try { - // Edit typing message with first chunk if available - if (i === 0 && typingMessageName) { - await updateGoogleChatMessage({ - account, - messageName: typingMessageName, - text: chunk, - }); - } else { - await sendGoogleChatMessage({ - account, - space: spaceId, - text: chunk, - thread: payload.replyToId, - }); - } - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Google Chat message send failed: ${String(err)}`); - } - } - } + }, + }); } async function uploadAttachmentForReply(params: { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index bd7df04e249..514b798b7df 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,5 +1,8 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAttachedChannelResultAdapter, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { type RoutePeer } from "openclaw/plugin-sdk/routing"; @@ -160,34 +163,33 @@ export const imessagePlugin: ChannelPlugin = { chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), chunkerMode: "text", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => { - const result = await ( - await loadIMessageChannelRuntime() - ).sendIMessageOutbound({ - cfg, - to, - text, - accountId: accountId ?? undefined, - deps, - replyToId: replyToId ?? undefined, - }); - return { channel: "imessage", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => { - const result = await ( - await loadIMessageChannelRuntime() - ).sendIMessageOutbound({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId: accountId ?? undefined, - deps, - replyToId: replyToId ?? undefined, - }); - return { channel: "imessage", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "imessage", + sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => + await ( + await loadIMessageChannelRuntime() + ).sendIMessageOutbound({ + cfg, + to, + text, + accountId: accountId ?? undefined, + deps, + replyToId: replyToId ?? undefined, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => + await ( + await loadIMessageChannelRuntime() + ).sendIMessageOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + deps, + replyToId: replyToId ?? undefined, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts index 65dc125be68..d7b434a4e2d 100644 --- a/extensions/imessage/src/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -1,5 +1,6 @@ import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -30,15 +31,17 @@ export async function deliverReplies(params: { }); const chunkMode = resolveChunkMode(cfg, "imessage", accountId); for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const rawText = sanitizeOutboundText(payload.text ?? ""); const text = convertMarkdownTables(rawText, tableMode); - if (!text && mediaList.length === 0) { - continue; - } - if (mediaList.length === 0) { + const hasMedia = Boolean(payload.mediaUrls?.length ?? payload.mediaUrl); + if (!hasMedia && text) { sentMessageCache?.remember(scope, { text }); - for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { + } + const delivered = await deliverTextOrMediaReply({ + payload, + text, + chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), + sendText: async (chunk) => { const sent = await sendMessageIMessage(target, chunk, { maxBytes, client, @@ -46,14 +49,10 @@ export async function deliverReplies(params: { replyToId: payload.replyToId, }); sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId }); - } - } else { - let first = true; - for (const url of mediaList) { - const caption = first ? text : ""; - first = false; - const sent = await sendMessageIMessage(target, caption, { - mediaUrl: url, + }, + sendMedia: async ({ mediaUrl, caption }) => { + const sent = await sendMessageIMessage(target, caption ?? "", { + mediaUrl, maxBytes, client, accountId, @@ -63,8 +62,10 @@ export async function deliverReplies(params: { text: caption || undefined, messageId: sent.messageId, }); - } + }, + }); + if (delivered !== "empty") { + runtime.log?.(`imessage: delivered reply to ${target}`); } - runtime.log?.(`imessage: delivered reply to ${target}`); } } diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 216ce997d16..a4e75f72af5 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -9,6 +9,7 @@ import { createConditionalWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createTextPairingAdapter, listResolvedDirectoryEntriesFromSources, @@ -271,23 +272,21 @@ export const ircPlugin: ChannelPlugin = { chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 350, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const result = await sendMessageIrc(to, text, { - cfg: cfg as CoreConfig, - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - }); - return { channel: "irc", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { - const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; - const result = await sendMessageIrc(to, combined, { - cfg: cfg as CoreConfig, - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - }); - return { channel: "irc", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "irc", + sendText: async ({ cfg, to, text, accountId, replyToId }) => + await sendMessageIrc(to, text, { + cfg: cfg as CoreConfig, + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => + await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { + cfg: cfg as CoreConfig, + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 8d1995336b4..aa763d4c561 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -10,14 +10,13 @@ import { import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, + deliverFormattedTextWithAttachments, dispatchInboundReplyWithBase, - formatTextWithAttachmentLinks, issuePairingChallenge, logInboundDrop, isDangerousNameMatchingEnabled, readStoreAllowFromForDmPolicy, resolveControlCommandGate, - resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveEffectiveAllowFromLists, @@ -61,23 +60,23 @@ async function deliverIrcReply(params: { sendReply?: (target: string, text: string, replyToId?: string) => Promise; statusSink?: (patch: { lastOutboundAt?: number }) => void; }) { - const combined = formatTextWithAttachmentLinks( - params.payload.text, - resolveOutboundMediaUrls(params.payload), - ); - if (!combined) { + const delivered = await deliverFormattedTextWithAttachments({ + payload: params.payload, + send: async ({ text, replyToId }) => { + if (params.sendReply) { + await params.sendReply(params.target, text, replyToId); + } else { + await sendMessageIrc(params.target, text, { + accountId: params.accountId, + replyTo: replyToId, + }); + } + params.statusSink?.({ lastOutboundAt: Date.now() }); + }, + }); + if (!delivered) { return; } - - if (params.sendReply) { - await params.sendReply(params.target, combined, params.payload.replyToId); - } else { - await sendMessageIrc(params.target, combined, { - accountId: params.accountId, - replyTo: params.payload.replyToId, - }); - } - params.statusSink?.({ lastOutboundAt: Date.now() }); } export async function handleIrcInbound(params: { diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index edc9f861d28..d983d2a0172 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,10 +1,13 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createEmptyChannelDirectoryAdapter, + createEmptyChannelResult, createPairingPrefixStripper, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; import { buildChannelConfigSchema, buildComputedAccountStatusSnapshot, @@ -184,7 +187,7 @@ export const linePlugin: ChannelPlugin = { const chunks = processed.text ? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit) : []; - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaUrls = resolveOutboundMediaUrls(payload); const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies; const sendMediaMessages = async () => { for (const url of mediaUrls) { @@ -317,54 +320,45 @@ export const linePlugin: ChannelPlugin = { } if (lastResult) { - return { channel: "line", ...lastResult }; + return createEmptyChannelResult("line", { ...lastResult }); } - return { channel: "line", messageId: "empty", chatId: to }; + return createEmptyChannelResult("line", { messageId: "empty", chatId: to }); }, - sendText: async ({ cfg, to, text, accountId }) => { - const runtime = getLineRuntime(); - const sendText = runtime.channel.line.pushMessageLine; - const sendFlex = runtime.channel.line.pushFlexMessage; - - // Process markdown: extract tables/code blocks, strip formatting - const processed = processLineMessage(text); - - // Send cleaned text first (if non-empty) - let result: { messageId: string; chatId: string }; - if (processed.text.trim()) { - result = await sendText(to, processed.text, { + ...createAttachedChannelResultAdapter({ + channel: "line", + sendText: async ({ cfg, to, text, accountId }) => { + const runtime = getLineRuntime(); + const sendText = runtime.channel.line.pushMessageLine; + const sendFlex = runtime.channel.line.pushFlexMessage; + const processed = processLineMessage(text); + let result: { messageId: string; chatId: string }; + if (processed.text.trim()) { + result = await sendText(to, processed.text, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + }); + } else { + result = { messageId: "processed", chatId: to }; + } + for (const flexMsg of processed.flexMessages) { + const flexContents = flexMsg.contents as Parameters[2]; + await sendFlex(to, flexMsg.altText, flexContents, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + }); + } + return result; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => + await getLineRuntime().channel.line.sendMessageLine(to, text, { verbose: false, + mediaUrl, cfg, accountId: accountId ?? undefined, - }); - } else { - // If text is empty after processing, still need a result - result = { messageId: "processed", chatId: to }; - } - - // Send flex messages for tables/code blocks - for (const flexMsg of processed.flexMessages) { - // LINE SDK expects FlexContainer but we receive contents as unknown - const flexContents = flexMsg.contents as Parameters[2]; - await sendFlex(to, flexMsg.altText, flexContents, { - verbose: false, - cfg, - accountId: accountId ?? undefined, - }); - } - - return { channel: "line", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { - const send = getLineRuntime().channel.line.sendMessageLine; - const result = await send(to, text, { - verbose: false, - mediaUrl, - cfg, - accountId: accountId ?? undefined, - }); - return { channel: "line", ...result }; - }, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 2334476c224..4c83f627261 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -9,6 +9,7 @@ import { import { createChannelDirectoryAdapter, createPairingPrefixStripper, + createScopedAccountReplyToModeResolver, createRuntimeDirectoryLiveAdapter, createRuntimeOutboundDelegates, createTextPairingAdapter, @@ -168,8 +169,11 @@ export const matrixPlugin: ChannelPlugin = { resolveToolPolicy: resolveMatrixGroupToolPolicy, }, threading: { - resolveReplyToMode: ({ cfg, accountId }) => - resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off", + resolveReplyToMode: createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => + resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }), + resolveReplyToMode: (account) => account.replyToMode, + }), buildToolContext: ({ context, hasRepliedRef }) => { const currentTarget = context.To; return { diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 004701edae4..b1ab30b20ef 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,4 +1,5 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js"; import { getMatrixRuntime } from "../../runtime.js"; import { sendMessageMatrix } from "../send.js"; @@ -60,45 +61,34 @@ export async function deliverMatrixReplies(params: { Boolean(id) && (params.replyToMode === "all" || !hasReplied); const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined; - if (mediaList.length === 0) { - let sentTextChunk = false; - for (const chunk of core.channel.text.chunkMarkdownTextWithMode( - text, - chunkLimit, - chunkMode, - )) { - const trimmed = chunk.trim(); - if (!trimmed) { - continue; - } + const delivered = await deliverTextOrMediaReply({ + payload: reply, + text, + chunkText: (value) => + core.channel.text + .chunkMarkdownTextWithMode(value, chunkLimit, chunkMode) + .map((chunk) => chunk.trim()) + .filter(Boolean), + sendText: async (trimmed) => { await sendMessageMatrix(params.roomId, trimmed, { client: params.client, replyToId: replyToIdForReply, threadId: params.threadId, accountId: params.accountId, }); - sentTextChunk = true; - } - if (replyToIdForReply && !hasReplied && sentTextChunk) { - hasReplied = true; - } - continue; - } - - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : ""; - await sendMessageMatrix(params.roomId, caption, { - client: params.client, - mediaUrl, - replyToId: replyToIdForReply, - threadId: params.threadId, - audioAsVoice: reply.audioAsVoice, - accountId: params.accountId, - }); - first = false; - } - if (replyToIdForReply && !hasReplied) { + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendMessageMatrix(params.roomId, caption ?? "", { + client: params.client, + mediaUrl, + replyToId: replyToIdForReply, + threadId: params.threadId, + audioAsVoice: reply.audioAsVoice, + accountId: params.accountId, + }); + }, + }); + if (replyToIdForReply && !hasReplied && delivered !== "empty") { hasReplied = true; } } diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 511d46b76e6..cf8f51c245c 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -5,9 +5,11 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createLoggedPairingApprovalNotifier, createMessageToolButtonsSchema, + createScopedAccountReplyToModeResolver, type ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -308,14 +310,17 @@ export const mattermostPlugin: ChannelPlugin = { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, threading: { - resolveReplyToMode: ({ cfg, accountId, chatType }) => { - const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }); - const kind = - chatType === "direct" || chatType === "group" || chatType === "channel" - ? chatType - : "channel"; - return resolveMattermostReplyToMode(account, kind); - }, + resolveReplyToMode: createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => + resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }), + resolveReplyToMode: (account, chatType) => + resolveMattermostReplyToMode( + account, + chatType === "direct" || chatType === "group" || chatType === "channel" + ? chatType + : "channel", + ), + }), }, reload: { configPrefixes: ["channels.mattermost"] }, configSchema: buildChannelConfigSchema(MattermostConfigSchema), @@ -385,33 +390,32 @@ export const mattermostPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { - const result = await sendMessageMattermost(to, text, { + ...createAttachedChannelResultAdapter({ + channel: "mattermost", + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => + await sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + sendMedia: async ({ cfg, - accountId: accountId ?? undefined, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - }); - return { channel: "mattermost", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - replyToId, - threadId, - }) => { - const result = await sendMessageMattermost(to, text, { - cfg, - accountId: accountId ?? undefined, + to, + text, mediaUrl, mediaLocalRoots, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - }); - return { channel: "mattermost", ...result }; - }, + accountId, + replyToId, + threadId, + }) => + await sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + mediaUrl, + mediaLocalRoots, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 6fc88c8ba83..492d31ba0fc 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -1,3 +1,4 @@ +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js"; import { getAgentScopedMediaLocalRoots } from "../runtime-api.js"; @@ -26,46 +27,34 @@ export async function deliverMattermostReplyPayload(params: { tableMode: MarkdownTableMode; sendMessage: SendMattermostMessage; }): Promise { - const mediaUrls = - params.payload.mediaUrls ?? (params.payload.mediaUrl ? [params.payload.mediaUrl] : []); const text = params.core.channel.text.convertMarkdownTables( params.payload.text ?? "", params.tableMode, ); - - if (mediaUrls.length === 0) { - const chunkMode = params.core.channel.text.resolveChunkMode( - params.cfg, - "mattermost", - params.accountId, - ); - const chunks = params.core.channel.text.chunkMarkdownTextWithMode( - text, - params.textLimit, - chunkMode, - ); - for (const chunk of chunks.length > 0 ? chunks : [text]) { - if (!chunk) { - continue; - } + const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); + const chunkMode = params.core.channel.text.resolveChunkMode( + params.cfg, + "mattermost", + params.accountId, + ); + await deliverTextOrMediaReply({ + payload: params.payload, + text, + chunkText: (value) => + params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode), + sendText: async (chunk) => { await params.sendMessage(params.to, chunk, { accountId: params.accountId, replyToId: params.replyToId, }); - } - return; - } - - const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); - let first = true; - for (const mediaUrl of mediaUrls) { - const caption = first ? text : ""; - first = false; - await params.sendMessage(params.to, caption, { - accountId: params.accountId, - mediaUrl, - mediaLocalRoots, - replyToId: params.replyToId, - }); - } + }, + sendMedia: async ({ mediaUrl, caption }) => { + await params.sendMessage(params.to, caption ?? "", { + accountId: params.accountId, + mediaUrl, + mediaLocalRoots, + replyToId: params.replyToId, + }); + }, + }); } diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index f03431391ed..b024b53c1f5 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -5,6 +5,7 @@ import { type MarkdownTableMode, type MSTeamsReplyStyle, type ReplyPayload, + resolveOutboundMediaUrls, SILENT_REPLY_TOKEN, sleep, } from "../runtime-api.js"; @@ -216,7 +217,7 @@ export function renderReplyPayloadsToMessages( }); for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaList = resolveOutboundMediaUrls(payload); const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( payload.text ?? "", tableMode, diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 6334bb8c6b5..cf482825ed2 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,4 +1,5 @@ import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { getMSTeamsRuntime } from "./runtime.js"; @@ -10,56 +11,57 @@ export const msteamsOutbound: ChannelOutboundAdapter = { chunkerMode: "markdown", textChunkLimit: 4000, pollMaxOptions: 12, - sendText: async ({ cfg, to, text, deps }) => { - type SendFn = ( - to: string, - text: string, - ) => Promise<{ messageId: string; conversationId: string }>; - const send = - resolveOutboundSendDep(deps, "msteams") ?? - ((to, text) => sendMessageMSTeams({ cfg, to, text })); - const result = await send(to, text); - return { channel: "msteams", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { - type SendFn = ( - to: string, - text: string, - opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, - ) => Promise<{ messageId: string; conversationId: string }>; - const send = - resolveOutboundSendDep(deps, "msteams") ?? - ((to, text, opts) => - sendMessageMSTeams({ - cfg, - to, - text, - mediaUrl: opts?.mediaUrl, - mediaLocalRoots: opts?.mediaLocalRoots, - })); - const result = await send(to, text, { mediaUrl, mediaLocalRoots }); - return { channel: "msteams", ...result }; - }, - sendPoll: async ({ cfg, to, poll }) => { - const maxSelections = poll.maxSelections ?? 1; - const result = await sendPollMSTeams({ - cfg, - to, - question: poll.question, - options: poll.options, - maxSelections, - }); - const pollStore = createMSTeamsPollStoreFs(); - await pollStore.createPoll({ - id: result.pollId, - question: poll.question, - options: poll.options, - maxSelections, - createdAt: new Date().toISOString(), - conversationId: result.conversationId, - messageId: result.messageId, - votes: {}, - }); - return result; - }, + ...createAttachedChannelResultAdapter({ + channel: "msteams", + sendText: async ({ cfg, to, text, deps }) => { + type SendFn = ( + to: string, + text: string, + ) => Promise<{ messageId: string; conversationId: string }>; + const send = + resolveOutboundSendDep(deps, "msteams") ?? + ((to, text) => sendMessageMSTeams({ cfg, to, text })); + return await send(to, text); + }, + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { + type SendFn = ( + to: string, + text: string, + opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, + ) => Promise<{ messageId: string; conversationId: string }>; + const send = + resolveOutboundSendDep(deps, "msteams") ?? + ((to, text, opts) => + sendMessageMSTeams({ + cfg, + to, + text, + mediaUrl: opts?.mediaUrl, + mediaLocalRoots: opts?.mediaLocalRoots, + })); + return await send(to, text, { mediaUrl, mediaLocalRoots }); + }, + sendPoll: async ({ cfg, to, poll }) => { + const maxSelections = poll.maxSelections ?? 1; + const result = await sendPollMSTeams({ + cfg, + to, + question: poll.question, + options: poll.options, + maxSelections, + }); + const pollStore = createMSTeamsPollStoreFs(); + await pollStore.createPoll({ + id: result.pollId, + question: poll.question, + options: poll.options, + maxSelections, + createdAt: new Date().toISOString(), + conversationId: result.conversationId, + messageId: result.messageId, + votes: {}, + }); + return result; + }, + }), }; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 5416a71f9af..d24822efb26 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -6,6 +6,7 @@ import { import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createLoggedPairingApprovalNotifier, createPairingPrefixStripper, } from "openclaw/plugin-sdk/channel-runtime"; @@ -174,23 +175,21 @@ export const nextcloudTalkPlugin: ChannelPlugin = chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const result = await sendMessageNextcloudTalk(to, text, { - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - cfg: cfg as CoreConfig, - }); - return { channel: "nextcloud-talk", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { - const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; - const result = await sendMessageNextcloudTalk(to, messageWithMedia, { - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - cfg: cfg as CoreConfig, - }); - return { channel: "nextcloud-talk", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "nextcloud-talk", + sendText: async ({ cfg, to, text, accountId, replyToId }) => + await sendMessageNextcloudTalk(to, text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => + await sendMessageNextcloudTalk(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 9eefe831835..d9f4de2f9a2 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,13 +1,12 @@ import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, + deliverFormattedTextWithAttachments, dispatchInboundReplyWithBase, - formatTextWithAttachmentLinks, issuePairingChallenge, logInboundDrop, readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithCommandGate, - resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, @@ -38,16 +37,16 @@ async function deliverNextcloudTalkReply(params: { statusSink?: (patch: { lastOutboundAt?: number }) => void; }): Promise { const { payload, roomToken, accountId, statusSink } = params; - const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload)); - if (!combined) { - return; - } - - await sendMessageNextcloudTalk(roomToken, combined, { - accountId, - replyTo: payload.replyToId, + await deliverFormattedTextWithAttachments({ + payload, + send: async ({ text, replyToId }) => { + await sendMessageNextcloudTalk(roomToken, text, { + accountId, + replyTo: replyToId, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + }, }); - statusSink?.({ lastOutboundAt: Date.now() }); } export async function handleNextcloudTalkInbound(params: { diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 3db834e8ad6..a11a882b81e 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -2,6 +2,7 @@ import { createScopedDmSecurityResolver, createTopLevelChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; import { buildPassiveChannelStatusSummary, buildTrafficStatusSummary, @@ -176,11 +177,10 @@ export const nostrPlugin: ChannelPlugin = { const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode); const normalizedTo = normalizePubkey(to); await bus.sendDm(normalizedTo, message); - return { - channel: "nostr" as const, + return attachChannelToResult("nostr", { to: normalizedTo, messageId: `nostr-${Date.now()}`, - }; + }); }, }, diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index e5f8f392202..6ba7fce6084 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,9 +1,12 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; import { + attachChannelToResult, + createAttachedChannelResultAdapter, createPairingPrefixStripper, createTextPairingAdapter, resolveOutboundSendDep, } from "openclaw/plugin-sdk/channel-runtime"; +import { attachChannelToResults } from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; @@ -223,9 +226,9 @@ async function sendFormattedSignalText(ctx: { textMode: "plain", textStyles: chunk.styles, }); - results.push({ channel: "signal" as const, ...result }); + results.push(result); } - return results; + return attachChannelToResults("signal", results); } async function sendFormattedSignalMedia(ctx: { @@ -264,7 +267,7 @@ async function sendFormattedSignalMedia(ctx: { textMode: "plain", textStyles: formatted.styles, }); - return { channel: "signal" as const, ...result }; + return attachChannelToResult("signal", result); } export const signalPlugin: ChannelPlugin = { @@ -340,28 +343,27 @@ export const signalPlugin: ChannelPlugin = { deps, abortSignal, }), - sendText: async ({ cfg, to, text, accountId, deps }) => { - const result = await sendSignalOutbound({ - cfg, - to, - text, - accountId: accountId ?? undefined, - deps, - }); - return { channel: "signal", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { - const result = await sendSignalOutbound({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId: accountId ?? undefined, - deps, - }); - return { channel: "signal", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "signal", + sendText: async ({ cfg, to, text, accountId, deps }) => + await sendSignalOutbound({ + cfg, + to, + text, + accountId: accountId ?? undefined, + deps, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => + await sendSignalOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + deps, + }), + }), }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 02fd94ff8b8..5a4882b1068 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -9,6 +9,7 @@ import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config- import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import { chunkTextWithMode, resolveChunkMode, @@ -296,35 +297,31 @@ async function deliverReplies(params: { const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } = params; for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; - if (!text && mediaList.length === 0) { - continue; - } - if (mediaList.length === 0) { - for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { + const delivered = await deliverTextOrMediaReply({ + payload, + text: payload.text ?? "", + chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), + sendText: async (chunk) => { await sendMessageSignal(target, chunk, { baseUrl, account, maxBytes, accountId, }); - } - } else { - let first = true; - for (const url of mediaList) { - const caption = first ? text : ""; - first = false; - await sendMessageSignal(target, caption, { + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendMessageSignal(target, caption ?? "", { baseUrl, account, - mediaUrl: url, + mediaUrl, maxBytes, accountId, }); - } + }, + }); + if (delivered !== "empty") { + runtime.log?.(`delivered reply to ${target}`); } - runtime.log?.(`delivered reply to ${target}`); } } diff --git a/extensions/signal/src/outbound-adapter.ts b/extensions/signal/src/outbound-adapter.ts index cd61b825981..4471871b69b 100644 --- a/extensions/signal/src/outbound-adapter.ts +++ b/extensions/signal/src/outbound-adapter.ts @@ -1,6 +1,11 @@ import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + attachChannelToResults, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { markdownToSignalTextChunks } from "./format.js"; @@ -53,9 +58,9 @@ export const signalOutbound: ChannelOutboundAdapter = { textMode: "plain", textStyles: chunk.styles, }); - results.push({ channel: "signal" as const, ...result }); + results.push(result); } - return results; + return attachChannelToResults("signal", results); }, sendFormattedMedia: async ({ cfg, @@ -89,34 +94,35 @@ export const signalOutbound: ChannelOutboundAdapter = { textStyles: formatted.styles, mediaLocalRoots, }); - return { channel: "signal", ...result }; - }, - sendText: async ({ cfg, to, text, accountId, deps }) => { - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const result = await send(to, text, { - cfg, - maxBytes, - accountId: accountId ?? undefined, - }); - return { channel: "signal", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const result = await send(to, text, { - cfg, - mediaUrl, - maxBytes, - accountId: accountId ?? undefined, - mediaLocalRoots, - }); - return { channel: "signal", ...result }; + return attachChannelToResult("signal", result); }, + ...createAttachedChannelResultAdapter({ + channel: "signal", + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + return await send(to, text, { + cfg, + maxBytes, + accountId: accountId ?? undefined, + }); + }, + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + return await send(to, text, { + cfg, + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + mediaLocalRoots, + }); + }, + }), }; diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index e8d03f88b45..93b10d6522d 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; import { describe, expect, it, vi } from "vitest"; +import { slackOutbound } from "./outbound-adapter.js"; const handleSlackActionMock = vi.fn(); @@ -169,6 +170,79 @@ describe("slackPlugin outbound", () => { ); expect(result).toEqual({ channel: "slack", messageId: "m-media-local" }); }); + + it("sends block payload media first, then the final block message", async () => { + const sendSlack = vi + .fn() + .mockResolvedValueOnce({ messageId: "m-media-1" }) + .mockResolvedValueOnce({ messageId: "m-media-2" }) + .mockResolvedValueOnce({ messageId: "m-final" }); + const sendPayload = slackOutbound.sendPayload; + expect(sendPayload).toBeDefined(); + + const result = await sendPayload!({ + cfg, + to: "C999", + text: "", + payload: { + text: "hello", + mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"], + channelData: { + slack: { + blocks: [ + { + type: "section", + text: { + type: "plain_text", + text: "Block body", + }, + }, + ], + }, + }, + }, + accountId: "default", + deps: { sendSlack }, + mediaLocalRoots: ["/tmp/media"], + }); + + expect(sendSlack).toHaveBeenCalledTimes(3); + expect(sendSlack).toHaveBeenNthCalledWith( + 1, + "C999", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/1.png", + mediaLocalRoots: ["/tmp/media"], + }), + ); + expect(sendSlack).toHaveBeenNthCalledWith( + 2, + "C999", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/2.png", + mediaLocalRoots: ["/tmp/media"], + }), + ); + expect(sendSlack).toHaveBeenNthCalledWith( + 3, + "C999", + "hello", + expect.objectContaining({ + blocks: [ + { + type: "section", + text: { + type: "plain_text", + text: "Block body", + }, + }, + ], + }), + ); + expect(result).toEqual({ channel: "slack", messageId: "m-final" }); + }); }); describe("slackPlugin directory", () => { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 1942d3674ed..379d0537e2b 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -6,8 +6,10 @@ import { import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createPairingPrefixStripper, + createScopedAccountReplyToModeResolver, createRuntimeDirectoryLiveAdapter, createTextPairingAdapter, resolveOutboundSendDep, @@ -374,8 +376,10 @@ export const slackPlugin: ChannelPlugin = { resolveToolPolicy: resolveSlackGroupToolPolicy, }, threading: { - resolveReplyToMode: ({ cfg, accountId, chatType }) => - resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), + resolveReplyToMode: createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + resolveReplyToMode: (account, chatType) => resolveSlackReplyToMode(account, chatType), + }), allowExplicitReplyTagsWhenOff: false, buildToolContext: (params) => buildSlackThreadingToolContext(params), resolveAutoThreadId: ({ cfg, accountId, to, toolContext, replyToId }) => @@ -479,50 +483,51 @@ export const slackPlugin: ChannelPlugin = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { - const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ - cfg, - accountId: accountId ?? undefined, - deps, - replyToId, - threadId, - }); - const result = await send(to, text, { - cfg, - threadTs: threadTsValue != null ? String(threadTsValue) : undefined, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - return { channel: "slack", ...result }; - }, - sendMedia: async ({ - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - cfg, - }) => { - const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ - cfg, - accountId: accountId ?? undefined, - deps, - replyToId, - threadId, - }); - const result = await send(to, text, { - cfg, + ...createAttachedChannelResultAdapter({ + channel: "slack", + sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { + const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ + cfg, + accountId: accountId ?? undefined, + deps, + replyToId, + threadId, + }); + return await send(to, text, { + cfg, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, + accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), + }); + }, + sendMedia: async ({ + to, + text, mediaUrl, mediaLocalRoots, - threadTs: threadTsValue != null ? String(threadTsValue) : undefined, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - return { channel: "slack", ...result }; - }, + accountId, + deps, + replyToId, + threadId, + cfg, + }) => { + const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ + cfg, + accountId: accountId ?? undefined, + deps, + replyToId, + threadId, + }); + return await send(to, text, { + cfg, + mediaUrl, + mediaLocalRoots, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, + accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), + }); + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/slack/src/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts index a8ef26510f0..935adaab3bc 100644 --- a/extensions/slack/src/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -1,4 +1,5 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; @@ -44,7 +45,7 @@ export async function deliverReplies(params: { continue; } - if (mediaList.length === 0) { + if (mediaList.length === 0 && slackBlocks?.length) { const trimmed = text.trim(); if (!trimmed && !slackBlocks?.length) { continue; @@ -59,21 +60,44 @@ export async function deliverReplies(params: { ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), ...(params.identity ? { identity: params.identity } : {}), }); - } else { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : ""; - first = false; - await sendMessageSlack(params.target, caption, { + params.runtime.log?.(`delivered reply to ${params.target}`); + continue; + } + + const delivered = await deliverTextOrMediaReply({ + payload, + text, + chunkText: + mediaList.length === 0 + ? (value) => { + const trimmed = value.trim(); + if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { + return []; + } + return [trimmed]; + } + : undefined, + sendText: async (trimmed) => { + await sendMessageSlack(params.target, trimmed, { + token: params.token, + threadTs, + accountId: params.accountId, + ...(params.identity ? { identity: params.identity } : {}), + }); + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendMessageSlack(params.target, caption ?? "", { token: params.token, mediaUrl, threadTs, accountId: params.accountId, ...(params.identity ? { identity: params.identity } : {}), }); - } + }, + }); + if (delivered !== "empty") { + params.runtime.log?.(`delivered reply to ${params.target}`); } - params.runtime.log?.(`delivered reply to ${params.target}`); } } diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index 42888ea12b4..ed107d4c63f 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -1,10 +1,14 @@ import { resolvePayloadMediaUrls, - sendPayloadMediaSequence, + sendPayloadMediaSequenceAndFinalize, sendTextMediaPayload, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import { resolveInteractiveTextFallback, @@ -96,7 +100,6 @@ async function sendSlackOutboundMessage(params: { }); if (hookResult.cancelled) { return { - channel: "slack" as const, messageId: "cancelled-by-hook", channelId: params.to, meta: { cancelled: true }, @@ -114,7 +117,7 @@ async function sendSlackOutboundMessage(params: { ...(params.blocks ? { blocks: params.blocks } : {}), ...(slackIdentity ? { identity: slackIdentity } : {}), }); - return { channel: "slack" as const, ...result }; + return result; } function resolveSlackBlocks(payload: { @@ -166,75 +169,54 @@ export const slackOutbound: ChannelOutboundAdapter = { }); } const mediaUrls = resolvePayloadMediaUrls(payload); - if (mediaUrls.length === 0) { - return await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text: payload.text ?? "", - mediaLocalRoots: ctx.mediaLocalRoots, - blocks, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }); - } - await sendPayloadMediaSequence({ - text: "", - mediaUrls, - send: async ({ text, mediaUrl }) => - await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text, - mediaUrl, - mediaLocalRoots: ctx.mediaLocalRoots, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }), - }); - return await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text: payload.text ?? "", - mediaLocalRoots: ctx.mediaLocalRoots, - blocks, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }); + return attachChannelToResult( + "slack", + await sendPayloadMediaSequenceAndFinalize({ + text: "", + mediaUrls, + send: async ({ text, mediaUrl }) => + await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text, + mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }), + finalize: async () => + await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text: payload.text ?? "", + mediaLocalRoots: ctx.mediaLocalRoots, + blocks, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }), + }), + ); }, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => { - return await sendSlackOutboundMessage({ - cfg, - to, - text, - accountId, - deps, - replyToId, - threadId, - identity, - }); - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - identity, - }) => { - return await sendSlackOutboundMessage({ + ...createAttachedChannelResultAdapter({ + channel: "slack", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => + await sendSlackOutboundMessage({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + identity, + }), + sendMedia: async ({ cfg, to, text, @@ -245,6 +227,18 @@ export const slackOutbound: ChannelOutboundAdapter = { replyToId, threadId, identity, - }); - }, + }) => + await sendSlackOutboundMessage({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + identity, + }), + }), }; diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 65f6203a57e..547013dc398 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -5,6 +5,7 @@ import { fetchWithSsrFGuard, withTrustedEnvProxyGuardedFetchMode, } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload"; import { chunkMarkdownTextWithMode, resolveChunkMode, @@ -310,9 +311,7 @@ export async function sendMessageSlack( const chunks = markdownChunks.flatMap((markdown) => markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }), ); - if (!chunks.length && trimmedMessage) { - chunks.push(trimmedMessage); - } + const resolvedChunks = resolveTextChunksWithFallback(trimmedMessage, chunks); const mediaMaxBytes = typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024 @@ -320,7 +319,7 @@ export async function sendMessageSlack( let lastMessageId = ""; if (opts.mediaUrl) { - const [firstChunk, ...rest] = chunks; + const [firstChunk, ...rest] = resolvedChunks; lastMessageId = await uploadSlackFile({ client, channelId, @@ -341,7 +340,7 @@ export async function sendMessageSlack( lastMessageId = response.ts ?? lastMessageId; } } else { - for (const chunk of chunks.length ? chunks : [""]) { + for (const chunk of resolvedChunks.length ? resolvedChunks : [""]) { const response = await postSlackMessageBestEffort({ client, channelId, diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 1b53185cb0f..9617dc129ae 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -13,6 +13,7 @@ import { projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { + attachChannelToResult, createEmptyChannelDirectoryAdapter, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; @@ -188,7 +189,7 @@ export function createSynologyChatPlugin() { if (!ok) { throw new Error("Failed to send message to Synology Chat"); } - return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to }; + return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to }); }, sendMedia: async ({ to, mediaUrl, accountId, cfg }: any) => { @@ -205,7 +206,7 @@ export function createSynologyChatPlugin() { if (!ok) { throw new Error("Failed to send media to Synology Chat"); } - return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to }; + return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to }); }, }, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index d37b65fc447..6cfed61829e 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -5,8 +5,11 @@ import { import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + attachChannelToResult, + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createPairingPrefixStripper, + createTopLevelChannelReplyToModeResolver, createTextPairingAdapter, normalizeMessageChannel, type OutboundSendDeps, @@ -358,7 +361,7 @@ export const telegramPlugin: ChannelPlugin cfg.channels?.telegram?.replyToMode ?? "off", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"), resolveAutoThreadId: ({ to, toolContext, replyToId }) => replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }), }, @@ -496,34 +499,22 @@ export const telegramPlugin: ChannelPlugin { - const result = await sendTelegramOutbound({ - cfg, - to, - text, - accountId, - deps, - replyToId, - threadId, - silent, - }); - return { channel: "telegram", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - silent, - }) => { - const result = await sendTelegramOutbound({ + ...createAttachedChannelResultAdapter({ + channel: "telegram", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => + await sendTelegramOutbound({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + silent, + }), + sendMedia: async ({ cfg, to, text, @@ -534,17 +525,28 @@ export const telegramPlugin: ChannelPlugin - await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { - cfg, - accountId: accountId ?? undefined, - messageThreadId: parseTelegramThreadId(threadId), - silent: silent ?? undefined, - isAnonymous: isAnonymous ?? undefined, - }), + }) => + await sendTelegramOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + silent, + }), + sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, isAnonymous }) => + await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { + cfg, + accountId: accountId ?? undefined, + messageThreadId: parseTelegramThreadId(threadId), + silent: silent ?? undefined, + isAnonymous: isAnonymous ?? undefined, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 16ef036d93d..b5cb70a2c66 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -1,9 +1,13 @@ import { resolvePayloadMediaUrls, - sendPayloadMediaSequence, + sendPayloadMediaSequenceOrFallback, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; @@ -75,17 +79,16 @@ export async function sendTelegramPayloadMessages(params: { quoteText, }; - if (mediaUrls.length === 0) { - return await params.send(params.to, text, { - ...payloadOpts, - buttons, - }); - } - // Telegram allows reply_markup on media; attach buttons only to the first send. - const finalResult = await sendPayloadMediaSequence({ + return await sendPayloadMediaSequenceOrFallback({ text, mediaUrls, + fallbackResult: { messageId: "unknown", chatId: params.to }, + sendNoMedia: async () => + await params.send(params.to, text, { + ...payloadOpts, + buttons, + }), send: async ({ text, mediaUrl, isFirst }) => await params.send(params.to, text, { ...payloadOpts, @@ -93,7 +96,6 @@ export async function sendTelegramPayloadMessages(params: { ...(isFirst ? { buttons } : {}), }), }); - return finalResult ?? { messageId: "unknown", chatId: params.to }; } export const telegramOutbound: ChannelOutboundAdapter = { @@ -104,46 +106,47 @@ export const telegramOutbound: ChannelOutboundAdapter = { shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { - const { send, baseOpts } = resolveTelegramSendContext({ + ...createAttachedChannelResultAdapter({ + channel: "telegram", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { + const { send, baseOpts } = resolveTelegramSendContext({ + cfg, + deps, + accountId, + replyToId, + threadId, + }); + return await send(to, text, { + ...baseOpts, + }); + }, + sendMedia: async ({ cfg, - deps, - accountId, - replyToId, - threadId, - }); - const result = await send(to, text, { - ...baseOpts, - }); - return { channel: "telegram", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - forceDocument, - }) => { - const { send, baseOpts } = resolveTelegramSendContext({ - cfg, - deps, - accountId, - replyToId, - threadId, - }); - const result = await send(to, text, { - ...baseOpts, + to, + text, mediaUrl, mediaLocalRoots, - forceDocument: forceDocument ?? false, - }); - return { channel: "telegram", ...result }; - }, + accountId, + deps, + replyToId, + threadId, + forceDocument, + }) => { + const { send, baseOpts } = resolveTelegramSendContext({ + cfg, + deps, + accountId, + replyToId, + threadId, + }); + return await send(to, text, { + ...baseOpts, + mediaUrl, + mediaLocalRoots, + forceDocument: forceDocument ?? false, + }); + }, + }), sendPayload: async ({ cfg, to, @@ -172,6 +175,6 @@ export const telegramOutbound: ChannelOutboundAdapter = { forceDocument: forceDocument ?? false, }, }); - return { channel: "telegram", ...result }; + return attachChannelToResult("telegram", result); }, }; diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 6d9d8b541ae..92501c46fdd 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -1,4 +1,8 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -52,11 +56,7 @@ export async function deliverWebReply(params: { convertMarkdownTables(replyResult.text || "", tableMode), ); const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode); - const mediaList = replyResult.mediaUrls?.length - ? replyResult.mediaUrls - : replyResult.mediaUrl - ? [replyResult.mediaUrl] - : []; + const mediaList = resolveOutboundMediaUrls(replyResult); const sendWithRetry = async (fn: () => Promise, label: string, maxAttempts = 3) => { let lastErr: unknown; @@ -114,9 +114,11 @@ export async function deliverWebReply(params: { const remainingText = [...textChunks]; // Media (with optional caption on first item) - for (const [index, mediaUrl] of mediaList.entries()) { - const caption = index === 0 ? remainingText.shift() || undefined : undefined; - try { + const leadingCaption = remainingText.shift() || ""; + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList, + caption: leadingCaption, + send: async ({ mediaUrl, caption }) => { const media = await loadWebMedia(mediaUrl, { maxBytes: maxMediaBytes, localRoots: params.mediaLocalRoots, @@ -189,21 +191,24 @@ export async function deliverWebReply(params: { }, "auto-reply sent (media)", ); - } catch (err) { - whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(err)}`); - replyLogger.warn({ err, mediaUrl }, "failed to send web media reply"); - if (index === 0) { - const warning = - err instanceof Error ? `⚠️ Media failed: ${err.message}` : "⚠️ Media failed."; - const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean); - const fallbackText = fallbackTextParts.join("\n"); - if (fallbackText) { - whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); - await msg.reply(fallbackText); - } + }, + onError: async ({ error, mediaUrl, caption, isFirst }) => { + whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(error)}`); + replyLogger.warn({ err: error, mediaUrl }, "failed to send web media reply"); + if (!isFirst) { + return; } - } - } + const warning = + error instanceof Error ? `⚠️ Media failed: ${error.message}` : "⚠️ Media failed."; + const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean); + const fallbackText = fallbackTextParts.join("\n"); + if (!fallbackText) { + return; + } + whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); + await msg.reply(fallbackText); + }, + }); // Remaining text chunks after media for (const chunk of remainingText) { diff --git a/extensions/whatsapp/src/outbound-adapter.poll.test.ts b/extensions/whatsapp/src/outbound-adapter.poll.test.ts index 46c9696cc98..5e23748a233 100644 --- a/extensions/whatsapp/src/outbound-adapter.poll.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.poll.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../../src/config/config.js"; const hoisted = vi.hoisted(() => ({ sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })), + sendReactionWhatsApp: vi.fn(async () => undefined), })); vi.mock("../../../src/globals.js", () => ({ @@ -11,6 +12,7 @@ vi.mock("../../../src/globals.js", () => ({ vi.mock("./send.js", () => ({ sendPollWhatsApp: hoisted.sendPollWhatsApp, + sendReactionWhatsApp: hoisted.sendReactionWhatsApp, })); import { whatsappOutbound } from "./outbound-adapter.js"; @@ -36,6 +38,10 @@ describe("whatsappOutbound sendPoll", () => { accountId: "work", cfg, }); - expect(result).toEqual({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" }); + expect(result).toEqual({ + channel: "whatsapp", + messageId: "poll-1", + toJid: "1555@s.whatsapp.net", + }); }); }); diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index ffc0306d80b..d9710afb557 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -1,6 +1,10 @@ import { sendTextMediaPayload } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAttachedChannelResultAdapter, + createEmptyChannelResult, +} from "openclaw/plugin-sdk/channel-send-result"; import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; @@ -22,7 +26,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = { const text = trimLeadingWhitespace(ctx.payload.text); const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0; if (!text && !hasMedia) { - return { channel: "whatsapp", messageId: "" }; + return createEmptyChannelResult("whatsapp"); } return await sendTextMediaPayload({ channel: "whatsapp", @@ -36,41 +40,51 @@ export const whatsappOutbound: ChannelOutboundAdapter = { adapter: whatsappOutbound, }); }, - sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { - const normalizedText = trimLeadingWhitespace(text); - if (!normalizedText) { - return { channel: "whatsapp", messageId: "" }; - } - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? - (await import("./send.js")).sendMessageWhatsApp; - const result = await send(to, normalizedText, { - verbose: false, - cfg, - accountId: accountId ?? undefined, - gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => { - const normalizedText = trimLeadingWhitespace(text); - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? - (await import("./send.js")).sendMessageWhatsApp; - const result = await send(to, normalizedText, { - verbose: false, + ...createAttachedChannelResultAdapter({ + channel: "whatsapp", + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { + const normalizedText = trimLeadingWhitespace(text); + if (!normalizedText) { + return createEmptyChannelResult("whatsapp"); + } + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? + (await import("./send.js")).sendMessageWhatsApp; + return await send(to, normalizedText, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendMedia: async ({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - accountId: accountId ?? undefined, + accountId, + deps, gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { - verbose: shouldLogVerbose(), - accountId: accountId ?? undefined, - cfg, - }), + }) => { + const normalizedText = trimLeadingWhitespace(text); + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? + (await import("./send.js")).sendMessageWhatsApp; + return await send(to, normalizedText, { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId }) => + await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId: accountId ?? undefined, + cfg, + }), + }), }; diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 8bd6be02612..b8d11b50937 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -8,7 +8,12 @@ import { buildOpenGroupPolicyWarning, createOpenProviderGroupPolicyWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; -import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { + createChannelDirectoryAdapter, + createEmptyChannelResult, + createRawChannelSendResultAdapter, + createStaticReplyToModeResolver, +} from "openclaw/plugin-sdk/channel-runtime"; import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { @@ -23,7 +28,6 @@ import { buildBaseAccountStatusSnapshot, buildChannelConfigSchema, buildTokenChannelStatusSummary, - buildChannelSendResult, DEFAULT_ACCOUNT_ID, chunkTextForOutbound, formatAllowFromLowercase, @@ -150,7 +154,7 @@ export const zaloPlugin: ChannelPlugin = { resolveRequireMention: () => true, }, threading: { - resolveReplyToMode: () => "off", + resolveReplyToMode: createStaticReplyToModeResolver("off"), }, actions: zaloMessageActions, messaging: { @@ -189,31 +193,30 @@ export const zaloPlugin: ChannelPlugin = { chunker: zaloPlugin.outbound!.chunker, sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx), sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx), - emptyResult: { channel: "zalo", messageId: "" }, + emptyResult: createEmptyChannelResult("zalo"), }), - sendText: async ({ to, text, accountId, cfg }) => { - const result = await ( - await loadZaloChannelRuntime() - ).sendZaloText({ - to, - text, - accountId: accountId ?? undefined, - cfg: cfg, - }); - return buildChannelSendResult("zalo", result); - }, - sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { - const result = await ( - await loadZaloChannelRuntime() - ).sendZaloText({ - to, - text, - accountId: accountId ?? undefined, - mediaUrl, - cfg: cfg, - }); - return buildChannelSendResult("zalo", result); - }, + ...createRawChannelSendResultAdapter({ + channel: "zalo", + sendText: async ({ to, text, accountId, cfg }) => + await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, + accountId: accountId ?? undefined, + cfg: cfg, + }), + sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => + await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, + accountId: accountId ?? undefined, + mediaUrl, + cfg: cfg, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 8452fb661e2..768c556fd7b 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -32,15 +32,14 @@ import { createTypingCallbacks, createScopedPairingAccess, createReplyPrefixOptions, + deliverTextOrMediaReply, issuePairingChallenge, - logTypingFailure, - resolveDirectDmAuthorizationOutcome, - resolveSenderCommandAuthorizationWithRuntime, - resolveOutboundMediaUrls, - resolveDefaultGroupPolicy, - resolveInboundRouteEnvelopeBuilderWithRuntime, - sendMediaWithLeadingCaption, resolveWebhookPath, + logTypingFailure, + resolveDefaultGroupPolicy, + resolveDirectDmAuthorizationOutcome, + resolveInboundRouteEnvelopeBuilderWithRuntime, + resolveSenderCommandAuthorizationWithRuntime, waitForAbortSignal, warnMissingProviderGroupPolicyFallbackOnce, } from "./runtime-api.js"; @@ -581,33 +580,28 @@ async function deliverZaloReply(params: { const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params; const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const sentMedia = await sendMediaWithLeadingCaption({ - mediaUrls: resolveOutboundMediaUrls(payload), - caption: text, - send: async ({ mediaUrl, caption }) => { - await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); - statusSink?.({ lastOutboundAt: Date.now() }); - }, - onError: (error) => { - runtime.error?.(`Zalo photo send failed: ${String(error)}`); - }, - }); - if (sentMedia) { - return; - } - - if (text) { - const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId); - const chunks = core.channel.text.chunkMarkdownTextWithMode(text, ZALO_TEXT_LIMIT, chunkMode); - for (const chunk of chunks) { + const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId); + await deliverTextOrMediaReply({ + payload, + text, + chunkText: (value) => + core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode), + sendText: async (chunk) => { try { await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.(`Zalo message send failed: ${String(err)}`); } - } - } + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onMediaError: (error) => { + runtime.error?.(`Zalo photo send failed: ${String(error)}`); + }, + }); } export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise { diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 629125fb120..b6cf6111580 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,7 +1,10 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { + createEmptyChannelResult, createPairingPrefixStripper, + createRawChannelSendResultAdapter, + createStaticReplyToModeResolver, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -15,7 +18,6 @@ import type { GroupToolPolicyConfig, } from "../runtime-api.js"; import { - buildChannelSendResult, buildBaseAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, isDangerousNameMatchingEnabled, @@ -312,7 +314,7 @@ export const zalouserPlugin: ChannelPlugin = { resolveToolPolicy: resolveZalouserGroupToolPolicy, }, threading: { - resolveReplyToMode: () => "off", + resolveReplyToMode: createStaticReplyToModeResolver("off"), }, actions: zalouserMessageActions, messaging: { @@ -493,34 +495,35 @@ export const zalouserPlugin: ChannelPlugin = { ctx, sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx), sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx), - emptyResult: { channel: "zalouser", messageId: "" }, + emptyResult: createEmptyChannelResult("zalouser"), }), - sendText: async ({ to, text, accountId, cfg }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - const result = await sendMessageZalouser(target.threadId, text, { - profile: account.profile, - isGroup: target.isGroup, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), - }); - return buildChannelSendResult("zalouser", result); - }, - sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - const result = await sendMessageZalouser(target.threadId, text, { - profile: account.profile, - isGroup: target.isGroup, - mediaUrl, - mediaLocalRoots, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), - }); - return buildChannelSendResult("zalouser", result); - }, + ...createRawChannelSendResultAdapter({ + channel: "zalouser", + sendText: async ({ to, text, accountId, cfg }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); + }, + sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + mediaUrl, + mediaLocalRoots, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 5ae729c703e..d269345572c 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -21,17 +21,16 @@ import { createTypingCallbacks, createScopedPairingAccess, createReplyPrefixOptions, + deliverTextOrMediaReply, evaluateGroupRouteAccessForPolicy, isDangerousNameMatchingEnabled, issuePairingChallenge, - resolveOutboundMediaUrls, mergeAllowlist, resolveMentionGatingWithBypass, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveSenderCommandAuthorization, resolveSenderScopedGroupPolicy, - sendMediaWithLeadingCaption, summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, } from "../runtime-api.js"; @@ -712,11 +711,24 @@ async function deliverZalouserReply(params: { const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, { fallbackLimit: ZALOUSER_TEXT_LIMIT, }); - - const sentMedia = await sendMediaWithLeadingCaption({ - mediaUrls: resolveOutboundMediaUrls(payload), - caption: text, - send: async ({ mediaUrl, caption }) => { + await deliverTextOrMediaReply({ + payload, + text, + sendText: async (chunk) => { + try { + await sendMessageZalouser(chatId, chunk, { + profile, + isGroup, + textMode: "markdown", + textChunkMode: chunkMode, + textChunkLimit, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error(`Zalouser message send failed: ${String(err)}`); + } + }, + sendMedia: async ({ mediaUrl, caption }) => { logVerbose(core, runtime, `Sending media to ${chatId}`); await sendMessageZalouser(chatId, caption ?? "", { profile, @@ -728,28 +740,10 @@ async function deliverZalouserReply(params: { }); statusSink?.({ lastOutboundAt: Date.now() }); }, - onError: (error) => { + onMediaError: (error) => { runtime.error(`Zalouser media send failed: ${String(error)}`); }, }); - if (sentMedia) { - return; - } - - if (text) { - try { - await sendMessageZalouser(chatId, text, { - profile, - isGroup, - textMode: "markdown", - textChunkMode: chunkMode, - textChunkLimit, - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error(`Zalouser message send failed: ${String(err)}`); - } - } } export async function monitorZalouserProvider( diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 555c9e54bb7..e55bea9d053 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -13,6 +13,7 @@ "setup-tools", "config-runtime", "reply-runtime", + "reply-payload", "channel-runtime", "interactive-runtime", "infra-runtime", @@ -88,6 +89,7 @@ "channel-config-schema", "channel-lifecycle", "channel-policy", + "channel-send-result", "group-access", "directory-runtime", "json-store", diff --git a/src/channels/plugins/outbound/direct-text-media.test.ts b/src/channels/plugins/outbound/direct-text-media.test.ts new file mode 100644 index 00000000000..de979a7704d --- /dev/null +++ b/src/channels/plugins/outbound/direct-text-media.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { + sendPayloadMediaSequenceAndFinalize, + sendPayloadMediaSequenceOrFallback, +} from "./direct-text-media.js"; + +describe("sendPayloadMediaSequenceOrFallback", () => { + it("uses the no-media sender when no media entries exist", async () => { + const send = vi.fn(); + const sendNoMedia = vi.fn(async () => ({ messageId: "text-1" })); + + await expect( + sendPayloadMediaSequenceOrFallback({ + text: "hello", + mediaUrls: [], + send, + sendNoMedia, + fallbackResult: { messageId: "" }, + }), + ).resolves.toEqual({ messageId: "text-1" }); + + expect(send).not.toHaveBeenCalled(); + expect(sendNoMedia).toHaveBeenCalledOnce(); + }); + + it("returns the last media send result and clears text after the first media", async () => { + const calls: Array<{ text: string; mediaUrl: string; isFirst: boolean }> = []; + + await expect( + sendPayloadMediaSequenceOrFallback({ + text: "caption", + mediaUrls: ["a", "b"], + send: async ({ text, mediaUrl, isFirst }) => { + calls.push({ text, mediaUrl, isFirst }); + return { messageId: mediaUrl }; + }, + fallbackResult: { messageId: "" }, + }), + ).resolves.toEqual({ messageId: "b" }); + + expect(calls).toEqual([ + { text: "caption", mediaUrl: "a", isFirst: true }, + { text: "", mediaUrl: "b", isFirst: false }, + ]); + }); +}); + +describe("sendPayloadMediaSequenceAndFinalize", () => { + it("skips media sends and finalizes directly when no media entries exist", async () => { + const send = vi.fn(); + const finalize = vi.fn(async () => ({ messageId: "final-1" })); + + await expect( + sendPayloadMediaSequenceAndFinalize({ + text: "hello", + mediaUrls: [], + send, + finalize, + }), + ).resolves.toEqual({ messageId: "final-1" }); + + expect(send).not.toHaveBeenCalled(); + expect(finalize).toHaveBeenCalledOnce(); + }); + + it("sends the media sequence before the finalizing send", async () => { + const send = vi.fn(async ({ mediaUrl }: { mediaUrl: string }) => ({ messageId: mediaUrl })); + const finalize = vi.fn(async () => ({ messageId: "final-2" })); + + await expect( + sendPayloadMediaSequenceAndFinalize({ + text: "", + mediaUrls: ["a", "b"], + send, + finalize, + }), + ).resolves.toEqual({ messageId: "final-2" }); + + expect(send).toHaveBeenCalledTimes(2); + expect(finalize).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index ea813fcf75b..d6e13a4fce7 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -58,6 +58,41 @@ export async function sendPayloadMediaSequence(params: { return lastResult; } +export async function sendPayloadMediaSequenceOrFallback(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; + fallbackResult: TResult; + sendNoMedia?: () => Promise; +}): Promise { + if (params.mediaUrls.length === 0) { + return params.sendNoMedia ? await params.sendNoMedia() : params.fallbackResult; + } + return (await sendPayloadMediaSequence(params)) ?? params.fallbackResult; +} + +export async function sendPayloadMediaSequenceAndFinalize(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; + finalize: () => Promise; +}): Promise { + if (params.mediaUrls.length > 0) { + await sendPayloadMediaSequence(params); + } + return await params.finalize(); +} + export async function sendTextMediaPayload(params: { channel: string; ctx: SendPayloadContext; diff --git a/src/channels/plugins/threading-helpers.test.ts b/src/channels/plugins/threading-helpers.test.ts new file mode 100644 index 00000000000..48688d33ed0 --- /dev/null +++ b/src/channels/plugins/threading-helpers.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + createScopedAccountReplyToModeResolver, + createStaticReplyToModeResolver, + createTopLevelChannelReplyToModeResolver, +} from "./threading-helpers.js"; + +describe("createStaticReplyToModeResolver", () => { + it("always returns the configured mode", () => { + expect(createStaticReplyToModeResolver("off")({ cfg: {} as OpenClawConfig })).toBe("off"); + expect(createStaticReplyToModeResolver("all")({ cfg: {} as OpenClawConfig })).toBe("all"); + }); +}); + +describe("createTopLevelChannelReplyToModeResolver", () => { + it("reads the top-level channel config", () => { + const resolver = createTopLevelChannelReplyToModeResolver("discord"); + expect( + resolver({ + cfg: { channels: { discord: { replyToMode: "first" } } } as OpenClawConfig, + }), + ).toBe("first"); + }); + + it("falls back to off", () => { + const resolver = createTopLevelChannelReplyToModeResolver("discord"); + expect(resolver({ cfg: {} as OpenClawConfig })).toBe("off"); + }); +}); + +describe("createScopedAccountReplyToModeResolver", () => { + it("reads the scoped account reply mode", () => { + const resolver = createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => + (( + cfg.channels as { + matrix?: { accounts?: Record }; + } + ).matrix?.accounts?.[accountId?.toLowerCase() ?? "default"] ?? {}) as { + replyToMode?: "off" | "first" | "all"; + }, + resolveReplyToMode: (account) => account.replyToMode, + }); + + const cfg = { + channels: { + matrix: { + accounts: { + assistant: { replyToMode: "all" }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolver({ cfg, accountId: "assistant" })).toBe("all"); + expect(resolver({ cfg, accountId: "default" })).toBe("off"); + }); + + it("passes chatType through", () => { + const seen: Array = []; + const resolver = createScopedAccountReplyToModeResolver({ + resolveAccount: () => ({ replyToMode: "first" as const }), + resolveReplyToMode: (account, chatType) => { + seen.push(chatType); + return account.replyToMode; + }, + }); + + expect(resolver({ cfg: {} as OpenClawConfig, chatType: "group" })).toBe("first"); + expect(seen).toEqual(["group"]); + }); +}); diff --git a/src/channels/plugins/threading-helpers.ts b/src/channels/plugins/threading-helpers.ts new file mode 100644 index 00000000000..360e4a7048b --- /dev/null +++ b/src/channels/plugins/threading-helpers.ts @@ -0,0 +1,32 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ReplyToMode } from "../../config/types.base.js"; +import type { ChannelThreadingAdapter } from "./types.core.js"; + +type ReplyToModeResolver = NonNullable; + +export function createStaticReplyToModeResolver(mode: ReplyToMode): ReplyToModeResolver { + return () => mode; +} + +export function createTopLevelChannelReplyToModeResolver(channelId: string): ReplyToModeResolver { + return ({ cfg }) => { + const channelConfig = ( + cfg.channels as Record | undefined + )?.[channelId]; + return channelConfig?.replyToMode ?? "off"; + }; +} + +export function createScopedAccountReplyToModeResolver(params: { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount; + resolveReplyToMode: ( + account: TAccount, + chatType?: string | null, + ) => ReplyToMode | null | undefined; + fallback?: ReplyToMode; +}): ReplyToModeResolver { + return ({ cfg, accountId, chatType }) => + params.resolveReplyToMode(params.resolveAccount(cfg, accountId), chatType) ?? + params.fallback ?? + "off"; +} diff --git a/src/channels/plugins/whatsapp-shared.ts b/src/channels/plugins/whatsapp-shared.ts index c798e7fe3ca..efbd832dd09 100644 --- a/src/channels/plugins/whatsapp-shared.ts +++ b/src/channels/plugins/whatsapp-shared.ts @@ -1,4 +1,5 @@ import { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js"; +import { createAttachedChannelResultAdapter } from "../../plugin-sdk/channel-send-result.js"; import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js"; import { escapeRegExp } from "../../utils.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; @@ -62,48 +63,49 @@ export function createWhatsAppOutboundBase({ textChunkLimit: 4000, pollMaxOptions: 12, resolveTarget, - sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { - const normalizedText = normalizeText(text); - if (skipEmptyText && !normalizedText) { - return { channel: "whatsapp", messageId: "" }; - } - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; - const result = await send(to, normalizedText, { - verbose: false, - cfg, - accountId: accountId ?? undefined, - gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - gifPlayback, - }) => { - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; - const result = await send(to, normalizeText(text), { - verbose: false, + ...createAttachedChannelResultAdapter({ + channel: "whatsapp", + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { + const normalizedText = normalizeText(text); + if (skipEmptyText && !normalizedText) { + return { messageId: "" }; + } + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; + return await send(to, normalizedText, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendMedia: async ({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - accountId: accountId ?? undefined, + accountId, + deps, gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { - verbose: shouldLogVerbose(), - accountId: accountId ?? undefined, - cfg, - }), + }) => { + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; + return await send(to, normalizeText(text), { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId }) => + await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId: accountId ?? undefined, + cfg, + }), + }), }; } diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 4dcdd1f61f9..5cf36e39af2 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -13,6 +13,7 @@ import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; +import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js"; import { normalizePollInput } from "../../polls.js"; import { ErrorCodes, @@ -210,8 +211,8 @@ export const sendHandlers: GatewayRequestHandlers = { .map((payload) => payload.text) .filter(Boolean) .join("\n"); - const mirrorMediaUrls = mirrorPayloads.flatMap( - (payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + const mirrorMediaUrls = mirrorPayloads.flatMap((payload) => + resolveOutboundMediaUrls(payload), ); const providedSessionKey = typeof request.sessionKey === "string" && request.sessionKey.trim() diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 452875d9cff..b8bbc115988 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -26,6 +26,10 @@ import { import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +import { + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, +} from "../../plugin-sdk/reply-payload.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { throwIfAborted } from "./abort.js"; import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; @@ -338,7 +342,7 @@ function normalizePayloadsForChannelDelivery( function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload { return { text: payload.text ?? "", - mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + mediaUrls: resolveOutboundMediaUrls(payload), interactive: payload.interactive, channelData: payload.channelData, }; @@ -721,22 +725,27 @@ async function deliverOutboundPayloadsCore( continue; } - let first = true; let lastMessageId: string | undefined; - for (const url of payloadSummary.mediaUrls) { - throwIfAborted(abortSignal); - const caption = first ? payloadSummary.text : ""; - first = false; - if (handler.sendFormattedMedia) { - const delivery = await handler.sendFormattedMedia(caption, url, sendOverrides); + await sendMediaWithLeadingCaption({ + mediaUrls: payloadSummary.mediaUrls, + caption: payloadSummary.text, + send: async ({ mediaUrl, caption }) => { + throwIfAborted(abortSignal); + if (handler.sendFormattedMedia) { + const delivery = await handler.sendFormattedMedia( + caption ?? "", + mediaUrl, + sendOverrides, + ); + results.push(delivery); + lastMessageId = delivery.messageId; + return; + } + const delivery = await handler.sendMedia(caption ?? "", mediaUrl, sendOverrides); results.push(delivery); lastMessageId = delivery.messageId; - } else { - const delivery = await handler.sendMedia(caption, url, sendOverrides); - results.push(delivery); - lastMessageId = delivery.messageId; - } - } + }, + }); emitMessageSent({ success: true, content: payloadSummary.text, diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index d6e27b8a65f..806e3285aca 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js"; +import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js"; import type { PollInput } from "../../polls.js"; import { normalizePollInput } from "../../polls.js"; import { @@ -202,8 +203,8 @@ export async function sendMessage(params: MessageSendParams): Promise payload.text) .filter(Boolean) .join("\n"); - const mirrorMediaUrls = normalizedPayloads.flatMap( - (payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + const mirrorMediaUrls = normalizedPayloads.flatMap((payload) => + resolveOutboundMediaUrls(payload), ); const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index d98bf22c218..fa9790888a4 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -11,6 +11,7 @@ import { hasReplyContent, type InteractiveReply, } from "../../interactive/payload.js"; +import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js"; export type NormalizedOutboundPayload = { text: string; @@ -96,7 +97,7 @@ export function normalizeOutboundPayloads( ): NormalizedOutboundPayload[] { const normalizedPayloads: NormalizedOutboundPayload[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaUrls = resolveOutboundMediaUrls(payload); const interactive = payload.interactive; const channelData = payload.channelData; const hasChannelData = hasReplyChannelData(channelData); @@ -127,10 +128,11 @@ export function normalizeOutboundPayloadsForJson( ): OutboundPayloadJson[] { const normalized: OutboundPayloadJson[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { + const mediaUrls = resolveOutboundMediaUrls(payload); normalized.push({ text: payload.text ?? "", mediaUrl: payload.mediaUrl ?? null, - mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined), + mediaUrls: mediaUrls.length ? mediaUrls : undefined, interactive: payload.interactive, channelData: payload.channelData, }); diff --git a/src/line/auto-reply-delivery.ts b/src/line/auto-reply-delivery.ts index aa5443a536e..aea6210dda4 100644 --- a/src/line/auto-reply-delivery.ts +++ b/src/line/auto-reply-delivery.ts @@ -1,5 +1,6 @@ import type { messagingApi } from "@line/bot-sdk"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { resolveOutboundMediaUrls } from "../plugin-sdk/reply-payload.js"; import type { FlexContainer } from "./flex-templates.js"; import type { ProcessedLineMessage } from "./markdown-to-line.js"; import type { SendLineReplyChunksParams } from "./reply-chunks.js"; @@ -123,7 +124,7 @@ export async function deliverLineAutoReply(params: { const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : []; - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaUrls = resolveOutboundMediaUrls(payload); const mediaMessages = mediaUrls .map((url) => url?.trim()) .filter((url): url is string => Boolean(url)) diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index a7630924997..67e4ceef1ea 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -42,6 +42,7 @@ export * from "../channels/plugins/outbound/interactive.js"; export * from "../channels/plugins/pairing-adapters.js"; export * from "../channels/plugins/runtime-forwarders.js"; export * from "../channels/plugins/target-resolvers.js"; +export * from "../channels/plugins/threading-helpers.js"; export * from "../channels/plugins/status-issues/shared.js"; export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../infra/outbound/send-deps.js"; @@ -49,6 +50,7 @@ export * from "../polls.js"; export * from "../utils/message-channel.js"; export * from "../whatsapp/normalize.js"; export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js"; +export * from "./channel-send-result.js"; export * from "./channel-lifecycle.js"; export * from "./directory-runtime.js"; export type { diff --git a/src/plugin-sdk/channel-send-result.test.ts b/src/plugin-sdk/channel-send-result.test.ts new file mode 100644 index 00000000000..37d29a5a190 --- /dev/null +++ b/src/plugin-sdk/channel-send-result.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + attachChannelToResult, + attachChannelToResults, + buildChannelSendResult, + createAttachedChannelResultAdapter, + createEmptyChannelResult, + createRawChannelSendResultAdapter, +} from "./channel-send-result.js"; + +describe("attachChannelToResult", () => { + it("preserves the existing result shape and stamps the channel", () => { + expect( + attachChannelToResult("discord", { + messageId: "m1", + ok: true, + extra: "value", + }), + ).toEqual({ + channel: "discord", + messageId: "m1", + ok: true, + extra: "value", + }); + }); +}); + +describe("attachChannelToResults", () => { + it("stamps each result in a list with the shared channel id", () => { + expect( + attachChannelToResults("signal", [ + { messageId: "m1", timestamp: 1 }, + { messageId: "m2", timestamp: 2 }, + ]), + ).toEqual([ + { channel: "signal", messageId: "m1", timestamp: 1 }, + { channel: "signal", messageId: "m2", timestamp: 2 }, + ]); + }); +}); + +describe("buildChannelSendResult", () => { + it("normalizes raw send results", () => { + const result = buildChannelSendResult("zalo", { + ok: false, + messageId: null, + error: "boom", + }); + + expect(result.channel).toBe("zalo"); + expect(result.ok).toBe(false); + expect(result.messageId).toBe(""); + expect(result.error).toEqual(new Error("boom")); + }); +}); + +describe("createEmptyChannelResult", () => { + it("builds an empty outbound result with channel metadata", () => { + expect(createEmptyChannelResult("line", { chatId: "u1" })).toEqual({ + channel: "line", + messageId: "", + chatId: "u1", + }); + }); +}); + +describe("createAttachedChannelResultAdapter", () => { + it("wraps outbound delivery and poll results", async () => { + const adapter = createAttachedChannelResultAdapter({ + channel: "discord", + sendText: async () => ({ messageId: "m1", channelId: "c1" }), + sendMedia: async () => ({ messageId: "m2" }), + sendPoll: async () => ({ messageId: "m3", pollId: "p1" }), + }); + + await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "discord", + messageId: "m1", + channelId: "c1", + }); + await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "discord", + messageId: "m2", + }); + await expect( + adapter.sendPoll!({ + cfg: {} as never, + to: "x", + poll: { question: "t", options: ["a", "b"] }, + }), + ).resolves.toEqual({ + channel: "discord", + messageId: "m3", + pollId: "p1", + }); + }); +}); + +describe("createRawChannelSendResultAdapter", () => { + it("normalizes raw send results", async () => { + const adapter = createRawChannelSendResultAdapter({ + channel: "zalo", + sendText: async () => ({ ok: true, messageId: "m1" }), + sendMedia: async () => ({ ok: false, error: "boom" }), + }); + + await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "zalo", + ok: true, + messageId: "m1", + error: undefined, + }); + await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "zalo", + ok: false, + messageId: "", + error: new Error("boom"), + }); + }); +}); diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts index b73df6f0448..12e74741264 100644 --- a/src/plugin-sdk/channel-send-result.ts +++ b/src/plugin-sdk/channel-send-result.ts @@ -1,9 +1,74 @@ +import type { ChannelOutboundAdapter, ChannelPollResult } from "../channels/plugins/types.js"; +import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; + export type ChannelSendRawResult = { ok: boolean; messageId?: string | null; error?: string | null; }; +export function attachChannelToResult(channel: string, result: T) { + return { + channel, + ...result, + }; +} + +export function attachChannelToResults(channel: string, results: readonly T[]) { + return results.map((result) => attachChannelToResult(channel, result)); +} + +export function createEmptyChannelResult( + channel: string, + result: Partial> & { + messageId?: string; + } = {}, +): OutboundDeliveryResult { + return attachChannelToResult(channel, { + messageId: "", + ...result, + }); +} + +type MaybePromise = T | Promise; +type SendTextParams = Parameters>[0]; +type SendMediaParams = Parameters>[0]; +type SendPollParams = Parameters>[0]; + +export function createAttachedChannelResultAdapter(params: { + channel: string; + sendText?: (ctx: SendTextParams) => MaybePromise>; + sendMedia?: (ctx: SendMediaParams) => MaybePromise>; + sendPoll?: (ctx: SendPollParams) => MaybePromise>; +}): Pick { + return { + sendText: params.sendText + ? async (ctx) => attachChannelToResult(params.channel, await params.sendText!(ctx)) + : undefined, + sendMedia: params.sendMedia + ? async (ctx) => attachChannelToResult(params.channel, await params.sendMedia!(ctx)) + : undefined, + sendPoll: params.sendPoll + ? async (ctx) => attachChannelToResult(params.channel, await params.sendPoll!(ctx)) + : undefined, + }; +} + +export function createRawChannelSendResultAdapter(params: { + channel: string; + sendText?: (ctx: SendTextParams) => MaybePromise; + sendMedia?: (ctx: SendMediaParams) => MaybePromise; +}): Pick { + return { + sendText: params.sendText + ? async (ctx) => buildChannelSendResult(params.channel, await params.sendText!(ctx)) + : undefined, + sendMedia: params.sendMedia + ? async (ctx) => buildChannelSendResult(params.channel, await params.sendMedia!(ctx)) + : undefined, + }; +} + /** Normalize raw channel send results into the shape shared outbound callers expect. */ export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) { return { diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts index 679b5109a5e..7870bc2f2fa 100644 --- a/src/plugin-sdk/discord-send.ts +++ b/src/plugin-sdk/discord-send.ts @@ -1,4 +1,5 @@ import type { DiscordSendResult } from "../../extensions/discord/api.js"; +import { attachChannelToResult } from "./channel-send-result.js"; type DiscordSendOptionInput = { replyToId?: string | null; @@ -32,5 +33,5 @@ export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) /** Stamp raw Discord send results with the channel id expected by shared outbound flows. */ export function tagDiscordChannelResult(result: DiscordSendResult) { - return { channel: "discord" as const, ...result }; + return attachChannelToResult("discord", result); } diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 47ba490ec42..b64614348cb 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -76,6 +76,7 @@ export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/api.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, + deliverFormattedTextWithAttachments, formatTextWithAttachmentLinks, resolveOutboundMediaUrls, } from "./reply-payload.js"; diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 803dd999a62..02650a4a009 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -46,6 +46,7 @@ export { splitSetupEntries, } from "../channels/plugins/setup-wizard-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; +export { resolveOutboundMediaUrls } from "./reply-payload.js"; export type { BaseProbeResult, ChannelDirectoryEntry, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 4ce53e1ec15..e3be0cd868d 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -94,6 +94,7 @@ export { createPersistentDedupe } from "./persistent-dedupe.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, + deliverFormattedTextWithAttachments, formatTextWithAttachmentLinks, resolveOutboundMediaUrls, } from "./reply-payload.js"; diff --git a/src/plugin-sdk/reply-payload.test.ts b/src/plugin-sdk/reply-payload.test.ts index 780b75686a1..171b17f0e7e 100644 --- a/src/plugin-sdk/reply-payload.test.ts +++ b/src/plugin-sdk/reply-payload.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from "vitest"; -import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js"; +import { describe, expect, it, vi } from "vitest"; +import { + deliverFormattedTextWithAttachments, + deliverTextOrMediaReply, + isNumericTargetId, + resolveOutboundMediaUrls, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, + sendPayloadWithChunkedTextAndMedia, +} from "./reply-payload.js"; describe("sendPayloadWithChunkedTextAndMedia", () => { it("returns empty result when payload has no text and no media", async () => { @@ -56,3 +64,155 @@ describe("sendPayloadWithChunkedTextAndMedia", () => { expect(isNumericTargetId("")).toBe(false); }); }); + +describe("resolveOutboundMediaUrls", () => { + it("prefers mediaUrls over the legacy single-media field", () => { + expect( + resolveOutboundMediaUrls({ + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + mediaUrl: "https://example.com/legacy.png", + }), + ).toEqual(["https://example.com/a.png", "https://example.com/b.png"]); + }); + + it("falls back to the legacy single-media field", () => { + expect( + resolveOutboundMediaUrls({ + mediaUrl: "https://example.com/legacy.png", + }), + ).toEqual(["https://example.com/legacy.png"]); + }); +}); + +describe("resolveTextChunksWithFallback", () => { + it("returns existing chunks unchanged", () => { + expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]); + }); + + it("falls back to the full text when chunkers return nothing", () => { + expect(resolveTextChunksWithFallback("hello", [])).toEqual(["hello"]); + }); + + it("returns empty for empty text with no chunks", () => { + expect(resolveTextChunksWithFallback("", [])).toEqual([]); + }); +}); + +describe("deliverTextOrMediaReply", () => { + it("sends media first with caption only on the first attachment", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: "hello", mediaUrls: ["https://a", "https://b"] }, + text: "hello", + sendText, + sendMedia, + }), + ).resolves.toBe("media"); + + expect(sendMedia).toHaveBeenNthCalledWith(1, { + mediaUrl: "https://a", + caption: "hello", + }); + expect(sendMedia).toHaveBeenNthCalledWith(2, { + mediaUrl: "https://b", + caption: undefined, + }); + expect(sendText).not.toHaveBeenCalled(); + }); + + it("falls back to chunked text delivery when there is no media", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: "alpha beta gamma" }, + text: "alpha beta gamma", + chunkText: () => ["alpha", "beta", "gamma"], + sendText, + sendMedia, + }), + ).resolves.toBe("text"); + + expect(sendText).toHaveBeenCalledTimes(3); + expect(sendText).toHaveBeenNthCalledWith(1, "alpha"); + expect(sendText).toHaveBeenNthCalledWith(2, "beta"); + expect(sendText).toHaveBeenNthCalledWith(3, "gamma"); + expect(sendMedia).not.toHaveBeenCalled(); + }); + + it("returns empty when chunking produces no sendable text", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: " " }, + text: " ", + chunkText: () => [], + sendText, + sendMedia, + }), + ).resolves.toBe("empty"); + + expect(sendText).not.toHaveBeenCalled(); + expect(sendMedia).not.toHaveBeenCalled(); + }); +}); + +describe("sendMediaWithLeadingCaption", () => { + it("passes leading-caption metadata to async error handlers", async () => { + const send = vi + .fn<({ mediaUrl, caption }: { mediaUrl: string; caption?: string }) => Promise>() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce(undefined); + const onError = vi.fn(async () => undefined); + + await expect( + sendMediaWithLeadingCaption({ + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + caption: "hello", + send, + onError, + }), + ).resolves.toBe(true); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + caption: "hello", + index: 0, + isFirst: true, + }), + ); + expect(send).toHaveBeenNthCalledWith(2, { + mediaUrl: "https://example.com/b.png", + caption: undefined, + }); + }); +}); + +describe("deliverFormattedTextWithAttachments", () => { + it("combines attachment links and forwards replyToId", async () => { + const send = vi.fn(async () => undefined); + + await expect( + deliverFormattedTextWithAttachments({ + payload: { + text: "hello", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + replyToId: "r1", + }, + send, + }), + ).resolves.toBe(true); + + expect(send).toHaveBeenCalledWith({ + text: "hello\n\nAttachment: https://example.com/a.png\nAttachment: https://example.com/b.png", + replyToId: "r1", + }); + }); +}); diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index a35380f5250..3bee0c9e81b 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -52,6 +52,17 @@ export function resolveOutboundMediaUrls(payload: { return []; } +/** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */ +export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] { + if (chunks.length > 0) { + return [...chunks]; + } + if (!text) { + return []; + } + return [text]; +} + /** Send media-first payloads intact, or chunk text-only payloads through the caller's transport hooks. */ export async function sendPayloadWithChunkedTextAndMedia< TContext extends { payload: object }, @@ -129,21 +140,32 @@ export async function sendMediaWithLeadingCaption(params: { mediaUrls: string[]; caption: string; send: (payload: { mediaUrl: string; caption?: string }) => Promise; - onError?: (error: unknown, mediaUrl: string) => void; + onError?: (params: { + error: unknown; + mediaUrl: string; + caption?: string; + index: number; + isFirst: boolean; + }) => Promise | void; }): Promise { if (params.mediaUrls.length === 0) { return false; } - let first = true; - for (const mediaUrl of params.mediaUrls) { - const caption = first ? params.caption : undefined; - first = false; + for (const [index, mediaUrl] of params.mediaUrls.entries()) { + const isFirst = index === 0; + const caption = isFirst ? params.caption : undefined; try { await params.send({ mediaUrl, caption }); } catch (error) { if (params.onError) { - params.onError(error, mediaUrl); + await params.onError({ + error, + mediaUrl, + caption, + index, + isFirst, + }); continue; } throw error; @@ -151,3 +173,60 @@ export async function sendMediaWithLeadingCaption(params: { } return true; } + +export async function deliverTextOrMediaReply(params: { + payload: OutboundReplyPayload; + text: string; + chunkText?: (text: string) => readonly string[]; + sendText: (text: string) => Promise; + sendMedia: (payload: { mediaUrl: string; caption?: string }) => Promise; + onMediaError?: (params: { + error: unknown; + mediaUrl: string; + caption?: string; + index: number; + isFirst: boolean; + }) => Promise | void; +}): Promise<"empty" | "text" | "media"> { + const mediaUrls = resolveOutboundMediaUrls(params.payload); + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls, + caption: params.text, + send: params.sendMedia, + onError: params.onMediaError, + }); + if (sentMedia) { + return "media"; + } + if (!params.text) { + return "empty"; + } + const chunks = params.chunkText ? params.chunkText(params.text) : [params.text]; + let sentText = false; + for (const chunk of chunks) { + if (!chunk) { + continue; + } + await params.sendText(chunk); + sentText = true; + } + return sentText ? "text" : "empty"; +} + +export async function deliverFormattedTextWithAttachments(params: { + payload: OutboundReplyPayload; + send: (params: { text: string; replyToId?: string }) => Promise; +}): Promise { + const text = formatTextWithAttachmentLinks( + params.payload.text, + resolveOutboundMediaUrls(params.payload), + ); + if (!text) { + return false; + } + await params.send({ + text, + replyToId: params.payload.replyToId, + }); + return true; +} diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 079fa8b3a01..93ad61651e0 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,4 +1,5 @@ import * as channelRuntimeSdk from "openclaw/plugin-sdk/channel-runtime"; +import * as channelSendResultSdk from "openclaw/plugin-sdk/channel-send-result"; import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as coreSdk from "openclaw/plugin-sdk/core"; import type { @@ -16,6 +17,7 @@ import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; +import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload"; import * as routingSdk from "openclaw/plugin-sdk/routing"; import * as runtimeSdk from "openclaw/plugin-sdk/runtime"; import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; @@ -93,6 +95,16 @@ describe("plugin-sdk subpath exports", () => { expect(typeof routingSdk.resolveThreadSessionKeys).toBe("function"); }); + it("exports reply payload helpers from the dedicated subpath", () => { + expect(typeof replyPayloadSdk.deliverFormattedTextWithAttachments).toBe("function"); + expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function"); + expect(typeof replyPayloadSdk.formatTextWithAttachmentLinks).toBe("function"); + expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function"); + expect(typeof replyPayloadSdk.resolveTextChunksWithFallback).toBe("function"); + expect(typeof replyPayloadSdk.sendMediaWithLeadingCaption).toBe("function"); + expect(typeof replyPayloadSdk.sendPayloadWithChunkedTextAndMedia).toBe("function"); + }); + it("exports account helper builders from the dedicated subpath", () => { expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function"); }); @@ -122,17 +134,36 @@ describe("plugin-sdk subpath exports", () => { }); it("exports channel runtime helpers from the dedicated subpath", () => { + expect(typeof channelRuntimeSdk.attachChannelToResult).toBe("function"); + expect(typeof channelRuntimeSdk.attachChannelToResults).toBe("function"); expect(typeof channelRuntimeSdk.buildUnresolvedTargetResults).toBe("function"); + expect(typeof channelRuntimeSdk.createAttachedChannelResultAdapter).toBe("function"); expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createEmptyChannelResult).toBe("function"); expect(typeof channelRuntimeSdk.createEmptyChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createRawChannelSendResultAdapter).toBe("function"); expect(typeof channelRuntimeSdk.createLoggedPairingApprovalNotifier).toBe("function"); expect(typeof channelRuntimeSdk.createPairingPrefixStripper).toBe("function"); + expect(typeof channelRuntimeSdk.createScopedAccountReplyToModeResolver).toBe("function"); + expect(typeof channelRuntimeSdk.createStaticReplyToModeResolver).toBe("function"); + expect(typeof channelRuntimeSdk.createTopLevelChannelReplyToModeResolver).toBe("function"); expect(typeof channelRuntimeSdk.createRuntimeDirectoryLiveAdapter).toBe("function"); expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); + expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceAndFinalize).toBe("function"); + expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceOrFallback).toBe("function"); expect(typeof channelRuntimeSdk.resolveTargetsWithOptionalToken).toBe("function"); expect(typeof channelRuntimeSdk.createTextPairingAdapter).toBe("function"); }); + it("exports channel send-result helpers from the dedicated subpath", () => { + expect(typeof channelSendResultSdk.attachChannelToResult).toBe("function"); + expect(typeof channelSendResultSdk.attachChannelToResults).toBe("function"); + expect(typeof channelSendResultSdk.buildChannelSendResult).toBe("function"); + expect(typeof channelSendResultSdk.createAttachedChannelResultAdapter).toBe("function"); + expect(typeof channelSendResultSdk.createEmptyChannelResult).toBe("function"); + expect(typeof channelSendResultSdk.createRawChannelSendResultAdapter).toBe("function"); + }); + it("exports provider setup helpers from the dedicated subpath", () => { expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 2655e26e18f..21a5dd09b89 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -77,6 +77,7 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { + deliverTextOrMediaReply, isNumericTargetId, resolveOutboundMediaUrls, sendMediaWithLeadingCaption, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index e2ab63e0e7a..b02800880ec 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -68,6 +68,7 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { + deliverTextOrMediaReply, isNumericTargetId, resolveOutboundMediaUrls, sendMediaWithLeadingCaption, From 7d08070dd75fb8e65f46d8bdadf9eb4855fd18fa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 10:53:48 -0700 Subject: [PATCH 20/34] Plugins: generate bundled auth env metadata --- package.json | 11 +- ...erate-bundled-provider-auth-env-vars.d.mts | 17 ++ ...enerate-bundled-provider-auth-env-vars.mjs | 131 +++++++++ ...undled-provider-auth-env-vars.generated.ts | 38 +++ .../bundled-provider-auth-env-vars.test.ts | 71 ++++- src/plugins/bundled-provider-auth-env-vars.ts | 96 +------ ...n-extension-import-boundary-inventory.json | 248 ------------------ 7 files changed, 269 insertions(+), 343 deletions(-) create mode 100644 scripts/generate-bundled-provider-auth-env-vars.d.mts create mode 100644 scripts/generate-bundled-provider-auth-env-vars.mjs create mode 100644 src/plugins/bundled-provider-auth-env-vars.generated.ts diff --git a/package.json b/package.json index 413fee96094..124f51927db 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,10 @@ "types": "./dist/plugin-sdk/reply-runtime.d.ts", "default": "./dist/plugin-sdk/reply-runtime.js" }, + "./plugin-sdk/reply-payload": { + "types": "./dist/plugin-sdk/reply-payload.d.ts", + "default": "./dist/plugin-sdk/reply-payload.js" + }, "./plugin-sdk/channel-runtime": { "types": "./dist/plugin-sdk/channel-runtime.d.ts", "default": "./dist/plugin-sdk/channel-runtime.js" @@ -394,6 +398,10 @@ "types": "./dist/plugin-sdk/channel-policy.d.ts", "default": "./dist/plugin-sdk/channel-policy.js" }, + "./plugin-sdk/channel-send-result": { + "types": "./dist/plugin-sdk/channel-send-result.d.ts", + "default": "./dist/plugin-sdk/channel-send-result.js" + }, "./plugin-sdk/group-access": { "types": "./dist/plugin-sdk/group-access.d.ts", "default": "./dist/plugin-sdk/group-access.js" @@ -519,7 +527,8 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check": "pnpm check:host-env-policy:swift && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check:bundled-provider-auth-env-vars": "node scripts/generate-bundled-provider-auth-env-vars.mjs --check", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", diff --git a/scripts/generate-bundled-provider-auth-env-vars.d.mts b/scripts/generate-bundled-provider-auth-env-vars.d.mts new file mode 100644 index 00000000000..d5e189e743a --- /dev/null +++ b/scripts/generate-bundled-provider-auth-env-vars.d.mts @@ -0,0 +1,17 @@ +export function collectBundledProviderAuthEnvVars(params?: { + repoRoot?: string; +}): Record; + +export function renderBundledProviderAuthEnvVarModule( + entries: Record, +): string; + +export function writeBundledProviderAuthEnvVarModule(params?: { + repoRoot?: string; + outputPath?: string; + check?: boolean; +}): { + changed: boolean; + wrote: boolean; + outputPath: string; +}; diff --git a/scripts/generate-bundled-provider-auth-env-vars.mjs b/scripts/generate-bundled-provider-auth-env-vars.mjs new file mode 100644 index 00000000000..ebcd29360e8 --- /dev/null +++ b/scripts/generate-bundled-provider-auth-env-vars.mjs @@ -0,0 +1,131 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; + +const GENERATED_BY = "scripts/generate-bundled-provider-auth-env-vars.mjs"; +const DEFAULT_OUTPUT_PATH = "src/plugins/bundled-provider-auth-env-vars.generated.ts"; + +function readIfExists(filePath) { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } +} + +function normalizeProviderAuthEnvVars(providerAuthEnvVars) { + if ( + !providerAuthEnvVars || + typeof providerAuthEnvVars !== "object" || + Array.isArray(providerAuthEnvVars) + ) { + return []; + } + + return Object.entries(providerAuthEnvVars) + .map(([providerId, envVars]) => { + const normalizedProviderId = providerId.trim(); + const normalizedEnvVars = Array.isArray(envVars) + ? envVars.map((value) => String(value).trim()).filter(Boolean) + : []; + if (!normalizedProviderId || normalizedEnvVars.length === 0) { + return null; + } + return [normalizedProviderId, normalizedEnvVars]; + }) + .filter(Boolean) + .toSorted(([left], [right]) => left.localeCompare(right)); +} + +export function collectBundledProviderAuthEnvVars(params = {}) { + const repoRoot = path.resolve(params.repoRoot ?? process.cwd()); + const extensionsRoot = path.join(repoRoot, "extensions"); + if (!fs.existsSync(extensionsRoot)) { + return {}; + } + + const entries = new Map(); + for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + + const manifestPath = path.join(extensionsRoot, dirent.name, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + for (const [providerId, envVars] of normalizeProviderAuthEnvVars( + manifest.providerAuthEnvVars, + )) { + entries.set(providerId, envVars); + } + } + + return Object.fromEntries( + [...entries.entries()].toSorted(([left], [right]) => left.localeCompare(right)), + ); +} + +export function renderBundledProviderAuthEnvVarModule(entries) { + const renderedEntries = Object.entries(entries) + .map(([providerId, envVars]) => { + const renderedKey = /^[$A-Z_a-z][\w$]*$/u.test(providerId) + ? providerId + : JSON.stringify(providerId); + const renderedEnvVars = envVars.map((value) => JSON.stringify(value)).join(", "); + return ` ${renderedKey}: [${renderedEnvVars}],`; + }) + .join("\n"); + return `// Auto-generated by ${GENERATED_BY}. Do not edit directly. + +export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { +${renderedEntries} +} as const satisfies Record; +`; +} + +export function writeBundledProviderAuthEnvVarModule(params = {}) { + const repoRoot = path.resolve(params.repoRoot ?? process.cwd()); + const outputPath = path.resolve(repoRoot, params.outputPath ?? DEFAULT_OUTPUT_PATH); + const next = renderBundledProviderAuthEnvVarModule( + collectBundledProviderAuthEnvVars({ repoRoot }), + ); + const current = readIfExists(outputPath); + const changed = current !== next; + + if (params.check) { + return { + changed, + wrote: false, + outputPath, + }; + } + + return { + changed, + wrote: writeTextFileIfChanged(outputPath, next), + outputPath, + }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const result = writeBundledProviderAuthEnvVarModule({ + check: process.argv.includes("--check"), + }); + + if (result.changed) { + if (process.argv.includes("--check")) { + console.error( + `[bundled-provider-auth-env-vars] stale generated output at ${path.relative(process.cwd(), result.outputPath)}`, + ); + process.exitCode = 1; + } else { + console.log( + `[bundled-provider-auth-env-vars] wrote ${path.relative(process.cwd(), result.outputPath)}`, + ); + } + } +} diff --git a/src/plugins/bundled-provider-auth-env-vars.generated.ts b/src/plugins/bundled-provider-auth-env-vars.generated.ts new file mode 100644 index 00000000000..416036b28ea --- /dev/null +++ b/src/plugins/bundled-provider-auth-env-vars.generated.ts @@ -0,0 +1,38 @@ +// Auto-generated by scripts/generate-bundled-provider-auth-env-vars.mjs. Do not edit directly. + +export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { + anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + byteplus: ["BYTEPLUS_API_KEY"], + chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], + "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + fal: ["FAL_KEY"], + "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], + google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], + kilocode: ["KILOCODE_API_KEY"], + kimi: ["KIMI_API_KEY", "KIMICODE_API_KEY"], + "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], + minimax: ["MINIMAX_API_KEY"], + "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], + mistral: ["MISTRAL_API_KEY"], + modelstudio: ["MODELSTUDIO_API_KEY"], + moonshot: ["MOONSHOT_API_KEY"], + nvidia: ["NVIDIA_API_KEY"], + ollama: ["OLLAMA_API_KEY"], + openai: ["OPENAI_API_KEY"], + opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + openrouter: ["OPENROUTER_API_KEY"], + qianfan: ["QIANFAN_API_KEY"], + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + sglang: ["SGLANG_API_KEY"], + synthetic: ["SYNTHETIC_API_KEY"], + together: ["TOGETHER_API_KEY"], + venice: ["VENICE_API_KEY"], + "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], + vllm: ["VLLM_API_KEY"], + volcengine: ["VOLCANO_ENGINE_API_KEY"], + xai: ["XAI_API_KEY"], + xiaomi: ["XIAOMI_API_KEY"], + zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], +} as const satisfies Record; diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts index 81523392e7a..a41b60d7b6d 100644 --- a/src/plugins/bundled-provider-auth-env-vars.test.ts +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -1,7 +1,35 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; +import { afterEach } from "vitest"; +import { + collectBundledProviderAuthEnvVars, + writeBundledProviderAuthEnvVarModule, +} from "../../scripts/generate-bundled-provider-auth-env-vars.mjs"; import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js"; +const repoRoot = path.resolve(import.meta.dirname, "../.."); +const tempDirs: string[] = []; + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + describe("bundled provider auth env vars", () => { + it("matches the generated manifest snapshot", () => { + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual( + collectBundledProviderAuthEnvVars({ repoRoot }), + ); + }); + it("reads bundled provider auth env vars from plugin manifests", () => { expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["github-copilot"]).toEqual([ "COPILOT_GITHUB_TOKEN", @@ -17,6 +45,47 @@ describe("bundled provider auth env vars", () => { "MINIMAX_API_KEY", ]); expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.openai).toEqual(["OPENAI_API_KEY"]); - expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["openai-codex"]).toBeUndefined(); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.fal).toEqual(["FAL_KEY"]); + expect("openai-codex" in BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toBe(false); + }); + + it("supports check mode for stale generated artifacts", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-auth-env-vars-")); + tempDirs.push(tempRoot); + + writeJson(path.join(tempRoot, "extensions", "alpha", "openclaw.plugin.json"), { + id: "alpha", + providerAuthEnvVars: { + alpha: ["ALPHA_TOKEN"], + }, + }); + + const initial = writeBundledProviderAuthEnvVarModule({ + repoRoot: tempRoot, + outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts", + }); + expect(initial.wrote).toBe(true); + + const current = writeBundledProviderAuthEnvVarModule({ + repoRoot: tempRoot, + outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts", + check: true, + }); + expect(current.changed).toBe(false); + expect(current.wrote).toBe(false); + + fs.writeFileSync( + path.join(tempRoot, "src/plugins/bundled-provider-auth-env-vars.generated.ts"), + "// stale\n", + "utf8", + ); + + const stale = writeBundledProviderAuthEnvVarModule({ + repoRoot: tempRoot, + outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts", + check: true, + }); + expect(stale.changed).toBe(true); + expect(stale.wrote).toBe(false); }); }); diff --git a/src/plugins/bundled-provider-auth-env-vars.ts b/src/plugins/bundled-provider-auth-env-vars.ts index 42ca376959d..3df3d5c9d36 100644 --- a/src/plugins/bundled-provider-auth-env-vars.ts +++ b/src/plugins/bundled-provider-auth-env-vars.ts @@ -1,93 +1,3 @@ -import ANTHROPIC_MANIFEST from "../../extensions/anthropic/openclaw.plugin.json" with { type: "json" }; -import BYTEPLUS_MANIFEST from "../../extensions/byteplus/openclaw.plugin.json" with { type: "json" }; -import CLOUDFLARE_AI_GATEWAY_MANIFEST from "../../extensions/cloudflare-ai-gateway/openclaw.plugin.json" with { type: "json" }; -import COPILOT_PROXY_MANIFEST from "../../extensions/copilot-proxy/openclaw.plugin.json" with { type: "json" }; -import GITHUB_COPILOT_MANIFEST from "../../extensions/github-copilot/openclaw.plugin.json" with { type: "json" }; -import GOOGLE_MANIFEST from "../../extensions/google/openclaw.plugin.json" with { type: "json" }; -import HUGGINGFACE_MANIFEST from "../../extensions/huggingface/openclaw.plugin.json" with { type: "json" }; -import KILOCODE_MANIFEST from "../../extensions/kilocode/openclaw.plugin.json" with { type: "json" }; -import KIMI_CODING_MANIFEST from "../../extensions/kimi-coding/openclaw.plugin.json" with { type: "json" }; -import MINIMAX_MANIFEST from "../../extensions/minimax/openclaw.plugin.json" with { type: "json" }; -import MISTRAL_MANIFEST from "../../extensions/mistral/openclaw.plugin.json" with { type: "json" }; -import MODELSTUDIO_MANIFEST from "../../extensions/modelstudio/openclaw.plugin.json" with { type: "json" }; -import MOONSHOT_MANIFEST from "../../extensions/moonshot/openclaw.plugin.json" with { type: "json" }; -import NVIDIA_MANIFEST from "../../extensions/nvidia/openclaw.plugin.json" with { type: "json" }; -import OLLAMA_MANIFEST from "../../extensions/ollama/openclaw.plugin.json" with { type: "json" }; -import OPENAI_MANIFEST from "../../extensions/openai/openclaw.plugin.json" with { type: "json" }; -import OPENCODE_GO_MANIFEST from "../../extensions/opencode-go/openclaw.plugin.json" with { type: "json" }; -import OPENCODE_MANIFEST from "../../extensions/opencode/openclaw.plugin.json" with { type: "json" }; -import OPENROUTER_MANIFEST from "../../extensions/openrouter/openclaw.plugin.json" with { type: "json" }; -import QIANFAN_MANIFEST from "../../extensions/qianfan/openclaw.plugin.json" with { type: "json" }; -import QWEN_PORTAL_AUTH_MANIFEST from "../../extensions/qwen-portal-auth/openclaw.plugin.json" with { type: "json" }; -import SGLANG_MANIFEST from "../../extensions/sglang/openclaw.plugin.json" with { type: "json" }; -import SYNTHETIC_MANIFEST from "../../extensions/synthetic/openclaw.plugin.json" with { type: "json" }; -import TOGETHER_MANIFEST from "../../extensions/together/openclaw.plugin.json" with { type: "json" }; -import VENICE_MANIFEST from "../../extensions/venice/openclaw.plugin.json" with { type: "json" }; -import VERCEL_AI_GATEWAY_MANIFEST from "../../extensions/vercel-ai-gateway/openclaw.plugin.json" with { type: "json" }; -import VLLM_MANIFEST from "../../extensions/vllm/openclaw.plugin.json" with { type: "json" }; -import VOLCENGINE_MANIFEST from "../../extensions/volcengine/openclaw.plugin.json" with { type: "json" }; -import XAI_MANIFEST from "../../extensions/xai/openclaw.plugin.json" with { type: "json" }; -import XIAOMI_MANIFEST from "../../extensions/xiaomi/openclaw.plugin.json" with { type: "json" }; -import ZAI_MANIFEST from "../../extensions/zai/openclaw.plugin.json" with { type: "json" }; - -type ProviderAuthEnvVarManifest = { - id?: string; - providerAuthEnvVars?: Record; -}; - -function collectBundledProviderAuthEnvVars( - manifests: readonly ProviderAuthEnvVarManifest[], -): Record { - const entries: Record = {}; - for (const manifest of manifests) { - const providerAuthEnvVars = manifest.providerAuthEnvVars; - if (!providerAuthEnvVars) { - continue; - } - for (const [providerId, envVars] of Object.entries(providerAuthEnvVars)) { - const normalizedProviderId = providerId.trim(); - const normalizedEnvVars = envVars.map((value) => value.trim()).filter(Boolean); - if (!normalizedProviderId || normalizedEnvVars.length === 0) { - continue; - } - entries[normalizedProviderId] = normalizedEnvVars; - } - } - return entries; -} - -// Read bundled provider auth env metadata from manifests so env-based auth -// lookup stays cheap and does not need to boot plugin runtime code. -export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = collectBundledProviderAuthEnvVars([ - ANTHROPIC_MANIFEST, - BYTEPLUS_MANIFEST, - CLOUDFLARE_AI_GATEWAY_MANIFEST, - COPILOT_PROXY_MANIFEST, - GITHUB_COPILOT_MANIFEST, - GOOGLE_MANIFEST, - HUGGINGFACE_MANIFEST, - KILOCODE_MANIFEST, - KIMI_CODING_MANIFEST, - MINIMAX_MANIFEST, - MISTRAL_MANIFEST, - MODELSTUDIO_MANIFEST, - MOONSHOT_MANIFEST, - NVIDIA_MANIFEST, - OLLAMA_MANIFEST, - OPENAI_MANIFEST, - OPENCODE_GO_MANIFEST, - OPENCODE_MANIFEST, - OPENROUTER_MANIFEST, - QIANFAN_MANIFEST, - QWEN_PORTAL_AUTH_MANIFEST, - SGLANG_MANIFEST, - SYNTHETIC_MANIFEST, - TOGETHER_MANIFEST, - VENICE_MANIFEST, - VERCEL_AI_GATEWAY_MANIFEST, - VLLM_MANIFEST, - VOLCENGINE_MANIFEST, - XAI_MANIFEST, - XIAOMI_MANIFEST, - ZAI_MANIFEST, -]); +// Generated from extension manifests so core secrets/auth code does not need +// static imports into extension source trees. +export { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.generated.js"; diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index 2e1e1fb4156..8ba8e6ed9d2 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -1,252 +1,4 @@ [ - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 1, - "kind": "import", - "specifier": "../../extensions/anthropic/openclaw.plugin.json", - "resolvedPath": "extensions/anthropic/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 2, - "kind": "import", - "specifier": "../../extensions/byteplus/openclaw.plugin.json", - "resolvedPath": "extensions/byteplus/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 3, - "kind": "import", - "specifier": "../../extensions/cloudflare-ai-gateway/openclaw.plugin.json", - "resolvedPath": "extensions/cloudflare-ai-gateway/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 4, - "kind": "import", - "specifier": "../../extensions/copilot-proxy/openclaw.plugin.json", - "resolvedPath": "extensions/copilot-proxy/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 5, - "kind": "import", - "specifier": "../../extensions/github-copilot/openclaw.plugin.json", - "resolvedPath": "extensions/github-copilot/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 6, - "kind": "import", - "specifier": "../../extensions/google/openclaw.plugin.json", - "resolvedPath": "extensions/google/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 7, - "kind": "import", - "specifier": "../../extensions/huggingface/openclaw.plugin.json", - "resolvedPath": "extensions/huggingface/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 8, - "kind": "import", - "specifier": "../../extensions/kilocode/openclaw.plugin.json", - "resolvedPath": "extensions/kilocode/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 9, - "kind": "import", - "specifier": "../../extensions/kimi-coding/openclaw.plugin.json", - "resolvedPath": "extensions/kimi-coding/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 10, - "kind": "import", - "specifier": "../../extensions/minimax/openclaw.plugin.json", - "resolvedPath": "extensions/minimax/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 11, - "kind": "import", - "specifier": "../../extensions/mistral/openclaw.plugin.json", - "resolvedPath": "extensions/mistral/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 12, - "kind": "import", - "specifier": "../../extensions/modelstudio/openclaw.plugin.json", - "resolvedPath": "extensions/modelstudio/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 13, - "kind": "import", - "specifier": "../../extensions/moonshot/openclaw.plugin.json", - "resolvedPath": "extensions/moonshot/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 14, - "kind": "import", - "specifier": "../../extensions/nvidia/openclaw.plugin.json", - "resolvedPath": "extensions/nvidia/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 15, - "kind": "import", - "specifier": "../../extensions/ollama/openclaw.plugin.json", - "resolvedPath": "extensions/ollama/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 16, - "kind": "import", - "specifier": "../../extensions/openai/openclaw.plugin.json", - "resolvedPath": "extensions/openai/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 17, - "kind": "import", - "specifier": "../../extensions/opencode-go/openclaw.plugin.json", - "resolvedPath": "extensions/opencode-go/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 18, - "kind": "import", - "specifier": "../../extensions/opencode/openclaw.plugin.json", - "resolvedPath": "extensions/opencode/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 19, - "kind": "import", - "specifier": "../../extensions/openrouter/openclaw.plugin.json", - "resolvedPath": "extensions/openrouter/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 20, - "kind": "import", - "specifier": "../../extensions/qianfan/openclaw.plugin.json", - "resolvedPath": "extensions/qianfan/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 21, - "kind": "import", - "specifier": "../../extensions/qwen-portal-auth/openclaw.plugin.json", - "resolvedPath": "extensions/qwen-portal-auth/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 22, - "kind": "import", - "specifier": "../../extensions/sglang/openclaw.plugin.json", - "resolvedPath": "extensions/sglang/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 23, - "kind": "import", - "specifier": "../../extensions/synthetic/openclaw.plugin.json", - "resolvedPath": "extensions/synthetic/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 24, - "kind": "import", - "specifier": "../../extensions/together/openclaw.plugin.json", - "resolvedPath": "extensions/together/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 25, - "kind": "import", - "specifier": "../../extensions/venice/openclaw.plugin.json", - "resolvedPath": "extensions/venice/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 26, - "kind": "import", - "specifier": "../../extensions/vercel-ai-gateway/openclaw.plugin.json", - "resolvedPath": "extensions/vercel-ai-gateway/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 27, - "kind": "import", - "specifier": "../../extensions/vllm/openclaw.plugin.json", - "resolvedPath": "extensions/vllm/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 28, - "kind": "import", - "specifier": "../../extensions/volcengine/openclaw.plugin.json", - "resolvedPath": "extensions/volcengine/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 29, - "kind": "import", - "specifier": "../../extensions/xai/openclaw.plugin.json", - "resolvedPath": "extensions/xai/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 30, - "kind": "import", - "specifier": "../../extensions/xiaomi/openclaw.plugin.json", - "resolvedPath": "extensions/xiaomi/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 31, - "kind": "import", - "specifier": "../../extensions/zai/openclaw.plugin.json", - "resolvedPath": "extensions/zai/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, { "file": "src/plugins/provider-model-definitions.ts", "line": 1, From ea74123ab21209ec31f46305df737b448dec57b1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 10:54:00 -0700 Subject: [PATCH 21/34] Slack: fix directory test runtime stub --- extensions/slack/src/channel.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 93b10d6522d..73acfe3aeb7 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { slackOutbound } from "./outbound-adapter.js"; const handleSlackActionMock = vi.fn(); @@ -261,7 +262,7 @@ describe("slackPlugin directory", () => { }, }, }, - runtime: undefined, + runtime: createRuntimeEnv(), }), ).resolves.toEqual([{ id: "user:u123", kind: "user" }]); }); From 505d140aeb350286f79191b83cea9ec674171ba4 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Wed, 18 Mar 2026 10:55:25 -0700 Subject: [PATCH 22/34] fix: stabilize build dependency resolution (#49928) * build: mirror uuid for msteams Add uuid to both the msteams bundled extension and the root package so the workspace build can resolve @microsoft/agents-hosting during tsdown while standalone extension installs also have the runtime dependency available. Regeneration-Prompt: | pnpm build failed because @microsoft/agents-hosting 1.3.1 requires uuid in its published JS but does not declare it in its package manifest. The msteams extension dynamically imports that package, and the workspace build resolves it from the root dependency graph. Mirror uuid into the root package for workspace builds and keep it in extensions/msteams/package.json so standalone plugin installs also resolve it. Update the lockfile to match the manifest changes. * build: prune stale plugin dist symlinks Remove stale dist and dist-runtime plugin node_modules symlinks before tsdown runs. These links point back into extension installs, and tsdown's clean step can traverse them on rebuilds and hollow out the active pnpm dependency tree before plugin-sdk declaration generation runs. Regeneration-Prompt: | pnpm build was intermittently failing in the plugin-sdk:dts phase after earlier build steps had already run. The symptom looked like missing root packages such as zod, ajv, commander, and undici even though a fresh install briefly fixed the problem. Investigate the build pipeline step by step rather than patching TypeScript errors. Confirm whether rebuilds mutate node_modules, identify the first step that does it, and preserve existing runtime-postbuild behavior. The key constraint is that dist and dist-runtime plugin node_modules links are intentional for runtime packaging, so do not remove that feature globally. Instead, make rebuilds safe by deleting only stale symlinks left in generated output before invoking tsdown, so tsdown cleanup cannot recurse back into the live pnpm install tree. Verify with repeated pnpm build runs. --- extensions/msteams/package.json | 3 ++- package.json | 1 + pnpm-lock.yaml | 6 ++++++ scripts/tsdown-build.mjs | 34 +++++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 6365de0b725..c29afcfebbb 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -5,7 +5,8 @@ "type": "module", "dependencies": { "@microsoft/agents-hosting": "^1.3.1", - "express": "^5.2.1" + "express": "^5.2.1", + "uuid": "^11.1.0" }, "openclaw": { "extensions": [ diff --git a/package.json b/package.json index 124f51927db..5b7887dcef4 100644 --- a/package.json +++ b/package.json @@ -718,6 +718,7 @@ "tar": "7.5.11", "tslog": "^4.10.2", "undici": "^7.24.4", + "uuid": "^11.1.0", "ws": "^8.19.0", "yaml": "^2.8.2", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0447e4ef9bc..73e329eedb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,6 +196,9 @@ importers: undici: specifier: ^7.24.4 version: 7.24.4 + uuid: + specifier: ^11.1.0 + version: 11.1.0 ws: specifier: ^8.19.0 version: 8.19.0 @@ -477,6 +480,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + uuid: + specifier: ^11.1.0 + version: 11.1.0 extensions/nextcloud-talk: dependencies: diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 871e89ddbf0..79f24ea65b8 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn"; const extraArgs = process.argv.slice(2); @@ -8,6 +10,38 @@ const INEFFECTIVE_DYNAMIC_IMPORT_RE = /\[INEFFECTIVE_DYNAMIC_IMPORT\]/; const UNRESOLVED_IMPORT_RE = /\[UNRESOLVED_IMPORT\]/; const ANSI_ESCAPE_RE = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g"); +function removeDistPluginNodeModulesSymlinks(rootDir) { + const extensionsDir = path.join(rootDir, "extensions"); + if (!fs.existsSync(extensionsDir)) { + return; + } + + for (const dirent of fs.readdirSync(extensionsDir, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + const nodeModulesPath = path.join(extensionsDir, dirent.name, "node_modules"); + try { + if (fs.lstatSync(nodeModulesPath).isSymbolicLink()) { + fs.rmSync(nodeModulesPath, { force: true, recursive: true }); + } + } catch { + // Skip missing or unreadable paths so the build can proceed. + } + } +} + +function pruneStaleRuntimeSymlinks() { + const cwd = process.cwd(); + // runtime-postbuild links dist/dist-runtime plugin node_modules back into the + // source extensions. Remove only those symlinks up front so tsdown's clean + // step cannot traverse into the active pnpm install tree on rebuilds. + removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist")); + removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist-runtime")); +} + +pruneStaleRuntimeSymlinks(); + function findFatalUnresolvedImport(lines) { for (const line of lines) { if (!UNRESOLVED_IMPORT_RE.test(line)) { From 8240fd900ace61a3bbe41c8096a4e9e2f17c3666 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 11:00:33 -0700 Subject: [PATCH 23/34] Plugin SDK: route core channel runtimes through public subpaths --- src/plugin-sdk/discord.ts | 15 ++ src/plugin-sdk/imessage.ts | 6 +- src/plugin-sdk/slack.ts | 11 +- src/plugin-sdk/telegram.ts | 13 ++ .../runtime/runtime-discord-ops.runtime.ts | 14 +- src/plugins/runtime/runtime-discord.ts | 4 +- src/plugins/runtime/runtime-imessage.ts | 2 +- src/plugins/runtime/runtime-signal.ts | 2 +- .../runtime/runtime-slack-ops.runtime.ts | 14 +- .../runtime/runtime-telegram-ops.runtime.ts | 8 +- src/plugins/runtime/runtime-telegram.ts | 8 +- ...n-extension-import-boundary-inventory.json | 208 ------------------ 12 files changed, 69 insertions(+), 236 deletions(-) diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 2949446fef6..4a968f2fbbc 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -84,7 +84,14 @@ export { export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/session-key-api.js"; export { autoBindSpawnedDiscordSubagent, + getThreadBindingManager, listThreadBindingsBySessionKey, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingInactivityExpiresAt, + resolveThreadBindingMaxAgeExpiresAt, + resolveThreadBindingMaxAgeMs, + setThreadBindingIdleTimeoutBySessionKey, + setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, } from "../../extensions/discord/runtime-api.js"; export { getGateway } from "../../extensions/discord/runtime-api.js"; @@ -93,6 +100,7 @@ export { readDiscordComponentSpec } from "../../extensions/discord/api.js"; export { resolveDiscordChannelId } from "../../extensions/discord/api.js"; export { addRoleDiscord, + auditDiscordChannelPermissions, banMemberDiscord, createChannelDiscord, createScheduledEventDiscord, @@ -110,23 +118,30 @@ export { fetchVoiceStatusDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, + listDiscordDirectoryGroupsLive, + listDiscordDirectoryPeersLive, listGuildChannelsDiscord, listGuildEmojisDiscord, listPinsDiscord, listScheduledEventsDiscord, listThreadsDiscord, + monitorDiscordProvider, moveChannelDiscord, pinMessageDiscord, + probeDiscord, reactMessageDiscord, readMessagesDiscord, removeChannelPermissionDiscord, removeOwnReactionsDiscord, removeReactionDiscord, removeRoleDiscord, + resolveDiscordChannelAllowlist, + resolveDiscordUserAllowlist, searchMessagesDiscord, sendDiscordComponentMessage, sendMessageDiscord, sendPollDiscord, + sendTypingDiscord, sendStickerDiscord, sendVoiceMessageDiscord, setChannelPermissionDiscord, diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index c69abdc6b5c..b6c98da97c6 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -43,4 +43,8 @@ export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { collectStatusIssuesFromLastError } from "./status-helpers.js"; -export { sendMessageIMessage } from "../../extensions/imessage/runtime-api.js"; +export { + monitorIMessageProvider, + probeIMessage, + sendMessageIMessage, +} from "../../extensions/imessage/runtime-api.js"; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 0b1159cbb22..bef98db2bfc 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -60,7 +60,16 @@ export { extractSlackToolSend, listSlackMessageActions } from "../../extensions/ export { buildSlackThreadingToolContext } from "../../extensions/slack/api.js"; export { parseSlackBlocksInput } from "../../extensions/slack/api.js"; export { handleSlackHttpRequest } from "../../extensions/slack/api.js"; -export { sendMessageSlack } from "../../extensions/slack/runtime-api.js"; +export { + handleSlackAction, + listSlackDirectoryGroupsLive, + listSlackDirectoryPeersLive, + monitorSlackProvider, + probeSlack, + resolveSlackChannelAllowlist, + resolveSlackUserAllowlist, + sendMessageSlack, +} from "../../extensions/slack/runtime-api.js"; export { deleteSlackMessage, downloadSlackFile, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 47bed87544f..fa06fded55d 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -86,18 +86,31 @@ export { } from "../../extensions/telegram/api.js"; export { resolveTelegramReactionLevel } from "../../extensions/telegram/api.js"; export { + auditTelegramGroupMembership, + collectTelegramUnmentionedGroupIds, createForumTopicTelegram, deleteMessageTelegram, editForumTopicTelegram, + editMessageReplyMarkupTelegram, editMessageTelegram, + monitorTelegramProvider, + pinMessageTelegram, reactMessageTelegram, + renameForumTopicTelegram, + probeTelegram, sendMessageTelegram, sendPollTelegram, sendStickerTelegram, + sendTypingTelegram, + unpinMessageTelegram, } from "../../extensions/telegram/runtime-api.js"; export { getCacheStats, searchStickers } from "../../extensions/telegram/api.js"; export { resolveTelegramToken } from "../../extensions/telegram/runtime-api.js"; export { telegramMessageActions } from "../../extensions/telegram/runtime-api.js"; +export { + setTelegramThreadBindingIdleTimeoutBySessionKey, + setTelegramThreadBindingMaxAgeBySessionKey, +} from "../../extensions/telegram/runtime-api.js"; export { collectTelegramStatusIssues } from "../../extensions/telegram/api.js"; export { sendTelegramPayloadMessages } from "../../extensions/telegram/api.js"; export { diff --git a/src/plugins/runtime/runtime-discord-ops.runtime.ts b/src/plugins/runtime/runtime-discord-ops.runtime.ts index e1bc99166af..02a4cc22eb0 100644 --- a/src/plugins/runtime/runtime-discord-ops.runtime.ts +++ b/src/plugins/runtime/runtime-discord-ops.runtime.ts @@ -1,12 +1,12 @@ -import { auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl } from "../../../extensions/discord/runtime-api.js"; +import { auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl } from "openclaw/plugin-sdk/discord"; import { listDiscordDirectoryGroupsLive as listDiscordDirectoryGroupsLiveImpl, listDiscordDirectoryPeersLive as listDiscordDirectoryPeersLiveImpl, -} from "../../../extensions/discord/runtime-api.js"; -import { monitorDiscordProvider as monitorDiscordProviderImpl } from "../../../extensions/discord/runtime-api.js"; -import { probeDiscord as probeDiscordImpl } from "../../../extensions/discord/runtime-api.js"; -import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "../../../extensions/discord/runtime-api.js"; -import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "../../../extensions/discord/runtime-api.js"; +} from "openclaw/plugin-sdk/discord"; +import { monitorDiscordProvider as monitorDiscordProviderImpl } from "openclaw/plugin-sdk/discord"; +import { probeDiscord as probeDiscordImpl } from "openclaw/plugin-sdk/discord"; +import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "openclaw/plugin-sdk/discord"; +import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "openclaw/plugin-sdk/discord"; import { createThreadDiscord as createThreadDiscordImpl, deleteMessageDiscord as deleteMessageDiscordImpl, @@ -18,7 +18,7 @@ import { sendPollDiscord as sendPollDiscordImpl, sendTypingDiscord as sendTypingDiscordImpl, unpinMessageDiscord as unpinMessageDiscordImpl, -} from "../../../extensions/discord/runtime-api.js"; +} from "openclaw/plugin-sdk/discord"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeDiscordOps = Pick< diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 8264a7f04df..354d205a66d 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -1,4 +1,4 @@ -import { discordMessageActions } from "../../../extensions/discord/runtime-api.js"; +import { discordMessageActions } from "openclaw/plugin-sdk/discord"; import { getThreadBindingManager, resolveThreadBindingIdleTimeoutMs, @@ -8,7 +8,7 @@ import { setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, -} from "../../../extensions/discord/runtime-api.js"; +} from "openclaw/plugin-sdk/discord"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/src/plugins/runtime/runtime-imessage.ts b/src/plugins/runtime/runtime-imessage.ts index 56136197626..7740b6bdfa3 100644 --- a/src/plugins/runtime/runtime-imessage.ts +++ b/src/plugins/runtime/runtime-imessage.ts @@ -2,7 +2,7 @@ import { monitorIMessageProvider, probeIMessage, sendMessageIMessage, -} from "../../../extensions/imessage/runtime-api.js"; +} from "openclaw/plugin-sdk/imessage"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeIMessage(): PluginRuntimeChannel["imessage"] { diff --git a/src/plugins/runtime/runtime-signal.ts b/src/plugins/runtime/runtime-signal.ts index 5eade131012..e0b3c244e39 100644 --- a/src/plugins/runtime/runtime-signal.ts +++ b/src/plugins/runtime/runtime-signal.ts @@ -3,7 +3,7 @@ import { probeSignal, signalMessageActions, sendMessageSignal, -} from "../../../extensions/signal/runtime-api.js"; +} from "openclaw/plugin-sdk/signal"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeSignal(): PluginRuntimeChannel["signal"] { diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts index 8c06f2dda34..89411fafc00 100644 --- a/src/plugins/runtime/runtime-slack-ops.runtime.ts +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -1,13 +1,13 @@ import { listSlackDirectoryGroupsLive as listSlackDirectoryGroupsLiveImpl, listSlackDirectoryPeersLive as listSlackDirectoryPeersLiveImpl, -} from "../../../extensions/slack/runtime-api.js"; -import { monitorSlackProvider as monitorSlackProviderImpl } from "../../../extensions/slack/runtime-api.js"; -import { probeSlack as probeSlackImpl } from "../../../extensions/slack/runtime-api.js"; -import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; -import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; -import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/runtime-api.js"; -import { handleSlackAction as handleSlackActionImpl } from "../../../extensions/slack/runtime-api.js"; +} from "openclaw/plugin-sdk/slack"; +import { monitorSlackProvider as monitorSlackProviderImpl } from "openclaw/plugin-sdk/slack"; +import { probeSlack as probeSlackImpl } from "openclaw/plugin-sdk/slack"; +import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "openclaw/plugin-sdk/slack"; +import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "openclaw/plugin-sdk/slack"; +import { sendMessageSlack as sendMessageSlackImpl } from "openclaw/plugin-sdk/slack"; +import { handleSlackAction as handleSlackActionImpl } from "openclaw/plugin-sdk/slack"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeSlackOps = Pick< diff --git a/src/plugins/runtime/runtime-telegram-ops.runtime.ts b/src/plugins/runtime/runtime-telegram-ops.runtime.ts index dcd3fa05dec..5b49e854651 100644 --- a/src/plugins/runtime/runtime-telegram-ops.runtime.ts +++ b/src/plugins/runtime/runtime-telegram-ops.runtime.ts @@ -1,6 +1,6 @@ -import { auditTelegramGroupMembership as auditTelegramGroupMembershipImpl } from "../../../extensions/telegram/runtime-api.js"; -import { monitorTelegramProvider as monitorTelegramProviderImpl } from "../../../extensions/telegram/runtime-api.js"; -import { probeTelegram as probeTelegramImpl } from "../../../extensions/telegram/runtime-api.js"; +import { auditTelegramGroupMembership as auditTelegramGroupMembershipImpl } from "openclaw/plugin-sdk/telegram"; +import { monitorTelegramProvider as monitorTelegramProviderImpl } from "openclaw/plugin-sdk/telegram"; +import { probeTelegram as probeTelegramImpl } from "openclaw/plugin-sdk/telegram"; import { deleteMessageTelegram as deleteMessageTelegramImpl, editMessageReplyMarkupTelegram as editMessageReplyMarkupTelegramImpl, @@ -11,7 +11,7 @@ import { sendPollTelegram as sendPollTelegramImpl, sendTypingTelegram as sendTypingTelegramImpl, unpinMessageTelegram as unpinMessageTelegramImpl, -} from "../../../extensions/telegram/runtime-api.js"; +} from "openclaw/plugin-sdk/telegram"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeTelegramOps = Pick< diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index 74b4de7e48e..fd01f964f2a 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,10 +1,10 @@ -import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/runtime-api.js"; -import { telegramMessageActions } from "../../../extensions/telegram/runtime-api.js"; +import { collectTelegramUnmentionedGroupIds } from "openclaw/plugin-sdk/telegram"; +import { telegramMessageActions } from "openclaw/plugin-sdk/telegram"; import { setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, -} from "../../../extensions/telegram/runtime-api.js"; -import { resolveTelegramToken } from "../../../extensions/telegram/runtime-api.js"; +} from "openclaw/plugin-sdk/telegram"; +import { resolveTelegramToken } from "openclaw/plugin-sdk/telegram"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index 8ba8e6ed9d2..a91dc57c85e 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -95,214 +95,6 @@ "resolvedPath": "extensions/zai/model-definitions.js", "reason": "imports extension-owned file from src/plugins" }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 5, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 6, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 7, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 8, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 9, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 21, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord.ts", - "line": 11, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-imessage.ts", - "line": 5, - "kind": "import", - "specifier": "../../../extensions/imessage/runtime-api.js", - "resolvedPath": "extensions/imessage/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-signal.ts", - "line": 6, - "kind": "import", - "specifier": "../../../extensions/signal/runtime-api.js", - "resolvedPath": "extensions/signal/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 4, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 5, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 6, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 7, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 8, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 9, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 10, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", - "line": 2, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", - "line": 3, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", - "line": 14, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram.ts", - "line": 2, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram.ts", - "line": 6, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram.ts", - "line": 7, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, { "file": "src/plugins/runtime/runtime-whatsapp-login-tool.ts", "line": 1, From 152d17930297f547f92e7541c50f90a4cb7a5469 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 11:13:19 -0700 Subject: [PATCH 24/34] Plugin SDK: add public WhatsApp runtime subpaths --- package.json | 8 +++ scripts/lib/plugin-sdk-entrypoints.json | 2 + src/plugin-sdk/subpaths.test.ts | 11 ++++ src/plugin-sdk/whatsapp-action-runtime.ts | 1 + src/plugin-sdk/whatsapp-login-qr.ts | 1 + src/plugin-sdk/whatsapp.ts | 3 + .../runtime/runtime-whatsapp-login-tool.ts | 2 +- .../runtime/runtime-whatsapp-login.runtime.ts | 2 +- .../runtime-whatsapp-outbound.runtime.ts | 2 +- src/plugins/runtime/runtime-whatsapp.ts | 17 +++--- src/plugins/runtime/types-channel.ts | 24 ++++---- ...n-extension-import-boundary-inventory.json | 56 ------------------- 12 files changed, 49 insertions(+), 80 deletions(-) create mode 100644 src/plugin-sdk/whatsapp-action-runtime.ts create mode 100644 src/plugin-sdk/whatsapp-login-qr.ts diff --git a/package.json b/package.json index 5b7887dcef4..d28200d336f 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,14 @@ "types": "./dist/plugin-sdk/whatsapp.d.ts", "default": "./dist/plugin-sdk/whatsapp.js" }, + "./plugin-sdk/whatsapp-action-runtime": { + "types": "./dist/plugin-sdk/whatsapp-action-runtime.d.ts", + "default": "./dist/plugin-sdk/whatsapp-action-runtime.js" + }, + "./plugin-sdk/whatsapp-login-qr": { + "types": "./dist/plugin-sdk/whatsapp-login-qr.d.ts", + "default": "./dist/plugin-sdk/whatsapp-login-qr.js" + }, "./plugin-sdk/whatsapp-core": { "types": "./dist/plugin-sdk/whatsapp-core.d.ts", "default": "./dist/plugin-sdk/whatsapp-core.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index e55bea9d053..e0d707523a8 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -42,6 +42,8 @@ "imessage", "imessage-core", "whatsapp", + "whatsapp-action-runtime", + "whatsapp-login-qr", "whatsapp-core", "line", "line-core", diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 93ad61651e0..2f4a30ae5ce 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -29,6 +29,8 @@ import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as testingSdk from "openclaw/plugin-sdk/testing"; import * as voiceCallSdk from "openclaw/plugin-sdk/voice-call"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; +import * as whatsappActionRuntimeSdk from "openclaw/plugin-sdk/whatsapp-action-runtime"; +import * as whatsappLoginQrSdk from "openclaw/plugin-sdk/whatsapp-login-qr"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; @@ -297,6 +299,15 @@ describe("plugin-sdk subpath exports", () => { expect("resolveWhatsAppMentionStripPatterns" in whatsappSdk).toBe(false); }); + it("exports WhatsApp QR login helpers from the dedicated subpath", () => { + expect(typeof whatsappLoginQrSdk.startWebLoginWithQr).toBe("function"); + expect(typeof whatsappLoginQrSdk.waitForWebLogin).toBe("function"); + }); + + it("exports WhatsApp action runtime helpers from the dedicated subpath", () => { + expect(typeof whatsappActionRuntimeSdk.handleWhatsAppAction).toBe("function"); + }); + it("exports Feishu helpers", async () => { expect(typeof feishuSdk.feishuSetupWizard).toBe("object"); expect(typeof feishuSdk.feishuSetupAdapter).toBe("object"); diff --git a/src/plugin-sdk/whatsapp-action-runtime.ts b/src/plugin-sdk/whatsapp-action-runtime.ts new file mode 100644 index 00000000000..87e7a29e437 --- /dev/null +++ b/src/plugin-sdk/whatsapp-action-runtime.ts @@ -0,0 +1 @@ +export { handleWhatsAppAction } from "../../extensions/whatsapp/action-runtime-api.js"; diff --git a/src/plugin-sdk/whatsapp-login-qr.ts b/src/plugin-sdk/whatsapp-login-qr.ts new file mode 100644 index 00000000000..bde71742811 --- /dev/null +++ b/src/plugin-sdk/whatsapp-login-qr.ts @@ -0,0 +1 @@ +export { startWebLoginWithQr, waitForWebLogin } from "../../extensions/whatsapp/login-qr-api.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 3e16da46d80..d5182f9004c 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -71,10 +71,13 @@ export { resolveWhatsAppAccount, } from "../../extensions/whatsapp/api.js"; export { + getActiveWebListener, + getWebAuthAgeMs, WA_WEB_AUTH_DIR, logWebSelfId, logoutWeb, pickWebChannel, + readWebSelfId, webAuthExists, } from "../../extensions/whatsapp/runtime-api.js"; export { diff --git a/src/plugins/runtime/runtime-whatsapp-login-tool.ts b/src/plugins/runtime/runtime-whatsapp-login-tool.ts index 094e47c9a1d..33c2355cda1 100644 --- a/src/plugins/runtime/runtime-whatsapp-login-tool.ts +++ b/src/plugins/runtime/runtime-whatsapp-login-tool.ts @@ -1 +1 @@ -export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "../../../extensions/whatsapp/runtime-api.js"; +export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "openclaw/plugin-sdk/whatsapp"; diff --git a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts index baef795d478..c0e89600bde 100644 --- a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts @@ -1,4 +1,4 @@ -import { loginWeb as loginWebImpl } from "../../../extensions/whatsapp/runtime-api.js"; +import { loginWeb as loginWebImpl } from "openclaw/plugin-sdk/whatsapp"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppLogin = Pick; diff --git a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts index 91fcba6fd39..c213afe141e 100644 --- a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts @@ -1,7 +1,7 @@ import { sendMessageWhatsApp as sendMessageWhatsAppImpl, sendPollWhatsApp as sendPollWhatsAppImpl, -} from "../../../extensions/whatsapp/runtime-api.js"; +} from "openclaw/plugin-sdk/whatsapp"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppOutbound = Pick< diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 796bc80bb5a..ca266581d21 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -1,11 +1,11 @@ -import { getActiveWebListener } from "../../../extensions/whatsapp/runtime-api.js"; +import { getActiveWebListener } from "openclaw/plugin-sdk/whatsapp"; import { getWebAuthAgeMs, - logoutWeb, logWebSelfId, + logoutWeb, readWebSelfId, webAuthExists, -} from "../../../extensions/whatsapp/runtime-api.js"; +} from "openclaw/plugin-sdk/whatsapp"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, @@ -63,16 +63,15 @@ const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhat return handleWhatsAppAction(...args); }; -let webLoginQrPromise: Promise< - typeof import("../../../extensions/whatsapp/login-qr-api.js") -> | null = null; +let webLoginQrPromise: Promise | null = + null; let webChannelPromise: Promise | null = null; let whatsappActionsPromise: Promise< - typeof import("../../../extensions/whatsapp/action-runtime-api.js") + typeof import("openclaw/plugin-sdk/whatsapp-action-runtime") > | null = null; function loadWebLoginQr() { - webLoginQrPromise ??= import("../../../extensions/whatsapp/login-qr-api.js"); + webLoginQrPromise ??= import("openclaw/plugin-sdk/whatsapp-login-qr"); return webLoginQrPromise; } @@ -82,7 +81,7 @@ function loadWebChannel() { } function loadWhatsAppActions() { - whatsappActionsPromise ??= import("../../../extensions/whatsapp/action-runtime-api.js"); + whatsappActionsPromise ??= import("openclaw/plugin-sdk/whatsapp-action-runtime"); return whatsappActionsPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 7b53a0e0025..b5f9a8e8e7a 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -205,19 +205,19 @@ export type PluginRuntimeChannel = { sendMessageIMessage: typeof import("../../../extensions/imessage/runtime-api.js").sendMessageIMessage; }; whatsapp: { - getActiveWebListener: typeof import("../../../extensions/whatsapp/runtime-api.js").getActiveWebListener; - getWebAuthAgeMs: typeof import("../../../extensions/whatsapp/runtime-api.js").getWebAuthAgeMs; - logoutWeb: typeof import("../../../extensions/whatsapp/runtime-api.js").logoutWeb; - logWebSelfId: typeof import("../../../extensions/whatsapp/runtime-api.js").logWebSelfId; - readWebSelfId: typeof import("../../../extensions/whatsapp/runtime-api.js").readWebSelfId; - webAuthExists: typeof import("../../../extensions/whatsapp/runtime-api.js").webAuthExists; - sendMessageWhatsApp: typeof import("../../../extensions/whatsapp/runtime-api.js").sendMessageWhatsApp; - sendPollWhatsApp: typeof import("../../../extensions/whatsapp/runtime-api.js").sendPollWhatsApp; - loginWeb: typeof import("../../../extensions/whatsapp/runtime-api.js").loginWeb; - startWebLoginWithQr: typeof import("../../../extensions/whatsapp/login-qr-api.js").startWebLoginWithQr; - waitForWebLogin: typeof import("../../../extensions/whatsapp/login-qr-api.js").waitForWebLogin; + getActiveWebListener: typeof import("openclaw/plugin-sdk/whatsapp").getActiveWebListener; + getWebAuthAgeMs: typeof import("openclaw/plugin-sdk/whatsapp").getWebAuthAgeMs; + logoutWeb: typeof import("openclaw/plugin-sdk/whatsapp").logoutWeb; + logWebSelfId: typeof import("openclaw/plugin-sdk/whatsapp").logWebSelfId; + readWebSelfId: typeof import("openclaw/plugin-sdk/whatsapp").readWebSelfId; + webAuthExists: typeof import("openclaw/plugin-sdk/whatsapp").webAuthExists; + sendMessageWhatsApp: typeof import("openclaw/plugin-sdk/whatsapp").sendMessageWhatsApp; + sendPollWhatsApp: typeof import("openclaw/plugin-sdk/whatsapp").sendPollWhatsApp; + loginWeb: typeof import("openclaw/plugin-sdk/whatsapp").loginWeb; + startWebLoginWithQr: typeof import("openclaw/plugin-sdk/whatsapp-login-qr").startWebLoginWithQr; + waitForWebLogin: typeof import("openclaw/plugin-sdk/whatsapp-login-qr").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; - handleWhatsAppAction: typeof import("../../../extensions/whatsapp/action-runtime-api.js").handleWhatsAppAction; + handleWhatsAppAction: typeof import("openclaw/plugin-sdk/whatsapp-action-runtime").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; }; line: { diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index a91dc57c85e..740e9b6226f 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -94,61 +94,5 @@ "specifier": "../../extensions/zai/model-definitions.js", "resolvedPath": "extensions/zai/model-definitions.js", "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp-login-tool.ts", - "line": 1, - "kind": "export", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "re-exports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp-login.runtime.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts", - "line": 4, - "kind": "import", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp.ts", - "line": 8, - "kind": "import", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp.ts", - "line": 75, - "kind": "dynamic-import", - "specifier": "../../../extensions/whatsapp/login-qr-api.js", - "resolvedPath": "extensions/whatsapp/login-qr-api.js", - "reason": "dynamically imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp.ts", - "line": 85, - "kind": "dynamic-import", - "specifier": "../../../extensions/whatsapp/action-runtime-api.js", - "resolvedPath": "extensions/whatsapp/action-runtime-api.js", - "reason": "dynamically imports extension-owned file from src/plugins" } ] From 62edfdffbdd027c0c19ee0a3d01c1ae089b20ec2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 18:14:36 +0000 Subject: [PATCH 25/34] refactor: deduplicate reply payload handling --- .../src/monitor/message-handler.process.ts | 4 +- .../discord/src/monitor/native-command.ts | 29 ++-- .../discord/src/monitor/reply-delivery.ts | 26 +-- extensions/feishu/src/reply-dispatcher.ts | 148 +++++++++--------- extensions/googlechat/src/monitor.ts | 16 +- extensions/imessage/src/monitor/deliver.ts | 16 +- .../matrix/src/matrix/monitor/replies.ts | 20 ++- .../src/mattermost/reply-delivery.ts | 17 +- extensions/msteams/src/messenger.ts | 32 ++-- extensions/signal/src/monitor.ts | 8 +- .../src/monitor/message-handler/dispatch.ts | 23 ++- extensions/slack/src/monitor/replies.ts | 45 +++--- .../telegram/src/bot-message-dispatch.ts | 9 +- .../src/lane-delivery-text-deliverer.ts | 4 +- .../src/auto-reply/heartbeat-runner.ts | 14 +- .../src/auto-reply/monitor/process-message.ts | 6 +- extensions/whatsapp/src/outbound-adapter.ts | 3 +- extensions/zalo/src/monitor.ts | 7 +- extensions/zalouser/src/monitor.ts | 7 +- src/agents/pi-embedded-runner/run/payloads.ts | 3 +- ...bedded-subscribe.handlers.messages.test.ts | 34 +++- ...pi-embedded-subscribe.handlers.messages.ts | 76 +++++---- src/auto-reply/heartbeat-reply-payload.ts | 3 +- .../reply/agent-runner-execution.ts | 6 +- src/auto-reply/reply/agent-runner-helpers.ts | 22 +-- src/auto-reply/reply/agent-runner-payloads.ts | 15 +- src/auto-reply/reply/block-reply-coalescer.ts | 8 +- src/auto-reply/reply/block-reply-pipeline.ts | 23 +-- src/auto-reply/reply/dispatch-acp-delivery.ts | 3 +- src/auto-reply/reply/dispatch-from-config.ts | 3 +- src/auto-reply/reply/followup-runner.ts | 11 +- src/auto-reply/reply/normalize-reply.ts | 63 ++------ src/auto-reply/reply/reply-delivery.ts | 8 +- src/auto-reply/reply/reply-media-paths.ts | 3 +- src/auto-reply/reply/reply-payloads.ts | 11 +- src/auto-reply/reply/route-reply.ts | 18 ++- src/auto-reply/reply/streaming-directives.ts | 6 +- .../plugins/outbound/direct-text-media.ts | 3 +- src/commands/agent-via-gateway.ts | 17 +- src/cron/heartbeat-policy.ts | 3 +- src/cron/isolated-agent/helpers.ts | 5 +- src/cron/isolated-agent/run.ts | 10 +- src/gateway/server-methods/send.ts | 6 +- src/gateway/ws-log.ts | 9 +- src/infra/heartbeat-runner.ts | 14 +- src/infra/outbound/deliver.ts | 28 ++-- src/infra/outbound/message-action-runner.ts | 20 ++- src/infra/outbound/message.ts | 6 +- src/infra/outbound/payloads.ts | 23 ++- src/interactive/payload.test.ts | 36 +++++ src/interactive/payload.ts | 24 +++ src/line/auto-reply-delivery.ts | 4 +- src/plugin-sdk/msteams.ts | 2 +- src/plugin-sdk/reply-payload.test.ts | 121 ++++++++++++++ src/plugin-sdk/reply-payload.ts | 62 +++++++- src/plugin-sdk/subpaths.test.ts | 4 + src/plugin-sdk/zalouser.ts | 1 + src/tts/tts.ts | 6 +- 58 files changed, 704 insertions(+), 450 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 526ca4ecb71..f24a9e27774 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -16,6 +16,7 @@ import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runt import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { @@ -610,7 +611,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) } if (draftStream && isFinal) { await flushDraft(); - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const reply = resolveSendableOutboundReplyParts(payload); + const hasMedia = reply.hasMedia; const finalText = payload.text; const previewFinalText = resolvePreviewFinalText(finalText); const previewMessageId = draftStream.messageId(); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 61e225d4f32..39bdad5b738 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -26,7 +26,7 @@ import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; import { - resolveOutboundMediaUrls, + resolveSendableOutboundReplyParts, resolveTextChunksWithFallback, } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; @@ -236,13 +236,7 @@ function isDiscordUnknownInteraction(error: unknown): boolean { } function hasRenderableReplyPayload(payload: ReplyPayload): boolean { - if ((payload.text ?? "").trim()) { - return true; - } - if ((payload.mediaUrl ?? "").trim()) { - return true; - } - if (payload.mediaUrls?.some((entry) => entry.trim())) { + if (resolveSendableOutboundReplyParts(payload).hasContent) { return true; } const discordData = payload.channelData?.discord as @@ -891,8 +885,7 @@ async function deliverDiscordInteractionReply(params: { chunkMode: "length" | "newline"; }) { const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; - const mediaList = resolveOutboundMediaUrls(payload); - const text = payload.text ?? ""; + const reply = resolveSendableOutboundReplyParts(payload); const discordData = payload.channelData?.discord as | { components?: TopLevelComponents[] } | undefined; @@ -937,9 +930,9 @@ async function deliverDiscordInteractionReply(params: { }); }; - if (mediaList.length > 0) { + if (reply.hasMedia) { const media = await Promise.all( - mediaList.map(async (url) => { + reply.mediaUrls.map(async (url) => { const loaded = await loadWebMedia(url, { localRoots: params.mediaLocalRoots, }); @@ -950,8 +943,8 @@ async function deliverDiscordInteractionReply(params: { }), ); const chunks = resolveTextChunksWithFallback( - text, - chunkDiscordTextWithMode(text, { + reply.text, + chunkDiscordTextWithMode(reply.text, { maxChars: textLimit, maxLines: maxLinesPerMessage, chunkMode, @@ -968,14 +961,14 @@ async function deliverDiscordInteractionReply(params: { return; } - if (!text.trim() && !firstMessageComponents) { + if (!reply.hasText && !firstMessageComponents) { return; } const chunks = - text || firstMessageComponents + reply.text || firstMessageComponents ? resolveTextChunksWithFallback( - text, - chunkDiscordTextWithMode(text, { + reply.text, + chunkDiscordTextWithMode(reply.text, { maxChars: textLimit, maxLines: maxLinesPerMessage, chunkMode, diff --git a/extensions/discord/src/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts index 84efdb24237..a098c41d056 100644 --- a/extensions/discord/src/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -9,7 +9,7 @@ import { type RetryConfig, } from "openclaw/plugin-sdk/infra-runtime"; import { - resolveOutboundMediaUrls, + resolveSendableOutboundReplyParts, resolveTextChunksWithFallback, sendMediaWithLeadingCaption, } from "openclaw/plugin-sdk/reply-payload"; @@ -268,18 +268,18 @@ export async function deliverDiscordReply(params: { : undefined; let deliveredAny = false; for (const payload of params.replies) { - const mediaList = resolveOutboundMediaUrls(payload); - const rawText = payload.text ?? ""; const tableMode = params.tableMode ?? "code"; - const text = convertMarkdownTables(rawText, tableMode); - if (!text && mediaList.length === 0) { + const reply = resolveSendableOutboundReplyParts(payload, { + text: convertMarkdownTables(payload.text ?? "", tableMode), + }); + if (!reply.hasContent) { continue; } - if (mediaList.length === 0) { + if (!reply.hasMedia) { const mode = params.chunkMode ?? "length"; const chunks = resolveTextChunksWithFallback( - text, - chunkDiscordTextWithMode(text, { + reply.text, + chunkDiscordTextWithMode(reply.text, { maxChars: chunkLimit, maxLines: params.maxLinesPerMessage, chunkMode: mode, @@ -312,7 +312,7 @@ export async function deliverDiscordReply(params: { continue; } - const firstMedia = mediaList[0]; + const firstMedia = reply.mediaUrls[0]; if (!firstMedia) { continue; } @@ -331,7 +331,7 @@ export async function deliverDiscordReply(params: { await sendDiscordChunkWithFallback({ cfg: params.cfg, target: params.target, - text, + text: reply.text, token: params.token, rest: params.rest, accountId: params.accountId, @@ -347,7 +347,7 @@ export async function deliverDiscordReply(params: { }); // Additional media items are sent as regular attachments (voice is single-file only). await sendMediaWithLeadingCaption({ - mediaUrls: mediaList.slice(1), + mediaUrls: reply.mediaUrls.slice(1), caption: "", send: async ({ mediaUrl }) => { const replyTo = resolveReplyTo(); @@ -370,8 +370,8 @@ export async function deliverDiscordReply(params: { } await sendMediaWithLeadingCaption({ - mediaUrls: mediaList, - caption: text, + mediaUrls: reply.mediaUrls, + caption: reply.text, send: async ({ mediaUrl, caption }) => { const replyTo = resolveReplyTo(); await sendWithRetry( diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 8c2d533fbfa..ff787bc7cb0 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -1,3 +1,8 @@ +import { + resolveSendableOutboundReplyParts, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { createReplyPrefixContext, createTypingCallbacks, @@ -13,12 +18,7 @@ import { sendMediaFeishu } from "./media.js"; import type { MentionTarget } from "./mention.js"; import { buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; -import { - sendMarkdownCardFeishu, - sendMessageFeishu, - sendStructuredCardFeishu, - type CardHeaderConfig, -} from "./send.js"; +import { sendMessageFeishu, sendStructuredCardFeishu, type CardHeaderConfig } from "./send.js"; import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js"; import { resolveReceiveIdType } from "./targets.js"; import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js"; @@ -300,37 +300,43 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP text: string; useCard: boolean; infoKind?: string; + sendChunk: (params: { chunk: string; isFirst: boolean }) => Promise; }) => { - let first = true; const chunkSource = params.useCard ? params.text : core.channel.text.convertMarkdownTables(params.text, tableMode); - for (const chunk of core.channel.text.chunkTextWithMode( + const chunks = resolveTextChunksWithFallback( chunkSource, - textChunkLimit, - chunkMode, - )) { - const message = { - cfg, - to: chatId, - text: chunk, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - mentions: first ? mentionTargets : undefined, - accountId, - }; - if (params.useCard) { - await sendMarkdownCardFeishu(message); - } else { - await sendMessageFeishu(message); - } - first = false; + core.channel.text.chunkTextWithMode(chunkSource, textChunkLimit, chunkMode), + ); + for (const [index, chunk] of chunks.entries()) { + await params.sendChunk({ + chunk, + isFirst: index === 0, + }); } if (params.infoKind === "final") { deliveredFinalTexts.add(params.text); } }; + const sendMediaReplies = async (payload: ReplyPayload) => { + await sendMediaWithLeadingCaption({ + mediaUrls: resolveSendableOutboundReplyParts(payload).mediaUrls, + caption: "", + send: async ({ mediaUrl }) => { + await sendMediaFeishu({ + cfg, + to: chatId, + mediaUrl, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + accountId, + }); + }, + }); + }; + const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: prefixContext.responsePrefix, @@ -344,15 +350,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP void typingCallbacks.onReplyStart?.(); }, deliver: async (payload: ReplyPayload, info) => { - const text = payload.text ?? ""; - const mediaList = - payload.mediaUrls && payload.mediaUrls.length > 0 - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - const hasText = Boolean(text.trim()); - const hasMedia = mediaList.length > 0; + const reply = resolveSendableOutboundReplyParts(payload); + const text = reply.text; + const hasText = reply.hasText; + const hasMedia = reply.hasMedia; const skipTextForDuplicateFinal = info?.kind === "final" && hasText && deliveredFinalTexts.has(text); const shouldDeliverText = hasText && !skipTextForDuplicateFinal; @@ -363,7 +364,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (shouldDeliverText) { const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); - let first = true; if (info?.kind === "block") { // Drop internal block chunks unless we can safely consume them as @@ -397,16 +397,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } // Send media even when streaming handled the text if (hasMedia) { - for (const mediaUrl of mediaList) { - await sendMediaFeishu({ - cfg, - to: chatId, - mediaUrl, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - accountId, - }); - } + await sendMediaReplies(payload); } return; } @@ -414,43 +405,46 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (useCard) { const cardHeader = resolveCardHeader(agentId, identity); const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext); - for (const chunk of core.channel.text.chunkTextWithMode( + await sendChunkedTextReply({ text, - textChunkLimit, - chunkMode, - )) { - await sendStructuredCardFeishu({ - cfg, - to: chatId, - text: chunk, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - mentions: first ? mentionTargets : undefined, - accountId, - header: cardHeader, - note: cardNote, - }); - first = false; - } - if (info?.kind === "final") { - deliveredFinalTexts.add(text); - } + useCard: true, + infoKind: info?.kind, + sendChunk: async ({ chunk, isFirst }) => { + await sendStructuredCardFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + mentions: isFirst ? mentionTargets : undefined, + accountId, + header: cardHeader, + note: cardNote, + }); + }, + }); } else { - await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind }); + await sendChunkedTextReply({ + text, + useCard: false, + infoKind: info?.kind, + sendChunk: async ({ chunk, isFirst }) => { + await sendMessageFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + mentions: isFirst ? mentionTargets : undefined, + accountId, + }); + }, + }); } } if (hasMedia) { - for (const mediaUrl of mediaList) { - await sendMediaFeishu({ - cfg, - to: chatId, - mediaUrl, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - accountId, - }); - } + await sendMediaReplies(payload); } }, onError: async (error, info) => { diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index e6eeecb5138..b0612842919 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,5 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig } from "../runtime-api.js"; import { createWebhookInFlightLimiter, @@ -376,8 +379,10 @@ async function deliverGoogleChatReply(params: { }): Promise { const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params; - const hasMedia = Boolean(payload.mediaUrls?.length) || Boolean(payload.mediaUrl); - const text = payload.text ?? ""; + const reply = resolveSendableOutboundReplyParts(payload); + const mediaCount = reply.mediaCount; + const hasMedia = reply.hasMedia; + const text = reply.text; let firstTextChunk = true; let suppressCaption = false; @@ -390,8 +395,7 @@ async function deliverGoogleChatReply(params: { }); } catch (err) { runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); - const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0); - const fallbackText = text.trim() + const fallbackText = reply.hasText ? text : mediaCount > 1 ? "Sent attachments." @@ -414,7 +418,7 @@ async function deliverGoogleChatReply(params: { const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); await deliverTextOrMediaReply({ payload, - text: suppressCaption ? "" : text, + text: suppressCaption ? "" : reply.text, chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode), sendText: async (chunk) => { try { diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts index d7b434a4e2d..708d319b640 100644 --- a/extensions/imessage/src/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -1,6 +1,9 @@ import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; -import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -32,14 +35,15 @@ export async function deliverReplies(params: { const chunkMode = resolveChunkMode(cfg, "imessage", accountId); for (const payload of replies) { const rawText = sanitizeOutboundText(payload.text ?? ""); - const text = convertMarkdownTables(rawText, tableMode); - const hasMedia = Boolean(payload.mediaUrls?.length ?? payload.mediaUrl); - if (!hasMedia && text) { - sentMessageCache?.remember(scope, { text }); + const reply = resolveSendableOutboundReplyParts(payload, { + text: convertMarkdownTables(rawText, tableMode), + }); + if (!reply.hasMedia && reply.hasText) { + sentMessageCache?.remember(scope, { text: reply.text }); } const delivered = await deliverTextOrMediaReply({ payload, - text, + text: reply.text, chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), sendText: async (chunk) => { const sent = await sendMessageIMessage(target, chunk, { diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index b1ab30b20ef..dac58c680ed 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,5 +1,8 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js"; import { getMatrixRuntime } from "../../runtime.js"; import { sendMessageMatrix } from "../send.js"; @@ -33,8 +36,10 @@ export async function deliverMatrixReplies(params: { const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId); let hasReplied = false; for (const reply of params.replies) { - const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; - if (!reply?.text && !hasMedia) { + const rawText = reply.text ?? ""; + const text = core.channel.text.convertMarkdownTables(rawText, tableMode); + const replyContent = resolveSendableOutboundReplyParts(reply, { text }); + if (!replyContent.hasContent) { if (reply?.audioAsVoice) { logVerbose("matrix reply has audioAsVoice without media/text; skipping"); continue; @@ -49,13 +54,6 @@ export async function deliverMatrixReplies(params: { } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; - const rawText = reply.text ?? ""; - const text = core.channel.text.convertMarkdownTables(rawText, tableMode); - const mediaList = reply.mediaUrls?.length - ? reply.mediaUrls - : reply.mediaUrl - ? [reply.mediaUrl] - : []; const shouldIncludeReply = (id?: string) => Boolean(id) && (params.replyToMode === "all" || !hasReplied); @@ -63,7 +61,7 @@ export async function deliverMatrixReplies(params: { const delivered = await deliverTextOrMediaReply({ payload: reply, - text, + text: replyContent.text, chunkText: (value) => core.channel.text .chunkMarkdownTextWithMode(value, chunkLimit, chunkMode) diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 492d31ba0fc..5f2c2e7191d 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -1,4 +1,7 @@ -import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js"; import { getAgentScopedMediaLocalRoots } from "../runtime-api.js"; @@ -27,10 +30,12 @@ export async function deliverMattermostReplyPayload(params: { tableMode: MarkdownTableMode; sendMessage: SendMattermostMessage; }): Promise { - const text = params.core.channel.text.convertMarkdownTables( - params.payload.text ?? "", - params.tableMode, - ); + const reply = resolveSendableOutboundReplyParts(params.payload, { + text: params.core.channel.text.convertMarkdownTables( + params.payload.text ?? "", + params.tableMode, + ), + }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); const chunkMode = params.core.channel.text.resolveChunkMode( params.cfg, @@ -39,7 +44,7 @@ export async function deliverMattermostReplyPayload(params: { ); await deliverTextOrMediaReply({ payload: params.payload, - text, + text: reply.text, chunkText: (value) => params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode), sendText: async (chunk) => { diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index b024b53c1f5..c2263a4975f 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -5,7 +5,7 @@ import { type MarkdownTableMode, type MSTeamsReplyStyle, type ReplyPayload, - resolveOutboundMediaUrls, + resolveSendableOutboundReplyParts, SILENT_REPLY_TOKEN, sleep, } from "../runtime-api.js"; @@ -217,41 +217,39 @@ export function renderReplyPayloadsToMessages( }); for (const payload of replies) { - const mediaList = resolveOutboundMediaUrls(payload); - const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( - payload.text ?? "", - tableMode, - ); + const reply = resolveSendableOutboundReplyParts(payload, { + text: getMSTeamsRuntime().channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + }); - if (!text && mediaList.length === 0) { + if (!reply.hasContent) { continue; } - if (mediaList.length === 0) { - pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); + if (!reply.hasMedia) { + pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode }); continue; } if (mediaMode === "inline") { // For inline mode, combine text with first media as attachment - const firstMedia = mediaList[0]; + const firstMedia = reply.mediaUrls[0]; if (firstMedia) { - out.push({ text: text || undefined, mediaUrl: firstMedia }); + out.push({ text: reply.text || undefined, mediaUrl: firstMedia }); // Additional media URLs as separate messages - for (let i = 1; i < mediaList.length; i++) { - if (mediaList[i]) { - out.push({ mediaUrl: mediaList[i] }); + for (let i = 1; i < reply.mediaUrls.length; i++) { + if (reply.mediaUrls[i]) { + out.push({ mediaUrl: reply.mediaUrls[i] }); } } } else { - pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); + pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode }); } continue; } // mediaMode === "split" - pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); - for (const mediaUrl of mediaList) { + pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode }); + for (const mediaUrl of reply.mediaUrls) { if (!mediaUrl) { continue; } diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 5a4882b1068..20f0c943823 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -9,7 +9,10 @@ import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config- import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; -import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkTextWithMode, resolveChunkMode, @@ -297,9 +300,10 @@ async function deliverReplies(params: { const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } = params; for (const payload of replies) { + const reply = resolveSendableOutboundReplyParts(payload); const delivered = await deliverTextOrMediaReply({ payload, - text: payload.text ?? "", + text: reply.text, chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), sendText: async (chunk) => { await sendMessageSignal(target, chunk, { diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 569ca8f60a7..5fac27f002b 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -5,6 +5,7 @@ import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime"; import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; @@ -33,7 +34,7 @@ import { import type { PreparedSlackMessage } from "./types.js"; function hasMedia(payload: ReplyPayload): boolean { - return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + return resolveSendableOutboundReplyParts(payload).hasMedia; } export function isSlackStreamingEnabled(params: { @@ -250,17 +251,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }; const deliverWithStreaming = async (payload: ReplyPayload): Promise => { - if ( - streamFailed || - hasMedia(payload) || - readSlackReplyBlocks(payload)?.length || - !payload.text?.trim() - ) { + const reply = resolveSendableOutboundReplyParts(payload); + if (streamFailed || reply.hasMedia || readSlackReplyBlocks(payload)?.length || !reply.hasText) { await deliverNormally(payload, streamSession?.threadTs); return; } - const text = payload.text.trim(); + const text = reply.trimmedText; let plannedThreadTs: string | undefined; try { if (!streamSession) { @@ -311,16 +308,16 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag return; } - const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0); + const reply = resolveSendableOutboundReplyParts(payload); const slackBlocks = readSlackReplyBlocks(payload); const draftMessageId = draftStream?.messageId(); const draftChannelId = draftStream?.channelId(); - const finalText = payload.text ?? ""; - const trimmedFinalText = finalText.trim(); + const finalText = reply.text; + const trimmedFinalText = reply.trimmedText; const canFinalizeViaPreviewEdit = previewStreamingEnabled && streamMode !== "status_final" && - mediaCount === 0 && + !reply.hasMedia && !payload.isError && (trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) && typeof draftMessageId === "string" && @@ -361,7 +358,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag } catch (err) { logVerbose(`slack: status_final completion update failed (${String(err)})`); } - } else if (mediaCount > 0) { + } else if (reply.hasMedia) { await draftStream?.clear(); hasStreamedMessage = false; } diff --git a/extensions/slack/src/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts index 935adaab3bc..f25e58673ca 100644 --- a/extensions/slack/src/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -1,5 +1,8 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; -import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; @@ -38,15 +41,14 @@ export async function deliverReplies(params: { // must not force threading. const inlineReplyToId = params.replyToMode === "off" ? undefined : payload.replyToId; const threadTs = inlineReplyToId ?? params.replyThreadTs; - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const reply = resolveSendableOutboundReplyParts(payload); const slackBlocks = readSlackReplyBlocks(payload); - if (!text && mediaList.length === 0 && !slackBlocks?.length) { + if (!reply.hasContent && !slackBlocks?.length) { continue; } - if (mediaList.length === 0 && slackBlocks?.length) { - const trimmed = text.trim(); + if (!reply.hasMedia && slackBlocks?.length) { + const trimmed = reply.trimmedText; if (!trimmed && !slackBlocks?.length) { continue; } @@ -66,17 +68,16 @@ export async function deliverReplies(params: { const delivered = await deliverTextOrMediaReply({ payload, - text, - chunkText: - mediaList.length === 0 - ? (value) => { - const trimmed = value.trim(); - if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { - return []; - } - return [trimmed]; + text: reply.text, + chunkText: !reply.hasMedia + ? (value) => { + const trimmed = value.trim(); + if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { + return []; } - : undefined, + return [trimmed]; + } + : undefined, sendText: async (trimmed) => { await sendMessageSlack(params.target, trimmed, { token: params.token, @@ -189,12 +190,12 @@ export async function deliverSlackSlashReplies(params: { const messages: string[] = []; const chunkLimit = Math.min(params.textLimit, 4000); for (const payload of params.replies) { - const textRaw = payload.text?.trim() ?? ""; - const text = textRaw && !isSilentReplyText(textRaw, SILENT_REPLY_TOKEN) ? textRaw : undefined; - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const combined = [text ?? "", ...mediaList.map((url) => url.trim()).filter(Boolean)] - .filter(Boolean) - .join("\n"); + const reply = resolveSendableOutboundReplyParts(payload); + const text = + reply.hasText && !isSilentReplyText(reply.trimmedText, SILENT_REPLY_TOKEN) + ? reply.trimmedText + : undefined; + const combined = [text ?? "", ...reply.mediaUrls].filter(Boolean).join("\n"); if (!combined) { continue; } diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 75df3bd5f2c..b6c3c01763c 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -22,6 +22,7 @@ import type { TelegramAccountConfig, } from "openclaw/plugin-sdk/config-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; @@ -567,7 +568,8 @@ export const dispatchTelegramMessage = async ({ )?.buttons; const split = splitTextIntoLaneSegments(payload.text); const segments = split.segments; - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const reply = resolveSendableOutboundReplyParts(payload); + const hasMedia = reply.hasMedia; const flushBufferedFinalAnswer = async () => { const buffered = reasoningStepState.takeBufferedFinalAnswer(); @@ -631,7 +633,7 @@ export const dispatchTelegramMessage = async ({ return; } if (split.suppressedReasoningOnly) { - if (hasMedia) { + if (reply.hasMedia) { const payloadWithoutSuppressedReasoning = typeof payload.text === "string" ? { ...payload, text: "" } : payload; await sendPayload(payloadWithoutSuppressedReasoning); @@ -647,8 +649,7 @@ export const dispatchTelegramMessage = async ({ await reasoningLane.stream?.stop(); reasoningStepState.resetForNextStep(); } - const canSendAsIs = - hasMedia || (typeof payload.text === "string" && payload.text.length > 0); + const canSendAsIs = reply.hasMedia || reply.text.length > 0; if (!canSendAsIs) { if (info.kind === "final") { await flushBufferedFinalAnswer(); diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index c99dc52661a..c67a091995e 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramDraftStream } from "./draft-stream.js"; @@ -459,7 +460,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { allowPreviewUpdateForNonFinal = false, }: DeliverLaneTextParams): Promise => { const lane = params.lanes[laneName]; - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const reply = resolveSendableOutboundReplyParts(payload, { text }); + const hasMedia = reply.hasMedia; const canEditViaPreview = !hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError; diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts index 7aa35705f43..8fb27a39fe4 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts @@ -9,6 +9,10 @@ import { } from "openclaw/plugin-sdk/config-runtime"; import { emitHeartbeatEvent, resolveIndicatorType } from "openclaw/plugin-sdk/infra-runtime"; import { resolveHeartbeatVisibility } from "openclaw/plugin-sdk/infra-runtime"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { resolveHeartbeatReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -178,10 +182,7 @@ export async function runWebHeartbeatOnce(opts: { ); const replyPayload = resolveHeartbeatReplyPayload(replyResult); - if ( - !replyPayload || - (!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length) - ) { + if (!replyPayload || !hasOutboundReplyContent(replyPayload)) { heartbeatLogger.info( { to: redactedTo, @@ -201,7 +202,8 @@ export async function runWebHeartbeatOnce(opts: { return; } - const hasMedia = Boolean(replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0); + const reply = resolveSendableOutboundReplyParts(replyPayload); + const hasMedia = reply.hasMedia; const ackMaxChars = Math.max( 0, cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -250,7 +252,7 @@ export async function runWebHeartbeatOnce(opts: { ); } - const finalText = stripped.text || replyPayload.text || ""; + const finalText = stripped.text || reply.text; // Check if alerts are disabled for WhatsApp if (!visibility.showAlerts) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index beaa564fe28..5db9cb31d0a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -6,6 +6,7 @@ import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/reply-runtime"; import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime"; @@ -429,10 +430,11 @@ export async function processMessage(params: { }); const fromDisplay = params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown"); - const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); + const reply = resolveSendableOutboundReplyParts(payload); + const hasMedia = reply.hasMedia; whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`); if (shouldLogVerbose()) { - const preview = payload.text != null ? elide(payload.text, 400) : ""; + const preview = payload.text != null ? elide(reply.text, 400) : ""; whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`); } }, diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index d9710afb557..4800e2ded43 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -5,6 +5,7 @@ import { createAttachedChannelResultAdapter, createEmptyChannelResult, } from "openclaw/plugin-sdk/channel-send-result"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; @@ -24,7 +25,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = { resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), sendPayload: async (ctx) => { const text = trimLeadingWhitespace(ctx.payload.text); - const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(ctx.payload).hasMedia; if (!text && !hasMedia) { return createEmptyChannelResult("whatsapp"); } diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 768c556fd7b..b21476fbf8f 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError, @@ -579,11 +580,13 @@ async function deliverZaloReply(params: { }): Promise { const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params; const tableMode = params.tableMode ?? "code"; - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const reply = resolveSendableOutboundReplyParts(payload, { + text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + }); const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId); await deliverTextOrMediaReply({ payload, - text, + text: reply.text, chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode), sendText: async (chunk) => { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index d269345572c..7f455d93166 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -28,6 +28,7 @@ import { mergeAllowlist, resolveMentionGatingWithBypass, resolveOpenProviderRuntimeGroupPolicy, + resolveSendableOutboundReplyParts, resolveDefaultGroupPolicy, resolveSenderCommandAuthorization, resolveSenderScopedGroupPolicy, @@ -706,14 +707,16 @@ async function deliverZalouserReply(params: { const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } = params; const tableMode = params.tableMode ?? "code"; - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const reply = resolveSendableOutboundReplyParts(payload, { + text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + }); const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId); const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, { fallbackLimit: ZALOUSER_TEXT_LIMIT, }); await deliverTextOrMediaReply({ payload, - text, + text: reply.text, sendText: async (chunk) => { try { await sendMessageZalouser(chatId, chunk, { diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index c0e0ded136e..6b0cf33e980 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -4,6 +4,7 @@ import type { ReasoningLevel, VerboseLevel } from "../../../auto-reply/thinking. import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js"; import { formatToolAggregate } from "../../../auto-reply/tool-meta.js"; import type { OpenClawConfig } from "../../../config/config.js"; +import { hasOutboundReplyContent } from "../../../plugin-sdk/reply-payload.js"; import { BILLING_ERROR_USER_MESSAGE, formatAssistantErrorText, @@ -336,7 +337,7 @@ export function buildEmbeddedRunPayloads(params: { audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length), })) .filter((p) => { - if (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) { + if (!hasOutboundReplyContent(p)) { return false; } if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) { diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.test.ts b/src/agents/pi-embedded-subscribe.handlers.messages.test.ts index 6c508bdbdb6..1ecdd45f9af 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveSilentReplyFallbackText } from "./pi-embedded-subscribe.handlers.messages.js"; +import { + buildAssistantStreamData, + hasAssistantVisibleReply, + resolveSilentReplyFallbackText, +} from "./pi-embedded-subscribe.handlers.messages.js"; describe("resolveSilentReplyFallbackText", () => { it("replaces NO_REPLY with latest messaging tool text when available", () => { @@ -29,3 +33,31 @@ describe("resolveSilentReplyFallbackText", () => { ).toBe("NO_REPLY"); }); }); + +describe("hasAssistantVisibleReply", () => { + it("treats audio-only payloads as visible", () => { + expect(hasAssistantVisibleReply({ audioAsVoice: true })).toBe(true); + }); + + it("detects text or media visibility", () => { + expect(hasAssistantVisibleReply({ text: "hello" })).toBe(true); + expect(hasAssistantVisibleReply({ mediaUrls: ["https://example.com/a.png"] })).toBe(true); + expect(hasAssistantVisibleReply({})).toBe(false); + }); +}); + +describe("buildAssistantStreamData", () => { + it("normalizes media payloads for assistant stream events", () => { + expect( + buildAssistantStreamData({ + text: "hello", + delta: "he", + mediaUrl: "https://example.com/a.png", + }), + ).toEqual({ + text: "hello", + delta: "he", + mediaUrls: ["https://example.com/a.png"], + }); + }); +}); diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 04f47e67cde..d790eb912ca 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -3,6 +3,7 @@ import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { createInlineCodeState } from "../markdown/code-spans.js"; +import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import { isMessagingToolDuplicateNormalized, normalizeTextForComparison, @@ -56,6 +57,29 @@ export function resolveSilentReplyFallbackText(params: { return fallback; } +export function hasAssistantVisibleReply(params: { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string; + audioAsVoice?: boolean; +}): boolean { + return resolveSendableOutboundReplyParts(params).hasContent || Boolean(params.audioAsVoice); +} + +export function buildAssistantStreamData(params: { + text?: string; + delta?: string; + mediaUrls?: string[]; + mediaUrl?: string; +}): { text: string; delta: string; mediaUrls?: string[] } { + const mediaUrls = resolveSendableOutboundReplyParts(params).mediaUrls; + return { + text: params.text ?? "", + delta: params.delta ?? "", + mediaUrls: mediaUrls.length ? mediaUrls : undefined, + }; +} + export function handleMessageStart( ctx: EmbeddedPiSubscribeContext, evt: AgentEvent & { message: AgentMessage }, @@ -196,14 +220,13 @@ export function handleMessageUpdate( const parsedDelta = visibleDelta ? ctx.consumePartialReplyDirectives(visibleDelta) : null; const parsedFull = parseReplyDirectives(stripTrailingDirective(next)); const cleanedText = parsedFull.text; - const mediaUrls = parsedDelta?.mediaUrls; - const hasMedia = Boolean(mediaUrls && mediaUrls.length > 0); + const { mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedDelta ?? {}); const hasAudio = Boolean(parsedDelta?.audioAsVoice); const previousCleaned = ctx.state.lastStreamedAssistantCleaned ?? ""; let shouldEmit = false; let deltaText = ""; - if (!cleanedText && !hasMedia && !hasAudio) { + if (!hasAssistantVisibleReply({ text: cleanedText, mediaUrls, audioAsVoice: hasAudio })) { shouldEmit = false; } else if (previousCleaned && !cleanedText.startsWith(previousCleaned)) { shouldEmit = false; @@ -216,29 +239,23 @@ export function handleMessageUpdate( ctx.state.lastStreamedAssistantCleaned = cleanedText; if (shouldEmit) { + const data = buildAssistantStreamData({ + text: cleanedText, + delta: deltaText, + mediaUrls, + }); emitAgentEvent({ runId: ctx.params.runId, stream: "assistant", - data: { - text: cleanedText, - delta: deltaText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }, + data, }); void ctx.params.onAgentEvent?.({ stream: "assistant", - data: { - text: cleanedText, - delta: deltaText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }, + data, }); ctx.state.emittedAssistantUpdate = true; if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) { - void ctx.params.onPartialReply({ - text: cleanedText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }); + void ctx.params.onPartialReply(data); } } } @@ -291,8 +308,7 @@ export function handleMessageEnd( const trimmedText = text.trim(); const parsedText = trimmedText ? parseReplyDirectives(stripTrailingDirective(trimmedText)) : null; let cleanedText = parsedText?.text ?? ""; - let mediaUrls = parsedText?.mediaUrls; - let hasMedia = Boolean(mediaUrls && mediaUrls.length > 0); + let { mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedText ?? {}); if (!cleanedText && !hasMedia && !ctx.params.enforceFinalTag) { const rawTrimmed = rawText.trim(); @@ -301,28 +317,24 @@ export function handleMessageEnd( if (rawCandidate) { const parsedFallback = parseReplyDirectives(stripTrailingDirective(rawCandidate)); cleanedText = parsedFallback.text ?? rawCandidate; - mediaUrls = parsedFallback.mediaUrls; - hasMedia = Boolean(mediaUrls && mediaUrls.length > 0); + ({ mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedFallback)); } } if (!ctx.state.emittedAssistantUpdate && (cleanedText || hasMedia)) { + const data = buildAssistantStreamData({ + text: cleanedText, + delta: cleanedText, + mediaUrls, + }); emitAgentEvent({ runId: ctx.params.runId, stream: "assistant", - data: { - text: cleanedText, - delta: cleanedText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }, + data, }); void ctx.params.onAgentEvent?.({ stream: "assistant", - data: { - text: cleanedText, - delta: cleanedText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }, + data, }); ctx.state.emittedAssistantUpdate = true; } @@ -377,7 +389,7 @@ export function handleMessageEnd( replyToCurrent, } = splitResult; // Emit if there's content OR audioAsVoice flag (to propagate the flag). - if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) { + if (hasAssistantVisibleReply({ text: cleanedText, mediaUrls, audioAsVoice })) { emitBlockReplySafely({ text: cleanedText, mediaUrls: mediaUrls?.length ? mediaUrls : undefined, diff --git a/src/auto-reply/heartbeat-reply-payload.ts b/src/auto-reply/heartbeat-reply-payload.ts index 4bdf9e3a57b..3a235bc4273 100644 --- a/src/auto-reply/heartbeat-reply-payload.ts +++ b/src/auto-reply/heartbeat-reply-payload.ts @@ -1,3 +1,4 @@ +import { hasOutboundReplyContent } from "../plugin-sdk/reply-payload.js"; import type { ReplyPayload } from "./types.js"; export function resolveHeartbeatReplyPayload( @@ -14,7 +15,7 @@ export function resolveHeartbeatReplyPayload( if (!payload) { continue; } - if (payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0)) { + if (hasOutboundReplyContent(payload)) { return payload; } } diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 5c9b78c208f..7b22a5bdba1 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -23,6 +23,7 @@ import { } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { defaultRuntime } from "../../runtime.js"; import { isMarkdownCapableMessageChannel, @@ -148,6 +149,7 @@ export async function runAgentTurnWithFallback(params: { try { const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => { let text = payload.text; + const reply = resolveSendableOutboundReplyParts(payload); if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) { const stripped = stripHeartbeatToken(text, { mode: "message", @@ -156,7 +158,7 @@ export async function runAgentTurnWithFallback(params: { didLogHeartbeatStrip = true; logVerbose("Stripped stray HEARTBEAT_OK token from reply"); } - if (stripped.shouldSkip && (payload.mediaUrls?.length ?? 0) === 0) { + if (stripped.shouldSkip && !reply.hasMedia) { return { skip: true }; } text = stripped.text; @@ -172,7 +174,7 @@ export async function runAgentTurnWithFallback(params: { } if (!text) { // Allow media-only payloads (e.g. tool result screenshots) through. - if ((payload.mediaUrls?.length ?? 0) > 0) { + if (reply.hasMedia) { return { text: undefined, skip: false }; } return { skip: true }; diff --git a/src/auto-reply/reply/agent-runner-helpers.ts b/src/auto-reply/reply/agent-runner-helpers.ts index 11ea0fe9f53..b62e4683308 100644 --- a/src/auto-reply/reply/agent-runner-helpers.ts +++ b/src/auto-reply/reply/agent-runner-helpers.ts @@ -1,5 +1,9 @@ import { loadSessionStore } from "../../config/sessions.js"; import { isAudioFileName } from "../../media/mime.js"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "../../plugin-sdk/reply-payload.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { scheduleFollowupDrain } from "./queue.js"; @@ -9,7 +13,7 @@ const hasAudioMedia = (urls?: string[]): boolean => Boolean(urls?.some((url) => isAudioFileName(url))); export const isAudioPayload = (payload: ReplyPayload): boolean => - hasAudioMedia(payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined)); + hasAudioMedia(resolveSendableOutboundReplyParts(payload).mediaUrls); type VerboseGateParams = { sessionKey?: string; @@ -63,19 +67,9 @@ export const signalTypingIfNeeded = async ( payloads: ReplyPayload[], typingSignals: TypingSignaler, ): Promise => { - const shouldSignalTyping = payloads.some((payload) => { - const trimmed = payload.text?.trim(); - if (trimmed) { - return true; - } - if (payload.mediaUrl) { - return true; - } - if (payload.mediaUrls && payload.mediaUrls.length > 0) { - return true; - } - return false; - }); + const shouldSignalTyping = payloads.some((payload) => + hasOutboundReplyContent(payload, { trimText: true }), + ); if (shouldSignalTyping) { await typingSignals.signalRunStart(); } diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 9e89c921407..5f052b8f4f9 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -1,5 +1,6 @@ import type { ReplyToMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { OriginatingChannelType } from "../templating.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; @@ -20,15 +21,11 @@ import { shouldSuppressMessagingToolReplies, } from "./reply-payloads.js"; -function hasPayloadMedia(payload: ReplyPayload): boolean { - return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; -} - async function normalizeReplyPayloadMedia(params: { payload: ReplyPayload; normalizeMediaPaths?: (payload: ReplyPayload) => Promise; }): Promise { - if (!params.normalizeMediaPaths || !hasPayloadMedia(params.payload)) { + if (!params.normalizeMediaPaths || !resolveSendableOutboundReplyParts(params.payload).hasMedia) { return params.payload; } @@ -69,11 +66,7 @@ async function normalizeSentMediaUrlsForDedupe(params: { mediaUrl: trimmed, mediaUrls: [trimmed], }); - const normalizedMediaUrls = normalized.mediaUrls?.length - ? normalized.mediaUrls - : normalized.mediaUrl - ? [normalized.mediaUrl] - : []; + const normalizedMediaUrls = resolveSendableOutboundReplyParts(normalized).mediaUrls; for (const mediaUrl of normalizedMediaUrls) { const candidate = mediaUrl.trim(); if (!candidate || seen.has(candidate)) { @@ -130,7 +123,7 @@ export async function buildReplyPayloads(params: { didLogHeartbeatStrip = true; logVerbose("Stripped stray HEARTBEAT_OK token from reply"); } - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (stripped.shouldSkip && !hasMedia) { return []; } diff --git a/src/auto-reply/reply/block-reply-coalescer.ts b/src/auto-reply/reply/block-reply-coalescer.ts index 130f57b3d07..ea1022a469c 100644 --- a/src/auto-reply/reply/block-reply-coalescer.ts +++ b/src/auto-reply/reply/block-reply-coalescer.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import type { ReplyPayload } from "../types.js"; import type { BlockStreamingCoalescing } from "./block-streaming.js"; @@ -75,9 +76,10 @@ export function createBlockReplyCoalescer(params: { if (shouldAbort()) { return; } - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - const text = payload.text ?? ""; - const hasText = text.trim().length > 0; + const reply = resolveSendableOutboundReplyParts(payload); + const hasMedia = reply.hasMedia; + const text = reply.text; + const hasText = reply.hasText; if (hasMedia) { void flush({ force: true }); void onFlush(payload); diff --git a/src/auto-reply/reply/block-reply-pipeline.ts b/src/auto-reply/reply/block-reply-pipeline.ts index 9ce85334238..53a9e46c313 100644 --- a/src/auto-reply/reply/block-reply-pipeline.ts +++ b/src/auto-reply/reply/block-reply-pipeline.ts @@ -1,4 +1,5 @@ import { logVerbose } from "../../globals.js"; +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import type { ReplyPayload } from "../types.js"; import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; import type { BlockStreamingCoalescing } from "./block-streaming.js"; @@ -35,30 +36,20 @@ export function createAudioAsVoiceBuffer(params: { } export function createBlockReplyPayloadKey(payload: ReplyPayload): string { - const text = payload.text?.trim() ?? ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const reply = resolveSendableOutboundReplyParts(payload); return JSON.stringify({ - text, - mediaList, + text: reply.trimmedText, + mediaList: reply.mediaUrls, replyToId: payload.replyToId ?? null, }); } export function createBlockReplyContentKey(payload: ReplyPayload): string { - const text = payload.text?.trim() ?? ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const reply = resolveSendableOutboundReplyParts(payload); // Content-only key used for final-payload suppression after block streaming. // This intentionally ignores replyToId so a streamed threaded payload and the // later final payload still collapse when they carry the same content. - return JSON.stringify({ text, mediaList }); + return JSON.stringify({ text: reply.trimmedText, mediaList: reply.mediaUrls }); } const withTimeout = async ( @@ -217,7 +208,7 @@ export function createBlockReplyPipeline(params: { if (bufferPayload(payload)) { return; } - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (hasMedia) { void coalescer?.flush({ force: true }); sendPayload(payload, /* bypassSeenCheck */ false); diff --git a/src/auto-reply/reply/dispatch-acp-delivery.ts b/src/auto-reply/reply/dispatch-acp-delivery.ts index 6624f9868a2..a9d50521be2 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { TtsAutoMode } from "../../config/types.tts.js"; import { logVerbose } from "../../globals.js"; import { runMessageAction } from "../../infra/outbound/message-action-runner.js"; +import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js"; import { maybeApplyTtsToPayload } from "../../tts/tts.js"; import type { FinalizedMsgContext } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -127,7 +128,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { state.blockCount += 1; } - if ((payload.text?.trim() ?? "").length > 0 || payload.mediaUrl || payload.mediaUrls?.length) { + if (hasOutboundReplyContent(payload, { trimText: true })) { await startReplyLifecycleOnce(); } diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 34950c20950..3893d1d8138 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -29,6 +29,7 @@ import { logMessageQueued, logSessionStateChange, } from "../../logging/diagnostic.js"; +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { buildPluginBindingDeclinedText, buildPluginBindingErrorText, @@ -532,7 +533,7 @@ export async function dispatchReplyFromConfig(params: { } // Group/native flows intentionally suppress tool summary text, but media-only // tool results (for example TTS audio) must still be delivered. - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (!hasMedia) { return null; } diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 339883e730b..3e21490b990 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -9,6 +9,10 @@ import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "../../plugin-sdk/reply-payload.js"; import { defaultRuntime } from "../../runtime.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { stripHeartbeatToken } from "../heartbeat.js"; @@ -81,13 +85,12 @@ export function createFollowupRunner(params: { } for (const payload of payloads) { - if (!payload?.text && !payload?.mediaUrl && !payload?.mediaUrls?.length) { + if (!payload || !hasOutboundReplyContent(payload)) { continue; } if ( isSilentReplyText(payload.text, SILENT_REPLY_TOKEN) && - !payload.mediaUrl && - !payload.mediaUrls?.length + !resolveSendableOutboundReplyParts(payload).hasMedia ) { continue; } @@ -289,7 +292,7 @@ export function createFollowupRunner(params: { return [payload]; } const stripped = stripHeartbeatToken(text, { mode: "message" }); - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (stripped.shouldSkip && !hasMedia) { return []; } diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 52faa463bdb..a3ae3417d7d 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,5 +1,5 @@ import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js"; -import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import { HEARTBEAT_TOKEN, @@ -32,17 +32,18 @@ export function normalizeReplyPayload( payload: ReplyPayload, opts: NormalizeReplyOptions = {}, ): ReplyPayload | null { - const hasChannelData = hasReplyChannelData(payload.channelData); + const hasContent = (text: string | undefined) => + hasReplyPayloadContent( + { + ...payload, + text, + }, + { + trimText: true, + }, + ); const trimmed = payload.text?.trim() ?? ""; - if ( - !hasReplyContent({ - text: trimmed, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasContent(trimmed)) { opts.onSkip?.("empty"); return null; } @@ -50,14 +51,7 @@ export function normalizeReplyPayload( const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { - if ( - !hasReplyContent({ - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasContent("")) { opts.onSkip?.("silent"); return null; } @@ -68,15 +62,7 @@ export function normalizeReplyPayload( // silent just like the exact-match path above. (#30916, #30955) if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) { text = stripSilentToken(text, silentToken); - if ( - !hasReplyContent({ - text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasContent(text)) { opts.onSkip?.("silent"); return null; } @@ -92,16 +78,7 @@ export function normalizeReplyPayload( if (stripped.didStrip) { opts.onHeartbeatStrip?.(); } - if ( - stripped.shouldSkip && - !hasReplyContent({ - text: stripped.text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (stripped.shouldSkip && !hasContent(stripped.text)) { opts.onSkip?.("heartbeat"); return null; } @@ -111,15 +88,7 @@ export function normalizeReplyPayload( if (text) { text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) }); } - if ( - !hasReplyContent({ - text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasContent(text)) { opts.onSkip?.("empty"); return null; } diff --git a/src/auto-reply/reply/reply-delivery.ts b/src/auto-reply/reply/reply-delivery.ts index cacd6b083cb..0a410319959 100644 --- a/src/auto-reply/reply/reply-delivery.ts +++ b/src/auto-reply/reply/reply-delivery.ts @@ -1,4 +1,5 @@ import { logVerbose } from "../../globals.js"; +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { BlockReplyContext, ReplyPayload } from "../types.js"; import type { BlockReplyPipeline } from "./block-reply-pipeline.js"; @@ -57,9 +58,6 @@ export function normalizeReplyPayloadDirectives(params: { }; } -const hasRenderableMedia = (payload: ReplyPayload): boolean => - Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - export function createBlockReplyDeliveryHandler(params: { onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => Promise | void; currentMessageId?: string; @@ -73,7 +71,7 @@ export function createBlockReplyDeliveryHandler(params: { }): (payload: ReplyPayload) => Promise { return async (payload) => { const { text, skip } = params.normalizeStreamingText(payload); - if (skip && !hasRenderableMedia(payload)) { + if (skip && !resolveSendableOutboundReplyParts(payload).hasMedia) { return; } @@ -106,7 +104,7 @@ export function createBlockReplyDeliveryHandler(params: { ? await params.normalizeMediaPaths(normalized.payload) : normalized.payload; const blockPayload = params.applyReplyToMode(mediaNormalizedPayload); - const blockHasMedia = hasRenderableMedia(blockPayload); + const blockHasMedia = resolveSendableOutboundReplyParts(blockPayload).hasMedia; // Skip empty payloads unless they have audioAsVoice flag (need to track it). if (!blockPayload.text && !blockHasMedia && !blockPayload.audioAsVoice) { diff --git a/src/auto-reply/reply/reply-media-paths.ts b/src/auto-reply/reply/reply-media-paths.ts index 1c09316afad..45447e7b82d 100644 --- a/src/auto-reply/reply/reply-media-paths.ts +++ b/src/auto-reply/reply/reply-media-paths.ts @@ -2,6 +2,7 @@ import { resolvePathFromInput } from "../../agents/path-policy.js"; import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js"; import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import type { ReplyPayload } from "../types.js"; const HTTP_URL_RE = /^https?:\/\//i; @@ -25,7 +26,7 @@ function isLikelyLocalMediaSource(media: string): boolean { } function getPayloadMediaList(payload: ReplyPayload): string[] { - return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; + return resolveSendableOutboundReplyParts(payload).mediaUrls; } export function createReplyMediaPathNormalizer(params: { diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 7d7ae82975c..1826d1872af 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -4,7 +4,7 @@ import { normalizeChannelId } from "../../channels/plugins/index.js"; import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js"; import type { ReplyToMode } from "../../config/types.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; -import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -75,14 +75,7 @@ export function applyReplyTagsToPayload( } export function isRenderablePayload(payload: ReplyPayload): boolean { - return hasReplyContent({ - text: payload.text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData: hasReplyChannelData(payload.channelData), - extraContent: payload.audioAsVoice, - }); + return hasReplyPayloadContent(payload, { extraContent: payload.audioAsVoice }); } export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 3836ceb5ab6..3fed4655d99 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -12,7 +12,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; -import { hasReplyContent } from "../../interactive/payload.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -126,12 +126,16 @@ export async function routeReply(params: RouteReplyParams): Promise - Boolean(parsed.text) || - Boolean(parsed.mediaUrl) || - (parsed.mediaUrls?.length ?? 0) > 0 || - Boolean(parsed.audioAsVoice); + hasOutboundReplyContent(parsed) || Boolean(parsed.audioAsVoice); export function createStreamingDirectiveAccumulator() { let pendingTail = ""; diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index d6e13a4fce7..0209027342d 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -1,6 +1,7 @@ import { chunkText } from "../../../auto-reply/chunk.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; +import { resolveOutboundMediaUrls } from "../../../plugin-sdk/reply-payload.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import type { ChannelOutboundAdapter } from "../types.js"; @@ -29,7 +30,7 @@ type SendPayloadAdapter = Pick< >; export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { - return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; + return resolveOutboundMediaUrls(payload); } export async function sendPayloadMediaSequence(params: { diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index a44caa3f3bf..c37166218d1 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -4,6 +4,7 @@ import type { CliDeps } from "../cli/deps.js"; import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; +import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { @@ -69,16 +70,16 @@ function formatPayloadForLog(payload: { mediaUrls?: string[]; mediaUrl?: string | null; }) { + const parts = resolveSendableOutboundReplyParts({ + text: payload.text, + mediaUrls: payload.mediaUrls, + mediaUrl: typeof payload.mediaUrl === "string" ? payload.mediaUrl : undefined, + }); const lines: string[] = []; - if (payload.text) { - lines.push(payload.text.trimEnd()); + if (parts.text) { + lines.push(parts.text.trimEnd()); } - const mediaUrl = - typeof payload.mediaUrl === "string" && payload.mediaUrl.trim() - ? payload.mediaUrl.trim() - : undefined; - const media = payload.mediaUrls ?? (mediaUrl ? [mediaUrl] : []); - for (const url of media) { + for (const url of parts.mediaUrls) { lines.push(`MEDIA:${url}`); } return lines.join("\n").trimEnd(); diff --git a/src/cron/heartbeat-policy.ts b/src/cron/heartbeat-policy.ts index 61edfa0701f..d356bcdbda5 100644 --- a/src/cron/heartbeat-policy.ts +++ b/src/cron/heartbeat-policy.ts @@ -1,4 +1,5 @@ import { stripHeartbeatToken } from "../auto-reply/heartbeat.js"; +import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; export type HeartbeatDeliveryPayload = { text?: string; @@ -14,7 +15,7 @@ export function shouldSkipHeartbeatOnlyDelivery( return true; } const hasAnyMedia = payloads.some( - (payload) => (payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl), + (payload) => resolveSendableOutboundReplyParts(payload).hasMedia, ); if (hasAnyMedia) { return false; diff --git a/src/cron/isolated-agent/helpers.ts b/src/cron/isolated-agent/helpers.ts index 448ef1c59ae..66a07a58844 100644 --- a/src/cron/isolated-agent/helpers.ts +++ b/src/cron/isolated-agent/helpers.ts @@ -1,5 +1,6 @@ import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../../auto-reply/heartbeat.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js"; import { truncateUtf16Safe } from "../../utils.js"; import { shouldSkipHeartbeatOnlyDelivery } from "../heartbeat-policy.js"; @@ -61,11 +62,9 @@ export function pickLastNonEmptyTextFromPayloads( export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) { const isDeliverable = (p: DeliveryPayload) => { - const text = (p?.text ?? "").trim(); - const hasMedia = Boolean(p?.mediaUrl) || (p?.mediaUrls?.length ?? 0) > 0; const hasInteractive = (p?.interactive?.blocks?.length ?? 0) > 0; const hasChannelData = Object.keys(p?.channelData ?? {}).length > 0; - return text || hasMedia || hasInteractive || hasChannelData; + return hasOutboundReplyContent(p, { trimText: true }) || hasInteractive || hasChannelData; }; for (let i = payloads.length - 1; i >= 0; i--) { if (payloads[i]?.isError) { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 78f045d03cf..2ca8cf2b824 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -48,6 +48,7 @@ import { import type { AgentDefaultsConfig } from "../../config/types.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { logWarn } from "../../logger.js"; +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { buildSafeExternalPrompt, @@ -687,9 +688,9 @@ export async function runCronIsolatedAgentTurn(params: { const interimPayloads = interimRunResult.payloads ?? []; const interimDeliveryPayload = pickLastDeliverablePayload(interimPayloads); const interimPayloadHasStructuredContent = - Boolean(interimDeliveryPayload?.mediaUrl) || - (interimDeliveryPayload?.mediaUrls?.length ?? 0) > 0 || - Object.keys(interimDeliveryPayload?.channelData ?? {}).length > 0; + (interimDeliveryPayload + ? resolveSendableOutboundReplyParts(interimDeliveryPayload).hasMedia + : false) || Object.keys(interimDeliveryPayload?.channelData ?? {}).length > 0; const interimText = pickLastNonEmptyTextFromPayloads(interimPayloads)?.trim() ?? ""; const hasDescendantsSinceRunStart = listDescendantRunsForRequester(agentSessionKey).some( (entry) => { @@ -809,8 +810,7 @@ export async function runCronIsolatedAgentTurn(params: { ? [{ text: synthesizedText }] : []; const deliveryPayloadHasStructuredContent = - Boolean(deliveryPayload?.mediaUrl) || - (deliveryPayload?.mediaUrls?.length ?? 0) > 0 || + (deliveryPayload ? resolveSendableOutboundReplyParts(deliveryPayload).hasMedia : false) || Object.keys(deliveryPayload?.channelData ?? {}).length > 0; const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job); const hasErrorPayload = payloads.some((payload) => payload?.isError === true); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 5cf36e39af2..b980d9e890d 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -13,7 +13,7 @@ import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; -import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js"; +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { normalizePollInput } from "../../polls.js"; import { ErrorCodes, @@ -211,8 +211,8 @@ export const sendHandlers: GatewayRequestHandlers = { .map((payload) => payload.text) .filter(Boolean) .join("\n"); - const mirrorMediaUrls = mirrorPayloads.flatMap((payload) => - resolveOutboundMediaUrls(payload), + const mirrorMediaUrls = mirrorPayloads.flatMap( + (payload) => resolveSendableOutboundReplyParts(payload).mediaUrls, ); const providedSessionKey = typeof request.sessionKey === "string" && request.sessionKey.trim() diff --git a/src/gateway/ws-log.ts b/src/gateway/ws-log.ts index f987ccf8d37..52e07806dd1 100644 --- a/src/gateway/ws-log.ts +++ b/src/gateway/ws-log.ts @@ -3,6 +3,7 @@ import { isVerbose } from "../globals.js"; import { shouldLogSubsystemToConsole } from "../logging/console.js"; import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js"; @@ -204,9 +205,11 @@ export function summarizeAgentEventForWsLog(payload: unknown): Record 0) { - extra.media = mediaUrls.length; + const mediaCount = resolveSendableOutboundReplyParts({ + mediaUrls: Array.isArray(data.mediaUrls) ? data.mediaUrls : undefined, + }).mediaCount; + if (mediaCount > 0) { + extra.media = mediaCount; } return extra; } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 34b3a7b5f86..cf5b45f8993 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -35,6 +35,10 @@ import { import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import { resolveCronSession } from "../cron/isolated-agent/session.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "../plugin-sdk/reply-payload.js"; import { getQueueSize } from "../process/command-queue.js"; import { CommandLane } from "../process/lanes.js"; import { @@ -368,7 +372,7 @@ function normalizeHeartbeatReply( mode: "heartbeat", maxAckChars: ackMaxChars, }); - const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0); + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (stripped.shouldSkip && !hasMedia) { return { shouldSkip: true, @@ -720,10 +724,7 @@ export async function runHeartbeatOnce(opts: { ? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload) : []; - if ( - !replyPayload || - (!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length) - ) { + if (!replyPayload || !hasOutboundReplyContent(replyPayload)) { await restoreHeartbeatUpdatedAt({ storePath, sessionKey, @@ -780,8 +781,7 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } - const mediaUrls = - replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []); + const mediaUrls = resolveSendableOutboundReplyParts(replyPayload).mediaUrls; // Suppress duplicate heartbeats (same payload) within a short window. // This prevents "nagging" when nothing changed but the model repeats the same items. diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index b8bbc115988..84e1808e4f0 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -23,11 +23,11 @@ import { toPluginMessageContext, toPluginMessageSentEvent, } from "../../hooks/message-hook-mappers.js"; -import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { - resolveOutboundMediaUrls, + resolveSendableOutboundReplyParts, sendMediaWithLeadingCaption, } from "../../plugin-sdk/reply-payload.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; @@ -284,17 +284,8 @@ type MessageSentEvent = { function normalizeEmptyPayloadForDelivery(payload: ReplyPayload): ReplyPayload | null { const text = typeof payload.text === "string" ? payload.text : ""; - const hasChannelData = hasReplyChannelData(payload.channelData); if (!text.trim()) { - if ( - !hasReplyContent({ - text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasReplyPayloadContent({ ...payload, text })) { return null; } if (text) { @@ -340,9 +331,10 @@ function normalizePayloadsForChannelDelivery( } function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload { + const parts = resolveSendableOutboundReplyParts(payload); return { - text: payload.text ?? "", - mediaUrls: resolveOutboundMediaUrls(payload), + text: parts.text, + mediaUrls: parts.mediaUrls, interactive: payload.interactive, channelData: payload.channelData, }; @@ -669,10 +661,10 @@ async function deliverOutboundPayloadsCore( }; if ( handler.sendPayload && - (effectivePayload.channelData || - hasReplyContent({ - interactive: effectivePayload.interactive, - })) + hasReplyPayloadContent({ + interactive: effectivePayload.interactive, + channelData: effectivePayload.channelData, + }) ) { const delivery = await handler.sendPayload(effectivePayload, sendOverrides); results.push(delivery); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 1777fbb32e3..635c9df1005 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,7 +14,7 @@ import type { ChannelThreadingToolContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { hasInteractiveReplyBlocks, hasReplyContent } from "../../interactive/payload.js"; +import { hasInteractiveReplyBlocks, hasReplyPayloadContent } from "../../interactive/payload.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js"; import { resolvePollMaxSelections } from "../../polls.js"; @@ -484,13 +484,17 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise payload.text) .filter(Boolean) .join("\n"); - const mirrorMediaUrls = normalizedPayloads.flatMap((payload) => - resolveOutboundMediaUrls(payload), + const mirrorMediaUrls = normalizedPayloads.flatMap( + (payload) => resolveSendableOutboundReplyParts(payload).mediaUrls, ); const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index fa9790888a4..2d90bb85a09 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -8,10 +8,10 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import { hasInteractiveReplyBlocks, hasReplyChannelData, - hasReplyContent, + hasReplyPayloadContent, type InteractiveReply, } from "../../interactive/payload.js"; -import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js"; +import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; export type NormalizedOutboundPayload = { text: string; @@ -97,25 +97,20 @@ export function normalizeOutboundPayloads( ): NormalizedOutboundPayload[] { const normalizedPayloads: NormalizedOutboundPayload[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { - const mediaUrls = resolveOutboundMediaUrls(payload); + const parts = resolveSendableOutboundReplyParts(payload); const interactive = payload.interactive; const channelData = payload.channelData; const hasChannelData = hasReplyChannelData(channelData); const hasInteractive = hasInteractiveReplyBlocks(interactive); - const text = payload.text ?? ""; + const text = parts.text; if ( - !hasReplyContent({ - text, - mediaUrls, - interactive, - hasChannelData, - }) + !hasReplyPayloadContent({ ...payload, text, mediaUrls: parts.mediaUrls }, { hasChannelData }) ) { continue; } normalizedPayloads.push({ text, - mediaUrls, + mediaUrls: parts.mediaUrls, ...(hasInteractive ? { interactive } : {}), ...(hasChannelData ? { channelData } : {}), }); @@ -128,11 +123,11 @@ export function normalizeOutboundPayloadsForJson( ): OutboundPayloadJson[] { const normalized: OutboundPayloadJson[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { - const mediaUrls = resolveOutboundMediaUrls(payload); + const parts = resolveSendableOutboundReplyParts(payload); normalized.push({ - text: payload.text ?? "", + text: parts.text, mediaUrl: payload.mediaUrl ?? null, - mediaUrls: mediaUrls.length ? mediaUrls : undefined, + mediaUrls: parts.mediaUrls.length ? parts.mediaUrls : undefined, interactive: payload.interactive, channelData: payload.channelData, }); diff --git a/src/interactive/payload.test.ts b/src/interactive/payload.test.ts index 3000716cd2e..12c071d5652 100644 --- a/src/interactive/payload.test.ts +++ b/src/interactive/payload.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasReplyChannelData, hasReplyContent, + hasReplyPayloadContent, normalizeInteractiveReply, resolveInteractiveTextFallback, } from "./payload.js"; @@ -44,6 +45,41 @@ describe("hasReplyContent", () => { }); }); +describe("hasReplyPayloadContent", () => { + it("trims text and falls back to channel data by default", () => { + expect( + hasReplyPayloadContent({ + text: " ", + channelData: { slack: { blocks: [] } }, + }), + ).toBe(true); + }); + + it("accepts explicit channel-data overrides and extra content", () => { + expect( + hasReplyPayloadContent( + { + text: " ", + channelData: {}, + }, + { + hasChannelData: true, + }, + ), + ).toBe(true); + expect( + hasReplyPayloadContent( + { + text: " ", + }, + { + extraContent: true, + }, + ), + ).toBe(true); + }); +}); + describe("interactive payload helpers", () => { it("normalizes interactive replies and resolves text fallbacks", () => { const interactive = normalizeInteractiveReply({ diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 5ccd55d0eff..8ab80131a8e 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -160,6 +160,30 @@ export function hasReplyContent(params: { ); } +export function hasReplyPayloadContent( + payload: { + text?: string | null; + mediaUrl?: string | null; + mediaUrls?: ReadonlyArray; + interactive?: unknown; + channelData?: unknown; + }, + options?: { + trimText?: boolean; + hasChannelData?: boolean; + extraContent?: boolean; + }, +): boolean { + return hasReplyContent({ + text: options?.trimText ? payload.text?.trim() : payload.text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData: options?.hasChannelData ?? hasReplyChannelData(payload.channelData), + extraContent: options?.extraContent, + }); +} + export function resolveInteractiveTextFallback(params: { text?: string; interactive?: InteractiveReply; diff --git a/src/line/auto-reply-delivery.ts b/src/line/auto-reply-delivery.ts index aea6210dda4..91b2633f47c 100644 --- a/src/line/auto-reply-delivery.ts +++ b/src/line/auto-reply-delivery.ts @@ -1,6 +1,6 @@ import type { messagingApi } from "@line/bot-sdk"; import type { ReplyPayload } from "../auto-reply/types.js"; -import { resolveOutboundMediaUrls } from "../plugin-sdk/reply-payload.js"; +import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import type { FlexContainer } from "./flex-templates.js"; import type { ProcessedLineMessage } from "./markdown-to-line.js"; import type { SendLineReplyChunksParams } from "./reply-chunks.js"; @@ -124,7 +124,7 @@ export async function deliverLineAutoReply(params: { const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : []; - const mediaUrls = resolveOutboundMediaUrls(payload); + const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls; const mediaMessages = mediaUrls .map((url) => url?.trim()) .filter((url): url is string => Boolean(url)) diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 02650a4a009..51f8ef257b2 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -46,7 +46,7 @@ export { splitSetupEntries, } from "../channels/plugins/setup-wizard-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export { resolveOutboundMediaUrls } from "./reply-payload.js"; +export { resolveOutboundMediaUrls, resolveSendableOutboundReplyParts } from "./reply-payload.js"; export type { BaseProbeResult, ChannelDirectoryEntry, diff --git a/src/plugin-sdk/reply-payload.test.ts b/src/plugin-sdk/reply-payload.test.ts index 171b17f0e7e..ce393a9ecd3 100644 --- a/src/plugin-sdk/reply-payload.test.ts +++ b/src/plugin-sdk/reply-payload.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { + countOutboundMedia, deliverFormattedTextWithAttachments, deliverTextOrMediaReply, + hasOutboundMedia, + hasOutboundReplyContent, + hasOutboundText, isNumericTargetId, resolveOutboundMediaUrls, + resolveSendableOutboundReplyParts, resolveTextChunksWithFallback, sendMediaWithLeadingCaption, sendPayloadWithChunkedTextAndMedia, @@ -84,6 +89,102 @@ describe("resolveOutboundMediaUrls", () => { }); }); +describe("countOutboundMedia", () => { + it("counts normalized media entries", () => { + expect( + countOutboundMedia({ + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + }), + ).toBe(2); + }); + + it("counts legacy single-media payloads", () => { + expect( + countOutboundMedia({ + mediaUrl: "https://example.com/legacy.png", + }), + ).toBe(1); + }); +}); + +describe("hasOutboundMedia", () => { + it("reports whether normalized payloads include media", () => { + expect(hasOutboundMedia({ mediaUrls: ["https://example.com/a.png"] })).toBe(true); + expect(hasOutboundMedia({ mediaUrl: "https://example.com/legacy.png" })).toBe(true); + expect(hasOutboundMedia({})).toBe(false); + }); +}); + +describe("hasOutboundText", () => { + it("checks raw text presence by default", () => { + expect(hasOutboundText({ text: "hello" })).toBe(true); + expect(hasOutboundText({ text: " " })).toBe(true); + expect(hasOutboundText({})).toBe(false); + }); + + it("can trim whitespace-only text", () => { + expect(hasOutboundText({ text: " " }, { trim: true })).toBe(false); + expect(hasOutboundText({ text: " hi " }, { trim: true })).toBe(true); + }); +}); + +describe("hasOutboundReplyContent", () => { + it("detects text or media content", () => { + expect(hasOutboundReplyContent({ text: "hello" })).toBe(true); + expect(hasOutboundReplyContent({ mediaUrl: "https://example.com/a.png" })).toBe(true); + expect(hasOutboundReplyContent({})).toBe(false); + }); + + it("can ignore whitespace-only text unless media exists", () => { + expect(hasOutboundReplyContent({ text: " " }, { trimText: true })).toBe(false); + expect( + hasOutboundReplyContent( + { text: " ", mediaUrls: ["https://example.com/a.png"] }, + { trimText: true }, + ), + ).toBe(true); + }); +}); + +describe("resolveSendableOutboundReplyParts", () => { + it("normalizes missing text and trims media urls", () => { + expect( + resolveSendableOutboundReplyParts({ + mediaUrls: [" https://example.com/a.png ", " "], + }), + ).toEqual({ + text: "", + trimmedText: "", + mediaUrls: ["https://example.com/a.png"], + mediaCount: 1, + hasText: false, + hasMedia: true, + hasContent: true, + }); + }); + + it("accepts transformed text overrides", () => { + expect( + resolveSendableOutboundReplyParts( + { + text: "ignored", + }, + { + text: " hello ", + }, + ), + ).toEqual({ + text: " hello ", + trimmedText: "hello", + mediaUrls: [], + mediaCount: 0, + hasText: true, + hasMedia: false, + hasContent: true, + }); + }); +}); + describe("resolveTextChunksWithFallback", () => { it("returns existing chunks unchanged", () => { expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]); @@ -161,6 +262,26 @@ describe("deliverTextOrMediaReply", () => { expect(sendText).not.toHaveBeenCalled(); expect(sendMedia).not.toHaveBeenCalled(); }); + + it("ignores blank media urls before sending", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: "hello", mediaUrls: [" ", " https://a "] }, + text: "hello", + sendText, + sendMedia, + }), + ).resolves.toBe("media"); + + expect(sendMedia).toHaveBeenCalledTimes(1); + expect(sendMedia).toHaveBeenCalledWith({ + mediaUrl: "https://a", + caption: "hello", + }); + }); }); describe("sendMediaWithLeadingCaption", () => { diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index 3bee0c9e81b..52cc878c83d 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -5,6 +5,16 @@ export type OutboundReplyPayload = { replyToId?: string; }; +export type SendableOutboundReplyParts = { + text: string; + trimmedText: string; + mediaUrls: string[]; + mediaCount: number; + hasText: boolean; + hasMedia: boolean; + hasContent: boolean; +}; + /** Extract the supported outbound reply fields from loose tool or agent payload objects. */ export function normalizeOutboundReplyPayload( payload: Record, @@ -52,6 +62,54 @@ export function resolveOutboundMediaUrls(payload: { return []; } +/** Count outbound media items after legacy single-media fallback normalization. */ +export function countOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): number { + return resolveOutboundMediaUrls(payload).length; +} + +/** Check whether an outbound payload includes any media after normalization. */ +export function hasOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): boolean { + return countOutboundMedia(payload) > 0; +} + +/** Check whether an outbound payload includes text, optionally trimming whitespace first. */ +export function hasOutboundText(payload: { text?: string }, options?: { trim?: boolean }): boolean { + const text = options?.trim ? payload.text?.trim() : payload.text; + return Boolean(text); +} + +/** Check whether an outbound payload includes any sendable text or media. */ +export function hasOutboundReplyContent( + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, + options?: { trimText?: boolean }, +): boolean { + return hasOutboundText(payload, { trim: options?.trimText }) || hasOutboundMedia(payload); +} + +/** Normalize reply payload text/media into a trimmed, sendable shape for delivery paths. */ +export function resolveSendableOutboundReplyParts( + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, + options?: { text?: string }, +): SendableOutboundReplyParts { + const text = options?.text ?? payload.text ?? ""; + const trimmedText = text.trim(); + const mediaUrls = resolveOutboundMediaUrls(payload) + .map((entry) => entry.trim()) + .filter(Boolean); + const mediaCount = mediaUrls.length; + const hasText = Boolean(trimmedText); + const hasMedia = mediaCount > 0; + return { + text, + trimmedText, + mediaUrls, + mediaCount, + hasText, + hasMedia, + hasContent: hasText || hasMedia, + }; +} + /** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */ export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] { if (chunks.length > 0) { @@ -188,7 +246,9 @@ export async function deliverTextOrMediaReply(params: { isFirst: boolean; }) => Promise | void; }): Promise<"empty" | "text" | "media"> { - const mediaUrls = resolveOutboundMediaUrls(params.payload); + const { mediaUrls } = resolveSendableOutboundReplyParts(params.payload, { + text: params.text, + }); const sentMedia = await sendMediaWithLeadingCaption({ mediaUrls, caption: params.text, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 2f4a30ae5ce..6a63b0f57ba 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -98,9 +98,13 @@ describe("plugin-sdk subpath exports", () => { }); it("exports reply payload helpers from the dedicated subpath", () => { + expect(typeof replyPayloadSdk.countOutboundMedia).toBe("function"); expect(typeof replyPayloadSdk.deliverFormattedTextWithAttachments).toBe("function"); expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function"); expect(typeof replyPayloadSdk.formatTextWithAttachmentLinks).toBe("function"); + expect(typeof replyPayloadSdk.hasOutboundMedia).toBe("function"); + expect(typeof replyPayloadSdk.hasOutboundReplyContent).toBe("function"); + expect(typeof replyPayloadSdk.hasOutboundText).toBe("function"); expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function"); expect(typeof replyPayloadSdk.resolveTextChunksWithFallback).toBe("function"); expect(typeof replyPayloadSdk.sendMediaWithLeadingCaption).toBe("function"); diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index b02800880ec..e7fb506f227 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -71,6 +71,7 @@ export { deliverTextOrMediaReply, isNumericTargetId, resolveOutboundMediaUrls, + resolveSendableOutboundReplyParts, sendMediaWithLeadingCaption, sendPayloadWithChunkedTextAndMedia, } from "./reply-payload.js"; diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 7d48dfb8e07..019cffdb2e4 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -24,6 +24,7 @@ import type { import { logVerbose } from "../globals.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { stripMarkdown } from "../line/markdown-to-line.js"; +import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { getSpeechProvider, @@ -793,7 +794,8 @@ export async function maybeApplyTtsToPayload(params: { return params.payload; } - const text = params.payload.text ?? ""; + const reply = resolveSendableOutboundReplyParts(params.payload); + const text = reply.text; const directives = parseTtsDirectives(text, config.modelOverrides, config.openai.baseUrl); if (directives.warnings.length > 0) { logVerbose(`TTS: ignored directive overrides (${directives.warnings.join("; ")})`); @@ -827,7 +829,7 @@ export async function maybeApplyTtsToPayload(params: { if (!ttsText.trim()) { return nextPayload; } - if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) { + if (reply.hasMedia) { return nextPayload; } if (text.includes("MEDIA:")) { From fa52d122c46ae6a1aa61dbba494e5b5dd910deab Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 11:17:50 -0700 Subject: [PATCH 26/34] Plugin SDK: route provider metadata through public models subpath --- src/plugin-sdk/provider-models.ts | 20 +++- src/plugin-sdk/subpaths.test.ts | 9 ++ src/plugins/provider-model-definitions.ts | 45 +++------ src/plugins/provider-zai-endpoint.ts | 2 +- ...n-extension-import-boundary-inventory.json | 99 +------------------ 5 files changed, 45 insertions(+), 130 deletions(-) diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index b82bc09dc2f..8f6f2565138 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -36,8 +36,10 @@ export { OPENCODE_ZEN_DEFAULT_MODEL } from "../plugins/provider-model-defaults.j export { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; export { buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, DEFAULT_MINIMAX_BASE_URL, MINIMAX_API_BASE_URL, + MINIMAX_API_COST, MINIMAX_CN_API_BASE_URL, MINIMAX_HOSTED_COST, MINIMAX_HOSTED_MODEL_ID, @@ -47,6 +49,7 @@ export { export { buildMistralModelDefinition, MISTRAL_BASE_URL, + MISTRAL_DEFAULT_COST, MISTRAL_DEFAULT_MODEL_ID, MISTRAL_DEFAULT_MODEL_REF, } from "../../extensions/mistral/model-definitions.js"; @@ -54,15 +57,29 @@ export { buildModelStudioDefaultModelDefinition, buildModelStudioModelDefinition, MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, MODELSTUDIO_DEFAULT_MODEL_ID, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL, } from "../../extensions/modelstudio/model-definitions.js"; -export { MOONSHOT_BASE_URL } from "../../extensions/moonshot/provider-catalog.js"; +export { + buildMoonshotProvider, + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "../../extensions/moonshot/provider-catalog.js"; export { MOONSHOT_CN_BASE_URL } from "../../extensions/moonshot/onboard.js"; +export { + KIMI_CODING_BASE_URL, + KIMI_CODING_DEFAULT_MODEL_ID, +} from "../../extensions/kimi-coding/provider-catalog.js"; +export { + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, +} from "../../extensions/qianfan/provider-catalog.js"; export { buildXaiModelDefinition, XAI_BASE_URL, + XAI_DEFAULT_COST, XAI_DEFAULT_MODEL_ID, XAI_DEFAULT_MODEL_REF, } from "../../extensions/xai/model-definitions.js"; @@ -72,6 +89,7 @@ export { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL, ZAI_CN_BASE_URL, + ZAI_DEFAULT_COST, ZAI_DEFAULT_MODEL_ID, ZAI_DEFAULT_MODEL_REF, ZAI_GLOBAL_BASE_URL, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 6a63b0f57ba..ec0f4cb8d79 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -16,6 +16,7 @@ import * as lineCoreSdk from "openclaw/plugin-sdk/line-core"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; +import * as providerModelsSdk from "openclaw/plugin-sdk/provider-models"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload"; import * as routingSdk from "openclaw/plugin-sdk/routing"; @@ -178,6 +179,14 @@ describe("plugin-sdk subpath exports", () => { ); }); + it("exports provider model helpers from the dedicated subpath", () => { + expect(typeof providerModelsSdk.buildMinimaxApiModelDefinition).toBe("function"); + expect(typeof providerModelsSdk.buildMinimaxModelDefinition).toBe("function"); + expect(typeof providerModelsSdk.buildMoonshotProvider).toBe("function"); + expect(typeof providerModelsSdk.resolveZaiBaseUrl).toBe("function"); + expect(providerModelsSdk.QIANFAN_BASE_URL).toBe("https://qianfan.baidubce.com/v2"); + }); + it("exports shared setup helpers from the dedicated subpath", () => { expect(typeof setupSdk.DEFAULT_ACCOUNT_ID).toBe("string"); expect(typeof setupSdk.createAccountScopedAllowFromSection).toBe("function"); diff --git a/src/plugins/provider-model-definitions.ts b/src/plugins/provider-model-definitions.ts index 5788d0ad2ca..5eebcb204db 100644 --- a/src/plugins/provider-model-definitions.ts +++ b/src/plugins/provider-model-definitions.ts @@ -1,9 +1,14 @@ -import { KIMI_CODING_MODEL_REF } from "../../extensions/kimi-coding/onboard.js"; import { - KIMI_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID, KIMI_CODING_BASE_URL, -} from "../../extensions/kimi-coding/provider-catalog.js"; -import { + KIMI_CODING_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID, + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, + buildMistralModelDefinition, + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, + buildMoonshotProvider, + buildXaiModelDefinition, + buildZaiModelDefinition, DEFAULT_MINIMAX_BASE_URL, MINIMAX_API_BASE_URL, MINIMAX_API_COST, @@ -12,48 +17,24 @@ import { MINIMAX_HOSTED_MODEL_ID, MINIMAX_HOSTED_MODEL_REF, MINIMAX_LM_STUDIO_COST, - buildMinimaxApiModelDefinition, - buildMinimaxModelDefinition, -} from "../../extensions/minimax/model-definitions.js"; -import { - buildMistralModelDefinition, MISTRAL_BASE_URL, MISTRAL_DEFAULT_COST, MISTRAL_DEFAULT_MODEL_ID, MISTRAL_DEFAULT_MODEL_REF, -} from "../../extensions/mistral/model-definitions.js"; -import { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_COST, MODELSTUDIO_DEFAULT_MODEL_ID, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL, - buildModelStudioDefaultModelDefinition, - buildModelStudioModelDefinition, -} from "../../extensions/modelstudio/model-definitions.js"; -import { - MOONSHOT_CN_BASE_URL, - MOONSHOT_DEFAULT_MODEL_REF, -} from "../../extensions/moonshot/onboard.js"; -import { - buildMoonshotProvider, MOONSHOT_BASE_URL, + MOONSHOT_CN_BASE_URL, MOONSHOT_DEFAULT_MODEL_ID, -} from "../../extensions/moonshot/provider-catalog.js"; -import { QIANFAN_DEFAULT_MODEL_REF } from "../../extensions/qianfan/onboard.js"; -import { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, -} from "../../extensions/qianfan/provider-catalog.js"; -import { XAI_BASE_URL, XAI_DEFAULT_COST, XAI_DEFAULT_MODEL_ID, XAI_DEFAULT_MODEL_REF, - buildXaiModelDefinition, -} from "../../extensions/xai/model-definitions.js"; -import { - buildZaiModelDefinition, resolveZaiBaseUrl, ZAI_CN_BASE_URL, ZAI_CODING_CN_BASE_URL, @@ -61,7 +42,7 @@ import { ZAI_DEFAULT_COST, ZAI_DEFAULT_MODEL_ID, ZAI_GLOBAL_BASE_URL, -} from "../../extensions/zai/model-definitions.js"; +} from "openclaw/plugin-sdk/provider-models"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import { KILOCODE_DEFAULT_CONTEXT_WINDOW, @@ -71,6 +52,10 @@ import { KILOCODE_DEFAULT_MODEL_NAME, } from "../providers/kilocode-shared.js"; +const KIMI_CODING_MODEL_REF = `kimi/${KIMI_CODING_MODEL_ID}`; +const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; +const QIANFAN_DEFAULT_MODEL_REF = `qianfan/${QIANFAN_DEFAULT_MODEL_ID}`; + export { DEFAULT_MINIMAX_BASE_URL, MINIMAX_API_BASE_URL, diff --git a/src/plugins/provider-zai-endpoint.ts b/src/plugins/provider-zai-endpoint.ts index 4426b1065fe..5e76755c969 100644 --- a/src/plugins/provider-zai-endpoint.ts +++ b/src/plugins/provider-zai-endpoint.ts @@ -3,7 +3,7 @@ import { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL, ZAI_GLOBAL_BASE_URL, -} from "../../extensions/zai/model-definitions.js"; +} from "openclaw/plugin-sdk/provider-models"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; export type ZaiEndpointId = "global" | "cn" | "coding-global" | "coding-cn"; diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index 740e9b6226f..fe51488c706 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -1,98 +1 @@ -[ - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 1, - "kind": "import", - "specifier": "../../extensions/kimi-coding/onboard.js", - "resolvedPath": "extensions/kimi-coding/onboard.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 5, - "kind": "import", - "specifier": "../../extensions/kimi-coding/provider-catalog.js", - "resolvedPath": "extensions/kimi-coding/provider-catalog.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 17, - "kind": "import", - "specifier": "../../extensions/minimax/model-definitions.js", - "resolvedPath": "extensions/minimax/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 24, - "kind": "import", - "specifier": "../../extensions/mistral/model-definitions.js", - "resolvedPath": "extensions/mistral/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 33, - "kind": "import", - "specifier": "../../extensions/modelstudio/model-definitions.js", - "resolvedPath": "extensions/modelstudio/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 37, - "kind": "import", - "specifier": "../../extensions/moonshot/onboard.js", - "resolvedPath": "extensions/moonshot/onboard.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 42, - "kind": "import", - "specifier": "../../extensions/moonshot/provider-catalog.js", - "resolvedPath": "extensions/moonshot/provider-catalog.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 43, - "kind": "import", - "specifier": "../../extensions/qianfan/onboard.js", - "resolvedPath": "extensions/qianfan/onboard.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 47, - "kind": "import", - "specifier": "../../extensions/qianfan/provider-catalog.js", - "resolvedPath": "extensions/qianfan/provider-catalog.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 54, - "kind": "import", - "specifier": "../../extensions/xai/model-definitions.js", - "resolvedPath": "extensions/xai/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 64, - "kind": "import", - "specifier": "../../extensions/zai/model-definitions.js", - "resolvedPath": "extensions/zai/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-zai-endpoint.ts", - "line": 6, - "kind": "import", - "specifier": "../../extensions/zai/model-definitions.js", - "resolvedPath": "extensions/zai/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - } -] +[] From a0d3dc94d0a1e7a1928852d36f999ab70bbaf5fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 18:19:12 +0000 Subject: [PATCH 27/34] perf: reduce unit test hot path overhead --- extensions/whatsapp/src/shared.ts | 30 +++--- scripts/lib/optional-bundled-clusters.d.mts | 2 +- scripts/lib/optional-bundled-clusters.d.ts | 6 ++ scripts/test-parallel.mjs | 17 +++- src/acp/translator.session-rate-limit.test.ts | 7 +- src/auto-reply/thinking.shared.ts | 40 ++++++++ src/auto-reply/thinking.ts | 7 ++ src/commands/channel-test-helpers.ts | 12 ++- ...rovider-usage.auth.normalizes-keys.test.ts | 19 +++- src/infra/provider-usage.auth.ts | 6 +- src/infra/provider-usage.load.ts | 2 + src/infra/provider-usage.test-support.ts | 4 + src/infra/provider-usage.test.ts | 1 + src/plugin-sdk/outbound-media.test.ts | 2 +- test/fixtures/test-timings.unit.json | 92 +++++++++++++++++++ 15 files changed, 213 insertions(+), 34 deletions(-) create mode 100644 scripts/lib/optional-bundled-clusters.d.ts diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 3888cdc36d3..3e241c9f94c 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -92,20 +92,7 @@ export function createWhatsAppPluginBase(params: { setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; isConfigured: NonNullable["config"]>["isConfigured"]; -}): Pick< - ChannelPlugin, - | "id" - | "meta" - | "setupWizard" - | "capabilities" - | "reload" - | "gatewayMethods" - | "configSchema" - | "config" - | "security" - | "setup" - | "groups" -> { +}) { const collectWhatsAppSecurityWarnings = createAllowlistProviderRouteAllowlistWarningCollector({ providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined, @@ -126,7 +113,7 @@ export function createWhatsAppPluginBase(params: { groupAllowFromPath: "channels.whatsapp.groupAllowFrom", }, }); - return createChannelPluginBase({ + const base = createChannelPluginBase({ id: WHATSAPP_CHANNEL, meta: { ...getChatChannelMeta(WHATSAPP_CHANNEL), @@ -167,7 +154,18 @@ export function createWhatsAppPluginBase(params: { }, setup: params.setup, groups: params.groups, - }) as Pick< + }); + return { + ...base, + setupWizard: base.setupWizard!, + capabilities: base.capabilities!, + reload: base.reload!, + gatewayMethods: base.gatewayMethods!, + configSchema: base.configSchema!, + config: base.config!, + security: base.security!, + groups: base.groups!, + } satisfies Pick< ChannelPlugin, | "id" | "meta" diff --git a/scripts/lib/optional-bundled-clusters.d.mts b/scripts/lib/optional-bundled-clusters.d.mts index 42640bd1772..425e241ced7 100644 --- a/scripts/lib/optional-bundled-clusters.d.mts +++ b/scripts/lib/optional-bundled-clusters.d.mts @@ -1,6 +1,6 @@ export const optionalBundledClusters: string[]; export const optionalBundledClusterSet: Set; -export const OPTIONAL_BUNDLED_BUILD_ENV: string; +export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; export function isOptionalBundledCluster(cluster: string): boolean; export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean; diff --git a/scripts/lib/optional-bundled-clusters.d.ts b/scripts/lib/optional-bundled-clusters.d.ts new file mode 100644 index 00000000000..425e241ced7 --- /dev/null +++ b/scripts/lib/optional-bundled-clusters.d.ts @@ -0,0 +1,6 @@ +export const optionalBundledClusters: string[]; +export const optionalBundledClusterSet: Set; +export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; +export function isOptionalBundledCluster(cluster: string): boolean; +export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; +export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean; diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 68361a6b094..94d2a173a0e 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -236,11 +236,16 @@ const parseEnvNumber = (name, fallback) => { const parsed = Number.parseInt(process.env[name] ?? "", 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; }; -const allKnownUnitFiles = allKnownTestFiles.filter((file) => inferTarget(file).owner === "unit"); +const allKnownUnitFiles = allKnownTestFiles.filter((file) => { + if (file.endsWith(".live.test.ts") || file.endsWith(".e2e.test.ts")) { + return false; + } + return inferTarget(file).owner !== "gateway"; +}); const defaultHeavyUnitFileLimit = - testProfile === "serial" ? 0 : testProfile === "low" ? 8 : highMemLocalHost ? 24 : 16; + testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60; const defaultHeavyUnitLaneCount = - testProfile === "serial" ? 0 : testProfile === "low" ? 1 : highMemLocalHost ? 3 : 2; + testProfile === "serial" ? 0 : testProfile === "low" ? 2 : highMemLocalHost ? 5 : 4; const heavyUnitFileLimit = parseEnvNumber( "OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT", defaultHeavyUnitFileLimit, @@ -582,8 +587,10 @@ const defaultWorkerBudget = } : highMemLocalHost ? { - // High-memory local hosts can prioritize wall-clock speed. - unit: Math.max(4, Math.min(14, Math.floor((localWorkers * 7) / 8))), + // After peeling measured hotspots into dedicated lanes, the shared + // unit-fast lane shuts down more reliably with a slightly smaller + // worker fan-out than the old "max it out" local default. + unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))), unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index d5897fa8172..566b61a5027 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -5,11 +5,10 @@ import type { SetSessionConfigOptionRequest, SetSessionModeRequest, } from "@agentclientprotocol/sdk"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { listThinkingLevels } from "../auto-reply/thinking.js"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; -import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js"; import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; @@ -121,10 +120,6 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text: sessionStore.clearAllSessionsForTest(); } -beforeEach(() => { - resetProviderRuntimeHookCacheForTest(); -}); - describe("acp session creation rate limit", () => { it("rate limits excessive newSession bursts", async () => { const sessionStore = createInMemorySessionStore(); diff --git a/src/auto-reply/thinking.shared.ts b/src/auto-reply/thinking.shared.ts index 7487928eac3..e5a80c8bdb3 100644 --- a/src/auto-reply/thinking.shared.ts +++ b/src/auto-reply/thinking.shared.ts @@ -14,6 +14,25 @@ export type ThinkingCatalogEntry = { const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high", "adaptive"]; const ANTHROPIC_CLAUDE_46_MODEL_RE = /^claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; const AMAZON_BEDROCK_CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; +const OPENAI_XHIGH_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.2", +] as const; +const OPENAI_CODEX_XHIGH_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.2-codex", + "gpt-5.1-codex", +] as const; +const GITHUB_COPILOT_XHIGH_MODEL_IDS = ["gpt-5.2", "gpt-5.2-codex"] as const; + +function matchesExactOrPrefix(modelId: string, ids: readonly string[]): boolean { + return ids.some((candidate) => modelId === candidate || modelId.startsWith(`${candidate}-`)); +} export function normalizeProviderId(provider?: string | null): string { if (!provider) { @@ -33,6 +52,27 @@ export function isBinaryThinkingProvider(provider?: string | null): boolean { return normalizeProviderId(provider) === "zai"; } +export function supportsBuiltInXHighThinking( + provider?: string | null, + model?: string | null, +): boolean { + const providerId = normalizeProviderId(provider); + const modelId = model?.trim().toLowerCase(); + if (!providerId || !modelId) { + return false; + } + if (providerId === "openai") { + return matchesExactOrPrefix(modelId, OPENAI_XHIGH_MODEL_IDS); + } + if (providerId === "openai-codex") { + return matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS); + } + if (providerId === "github-copilot") { + return GITHUB_COPILOT_XHIGH_MODEL_IDS.includes(modelId as never); + } + return false; +} + // Normalize user-provided thinking level strings to the canonical enum. export function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined { if (!raw) { diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 1f2f1738b1f..7c0f2df02c7 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -5,6 +5,7 @@ import { listThinkingLevels as listThinkingLevelsFallback, normalizeProviderId, resolveThinkingDefaultForModel as resolveThinkingDefaultForModelFallback, + supportsBuiltInXHighThinking, } from "./thinking.shared.js"; import type { ThinkLevel, ThinkingCatalogEntry } from "./thinking.shared.js"; export { @@ -36,6 +37,9 @@ import { } from "../plugins/provider-runtime.js"; export function isBinaryThinkingProvider(provider?: string | null, model?: string | null): boolean { + if (isBinaryThinkingProviderFallback(provider)) { + return true; + } const normalizedProvider = normalizeProviderId(provider); if (!normalizedProvider) { return false; @@ -59,6 +63,9 @@ export function supportsXHighThinking(provider?: string | null, model?: string | if (!modelKey) { return false; } + if (supportsBuiltInXHighThinking(provider, modelKey)) { + return true; + } const providerKey = normalizeProviderId(provider); if (providerKey) { const pluginDecision = resolveProviderXHighThinking({ diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index eff2b5ecc33..455ff235be6 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -1,3 +1,7 @@ +import { matrixPlugin } from "../../extensions/matrix/index.js"; +import { msteamsPlugin } from "../../extensions/msteams/index.js"; +import { nostrPlugin } from "../../extensions/nostr/index.js"; +import { tlonPlugin } from "../../extensions/tlon/index.js"; import { bundledChannelPlugins } from "../channels/plugins/bundled.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -20,7 +24,13 @@ type PatchedSetupAdapterFields = { }; export function setDefaultChannelPluginRegistryForTests(): void { - const channels = bundledChannelPlugins.map((plugin) => ({ + const channels = [ + ...bundledChannelPlugins, + matrixPlugin, + msteamsPlugin, + nostrPlugin, + tlonPlugin, + ].map((plugin) => ({ pluginId: plugin.id, plugin, source: "test" as const, diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 261ff0203bc..0309a63c7f6 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -1,9 +1,18 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; -import { resolveProviderAuths, type ProviderAuth } from "./provider-usage.auth.js"; + +const resolveProviderUsageAuthWithPluginMock = vi.fn(async () => null); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderUsageAuthWithPlugin: (...args: unknown[]) => + resolveProviderUsageAuthWithPluginMock(...args), +})); + +let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths; +type ProviderAuth = import("./provider-usage.auth.js").ProviderAuth; describe("resolveProviderAuths key normalization", () => { let suiteRoot = ""; @@ -18,6 +27,7 @@ describe("resolveProviderAuths key normalization", () => { beforeAll(async () => { suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-suite-")); + ({ resolveProviderAuths } = await import("./provider-usage.auth.js")); }); afterAll(async () => { @@ -26,6 +36,11 @@ describe("resolveProviderAuths key normalization", () => { suiteCase = 0; }); + beforeEach(() => { + resolveProviderUsageAuthWithPluginMock.mockReset(); + resolveProviderUsageAuthWithPluginMock.mockResolvedValue(null); + }); + async function withSuiteHome( fn: (home: string) => Promise, env: Record, diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 982ffbc8be5..c503779b6f5 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -229,17 +229,19 @@ export async function resolveProviderAuths(params: { providers: UsageProviderId[]; auth?: ProviderAuth[]; agentDir?: string; + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; }): Promise { if (params.auth) { return params.auth; } const state: UsageAuthState = { - cfg: loadConfig(), + cfg: params.config ?? loadConfig(), store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }), - env: process.env, + env: params.env ?? process.env, agentDir: params.agentDir, }; const auths: ProviderAuth[] = []; diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index a8658889c68..ec870aa27ee 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -179,6 +179,8 @@ export async function loadProviderUsageSummary( providers: opts.providers ?? usageProviders, auth: opts.auth, agentDir: opts.agentDir, + config, + env, }); if (auths.length === 0) { return { updatedAt: now, providers: [] }; diff --git a/src/infra/provider-usage.test-support.ts b/src/infra/provider-usage.test-support.ts index 2d2609a29d6..13006bb7213 100644 --- a/src/infra/provider-usage.test-support.ts +++ b/src/infra/provider-usage.test-support.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "../config/config.js"; import { createProviderUsageFetch } from "../test-utils/provider-usage-fetch.js"; import type { ProviderAuth } from "./provider-usage.auth.js"; import type { UsageSummary } from "./provider-usage.types.js"; @@ -8,6 +9,7 @@ type ProviderUsageLoader = (params: { now: number; auth?: ProviderAuth[]; fetch?: typeof fetch; + config?: OpenClawConfig; }) => Promise; export type ProviderUsageAuth = NonNullable< @@ -23,5 +25,7 @@ export async function loadUsageWithAuth( now: usageNow, auth, fetch: mockFetch as unknown as typeof fetch, + // These tests exercise the built-in usage fetchers, not provider plugin hooks. + config: { plugins: { enabled: false } } as OpenClawConfig, }); } diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index fdd2326a9a0..fb267613184 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -294,6 +294,7 @@ describe("provider usage loading", () => { providers: ["anthropic"], agentDir, fetch: mockFetch as unknown as typeof fetch, + config: { plugins: { enabled: false } }, }); const claude = expectSingleAnthropicProvider(summary); diff --git a/src/plugin-sdk/outbound-media.test.ts b/src/plugin-sdk/outbound-media.test.ts index 6efb42df7fe..b68f382cd3a 100644 --- a/src/plugin-sdk/outbound-media.test.ts +++ b/src/plugin-sdk/outbound-media.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadWebMediaMock = vi.hoisted(() => vi.fn()); -vi.mock("../media/web-media.js", () => ({ +vi.mock("./web-media.js", () => ({ loadWebMedia: loadWebMediaMock, })); diff --git a/test/fixtures/test-timings.unit.json b/test/fixtures/test-timings.unit.json index 2199276bc5b..cdb2505d881 100644 --- a/test/fixtures/test-timings.unit.json +++ b/test/fixtures/test-timings.unit.json @@ -130,6 +130,98 @@ "src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts": { "durationMs": 1600, "testCount": 22 + }, + "src/plugins/tools.optional.test.ts": { + "durationMs": 1590, + "testCount": 18 + }, + "src/security/fix.test.ts": { + "durationMs": 1580, + "testCount": 24 + }, + "src/utils.test.ts": { + "durationMs": 1570, + "testCount": 34 + }, + "src/auto-reply/tool-meta.test.ts": { + "durationMs": 1560, + "testCount": 26 + }, + "src/auto-reply/envelope.test.ts": { + "durationMs": 1550, + "testCount": 20 + }, + "src/commands/auth-choice.test.ts": { + "durationMs": 1540, + "testCount": 18 + }, + "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts": { + "durationMs": 1530, + "testCount": 14 + }, + "src/media/store.header-ext.test.ts": { + "durationMs": 1520, + "testCount": 16 + }, + "extensions/whatsapp/src/media.test.ts": { + "durationMs": 1510, + "testCount": 16 + }, + "extensions/whatsapp/src/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts": { + "durationMs": 1500, + "testCount": 10 + }, + "src/browser/server.covers-additional-endpoint-branches.test.ts": { + "durationMs": 1490, + "testCount": 18 + }, + "src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts": { + "durationMs": 1480, + "testCount": 12 + }, + "src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts": { + "durationMs": 1470, + "testCount": 10 + }, + "src/browser/server.auth-token-gates-http.test.ts": { + "durationMs": 1460, + "testCount": 15 + }, + "extensions/acpx/src/runtime.test.ts": { + "durationMs": 1450, + "testCount": 12 + }, + "test/scripts/ios-team-id.test.ts": { + "durationMs": 1440, + "testCount": 12 + }, + "src/agents/bash-tools.exec.background-abort.test.ts": { + "durationMs": 1430, + "testCount": 10 + }, + "src/agents/subagent-announce.format.test.ts": { + "durationMs": 1420, + "testCount": 12 + }, + "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts": { + "durationMs": 1410, + "testCount": 14 + }, + "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts": { + "durationMs": 1400, + "testCount": 10 + }, + "src/auto-reply/reply.triggers.group-intro-prompts.test.ts": { + "durationMs": 1390, + "testCount": 12 + }, + "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts": { + "durationMs": 1380, + "testCount": 10 + }, + "extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts": { + "durationMs": 1370, + "testCount": 10 } } } From 1746e130f9e31c4e5f194e02cd1017025cbff2dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 18:19:54 +0000 Subject: [PATCH 28/34] test: fix imessage extension CI mocks --- extensions/imessage/src/probe.test.ts | 10 +++++----- extensions/imessage/src/targets.test.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/extensions/imessage/src/probe.test.ts b/extensions/imessage/src/probe.test.ts index ef69337984b..fad23896170 100644 --- a/extensions/imessage/src/probe.test.ts +++ b/extensions/imessage/src/probe.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as onboardHelpers from "../../../src/commands/onboard-helpers.js"; -import * as execModule from "../../../src/process/exec.js"; +import * as processRuntime from "../../../src/plugin-sdk/process-runtime.js"; +import * as setupRuntime from "../../../src/plugin-sdk/setup.js"; import * as clientModule from "./client.js"; import { probeIMessage } from "./probe.js"; beforeEach(() => { vi.restoreAllMocks(); - vi.spyOn(onboardHelpers, "detectBinary").mockResolvedValue(true); - vi.spyOn(execModule, "runCommandWithTimeout").mockResolvedValue({ + vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true); + vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({ stdout: "", stderr: 'unknown command "rpc" for "imsg"', code: 1, @@ -25,7 +25,7 @@ describe("probeIMessage", () => { request: vi.fn(), stop: vi.fn(), } as unknown as Awaited>); - const result = await probeIMessage(1000, { cliPath: "imsg" }); + const result = await probeIMessage(1000, { cliPath: "imsg-test-rpc" }); expect(result.ok).toBe(false); expect(result.fatal).toBe(true); expect(result.error).toMatch(/rpc/i); diff --git a/extensions/imessage/src/targets.test.ts b/extensions/imessage/src/targets.test.ts index 2a29a7ea167..ec5360a50b0 100644 --- a/extensions/imessage/src/targets.test.ts +++ b/extensions/imessage/src/targets.test.ts @@ -10,9 +10,13 @@ import { const spawnMock = vi.hoisted(() => vi.fn()); -vi.mock("node:child_process", () => ({ - spawn: (...args: unknown[]) => spawnMock(...args), -})); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: unknown[]) => spawnMock(...args), + }; +}); describe("imessage targets", () => { it("parses chat_id targets", () => { From 8f0727d75c3539be78263eaa9d0b4d231d9952ab Mon Sep 17 00:00:00 2001 From: Onur Date: Wed, 18 Mar 2026 19:22:17 +0100 Subject: [PATCH 29/34] Delete CNAME --- docs/CNAME | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 715bc9df52a..00000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs.openclaw.ai From 4b5487ee8594d84290a7da4700da3e86bbff0490 Mon Sep 17 00:00:00 2001 From: darkamenosa Date: Thu, 19 Mar 2026 01:27:21 +0700 Subject: [PATCH 30/34] LINE: avoid runtime lookup during onboarding (#49960) --- extensions/line/src/config-adapter.ts | 23 ++++++++++---------- src/commands/onboard-channels.e2e.test.ts | 26 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/extensions/line/src/config-adapter.ts b/extensions/line/src/config-adapter.ts index 118159f16b2..1b10989b45c 100644 --- a/extensions/line/src/config-adapter.ts +++ b/extensions/line/src/config-adapter.ts @@ -1,13 +1,11 @@ import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { OpenClawConfig, ResolvedLineAccount } from "../api.js"; -import { getLineRuntime } from "./runtime.js"; - -function resolveLineRuntimeAccount(cfg: OpenClawConfig, accountId?: string | null) { - return getLineRuntime().channel.line.resolveLineAccount({ - cfg, - accountId: accountId ?? undefined, - }); -} +import { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, + type OpenClawConfig, + type ResolvedLineAccount, +} from "../runtime-api.js"; export function normalizeLineAllowFrom(entry: string): string { return entry.replace(/^line:(?:user:)?/i, ""); @@ -19,9 +17,10 @@ export const lineConfigAdapter = createScopedChannelConfigAdapter< OpenClawConfig >({ sectionKey: "line", - listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveLineRuntimeAccount(cfg, accountId), - defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg), + listAccountIds: listLineAccountIds, + resolveAccount: (cfg, accountId) => + resolveLineAccount({ cfg, accountId: accountId ?? undefined }), + defaultAccountId: resolveDefaultLineAccountId, clearBaseFields: ["channelSecret", "tokenFile", "secretFile"], resolveAllowFrom: (account) => account.config.allowFrom, formatAllowFrom: (allowFrom) => diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 7d64a4d120f..4934d3674ff 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -277,6 +277,32 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("renders the QuickStart channel picker without requiring the LINE runtime", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "__skip__"; + } + return "__done__"; + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await expect( + runSetupChannels({} as OpenClawConfig, prompter, { + quickstartDefaults: true, + }), + ).resolves.toEqual({} as OpenClawConfig); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: "Select channel (QuickStart)" }), + ); + expect(multiselect).not.toHaveBeenCalled(); + }); + it("continues Telegram setup when the plugin registry is empty", async () => { // Simulate missing registry entries (the scenario reported in #25545). setActivePluginRegistry(createEmptyPluginRegistry()); From 600f57c9791e8b8cf1e764ccf265387f65107b25 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:26:28 -0500 Subject: [PATCH 31/34] test: add architecture smell detector --- scripts/check-architecture-smells.mjs | 272 ++++++++++++++++++++++++++ test/architecture-smells.test.ts | 36 ++++ 2 files changed, 308 insertions(+) create mode 100644 scripts/check-architecture-smells.mjs create mode 100644 test/architecture-smells.test.ts diff --git a/scripts/check-architecture-smells.mjs b/scripts/check-architecture-smells.mjs new file mode 100644 index 00000000000..c10973355bc --- /dev/null +++ b/scripts/check-architecture-smells.mjs @@ -0,0 +1,272 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { + collectTypeScriptFilesFromRoots, + resolveSourceRoots, + runAsScript, + toLine, +} from "./lib/ts-guard-utils.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const scanRoots = resolveSourceRoots(repoRoot, ["src/plugin-sdk", "src/plugins/runtime"]); + +function normalizePath(filePath) { + return path.relative(repoRoot, filePath).split(path.sep).join("/"); +} + +function compareEntries(left, right) { + return ( + left.category.localeCompare(right.category) || + left.file.localeCompare(right.file) || + left.line - right.line || + left.kind.localeCompare(right.kind) || + left.specifier.localeCompare(right.specifier) || + left.reason.localeCompare(right.reason) + ); +} + +function resolveSpecifier(specifier, importerFile) { + if (specifier.startsWith(".")) { + return normalizePath(path.resolve(path.dirname(importerFile), specifier)); + } + if (specifier.startsWith("/")) { + return normalizePath(specifier); + } + return null; +} + +function pushEntry(entries, entry) { + entries.push(entry); +} + +function scanPluginSdkExtensionFacadeSmells(sourceFile, filePath) { + const relativeFile = normalizePath(filePath); + if (!relativeFile.startsWith("src/plugin-sdk/")) { + return []; + } + + const entries = []; + + function visit(node) { + if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const specifier = node.moduleSpecifier.text; + const resolvedPath = resolveSpecifier(specifier, filePath); + if (resolvedPath?.startsWith("extensions/")) { + pushEntry(entries, { + category: "plugin-sdk-extension-facade", + file: relativeFile, + line: toLine(sourceFile, node.moduleSpecifier), + kind: "export", + specifier, + resolvedPath, + reason: "plugin-sdk public surface re-exports extension-owned implementation", + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return entries; +} + +function scanRuntimeTypeImplementationSmells(sourceFile, filePath) { + const relativeFile = normalizePath(filePath); + if (!/^src\/plugins\/runtime\/types(?:-[^/]+)?\.ts$/.test(relativeFile)) { + return []; + } + + const entries = []; + + function visit(node) { + if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) + ) { + const specifier = node.argument.literal.text; + const resolvedPath = resolveSpecifier(specifier, filePath); + if ( + resolvedPath && + (/^src\/plugins\/runtime\/runtime-[^/]+\.ts$/.test(resolvedPath) || + /^extensions\/[^/]+\/runtime-api\.[^/]+$/.test(resolvedPath)) + ) { + pushEntry(entries, { + category: "runtime-type-implementation-edge", + file: relativeFile, + line: toLine(sourceFile, node.argument.literal), + kind: "import-type", + specifier, + resolvedPath, + reason: "runtime type file references implementation shim directly", + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return entries; +} + +function scanRuntimeServiceLocatorSmells(sourceFile, filePath) { + const relativeFile = normalizePath(filePath); + if ( + !relativeFile.startsWith("src/plugin-sdk/") && + !relativeFile.startsWith("src/plugins/runtime/") + ) { + return []; + } + + const entries = []; + const exportedNames = new Set(); + const runtimeStoreCalls = []; + const mutableStateNodes = []; + + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name) { + const isExported = statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); + if (isExported) { + exportedNames.add(statement.name.text); + } + } else if (ts.isVariableStatement(statement)) { + const isExported = statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && isExported) { + exportedNames.add(declaration.name.text); + } + if ( + !isExported && + (statement.declarationList.flags & ts.NodeFlags.Let) !== 0 && + ts.isIdentifier(declaration.name) + ) { + mutableStateNodes.push(declaration.name); + } + } + } + } + + function visit(node) { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "createPluginRuntimeStore" + ) { + runtimeStoreCalls.push(node.expression); + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + const getterNames = [...exportedNames].filter((name) => /^get[A-Z]/.test(name)); + const setterNames = [...exportedNames].filter((name) => /^set[A-Z]/.test(name)); + + if (runtimeStoreCalls.length > 0 && getterNames.length > 0 && setterNames.length > 0) { + for (const callNode of runtimeStoreCalls) { + pushEntry(entries, { + category: "runtime-service-locator", + file: relativeFile, + line: toLine(sourceFile, callNode), + kind: "runtime-store", + specifier: "createPluginRuntimeStore", + resolvedPath: relativeFile, + reason: `exports paired runtime accessors (${getterNames.join(", ")} / ${setterNames.join(", ")}) over module-global store state`, + }); + } + } + + if (mutableStateNodes.length > 0 && getterNames.length > 0 && setterNames.length > 0) { + for (const identifier of mutableStateNodes) { + pushEntry(entries, { + category: "runtime-service-locator", + file: relativeFile, + line: toLine(sourceFile, identifier), + kind: "mutable-state", + specifier: identifier.text, + resolvedPath: relativeFile, + reason: `module-global mutable state backs exported runtime accessors (${getterNames.join(", ")} / ${setterNames.join(", ")})`, + }); + } + } + + return entries; +} + +export async function collectArchitectureSmells() { + const files = (await collectTypeScriptFilesFromRoots(scanRoots)).toSorted((left, right) => + normalizePath(left).localeCompare(normalizePath(right)), + ); + + const inventory = []; + for (const filePath of files) { + const source = await fs.readFile(filePath, "utf8"); + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + inventory.push(...scanPluginSdkExtensionFacadeSmells(sourceFile, filePath)); + inventory.push(...scanRuntimeTypeImplementationSmells(sourceFile, filePath)); + inventory.push(...scanRuntimeServiceLocatorSmells(sourceFile, filePath)); + } + + return inventory.toSorted(compareEntries); +} + +function formatInventoryHuman(inventory) { + if (inventory.length === 0) { + return "No architecture smells found for the configured checks."; + } + + const lines = ["Architecture smell inventory:"]; + let activeCategory = ""; + let activeFile = ""; + for (const entry of inventory) { + if (entry.category !== activeCategory) { + activeCategory = entry.category; + activeFile = ""; + lines.push(entry.category); + } + if (entry.file !== activeFile) { + activeFile = entry.file; + lines.push(` ${activeFile}`); + } + lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`); + lines.push(` specifier: ${entry.specifier}`); + lines.push(` resolved: ${entry.resolvedPath}`); + } + return lines.join("\n"); +} + +export async function main(argv = process.argv.slice(2)) { + const json = argv.includes("--json"); + const inventory = await collectArchitectureSmells(); + + if (json) { + process.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`); + return; + } + + console.log(formatInventoryHuman(inventory)); + console.log(`${inventory.length} smell${inventory.length === 1 ? "" : "s"} found.`); +} + +runAsScript(import.meta.url, main); diff --git a/test/architecture-smells.test.ts b/test/architecture-smells.test.ts new file mode 100644 index 00000000000..ebc9c5bf7b4 --- /dev/null +++ b/test/architecture-smells.test.ts @@ -0,0 +1,36 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { collectArchitectureSmells } from "../scripts/check-architecture-smells.mjs"; + +const repoRoot = process.cwd(); +const scriptPath = path.join(repoRoot, "scripts", "check-architecture-smells.mjs"); + +describe("architecture smell inventory", () => { + it("produces stable sorted output", async () => { + const first = await collectArchitectureSmells(); + const second = await collectArchitectureSmells(); + + expect(second).toEqual(first); + expect( + [...first].toSorted( + (left, right) => + left.category.localeCompare(right.category) || + left.file.localeCompare(right.file) || + left.line - right.line || + left.kind.localeCompare(right.kind) || + left.specifier.localeCompare(right.specifier) || + left.reason.localeCompare(right.reason), + ), + ).toEqual(first); + }); + + it("script json output matches the collector", async () => { + const stdout = execFileSync(process.execPath, [scriptPath, "--json"], { + cwd: repoRoot, + encoding: "utf8", + }); + + expect(JSON.parse(stdout)).toEqual(await collectArchitectureSmells()); + }); +}); From ecfa79ee4ca43ffa8f596e2a9ca6b4f43502e6eb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 12:01:05 -0700 Subject: [PATCH 32/34] Tests: fix provider auth plugin mock spread --- src/infra/provider-usage.auth.normalizes-keys.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 0309a63c7f6..27d52b418cd 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; -const resolveProviderUsageAuthWithPluginMock = vi.fn(async () => null); +const resolveProviderUsageAuthWithPluginMock = vi.fn(async (..._args: unknown[]) => null); vi.mock("../plugins/provider-runtime.js", () => ({ resolveProviderUsageAuthWithPlugin: (...args: unknown[]) => From ef1346e50339935ed985d12235020f19d5c829bf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 12:01:15 -0700 Subject: [PATCH 33/34] Plugin SDK: route reply payload through public subpath --- src/agents/pi-embedded-runner/run/payloads.ts | 2 +- src/agents/pi-embedded-subscribe.handlers.messages.ts | 2 +- src/auto-reply/heartbeat-reply-payload.ts | 2 +- src/auto-reply/reply/agent-runner-execution.ts | 2 +- src/auto-reply/reply/agent-runner-helpers.ts | 6 +++--- src/auto-reply/reply/agent-runner-payloads.ts | 2 +- src/auto-reply/reply/block-reply-coalescer.ts | 2 +- src/auto-reply/reply/block-reply-pipeline.ts | 2 +- src/auto-reply/reply/dispatch-acp-delivery.ts | 2 +- src/auto-reply/reply/dispatch-from-config.ts | 2 +- src/auto-reply/reply/followup-runner.ts | 8 ++++---- src/auto-reply/reply/reply-delivery.ts | 2 +- src/auto-reply/reply/reply-media-paths.ts | 2 +- src/auto-reply/reply/streaming-directives.ts | 2 +- src/channels/plugins/outbound/direct-text-media.ts | 2 +- src/commands/agent-via-gateway.ts | 2 +- src/cron/heartbeat-policy.ts | 2 +- src/cron/isolated-agent/helpers.ts | 2 +- src/cron/isolated-agent/run.ts | 2 +- src/gateway/server-methods/send.ts | 2 +- src/gateway/ws-log.ts | 2 +- src/infra/heartbeat-runner.ts | 8 ++++---- src/infra/outbound/deliver.ts | 8 ++++---- src/infra/outbound/message.ts | 2 +- src/infra/outbound/payloads.ts | 2 +- src/line/auto-reply-delivery.ts | 2 +- src/tts/tts.ts | 2 +- 27 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 6b0cf33e980..a79fc592bf9 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -1,10 +1,10 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import { parseReplyDirectives } from "../../../auto-reply/reply/reply-directives.js"; import type { ReasoningLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js"; import { formatToolAggregate } from "../../../auto-reply/tool-meta.js"; import type { OpenClawConfig } from "../../../config/config.js"; -import { hasOutboundReplyContent } from "../../../plugin-sdk/reply-payload.js"; import { BILLING_ERROR_USER_MESSAGE, formatAssistantErrorText, diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index d790eb912ca..c3b4e92ba61 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -1,9 +1,9 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { createInlineCodeState } from "../markdown/code-spans.js"; -import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import { isMessagingToolDuplicateNormalized, normalizeTextForComparison, diff --git a/src/auto-reply/heartbeat-reply-payload.ts b/src/auto-reply/heartbeat-reply-payload.ts index 3a235bc4273..87f92c6b7c1 100644 --- a/src/auto-reply/heartbeat-reply-payload.ts +++ b/src/auto-reply/heartbeat-reply-payload.ts @@ -1,4 +1,4 @@ -import { hasOutboundReplyContent } from "../plugin-sdk/reply-payload.js"; +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "./types.js"; export function resolveHeartbeatReplyPayload( diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 7b22a5bdba1..c25342e4a28 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { runCliAgent } from "../../agents/cli-runner.js"; import { getCliSessionId } from "../../agents/cli-session.js"; @@ -23,7 +24,6 @@ import { } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { defaultRuntime } from "../../runtime.js"; import { isMarkdownCapableMessageChannel, diff --git a/src/auto-reply/reply/agent-runner-helpers.ts b/src/auto-reply/reply/agent-runner-helpers.ts index b62e4683308..168984c35b9 100644 --- a/src/auto-reply/reply/agent-runner-helpers.ts +++ b/src/auto-reply/reply/agent-runner-helpers.ts @@ -1,9 +1,9 @@ -import { loadSessionStore } from "../../config/sessions.js"; -import { isAudioFileName } from "../../media/mime.js"; import { hasOutboundReplyContent, resolveSendableOutboundReplyParts, -} from "../../plugin-sdk/reply-payload.js"; +} from "openclaw/plugin-sdk/reply-payload"; +import { loadSessionStore } from "../../config/sessions.js"; +import { isAudioFileName } from "../../media/mime.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { scheduleFollowupDrain } from "./queue.js"; diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 5f052b8f4f9..5f4eeab2694 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -1,6 +1,6 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyToMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { OriginatingChannelType } from "../templating.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; diff --git a/src/auto-reply/reply/block-reply-coalescer.ts b/src/auto-reply/reply/block-reply-coalescer.ts index ea1022a469c..c7a6f85c26b 100644 --- a/src/auto-reply/reply/block-reply-coalescer.ts +++ b/src/auto-reply/reply/block-reply-coalescer.ts @@ -1,4 +1,4 @@ -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "../types.js"; import type { BlockStreamingCoalescing } from "./block-streaming.js"; diff --git a/src/auto-reply/reply/block-reply-pipeline.ts b/src/auto-reply/reply/block-reply-pipeline.ts index 53a9e46c313..aee14715136 100644 --- a/src/auto-reply/reply/block-reply-pipeline.ts +++ b/src/auto-reply/reply/block-reply-pipeline.ts @@ -1,5 +1,5 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "../../globals.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import type { ReplyPayload } from "../types.js"; import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; import type { BlockStreamingCoalescing } from "./block-streaming.js"; diff --git a/src/auto-reply/reply/dispatch-acp-delivery.ts b/src/auto-reply/reply/dispatch-acp-delivery.ts index a9d50521be2..57be876132b 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -1,8 +1,8 @@ +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig } from "../../config/config.js"; import type { TtsAutoMode } from "../../config/types.tts.js"; import { logVerbose } from "../../globals.js"; import { runMessageAction } from "../../infra/outbound/message-action-runner.js"; -import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js"; import { maybeApplyTtsToPayload } from "../../tts/tts.js"; import type { FinalizedMsgContext } from "../templating.js"; import type { ReplyPayload } from "../types.js"; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 3893d1d8138..9df6ef2bc63 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveConversationBindingRecord, @@ -29,7 +30,6 @@ import { logMessageQueued, logSessionStateChange, } from "../../logging/diagnostic.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { buildPluginBindingDeclinedText, buildPluginBindingErrorText, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 3e21490b990..330c0a41ff2 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1,4 +1,8 @@ import crypto from "node:crypto"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { lookupContextTokens } from "../../agents/context.js"; @@ -9,10 +13,6 @@ import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; -import { - hasOutboundReplyContent, - resolveSendableOutboundReplyParts, -} from "../../plugin-sdk/reply-payload.js"; import { defaultRuntime } from "../../runtime.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { stripHeartbeatToken } from "../heartbeat.js"; diff --git a/src/auto-reply/reply/reply-delivery.ts b/src/auto-reply/reply/reply-delivery.ts index 0a410319959..ee19d2d0934 100644 --- a/src/auto-reply/reply/reply-delivery.ts +++ b/src/auto-reply/reply/reply-delivery.ts @@ -1,5 +1,5 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "../../globals.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { BlockReplyContext, ReplyPayload } from "../types.js"; import type { BlockReplyPipeline } from "./block-reply-pipeline.js"; diff --git a/src/auto-reply/reply/reply-media-paths.ts b/src/auto-reply/reply/reply-media-paths.ts index 45447e7b82d..915b7607092 100644 --- a/src/auto-reply/reply/reply-media-paths.ts +++ b/src/auto-reply/reply/reply-media-paths.ts @@ -1,8 +1,8 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolvePathFromInput } from "../../agents/path-policy.js"; import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js"; import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import type { ReplyPayload } from "../types.js"; const HTTP_URL_RE = /^https?:\/\//i; diff --git a/src/auto-reply/reply/streaming-directives.ts b/src/auto-reply/reply/streaming-directives.ts index e4f52ed85a2..ab4e6bedae1 100644 --- a/src/auto-reply/reply/streaming-directives.ts +++ b/src/auto-reply/reply/streaming-directives.ts @@ -1,5 +1,5 @@ +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import { splitMediaFromOutput } from "../../media/parse.js"; -import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js"; import { parseInlineDirectives } from "../../utils/directive-tags.js"; import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { ReplyDirectiveParseResult } from "./reply-directives.js"; diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index 0209027342d..c0b4caafeba 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -1,7 +1,7 @@ +import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; import { chunkText } from "../../../auto-reply/chunk.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; -import { resolveOutboundMediaUrls } from "../../../plugin-sdk/reply-payload.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import type { ChannelOutboundAdapter } from "../types.js"; diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index c37166218d1..79e05cc6047 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -1,10 +1,10 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { listAgentIds } from "../agents/agent-scope.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { CliDeps } from "../cli/deps.js"; import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; -import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { diff --git a/src/cron/heartbeat-policy.ts b/src/cron/heartbeat-policy.ts index d356bcdbda5..f95f9dd8422 100644 --- a/src/cron/heartbeat-policy.ts +++ b/src/cron/heartbeat-policy.ts @@ -1,5 +1,5 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { stripHeartbeatToken } from "../auto-reply/heartbeat.js"; -import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; export type HeartbeatDeliveryPayload = { text?: string; diff --git a/src/cron/isolated-agent/helpers.ts b/src/cron/isolated-agent/helpers.ts index 66a07a58844..2e647423036 100644 --- a/src/cron/isolated-agent/helpers.ts +++ b/src/cron/isolated-agent/helpers.ts @@ -1,6 +1,6 @@ +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../../auto-reply/heartbeat.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js"; import { truncateUtf16Safe } from "../../utils.js"; import { shouldSkipHeartbeatOnlyDelivery } from "../heartbeat-policy.js"; diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 2ca8cf2b824..1c0b42398e5 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentConfig, resolveAgentDir, @@ -48,7 +49,6 @@ import { import type { AgentDefaultsConfig } from "../../config/types.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { logWarn } from "../../logger.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { buildSafeExternalPrompt, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index b980d9e890d..a118002dc45 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import { createOutboundSendDeps } from "../../cli/deps.js"; @@ -13,7 +14,6 @@ import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { normalizePollInput } from "../../polls.js"; import { ErrorCodes, diff --git a/src/gateway/ws-log.ts b/src/gateway/ws-log.ts index 52e07806dd1..356d9a4c4dc 100644 --- a/src/gateway/ws-log.ts +++ b/src/gateway/ws-log.ts @@ -1,9 +1,9 @@ import chalk from "chalk"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { isVerbose } from "../globals.js"; import { shouldLogSubsystemToConsole } from "../logging/console.js"; import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js"; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index cf5b45f8993..5e6ddcf07cf 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,5 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentConfig, resolveAgentWorkspaceDir, @@ -35,10 +39,6 @@ import { import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import { resolveCronSession } from "../cron/isolated-agent/session.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { - hasOutboundReplyContent, - resolveSendableOutboundReplyParts, -} from "../plugin-sdk/reply-payload.js"; import { getQueueSize } from "../process/command-queue.js"; import { CommandLane } from "../process/lanes.js"; import { diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 84e1808e4f0..e1be816c910 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,3 +1,7 @@ +import { + resolveSendableOutboundReplyParts, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkByParagraph, chunkMarkdownTextWithMode, @@ -26,10 +30,6 @@ import { import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; -import { - resolveSendableOutboundReplyParts, - sendMediaWithLeadingCaption, -} from "../../plugin-sdk/reply-payload.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { throwIfAborted } from "./abort.js"; import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index a006612175b..852b9eef9fd 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -1,7 +1,7 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import type { PollInput } from "../../polls.js"; import { normalizePollInput } from "../../polls.js"; import { diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 2d90bb85a09..39da3d2fdcb 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { formatBtwTextForExternalDelivery, @@ -11,7 +12,6 @@ import { hasReplyPayloadContent, type InteractiveReply, } from "../../interactive/payload.js"; -import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; export type NormalizedOutboundPayload = { text: string; diff --git a/src/line/auto-reply-delivery.ts b/src/line/auto-reply-delivery.ts index 91b2633f47c..1e641707ce5 100644 --- a/src/line/auto-reply-delivery.ts +++ b/src/line/auto-reply-delivery.ts @@ -1,6 +1,6 @@ import type { messagingApi } from "@line/bot-sdk"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "../auto-reply/types.js"; -import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import type { FlexContainer } from "./flex-templates.js"; import type { ProcessedLineMessage } from "./markdown-to-line.js"; import type { SendLineReplyChunksParams } from "./reply-chunks.js"; diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 019cffdb2e4..0a5aa81126e 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -9,6 +9,7 @@ import { unlinkSync, } from "node:fs"; import path from "node:path"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "../auto-reply/types.js"; import { normalizeChannelId } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; @@ -24,7 +25,6 @@ import type { import { logVerbose } from "../globals.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { stripMarkdown } from "../line/markdown-to-line.js"; -import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { getSpeechProvider, From e6911f0448001d18d9df1b0a27cc2cc7b8ef6df8 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:05:04 -0500 Subject: [PATCH 34/34] Tests: restore deterministic plugins CLI coverage (#49955) * Tests: restore deterministic plugins CLI coverage * CLI: preserve plugins exit control-flow narrowing * Tests: fix plugins CLI mock typing for tsgo * Tests: fix provider usage mock typing in key normalization --- src/cli/plugins-cli.test.ts | 424 ++++++++++++++++++ src/cli/plugins-cli.ts | 34 +- ...rovider-usage.auth.normalizes-keys.test.ts | 3 +- 3 files changed, 442 insertions(+), 19 deletions(-) create mode 100644 src/cli/plugins-cli.test.ts diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts new file mode 100644 index 00000000000..50bc8633e70 --- /dev/null +++ b/src/cli/plugins-cli.test.ts @@ -0,0 +1,424 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const loadConfig = vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig); +const writeConfigFile = vi.fn<(config: OpenClawConfig) => Promise>(async () => undefined); +const resolveStateDir = vi.fn(() => "/tmp/openclaw-state"); +const installPluginFromMarketplace = vi.fn(); +const listMarketplacePlugins = vi.fn(); +const resolveMarketplaceInstallShortcut = vi.fn(); +const enablePluginInConfig = vi.fn(); +const recordPluginInstall = vi.fn(); +const clearPluginManifestRegistryCache = vi.fn(); +const buildPluginStatusReport = vi.fn(); +const applyExclusiveSlotSelection = vi.fn(); +const uninstallPlugin = vi.fn(); +const updateNpmInstalledPlugins = vi.fn(); +const promptYesNo = vi.fn(); +const installPluginFromNpmSpec = vi.fn(); +const installPluginFromPath = vi.fn(); + +const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = + createCliRuntimeCapture(); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => loadConfig(), + writeConfigFile: (config: OpenClawConfig) => writeConfigFile(config), + }; +}); + +vi.mock("../config/paths.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStateDir: () => resolveStateDir(), + }; +}); + +vi.mock("../plugins/marketplace.js", () => ({ + installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplace(...args), + listMarketplacePlugins: (...args: unknown[]) => listMarketplacePlugins(...args), + resolveMarketplaceInstallShortcut: (...args: unknown[]) => + resolveMarketplaceInstallShortcut(...args), +})); + +vi.mock("../plugins/enable.js", () => ({ + enablePluginInConfig: (...args: unknown[]) => enablePluginInConfig(...args), +})); + +vi.mock("../plugins/installs.js", () => ({ + recordPluginInstall: (...args: unknown[]) => recordPluginInstall(...args), +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(), +})); + +vi.mock("../plugins/status.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args), + }; +}); + +vi.mock("../plugins/slots.js", () => ({ + applyExclusiveSlotSelection: (...args: unknown[]) => applyExclusiveSlotSelection(...args), +})); + +vi.mock("../plugins/uninstall.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...args), + }; +}); + +vi.mock("../plugins/update.js", () => ({ + updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), +})); + +vi.mock("./prompt.js", () => ({ + promptYesNo: (...args: unknown[]) => promptYesNo(...args), +})); + +vi.mock("../plugins/install.js", () => ({ + installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args), + installPluginFromPath: (...args: unknown[]) => installPluginFromPath(...args), +})); + +const { registerPluginsCli } = await import("./plugins-cli.js"); + +describe("plugins cli", () => { + const createProgram = () => { + const program = new Command(); + program.exitOverride(); + registerPluginsCli(program); + return program; + }; + + const runCommand = (argv: string[]) => createProgram().parseAsync(argv, { from: "user" }); + + beforeEach(() => { + resetRuntimeCapture(); + loadConfig.mockReset(); + writeConfigFile.mockReset(); + resolveStateDir.mockReset(); + installPluginFromMarketplace.mockReset(); + listMarketplacePlugins.mockReset(); + resolveMarketplaceInstallShortcut.mockReset(); + enablePluginInConfig.mockReset(); + recordPluginInstall.mockReset(); + clearPluginManifestRegistryCache.mockReset(); + buildPluginStatusReport.mockReset(); + applyExclusiveSlotSelection.mockReset(); + uninstallPlugin.mockReset(); + updateNpmInstalledPlugins.mockReset(); + promptYesNo.mockReset(); + installPluginFromNpmSpec.mockReset(); + installPluginFromPath.mockReset(); + + loadConfig.mockReturnValue({} as OpenClawConfig); + writeConfigFile.mockResolvedValue(undefined); + resolveStateDir.mockReturnValue("/tmp/openclaw-state"); + resolveMarketplaceInstallShortcut.mockResolvedValue(null); + installPluginFromMarketplace.mockResolvedValue({ + ok: false, + error: "marketplace install failed", + }); + enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg })); + recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg); + buildPluginStatusReport.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({ + config, + warnings: [], + })); + uninstallPlugin.mockResolvedValue({ + ok: true, + config: {} as OpenClawConfig, + warnings: [], + actions: { + entry: false, + install: false, + allowlist: false, + loadPath: false, + memorySlot: false, + directory: false, + }, + }); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [], + changed: false, + config: {} as OpenClawConfig, + }); + promptYesNo.mockResolvedValue(true); + installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "npm install disabled in test", + }); + }); + + it("exits when --marketplace is combined with --link", async () => { + await expect( + runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain("`--link` is not supported with `--marketplace`."); + expect(installPluginFromMarketplace).not.toHaveBeenCalled(); + }); + + it("exits when marketplace install fails", async () => { + await expect( + runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]), + ).rejects.toThrow("__exit__:1"); + + expect(installPluginFromMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + marketplace: "local/repo", + plugin: "alpha", + }), + ); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("installs marketplace plugins and persists config", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = { + plugins: { + entries: { + alpha: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + const installedCfg = { + ...enabledCfg, + plugins: { + ...enabledCfg.plugins, + installs: { + alpha: { + source: "marketplace", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromMarketplace.mockResolvedValue({ + ok: true, + pluginId: "alpha", + targetDir: "/tmp/openclaw-state/extensions/alpha", + version: "1.2.3", + marketplaceName: "Claude", + marketplaceSource: "local/repo", + marketplacePlugin: "alpha", + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(installedCfg); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", kind: "provider" }], + diagnostics: [], + }); + applyExclusiveSlotSelection.mockReturnValue({ + config: installedCfg, + warnings: ["slot adjusted"], + }); + + await runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]); + + expect(clearPluginManifestRegistryCache).toHaveBeenCalledTimes(1); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true); + expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true); + }); + + it("shows uninstall dry-run preview without mutating config", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: { + alpha: { + enabled: true, + }, + }, + installs: { + alpha: { + source: "path", + sourcePath: "/tmp/openclaw-state/extensions/alpha", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + + await runCommand(["plugins", "uninstall", "alpha", "--dry-run"]); + + expect(uninstallPlugin).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); + }); + + it("uninstalls with --force and --keep-files without prompting", async () => { + const baseConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + installs: { + alpha: { + source: "path", + sourcePath: "/tmp/openclaw-state/extensions/alpha", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + entries: {}, + installs: {}, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(baseConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + uninstallPlugin.mockResolvedValue({ + ok: true, + config: nextConfig, + warnings: [], + actions: { + entry: true, + install: true, + allowlist: false, + loadPath: false, + memorySlot: false, + directory: false, + }, + }); + + await runCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]); + + expect(promptYesNo).not.toHaveBeenCalled(); + expect(uninstallPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "alpha", + deleteFiles: false, + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + }); + + it("exits when uninstall target is not managed by plugin install records", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: {}, + installs: {}, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + + await expect(runCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records"); + expect(uninstallPlugin).not.toHaveBeenCalled(); + }); + + it("exits when update is called without id and without --all", async () => { + loadConfig.mockReturnValue({ + plugins: { + installs: {}, + }, + } as OpenClawConfig); + + await expect(runCommand(["plugins", "update"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain("Provide a plugin id or use --all."); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + }); + + it("reports no tracked plugins when update --all has empty install records", async () => { + loadConfig.mockReturnValue({ + plugins: { + installs: {}, + }, + } as OpenClawConfig); + + await runCommand(["plugins", "update", "--all"]); + + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toBe("No tracked plugins to update."); + }); + + it("writes updated config when updater reports changes", async () => { + const cfg = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.0.0", + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.1.0", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }], + changed: true, + config: nextConfig, + }); + + await runCommand(["plugins", "update", "alpha"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + pluginIds: ["alpha"], + dryRun: false, + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect(runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins."))).toBe( + true, + ); + }); +}); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index b180b0a38e8..79fca829281 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -288,7 +288,7 @@ async function runPluginInstallCommand(params: { : null; if (shorthand?.ok === false) { defaultRuntime.error(shorthand.error); - process.exit(1); + return defaultRuntime.exit(1); } const raw = shorthand?.ok ? shorthand.plugin : params.raw; @@ -301,11 +301,11 @@ async function runPluginInstallCommand(params: { if (opts.marketplace) { if (opts.link) { defaultRuntime.error("`--link` is not supported with `--marketplace`."); - process.exit(1); + return defaultRuntime.exit(1); } if (opts.pin) { defaultRuntime.error("`--pin` is not supported with `--marketplace`."); - process.exit(1); + return defaultRuntime.exit(1); } const cfg = loadConfig(); @@ -316,7 +316,7 @@ async function runPluginInstallCommand(params: { }); if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } clearPluginManifestRegistryCache(); @@ -343,7 +343,7 @@ async function runPluginInstallCommand(params: { const fileSpec = resolveFileNpmSpecToLocalPath(raw); if (fileSpec && !fileSpec.ok) { defaultRuntime.error(fileSpec.error); - process.exit(1); + return defaultRuntime.exit(1); } const normalized = fileSpec && fileSpec.ok ? fileSpec.path : raw; const resolved = resolveUserPath(normalized); @@ -356,7 +356,7 @@ async function runPluginInstallCommand(params: { const probe = await installPluginFromPath({ path: resolved, dryRun: true }); if (!probe.ok) { defaultRuntime.error(probe.error); - process.exit(1); + return defaultRuntime.exit(1); } let next: OpenClawConfig = enablePluginInConfig( @@ -394,7 +394,7 @@ async function runPluginInstallCommand(params: { }); if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } // Plugin CLI registrars may have warmed the manifest registry cache before install; // force a rescan so config validation sees the freshly installed plugin. @@ -420,7 +420,7 @@ async function runPluginInstallCommand(params: { if (opts.link) { defaultRuntime.error("`--link` requires a local path."); - process.exit(1); + return defaultRuntime.exit(1); } if ( @@ -436,7 +436,7 @@ async function runPluginInstallCommand(params: { ]) ) { defaultRuntime.error(`Path not found: ${resolved}`); - process.exit(1); + return defaultRuntime.exit(1); } const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({ @@ -465,7 +465,7 @@ async function runPluginInstallCommand(params: { }); if (!bundledFallbackPlan) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } await installBundledPluginSource({ @@ -623,7 +623,7 @@ export function registerPluginsCli(program: Command) { if (opts.all) { if (id) { defaultRuntime.error("Pass either a plugin id or --all, not both."); - process.exit(1); + return defaultRuntime.exit(1); } const inspectAll = buildAllPluginInspectReports({ config: cfg, @@ -689,7 +689,7 @@ export function registerPluginsCli(program: Command) { if (!id) { defaultRuntime.error("Provide a plugin id or use --all."); - process.exit(1); + return defaultRuntime.exit(1); } const inspect = buildPluginInspectReport({ @@ -699,7 +699,7 @@ export function registerPluginsCli(program: Command) { }); if (!inspect) { defaultRuntime.error(`Plugin not found: ${id}`); - process.exit(1); + return defaultRuntime.exit(1); } const install = cfg.plugins?.installs?.[inspect.plugin.id]; @@ -905,7 +905,7 @@ export function registerPluginsCli(program: Command) { } else { defaultRuntime.error(`Plugin not found: ${id}`); } - process.exit(1); + return defaultRuntime.exit(1); } const install = cfg.plugins?.installs?.[pluginId]; @@ -972,7 +972,7 @@ export function registerPluginsCli(program: Command) { if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } for (const warning of result.warnings) { defaultRuntime.log(theme.warn(warning)); @@ -1040,7 +1040,7 @@ export function registerPluginsCli(program: Command) { return; } defaultRuntime.error("Provide a plugin id or use --all."); - process.exit(1); + return defaultRuntime.exit(1); } const result = await updateNpmInstalledPlugins({ @@ -1148,7 +1148,7 @@ export function registerPluginsCli(program: Command) { }); if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } if (opts.json) { diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 27d52b418cd..2408a28a9bd 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -7,8 +7,7 @@ import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; const resolveProviderUsageAuthWithPluginMock = vi.fn(async (..._args: unknown[]) => null); vi.mock("../plugins/provider-runtime.js", () => ({ - resolveProviderUsageAuthWithPlugin: (...args: unknown[]) => - resolveProviderUsageAuthWithPluginMock(...args), + resolveProviderUsageAuthWithPlugin: resolveProviderUsageAuthWithPluginMock, })); let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths;