diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index adf5045728a..1d4a0bbb53a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,7 +11,7 @@ Describe the problem and fix in 2–5 bullets: - [ ] Bug fix - [ ] Feature -- [ ] Refactor +- [ ] Refactor required for the fix - [ ] Docs - [ ] Security hardening - [ ] Chore/infra diff --git a/AGENTS.md b/AGENTS.md index e6c5b1a5e92..6df75f20ad2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). - Tests: colocated `*.test.ts`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. -- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. +- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. `extensions/*` remains the internal directory/package path to avoid repo-wide churn from a rename. +- Plugins: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. - Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias). - Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly. - Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). @@ -111,6 +112,7 @@ - Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat. - For targeted/local debugging, keep using the wrapper: `pnpm test -- [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing. - Do not set test workers above 16; tried already. +- Do not switch CI `pnpm test` lanes back to Vitest `vmForks` by default without fresh green evidence on current `main`; keep CI on `forks` unless explicitly re-validated. - If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. - Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. - Full kit + what’s covered: `docs/help/testing.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0f3618bc7..15fe8b08613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,9 +51,11 @@ Docs: https://docs.openclaw.ai - Web tools/Tavily: add Tavily as a bundled web-search provider with dedicated `tavily_search` and `tavily_extract` tools, using canonical plugin-owned config under `plugins.entries.tavily.config.webSearch.*`. (#49200) thanks @lakshyaag-tavily. - Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp. - Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp. +- Plugins/context engines: pass the embedded runner `modelId` into context-engine `assemble()` so plugins can adapt context formatting per model. (#47437) thanks @jscianna. ### Fixes +- CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc. - CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD. - Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev. - Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev. @@ -150,6 +152,8 @@ Docs: https://docs.openclaw.ai - Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant. - Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant. - Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant. +- LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant. +- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek ### Fixes @@ -180,6 +184,8 @@ Docs: https://docs.openclaw.ai - Tests/CLI: reduce command-secret gateway test import pressure while keeping the real protocol payload validator in place, so the isolated lane no longer carries the heavier runtime-web and message-channel graphs. (#50663) Thanks @huntharo. - Gateway/plugins: share plugin interactive callback routing and plugin bind approval state across duplicate module graphs so Telegram Codex picker buttons and plugin bind approvals no longer fall through to normal inbound message routing. (#50722) Thanks @huntharo. - Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp. +- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys. +- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras. ### Breaking @@ -193,6 +199,7 @@ Docs: https://docs.openclaw.ai - Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702) - Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras. - Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClaw’s local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow. +- Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras ## 2026.3.13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8914ffc1f31..1968040e3e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,8 +83,9 @@ 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. -4. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) +3. **Refactor-only PRs** → Don't open a PR. We are not accepting refactor-only changes unless a maintainer explicitly asks for them as part of a concrete fix. +4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix. +5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR @@ -97,7 +98,9 @@ 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 refactor-only PRs unless a maintainer explicitly requested that refactor for an active fix or deliverable. - 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-only PRs that just try to make known `main` CI failures pass. Test changes are acceptable when they are required to validate a new fix or cover new behavior in the same PR. - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Describe what & why diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index f4715f11ea3..de52713cc21 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -8347,8 +8347,8 @@ "channels", "network" ], - "label": "BlueBubbles", - "help": "iMessage via the BlueBubbles mac app + REST API.", + "label": "@openclaw/bluebubbles", + "help": "BlueBubbles channel provider configuration used for Apple messaging bridge integrations. Keep DM policy aligned with your trusted sender model in shared deployments.", "hasChildren": true }, { @@ -9317,8 +9317,8 @@ "channels", "network" ], - "label": "Discord", - "help": "very well supported right now.", + "label": "@openclaw/discord", + "help": "Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed.", "hasChildren": true }, { @@ -15229,8 +15229,7 @@ "channels", "network" ], - "label": "Feishu", - "help": "飞书/Lark enterprise messaging with doc/wiki/drive tools.", + "label": "@openclaw/feishu", "hasChildren": true }, { @@ -17231,8 +17230,7 @@ "channels", "network" ], - "label": "Google Chat", - "help": "Google Workspace Chat app via HTTP webhooks.", + "label": "@openclaw/googlechat", "hasChildren": true }, { @@ -18618,8 +18616,8 @@ "channels", "network" ], - "label": "iMessage", - "help": "this is still a work in progress.", + "label": "@openclaw/imessage", + "help": "iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations.", "hasChildren": true }, { @@ -19976,8 +19974,8 @@ "channels", "network" ], - "label": "IRC", - "help": "classic IRC networks with DM/channel routing and pairing controls.", + "label": "@openclaw/irc", + "help": "IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw.", "hasChildren": true }, { @@ -21499,8 +21497,7 @@ "channels", "network" ], - "label": "LINE", - "help": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", + "label": "@openclaw/line", "hasChildren": true }, { @@ -22068,8 +22065,7 @@ "channels", "network" ], - "label": "Matrix", - "help": "open protocol; install the plugin to enable.", + "label": "@openclaw/matrix", "hasChildren": true }, { @@ -23126,8 +23122,8 @@ "channels", "network" ], - "label": "Mattermost", - "help": "self-hosted Slack-style chat; install the plugin to enable.", + "label": "@openclaw/mattermost", + "help": "Mattermost channel provider configuration for bot credentials, base URL, and message trigger modes. Keep mention/trigger rules strict in high-volume team channels.", "hasChildren": true }, { @@ -24257,8 +24253,8 @@ "channels", "network" ], - "label": "Microsoft Teams", - "help": "Bot Framework; enterprise support.", + "label": "@openclaw/msteams", + "help": "Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers.", "hasChildren": true }, { @@ -25189,8 +25185,7 @@ "channels", "network" ], - "label": "Nextcloud Talk", - "help": "Self-hosted chat via Nextcloud Talk webhook bots.", + "label": "@openclaw/nextcloud-talk", "hasChildren": true }, { @@ -26410,8 +26405,7 @@ "channels", "network" ], - "label": "Nostr", - "help": "Decentralized protocol; encrypted DMs via NIP-04.", + "label": "@openclaw/nostr", "hasChildren": true }, { @@ -26639,8 +26633,8 @@ "channels", "network" ], - "label": "Signal", - "help": "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").", + "label": "@openclaw/signal", + "help": "Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups.", "hasChildren": true }, { @@ -28186,8 +28180,8 @@ "channels", "network" ], - "label": "Slack", - "help": "supported (Socket Mode).", + "label": "@openclaw/slack", + "help": "Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions.", "hasChildren": true }, { @@ -31018,8 +31012,7 @@ "channels", "network" ], - "label": "Synology Chat", - "help": "Connect your Synology NAS Chat to OpenClaw with full agent capabilities.", + "label": "@openclaw/synology-chat", "hasChildren": true }, { @@ -31042,8 +31035,8 @@ "channels", "network" ], - "label": "Telegram", - "help": "simplest way to get started — register a bot with @BotFather and get going.", + "label": "@openclaw/telegram", + "help": "Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics.", "hasChildren": true }, { @@ -35034,8 +35027,7 @@ "channels", "network" ], - "label": "Tlon", - "help": "decentralized messaging on Urbit; install the plugin to enable.", + "label": "@openclaw/tlon", "hasChildren": true }, { @@ -35473,8 +35465,7 @@ "channels", "network" ], - "label": "Twitch", - "help": "Twitch chat integration", + "label": "@openclaw/twitch", "hasChildren": true }, { @@ -35863,8 +35854,8 @@ "channels", "network" ], - "label": "WhatsApp", - "help": "works with your own number; recommend a separate phone + eSIM.", + "label": "@openclaw/whatsapp", + "help": "WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats.", "hasChildren": true }, { @@ -37231,8 +37222,7 @@ "channels", "network" ], - "label": "Zalo", - "help": "Vietnam-focused messaging platform with Bot API.", + "label": "@openclaw/zalo", "hasChildren": true }, { @@ -37812,8 +37802,7 @@ "channels", "network" ], - "label": "Zalo Personal", - "help": "Zalo personal account via QR code login.", + "label": "@openclaw/zalouser", "hasChildren": true }, { diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 819422ac9aa..85f12a83a8c 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -730,7 +730,7 @@ {"recordType":"path","path":"canvasHost.port","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Canvas Host Port","help":"TCP port used by the canvas host HTTP server when canvas hosting is enabled. Choose a non-conflicting port and align firewall/proxy policy accordingly.","hasChildren":false} {"recordType":"path","path":"canvasHost.root","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Canvas Host Root Directory","help":"Filesystem root directory served by canvas host for canvas content and static assets. Use a dedicated directory and avoid broad repo roots for least-privilege file exposure.","hasChildren":false} {"recordType":"path","path":"channels","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Channels","help":"Channel provider configurations plus shared defaults that control access policies, heartbeat visibility, and per-surface behavior. Keep defaults centralized and override per provider only where required.","hasChildren":true} -{"recordType":"path","path":"channels.bluebubbles","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"BlueBubbles","help":"iMessage via the BlueBubbles mac app + REST API.","hasChildren":true} +{"recordType":"path","path":"channels.bluebubbles","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/bluebubbles","help":"BlueBubbles channel provider configuration used for Apple messaging bridge integrations. Keep DM policy aligned with your trusted sender model in shared deployments.","hasChildren":true} {"recordType":"path","path":"channels.bluebubbles.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.bluebubbles.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.bluebubbles.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -818,7 +818,7 @@ {"recordType":"path","path":"channels.bluebubbles.serverUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.discord","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Discord","help":"very well supported right now.","hasChildren":true} +{"recordType":"path","path":"channels.discord","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed.","hasChildren":true} {"recordType":"path","path":"channels.discord.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.discord.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.discord.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1352,7 +1352,7 @@ {"recordType":"path","path":"channels.discord.voice.tts.provider","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.summaryModel","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","hasChildren":true} +{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/feishu","hasChildren":true} {"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -1532,7 +1532,7 @@ {"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app via HTTP webhooks.","hasChildren":true} +{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/googlechat","hasChildren":true} {"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.googlechat.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -1660,7 +1660,7 @@ {"recordType":"path","path":"channels.googlechat.typingIndicator","kind":"channel","type":"string","required":false,"enumValues":["none","message","reaction"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.imessage","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"iMessage","help":"this is still a work in progress.","hasChildren":true} +{"recordType":"path","path":"channels.imessage","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/imessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations.","hasChildren":true} {"recordType":"path","path":"channels.imessage.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.imessage.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.imessage.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -1788,7 +1788,7 @@ {"recordType":"path","path":"channels.imessage.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.imessage.service","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.imessage.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.irc","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"IRC","help":"classic IRC networks with DM/channel routing and pairing controls.","hasChildren":true} +{"recordType":"path","path":"channels.irc","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/irc","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw.","hasChildren":true} {"recordType":"path","path":"channels.irc.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.irc.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.irc.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -1928,7 +1928,7 @@ {"recordType":"path","path":"channels.irc.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.irc.tls","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.irc.username","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.line","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"LINE","help":"LINE Messaging API bot for Japan/Taiwan/Thailand markets.","hasChildren":true} +{"recordType":"path","path":"channels.line","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/line","hasChildren":true} {"recordType":"path","path":"channels.line.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.line.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.line.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -1980,7 +1980,7 @@ {"recordType":"path","path":"channels.line.secretFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.line.tokenFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.line.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.matrix","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Matrix","help":"open protocol; install the plugin to enable.","hasChildren":true} +{"recordType":"path","path":"channels.matrix","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/matrix","hasChildren":true} {"recordType":"path","path":"channels.matrix.accessToken","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.matrix.accounts.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2077,7 +2077,7 @@ {"recordType":"path","path":"channels.matrix.threadBindings.spawnSubagentSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.threadReplies","kind":"channel","type":"string","required":false,"enumValues":["off","inbound","always"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.userId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.mattermost","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost","help":"self-hosted Slack-style chat; install the plugin to enable.","hasChildren":true} +{"recordType":"path","path":"channels.mattermost","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/mattermost","help":"Mattermost channel provider configuration for bot credentials, base URL, and message trigger modes. Keep mention/trigger rules strict in high-volume team channels.","hasChildren":true} {"recordType":"path","path":"channels.mattermost.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.mattermost.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.mattermost.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -2177,7 +2177,7 @@ {"recordType":"path","path":"channels.mattermost.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Require Mention","help":"Require @mention in channels before responding (default: true).","hasChildren":false} {"recordType":"path","path":"channels.mattermost.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.msteams","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Microsoft Teams","help":"Bot Framework; enterprise support.","hasChildren":true} +{"recordType":"path","path":"channels.msteams","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/msteams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers.","hasChildren":true} {"recordType":"path","path":"channels.msteams.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.msteams.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.msteams.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2265,7 +2265,7 @@ {"recordType":"path","path":"channels.msteams.webhook","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.msteams.webhook.path","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.msteams.webhook.port","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.nextcloud-talk","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Nextcloud Talk","help":"Self-hosted chat via Nextcloud Talk webhook bots.","hasChildren":true} +{"recordType":"path","path":"channels.nextcloud-talk","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/nextcloud-talk","hasChildren":true} {"recordType":"path","path":"channels.nextcloud-talk.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nextcloud-talk.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nextcloud-talk.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -2381,7 +2381,7 @@ {"recordType":"path","path":"channels.nextcloud-talk.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nextcloud-talk.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nextcloud-talk.webhookPublicUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.nostr","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Nostr","help":"Decentralized protocol; encrypted DMs via NIP-04.","hasChildren":true} +{"recordType":"path","path":"channels.nostr","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/nostr","hasChildren":true} {"recordType":"path","path":"channels.nostr.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nostr.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nostr.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2402,7 +2402,7 @@ {"recordType":"path","path":"channels.nostr.profile.website","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nostr.relays","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nostr.relays.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.signal","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Signal","help":"signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").","hasChildren":true} +{"recordType":"path","path":"channels.signal","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups.","hasChildren":true} {"recordType":"path","path":"channels.signal.account","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","hasChildren":false} {"recordType":"path","path":"channels.signal.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.signal.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -2546,7 +2546,7 @@ {"recordType":"path","path":"channels.signal.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.signal.startupTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.signal.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.slack","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack","help":"supported (Socket Mode).","hasChildren":true} +{"recordType":"path","path":"channels.slack","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions.","hasChildren":true} {"recordType":"path","path":"channels.slack.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.slack.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.slack.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2798,9 +2798,9 @@ {"recordType":"path","path":"channels.slack.userToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.userTokenReadOnly","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":["auth","channels","network","security"],"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.","hasChildren":false} {"recordType":"path","path":"channels.slack.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/slack/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.synology-chat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Synology Chat","help":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","hasChildren":true} +{"recordType":"path","path":"channels.synology-chat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/synology-chat","hasChildren":true} {"recordType":"path","path":"channels.synology-chat.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.telegram","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram","help":"simplest way to get started — register a bot with @BotFather and get going.","hasChildren":true} +{"recordType":"path","path":"channels.telegram","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics.","hasChildren":true} {"recordType":"path","path":"channels.telegram.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3158,7 +3158,7 @@ {"recordType":"path","path":"channels.telegram.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.tlon","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Tlon","help":"decentralized messaging on Urbit; install the plugin to enable.","hasChildren":true} +{"recordType":"path","path":"channels.tlon","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/tlon","hasChildren":true} {"recordType":"path","path":"channels.tlon.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.tlon.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.tlon.accounts.*.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3201,7 +3201,7 @@ {"recordType":"path","path":"channels.tlon.ship","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.tlon.showModelSignature","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.tlon.url","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.twitch","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Twitch","help":"Twitch chat integration","hasChildren":true} +{"recordType":"path","path":"channels.twitch","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/twitch","hasChildren":true} {"recordType":"path","path":"channels.twitch.accessToken","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.twitch.accounts","kind":"channel","type":"object","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.twitch.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -3237,7 +3237,7 @@ {"recordType":"path","path":"channels.twitch.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.twitch.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.twitch.username","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.whatsapp","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"WhatsApp","help":"works with your own number; recommend a separate phone + eSIM.","hasChildren":true} +{"recordType":"path","path":"channels.whatsapp","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/whatsapp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats.","hasChildren":true} {"recordType":"path","path":"channels.whatsapp.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.whatsapp.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.whatsapp.accounts.*.ackReaction","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -3365,7 +3365,7 @@ {"recordType":"path","path":"channels.whatsapp.selfChatMode","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number).","hasChildren":false} {"recordType":"path","path":"channels.whatsapp.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.whatsapp.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.zalo","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Zalo","help":"Vietnam-focused messaging platform with Bot API.","hasChildren":true} +{"recordType":"path","path":"channels.zalo","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/zalo","hasChildren":true} {"recordType":"path","path":"channels.zalo.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalo.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalo.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -3417,7 +3417,7 @@ {"recordType":"path","path":"channels.zalo.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalo.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.zalo.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.zalouser","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Zalo Personal","help":"Zalo personal account via QR code login.","hasChildren":true} +{"recordType":"path","path":"channels.zalouser","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/zalouser","hasChildren":true} {"recordType":"path","path":"channels.zalouser.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.zalouser.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} diff --git a/docs/channels/line.md b/docs/channels/line.md index a965dc6e991..079025e10ac 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -51,6 +51,7 @@ If you need a custom path, set `channels.line.webhookPath` or Security note: - LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification. +- OpenClaw processes webhook events from the verified raw request bytes. Upstream middleware-transformed `req.body` values are ignored for signature-integrity safety. ## Configure diff --git a/docs/docs.json b/docs/docs.json index c9df5c4f0cc..be9fa476ea7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -64,6 +64,18 @@ "source": "/platforms/raspberry-pi", "destination": "/install/raspberry-pi" }, + { + "source": "/plugins/building-extensions", + "destination": "/plugins/building-plugins" + }, + { + "source": "/plugins/agent-tools", + "destination": "/plugins/building-plugins#registering-agent-tools" + }, + { + "source": "/tools/capability-cookbook", + "destination": "/plugins/architecture" + }, { "source": "/brave-search", "destination": "/tools/brave-search" @@ -948,6 +960,7 @@ "channels/telegram", "channels/tlon", "channels/twitch", + "plugins/voice-call", "channels/whatsapp", "channels/zalo", "channels/zalouser" @@ -1014,78 +1027,35 @@ ] }, { - "tab": "Tools", + "tab": "Tools & Plugins", "groups": [ { "group": "Overview", "pages": ["tools/index"] }, { - "group": "Built-in tools", + "group": "Plugins", "pages": [ - "tools/apply-patch", - "tools/brave-search", - "tools/btw", - "tools/diffs", - "tools/elevated", - "tools/exec", - "tools/exec-approvals", - "tools/firecrawl", - "tools/tavily", - "tools/llm-task", - "tools/lobster", - "tools/loop-detection", - "tools/pdf", - "tools/perplexity-search", - "tools/reactions", - "tools/thinking", - "tools/web" - ] - }, - { - "group": "Browser", - "pages": [ - "tools/browser", - "tools/browser-login", - "tools/browser-linux-troubleshooting", - "tools/browser-wsl2-windows-remote-cdp-troubleshooting" - ] - }, - { - "group": "Agent coordination", - "pages": [ - "tools/agent-send", - "tools/subagents", - "tools/acp-agents", - "tools/multi-agent-sandbox-tools" + "tools/plugin", + "plugins/building-plugins", + "plugins/community", + "plugins/bundles", + "plugins/manifest", + "plugins/sdk-migration", + "plugins/architecture" ] }, { "group": "Skills", "pages": [ - "tools/creating-skills", - "tools/slash-commands", "tools/skills", + "tools/creating-skills", "tools/skills-config", + "tools/slash-commands", "tools/clawhub", - "tools/plugin", "prose" ] }, - { - "group": "Extensions", - "pages": [ - "plugins/building-extensions", - "plugins/architecture", - "plugins/community", - "plugins/bundles", - "plugins/voice-call", - "plugins/zalouser", - "plugins/manifest", - "plugins/agent-tools", - "tools/capability-cookbook" - ] - }, { "group": "Automation", "pages": [ @@ -1101,18 +1071,48 @@ ] }, { - "group": "Media and devices", + "group": "Tools", "pages": [ - "nodes/index", - "nodes/troubleshooting", - "nodes/media-understanding", - "nodes/images", - "nodes/audio", - "nodes/camera", - "nodes/talk", - "nodes/voicewake", - "nodes/location-command", - "tools/tts" + "tools/apply-patch", + { + "group": "Browser", + "pages": [ + "tools/browser", + "tools/browser-login", + "tools/browser-linux-troubleshooting", + "tools/browser-wsl2-windows-remote-cdp-troubleshooting" + ] + }, + "tools/btw", + "tools/diffs", + "tools/elevated", + "tools/exec", + "tools/exec-approvals", + "tools/llm-task", + "tools/lobster", + "tools/loop-detection", + "tools/pdf", + "tools/reactions", + "tools/thinking", + { + "group": "Web and search", + "pages": [ + "tools/web", + "tools/brave-search", + "tools/firecrawl", + "tools/perplexity-search", + "tools/tavily" + ] + } + ] + }, + { + "group": "Agent coordination", + "pages": [ + "tools/agent-send", + "tools/subagents", + "tools/acp-agents", + "tools/multi-agent-sandbox-tools" ] } ] @@ -1282,6 +1282,21 @@ "security/CONTRIBUTING-THREAT-MODEL" ] }, + { + "group": "Nodes and devices", + "pages": [ + "nodes/index", + "nodes/troubleshooting", + "nodes/media-understanding", + "nodes/images", + "nodes/audio", + "nodes/camera", + "nodes/talk", + "nodes/voicewake", + "nodes/location-command", + "tools/tts" + ] + }, { "group": "Web interfaces", "pages": ["web/index", "web/control-ui", "web/dashboard", "web/webchat", "web/tui"] diff --git a/docs/plugins/agent-tools.md b/docs/plugins/agent-tools.md index 8740fd51fa4..930bdfbe629 100644 --- a/docs/plugins/agent-tools.md +++ b/docs/plugins/agent-tools.md @@ -1,99 +1,10 @@ --- -summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)" +summary: "Redirects to Building Plugins (registering tools section)" read_when: - - You want to add a new agent tool in a plugin - - You need to make a tool opt-in via allowlists -title: "Plugin Agent Tools" + - Legacy link to agent-tools +title: "Registering Tools" --- -# Plugin agent tools +# Registering Tools in Plugins -OpenClaw plugins can register **agent tools** (JSON‑schema functions) that are exposed -to the LLM during agent runs. Tools can be **required** (always available) or -**optional** (opt‑in). - -Agent tools are configured under `tools` in the main config, or per‑agent under -`agents.list[].tools`. The allowlist/denylist policy controls which tools the agent -can call. - -## Basic tool - -```ts -import { Type } from "@sinclair/typebox"; - -export default function (api) { - api.registerTool({ - name: "my_tool", - description: "Do a thing", - parameters: Type.Object({ - input: Type.String(), - }), - async execute(_id, params) { - return { content: [{ type: "text", text: params.input }] }; - }, - }); -} -``` - -## Optional tool (opt-in) - -Optional tools are **never** auto‑enabled. Users must add them to an agent -allowlist. - -```ts -export default function (api) { - api.registerTool( - { - name: "workflow_tool", - description: "Run a local workflow", - parameters: { - type: "object", - properties: { - pipeline: { type: "string" }, - }, - required: ["pipeline"], - }, - async execute(_id, params) { - return { content: [{ type: "text", text: params.pipeline }] }; - }, - }, - { optional: true }, - ); -} -``` - -Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`): - -```json5 -{ - agents: { - list: [ - { - id: "main", - tools: { - allow: [ - "workflow_tool", // specific tool name - "workflow", // plugin id (enables all tools from that plugin) - "group:plugins", // all plugin tools - ], - }, - }, - ], - }, -} -``` - -Other config knobs that affect tool availability: - -- Allowlists that only name plugin tools are treated as plugin opt-ins; core tools remain - enabled unless you also include core tools or groups in the allowlist. -- `tools.profile` / `agents.list[].tools.profile` (base allowlist) -- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny) -- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed) - -## Rules + tips - -- Tool names must **not** clash with core tool names; conflicting tools are skipped. -- Plugin ids used in allowlists must not clash with core tool names. -- Prefer `optional: true` for tools that trigger side effects or require extra - binaries/credentials. +This page has moved. See [Building Plugins: Registering agent tools](/plugins/building-plugins#registering-agent-tools). diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 19783028721..49aa6344ca9 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -1,17 +1,23 @@ --- -summary: "Plugin architecture internals: capability model, ownership, contracts, load pipeline, runtime helpers" +summary: "Plugin internals: capability model, ownership, contracts, load pipeline, and runtime helpers" read_when: - Building or debugging native OpenClaw plugins - Understanding the plugin capability model or ownership boundaries - Working on the plugin load pipeline or registry - Implementing provider runtime hooks or channel plugins -title: "Plugin Architecture" +title: "Plugin Internals" +sidebarTitle: "Internals" --- -# Plugin Architecture +# Plugin Internals -This page covers the internal architecture of the OpenClaw plugin system. For -user-facing setup, discovery, and configuration, see [Plugins](/tools/plugin). + + This page is for **plugin developers and contributors**. If you just want to + install and use plugins, see [Plugins](/tools/plugin). If you want to build + a plugin, see [Building Plugins](/plugins/building-plugins). + + +This page covers the internal architecture of the OpenClaw plugin system. ## Public capability model @@ -927,25 +933,31 @@ authoring plugins: - `openclaw/plugin-sdk/core` for the generic shared plugin-facing contract. - Stable channel primitives such as `openclaw/plugin-sdk/channel-setup`, `openclaw/plugin-sdk/channel-pairing`, + `openclaw/plugin-sdk/channel-contract`, + `openclaw/plugin-sdk/channel-feedback`, + `openclaw/plugin-sdk/channel-inbound`, + `openclaw/plugin-sdk/channel-lifecycle`, `openclaw/plugin-sdk/channel-reply-pipeline`, + `openclaw/plugin-sdk/command-auth`, `openclaw/plugin-sdk/secret-input`, and `openclaw/plugin-sdk/webhook-ingress` for shared setup/auth/reply/webhook - wiring. + wiring. `channel-inbound` is the shared home for debounce, mention matching, + envelope formatting, and inbound envelope context helpers. - Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`, + `openclaw/plugin-sdk/allow-from`, `openclaw/plugin-sdk/channel-config-schema`, `openclaw/plugin-sdk/channel-policy`, - `openclaw/plugin-sdk/channel-runtime`, `openclaw/plugin-sdk/config-runtime`, + `openclaw/plugin-sdk/infra-runtime`, `openclaw/plugin-sdk/agent-runtime`, `openclaw/plugin-sdk/lazy-runtime`, `openclaw/plugin-sdk/reply-history`, `openclaw/plugin-sdk/routing`, + `openclaw/plugin-sdk/status-helpers`, `openclaw/plugin-sdk/runtime-store`, and `openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers. -- Narrow channel-core subpaths such as `openclaw/plugin-sdk/discord-core`, - `openclaw/plugin-sdk/telegram-core`, and `openclaw/plugin-sdk/whatsapp-core` - for channel-specific primitives that should stay smaller than the full - channel helper barrels. +- `openclaw/plugin-sdk/channel-runtime` remains only as a compatibility shim. + New code should import the narrower primitives instead. - Bundled extension internals remain private. External plugins should use only `openclaw/plugin-sdk/*` subpaths. OpenClaw core/test code may use the repo public entry points under `extensions//index.js`, `api.js`, `runtime-api.js`, @@ -956,24 +968,26 @@ authoring plugins: `extensions//runtime-api.js` is the runtime-only barrel, `extensions//index.js` is the bundled plugin entry, and `extensions//setup-entry.js` is the setup plugin entry. -- `openclaw/plugin-sdk/telegram` for Telegram channel plugin types and shared channel-facing helpers. Built-in Telegram implementation internals stay private to the bundled extension. -- `openclaw/plugin-sdk/discord` for Discord channel plugin types and shared channel-facing helpers. Built-in Discord implementation internals stay private to the bundled extension. -- `openclaw/plugin-sdk/slack` for Slack channel plugin types and shared channel-facing helpers. Built-in Slack implementation internals stay private to the bundled extension. -- `openclaw/plugin-sdk/imessage` for iMessage channel plugin types and shared channel-facing helpers. Built-in iMessage implementation internals stay private to the bundled extension. -- `openclaw/plugin-sdk/whatsapp` for WhatsApp channel plugin types and shared channel-facing helpers. Built-in WhatsApp implementation internals stay private to the bundled extension. -- `openclaw/plugin-sdk/bluebubbles` remains public because it carries a small - focused helper surface that is shared intentionally. +- No bundled channel-branded public subpaths remain. Channel-specific helper and + runtime seams live under `extensions//api.js` and `extensions//runtime-api.js`; + the public SDK contract is the generic shared primitives instead. Compatibility note: - Avoid the root `openclaw/plugin-sdk` barrel for new code. - Prefer the narrow stable primitives first. The newer setup/pairing/reply/ - secret-input/webhook subpaths are the intended contract for new bundled and - external plugin work. + feedback/contract/inbound/threading/command/secret-input/webhook/infra/ + allowlist/status/message-tool subpaths are the intended contract for new + bundled and external plugin work. + Target parsing/matching belongs on `openclaw/plugin-sdk/channel-targets`. + Message action gates and reaction message-id helpers belong on + `openclaw/plugin-sdk/channel-actions`. - Bundled extension-specific helper barrels are not stable by default. If a helper is only needed by a bundled extension, keep it behind the extension's local `api.js` or `runtime-api.js` seam instead of promoting it into `openclaw/plugin-sdk/`. +- Channel-branded bundled bars stay private unless they are explicitly added + back to the public contract. - Capability-specific subpaths such as `image-generation`, `media-understanding`, and `speech` exist because bundled/native plugins use them today. Their presence does not by itself mean every exported helper is a @@ -985,7 +999,7 @@ Plugins should own channel-specific `describeMessageTool(...)` schema contributions. Keep provider-specific fields in the plugin, not in shared core. For shared portable schema fragments, reuse the generic helpers exported through -`openclaw/plugin-sdk/channel-runtime`: +`openclaw/plugin-sdk/channel-actions`: - `createMessageToolButtonsSchema()` for button-grid style payloads - `createMessageToolCardSchema()` for structured card payloads diff --git a/docs/plugins/building-extensions.md b/docs/plugins/building-extensions.md index 259accaa3f0..f0db0f3173f 100644 --- a/docs/plugins/building-extensions.md +++ b/docs/plugins/building-extensions.md @@ -1,205 +1,10 @@ --- -title: "Building Extensions" -summary: "Step-by-step guide for creating OpenClaw channel and provider extensions" +title: "Building Plugins" +summary: "Redirects to the current Building Plugins guide" read_when: - - You want to create a new OpenClaw plugin or extension - - You need to understand the plugin SDK import patterns - - You are adding a new channel or provider to OpenClaw + - Legacy link to building-extensions --- -# Building Extensions +# Building Plugins -This guide walks through creating an OpenClaw extension from scratch. Extensions -can add channels, model providers, tools, or other capabilities. - -## Prerequisites - -- OpenClaw repository cloned and dependencies installed (`pnpm install`) -- Familiarity with TypeScript (ESM) - -## Extension structure - -Every extension lives under `extensions//` and follows this layout: - -``` -extensions/my-channel/ -├── package.json # npm metadata + openclaw config -├── index.ts # Entry point (defineChannelPluginEntry) -├── setup-entry.ts # Setup wizard (optional) -├── api.ts # Public contract barrel (optional) -├── runtime-api.ts # Internal runtime barrel (optional) -└── src/ - ├── channel.ts # Channel adapter implementation - ├── runtime.ts # Runtime wiring - └── *.test.ts # Colocated tests -``` - -## Step 1: Create the package - -Create `extensions/my-channel/package.json`: - -```json -{ - "name": "@openclaw/my-channel", - "version": "2026.1.1", - "description": "OpenClaw My Channel plugin", - "type": "module", - "dependencies": {}, - "openclaw": { - "extensions": ["./index.ts"], - "setupEntry": "./setup-entry.ts", - "channel": { - "id": "my-channel", - "label": "My Channel", - "selectionLabel": "My Channel (plugin)", - "docsPath": "/channels/my-channel", - "docsLabel": "my-channel", - "blurb": "Short description of the channel.", - "order": 80 - }, - "install": { - "npmSpec": "@openclaw/my-channel", - "localPath": "extensions/my-channel" - } - } -} -``` - -The `openclaw` field tells the plugin system what your extension provides. -For provider plugins, use `providers` instead of `channel`. - -## Step 2: Define the entry point - -Create `extensions/my-channel/index.ts`: - -```typescript -import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; - -export default defineChannelPluginEntry({ - id: "my-channel", - name: "My Channel", - description: "Connects OpenClaw to My Channel", - plugin: { - // Channel adapter implementation - }, -}); -``` - -For provider plugins, use `definePluginEntry` instead. - -## Step 3: Import from focused subpaths - -The plugin SDK exposes many focused subpaths. Always import from specific -subpaths rather than the monolithic root: - -```typescript -// Correct: focused subpaths -import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup"; -import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; - -// Wrong: monolithic root (lint will reject this) -import { ... } from "openclaw/plugin-sdk"; -``` - -Common subpaths: - -| Subpath | Purpose | -| ----------------------------------- | ------------------------------------ | -| `plugin-sdk/core` | Plugin entry definitions, base types | -| `plugin-sdk/channel-setup` | Optional setup adapters/wizards | -| `plugin-sdk/channel-pairing` | DM pairing primitives | -| `plugin-sdk/channel-reply-pipeline` | Prefix + typing reply wiring | -| `plugin-sdk/channel-config-schema` | Config schema builders | -| `plugin-sdk/channel-policy` | Group/DM policy helpers | -| `plugin-sdk/secret-input` | Secret input parsing/helpers | -| `plugin-sdk/webhook-ingress` | Webhook request/target helpers | -| `plugin-sdk/runtime-store` | Persistent plugin storage | -| `plugin-sdk/allow-from` | Allowlist resolution | -| `plugin-sdk/reply-payload` | Message reply types | -| `plugin-sdk/provider-onboard` | Provider onboarding config patches | -| `plugin-sdk/testing` | Test utilities | - -Use the narrowest primitive that matches the job. Reach for `channel-runtime` -or other larger helper barrels only when a dedicated subpath does not exist yet. - -## Step 4: Use local barrels for internal imports - -Within your extension, create barrel files for internal code sharing instead -of importing through the plugin SDK: - -```typescript -// api.ts — public contract for this extension -export { MyChannelConfig } from "./src/config.js"; -export { MyChannelRuntime } from "./src/runtime.js"; - -// runtime-api.ts — internal-only exports (not for production consumers) -export { internalHelper } from "./src/helpers.js"; -``` - -**Self-import guardrail**: never import your own extension back through its -published SDK contract path from production files. Route internal imports -through `./api.ts` or `./runtime-api.ts` instead. The SDK contract is for -external consumers only. - -## Step 5: Add a plugin manifest - -Create `openclaw.plugin.json` in your extension root: - -```json -{ - "id": "my-channel", - "kind": "channel", - "channels": ["my-channel"], - "name": "My Channel Plugin", - "description": "Connects OpenClaw to My Channel" -} -``` - -See [Plugin manifest](/plugins/manifest) for the full schema. - -## Step 6: Test with contract tests - -OpenClaw runs contract tests against all registered plugins. After adding your -extension, run: - -```bash -pnpm test:contracts:channels # channel plugins -pnpm test:contracts:plugins # provider plugins -``` - -Contract tests verify your plugin conforms to the expected interface (setup -wizard, session binding, message handling, group policy, etc.). - -For unit tests, import test helpers from the public testing surface: - -```typescript -import { createTestRuntime } from "openclaw/plugin-sdk/testing"; -``` - -## Lint enforcement - -Three scripts enforce SDK boundaries: - -1. **No monolithic root imports** — `openclaw/plugin-sdk` root is rejected -2. **No direct src/ imports** — extensions cannot import `../../src/` directly -3. **No self-imports** — extensions cannot import their own `plugin-sdk/` subpath - -Run `pnpm check` to verify all boundaries before committing. - -## Checklist - -Before submitting your extension: - -- [ ] `package.json` has correct `openclaw` metadata -- [ ] Entry point uses `defineChannelPluginEntry` or `definePluginEntry` -- [ ] All imports use focused `plugin-sdk/` paths -- [ ] Internal imports use local barrels, not SDK self-imports -- [ ] `openclaw.plugin.json` manifest is present and valid -- [ ] Contract tests pass (`pnpm test:contracts`) -- [ ] Unit tests colocated as `*.test.ts` -- [ ] `pnpm check` passes (lint + format) -- [ ] Doc page created under `docs/channels/` or `docs/plugins/` +This page has moved to [Building Plugins](/plugins/building-plugins). diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md new file mode 100644 index 00000000000..121b673f5c6 --- /dev/null +++ b/docs/plugins/building-plugins.md @@ -0,0 +1,369 @@ +--- +title: "Building Plugins" +sidebarTitle: "Building Plugins" +summary: "Step-by-step guide for creating OpenClaw plugins with any combination of capabilities" +read_when: + - You want to create a new OpenClaw plugin + - You need to understand the plugin SDK import patterns + - You are adding a new channel, provider, tool, or other capability to OpenClaw +--- + +# Building Plugins + +Plugins extend OpenClaw with new capabilities: channels, model providers, speech, +image generation, web search, agent tools, or any combination. A single plugin +can register multiple capabilities. + +OpenClaw encourages **external plugin development**. You do not need to add your +plugin to the OpenClaw repository. Publish your plugin on npm, and users install +it with `openclaw plugins install `. OpenClaw also maintains a set of +core plugins in-repo, but the plugin system is designed for independent ownership +and distribution. + +## Prerequisites + +- Node >= 22 and a package manager (npm or pnpm) +- Familiarity with TypeScript (ESM) +- For in-repo plugins: OpenClaw repository cloned and `pnpm install` done + +## Plugin capabilities + +A plugin can register one or more capabilities. The capability you register +determines what your plugin provides to OpenClaw: + +| Capability | Registration method | What it adds | +| ------------------- | --------------------------------------------- | ------------------------------ | +| Text inference | `api.registerProvider(...)` | Model provider (LLM) | +| Channel / messaging | `api.registerChannel(...)` | Chat channel (e.g. Slack, IRC) | +| Speech | `api.registerSpeechProvider(...)` | Text-to-speech / STT | +| Media understanding | `api.registerMediaUnderstandingProvider(...)` | Image/audio/video analysis | +| Image generation | `api.registerImageGenerationProvider(...)` | Image generation | +| Web search | `api.registerWebSearchProvider(...)` | Web search provider | +| Agent tools | `api.registerTool(...)` | Tools callable by the agent | + +A plugin that registers zero capabilities but provides hooks or services is a +**hook-only** plugin. That pattern is still supported. + +## Plugin structure + +Plugins follow this layout (whether in-repo or standalone): + +``` +my-plugin/ +├── package.json # npm metadata + openclaw config +├── openclaw.plugin.json # Plugin manifest +├── index.ts # Entry point +├── setup-entry.ts # Setup wizard (optional) +├── api.ts # Public exports (optional) +├── runtime-api.ts # Internal exports (optional) +└── src/ + ├── provider.ts # Capability implementation + ├── runtime.ts # Runtime wiring + └── *.test.ts # Colocated tests +``` + +## Create a plugin + + + + Create `package.json` with the `openclaw` metadata block. The structure + depends on what capabilities your plugin provides. + + **Channel plugin example:** + + ```json + { + "name": "@myorg/openclaw-my-channel", + "version": "1.0.0", + "type": "module", + "openclaw": { + "extensions": ["./index.ts"], + "channel": { + "id": "my-channel", + "label": "My Channel", + "blurb": "Short description of the channel." + } + } + } + ``` + + **Provider plugin example:** + + ```json + { + "name": "@myorg/openclaw-my-provider", + "version": "1.0.0", + "type": "module", + "openclaw": { + "extensions": ["./index.ts"], + "providers": ["my-provider"] + } + } + ``` + + The `openclaw` field tells the plugin system what your plugin provides. + A plugin can declare both `channel` and `providers` if it provides multiple + capabilities. + + + + + The entry point registers your capabilities with the plugin API. + + **Channel plugin:** + + ```typescript + import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; + + export default defineChannelPluginEntry({ + id: "my-channel", + name: "My Channel", + description: "Connects OpenClaw to My Channel", + plugin: { + // Channel adapter implementation + }, + }); + ``` + + **Provider plugin:** + + ```typescript + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + + export default definePluginEntry({ + id: "my-provider", + name: "My Provider", + register(api) { + api.registerProvider({ + // Provider implementation + }); + }, + }); + ``` + + **Multi-capability plugin** (provider + tool): + + ```typescript + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + + export default definePluginEntry({ + id: "my-plugin", + name: "My Plugin", + register(api) { + api.registerProvider({ /* ... */ }); + api.registerTool({ /* ... */ }); + api.registerImageGenerationProvider({ /* ... */ }); + }, + }); + ``` + + Use `defineChannelPluginEntry` for channel plugins and `definePluginEntry` + for everything else. A single plugin can register as many capabilities as needed. + + + + + Always import from specific `openclaw/plugin-sdk/\` paths. The old + monolithic import is deprecated (see [SDK Migration](/plugins/sdk-migration)). + + If older plugin code still imports `openclaw/extension-api`, treat that as a + temporary compatibility bridge only. New code should use injected runtime + helpers such as `api.runtime.agent.*` instead of importing host-side agent + helpers directly. + + ```typescript + // Correct: focused subpaths + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; + import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; + + // Wrong: monolithic root (lint will reject this) + import { ... } from "openclaw/plugin-sdk"; + + // Deprecated: legacy host bridge + import { runEmbeddedPiAgent } from "openclaw/extension-api"; + ``` + + + | Subpath | Purpose | + | --- | --- | + | `plugin-sdk/core` | Plugin entry definitions and base types | + | `plugin-sdk/channel-setup` | Setup wizard adapters | + | `plugin-sdk/channel-pairing` | DM pairing primitives | + | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | + | `plugin-sdk/channel-config-schema` | Config schema builders | + | `plugin-sdk/channel-policy` | Group/DM policy helpers | + | `plugin-sdk/secret-input` | Secret input parsing/helpers | + | `plugin-sdk/webhook-ingress` | Webhook request/target helpers | + | `plugin-sdk/runtime-store` | Persistent plugin storage | + | `plugin-sdk/allow-from` | Allowlist resolution | + | `plugin-sdk/reply-payload` | Message reply types | + | `plugin-sdk/provider-oauth` | OAuth login + PKCE helpers | + | `plugin-sdk/provider-onboard` | Provider onboarding config patches | + | `plugin-sdk/testing` | Test utilities | + + + Use the narrowest subpath that matches the job. + + + + + Within your plugin, create local module files for internal code sharing + instead of re-importing through the plugin SDK: + + ```typescript + // api.ts — public exports for this plugin + export { MyConfig } from "./src/config.js"; + export { MyRuntime } from "./src/runtime.js"; + + // runtime-api.ts — internal-only exports + export { internalHelper } from "./src/helpers.js"; + ``` + + + Never import your own plugin back through its published SDK path from + production files. Route internal imports through local files like `./api.ts` + or `./runtime-api.ts`. The SDK path is for external consumers only. + + + + + + Create `openclaw.plugin.json` in your plugin root: + + ```json + { + "id": "my-plugin", + "kind": "provider", + "name": "My Plugin", + "description": "Adds My Provider to OpenClaw" + } + ``` + + For channel plugins, set `"kind": "channel"` and add `"channels": ["my-channel"]`. + + See [Plugin Manifest](/plugins/manifest) for the full schema. + + + + + **External plugins:** run your own test suite against the plugin SDK contracts. + + **In-repo plugins:** OpenClaw runs contract tests against all registered plugins: + + ```bash + pnpm test:contracts:channels # channel plugins + pnpm test:contracts:plugins # provider plugins + ``` + + For unit tests, import test helpers from the testing surface: + + ```typescript + import { createTestRuntime } from "openclaw/plugin-sdk/testing"; + ``` + + + + + **External plugins:** publish to npm, then install: + + ```bash + npm publish + openclaw plugins install @myorg/openclaw-my-plugin + ``` + + **In-repo plugins:** place the plugin under `extensions/` and it is + automatically discovered during build. + + Users can browse and install community plugins with: + + ```bash + openclaw plugins search + openclaw plugins install + ``` + + + + +## Registering agent tools + +Plugins can register **agent tools** — typed functions the LLM can call. Tools +can be required (always available) or optional (users opt in via allowlists). + +```typescript +import { Type } from "@sinclair/typebox"; + +export default definePluginEntry({ + id: "my-plugin", + name: "My Plugin", + register(api) { + // Required tool (always available) + api.registerTool({ + name: "my_tool", + description: "Do a thing", + parameters: Type.Object({ input: Type.String() }), + async execute(_id, params) { + return { content: [{ type: "text", text: params.input }] }; + }, + }); + + // Optional tool (user must add to allowlist) + api.registerTool( + { + name: "workflow_tool", + description: "Run a workflow", + parameters: Type.Object({ pipeline: Type.String() }), + async execute(_id, params) { + return { content: [{ type: "text", text: params.pipeline }] }; + }, + }, + { optional: true }, + ); + }, +}); +``` + +Enable optional tools in config: + +```json5 +{ + tools: { allow: ["workflow_tool"] }, +} +``` + +Tips: + +- Tool names must not clash with core tool names (conflicts are skipped) +- Use `optional: true` for tools that trigger side effects or require extra binaries +- Users can enable all tools from a plugin by adding the plugin id to `tools.allow` + +## Lint enforcement (in-repo plugins) + +Three scripts enforce SDK boundaries for plugins in the OpenClaw repository: + +1. **No monolithic root imports** — `openclaw/plugin-sdk` root is rejected +2. **No direct src/ imports** — plugins cannot import `../../src/` directly +3. **No self-imports** — plugins cannot import their own `plugin-sdk/\` subpath + +Run `pnpm check` to verify all boundaries before committing. + +External plugins are not subject to these lint rules, but following the same +patterns is strongly recommended. + +## Pre-submission checklist + +**package.json** has correct `openclaw` metadata +Entry point uses `defineChannelPluginEntry` or `definePluginEntry` +All imports use focused `plugin-sdk/\` paths +Internal imports use local modules, not SDK self-imports +`openclaw.plugin.json` manifest is present and valid +Tests pass +`pnpm check` passes (in-repo plugins) + +## Related + +- [Plugin SDK Migration](/plugins/sdk-migration) — migrating from deprecated compat surfaces +- [Plugin Architecture](/plugins/architecture) — internals and capability model +- [Plugin Manifest](/plugins/manifest) — full manifest schema +- [Plugin Agent Tools](/plugins/building-plugins#registering-agent-tools) — adding agent tools in a plugin +- [Community Plugins](/plugins/community) — listing and quality bar diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index 82a5605e099..b60b110e6b7 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -1,307 +1,181 @@ --- -summary: "Unified bundle format guide for Codex, Claude, and Cursor bundles in OpenClaw" +summary: "Install and use Codex, Claude, and Cursor bundles as OpenClaw plugins" read_when: - - You want to install or debug a Codex, Claude, or Cursor-compatible bundle + - You want to install a Codex, Claude, or Cursor-compatible bundle - You need to understand how OpenClaw maps bundle content into native features - - You are documenting bundle compatibility or current support limits + - You are debugging bundle detection or missing capabilities title: "Plugin Bundles" --- -# Plugin bundles +# Plugin Bundles -OpenClaw supports one shared class of external plugin package: **bundle -plugins**. +OpenClaw can install plugins from three external ecosystems: **Codex**, **Claude**, +and **Cursor**. These are called **bundles** — content and metadata packs that +OpenClaw maps into native features like skills, hooks, and MCP tools. -Today that means three closely related ecosystems: + + Bundles are **not** the same as native OpenClaw plugins. Native plugins run + in-process and can register any capability. Bundles are content packs with + selective feature mapping and a narrower trust boundary. + -- Codex bundles -- Claude bundles -- Cursor bundles +## Why bundles exist -OpenClaw shows all of them as `Format: bundle` in `openclaw plugins list`. -Verbose output and `openclaw plugins inspect ` also show the subtype -(`codex`, `claude`, or `cursor`). +Many useful plugins are published in Codex, Claude, or Cursor format. Instead +of requiring authors to rewrite them as native OpenClaw plugins, OpenClaw +detects these formats and maps their supported content into the native feature +set. This means you can install a Claude command pack or a Codex skill bundle +and use it immediately. -Related: +## Install a bundle -- Plugin system overview: [Plugins](/tools/plugin) -- CLI install/list flows: [plugins](/cli/plugins) -- Native manifest schema: [Plugin manifest](/plugins/manifest) + + + ```bash + # Local directory + openclaw plugins install ./my-bundle -## What a bundle is + # Archive + openclaw plugins install ./my-bundle.tgz -A bundle is a **content/metadata pack**, not a native in-process OpenClaw -plugin. + # Claude marketplace + openclaw plugins marketplace list + openclaw plugins install @ + ``` -Today, OpenClaw does **not** execute bundle runtime code in-process. Instead, -it detects known bundle files, reads the metadata, and maps supported bundle -content into native OpenClaw surfaces such as skills, hook packs, MCP config, -and embedded Pi settings. + -That is the main trust boundary: + + ```bash + openclaw plugins list + openclaw plugins inspect + ``` -- native OpenClaw plugin: runtime module executes in-process -- bundle: metadata/content pack, with selective feature mapping + Bundles show as `Format: bundle` with a subtype of `codex`, `claude`, or `cursor`. -## Shared bundle model + -Codex, Claude, and Cursor bundles are similar enough that OpenClaw treats them -as one normalized model. + + ```bash + openclaw gateway restart + ``` -Shared idea: + Mapped features (skills, hooks, MCP tools) are available in the next session. -- a small manifest file, or a default directory layout -- one or more content roots such as `skills/` or `commands/` -- optional tool/runtime metadata such as MCP, hooks, agents, or LSP -- install as a directory or archive, then enable in the normal plugin list + + -Common OpenClaw behavior: +## What OpenClaw maps from bundles -- detect the bundle subtype -- normalize it into one internal bundle record -- map supported parts into native OpenClaw features -- report unsupported parts as detected-but-not-wired capabilities - -In practice, most users do not need to think about the vendor-specific format -first. The more useful question is: which bundle surfaces does OpenClaw map -today? - -## Detection order - -OpenClaw prefers native OpenClaw plugin/package layouts before bundle handling. - -Practical effect: - -- `openclaw.plugin.json` wins over bundle detection -- package installs with valid `package.json` + `openclaw.extensions` use the - native install path -- if a directory contains both native and bundle metadata, OpenClaw treats it - as native first - -That avoids partially installing a dual-format package as a bundle and then -loading it later as a native plugin. - -## What works today - -OpenClaw normalizes bundle metadata into one internal bundle record, then maps -supported surfaces into existing native behavior. +Not every bundle feature runs in OpenClaw today. Here is what works and what +is detected but not yet wired. ### Supported now -#### Skill content - -- bundle skill roots load as normal OpenClaw skill roots -- Claude `commands` roots are treated as additional skill roots -- Cursor `.cursor/commands` roots are treated as additional skill roots - -This means Claude markdown command files work through the normal OpenClaw skill -loader. Cursor command markdown works through the same path. - -#### Hook packs - -- bundle hook roots work **only** when they use the normal OpenClaw hook-pack - layout. Today this is primarily the Codex-compatible case: - - `HOOK.md` - - `handler.ts` or `handler.js` - -#### MCP for Pi - -- enabled bundles can contribute MCP server config -- OpenClaw merges bundle MCP config into the effective embedded Pi settings as - `mcpServers` -- OpenClaw also exposes supported bundle MCP tools during embedded Pi agent - turns by launching supported stdio MCP servers as subprocesses -- project-local Pi settings still apply after bundle defaults, so workspace - settings can override bundle MCP entries when needed - -#### Embedded Pi settings - -- Claude `settings.json` is imported as default embedded Pi settings when the - bundle is enabled -- OpenClaw sanitizes shell override keys before applying them - -Sanitized keys: - -- `shellPath` -- `shellCommandPrefix` +| Feature | How it maps | Applies to | +| ------------- | ---------------------------------------------------------------------------------------------------- | -------------- | +| Skill content | Bundle skill roots load as normal OpenClaw skills | All formats | +| Commands | `commands/` and `.cursor/commands/` treated as skill roots | Claude, Cursor | +| Hook packs | OpenClaw-style `HOOK.md` + `handler.ts` layouts | Codex | +| MCP tools | Bundle MCP config merged into embedded Pi settings; supported stdio servers launched as subprocesses | All formats | +| Settings | Claude `settings.json` imported as embedded Pi defaults | Claude | ### Detected but not executed -These surfaces are detected, shown in bundle capabilities, and may appear in -diagnostics/info output, but OpenClaw does not run them yet: +These are recognized and shown in diagnostics, but OpenClaw does not run them: -- Claude `agents` -- Claude `hooks.json` automation -- Claude `lspServers` -- Claude `outputStyles` -- Cursor `.cursor/agents` -- Cursor `.cursor/hooks.json` -- Cursor `.cursor/rules` +- Claude `agents`, `hooks.json` automation, `lspServers`, `outputStyles` +- Cursor `.cursor/agents`, `.cursor/hooks.json`, `.cursor/rules` - Codex inline/app metadata beyond capability reporting -## Capability reporting +## Bundle formats -`openclaw plugins inspect ` shows bundle capabilities from the normalized -bundle record. + + + Markers: `.codex-plugin/plugin.json` -Supported capabilities are loaded quietly. Unsupported capabilities produce a -warning such as: + Optional content: `skills/`, `hooks/`, `.mcp.json`, `.app.json` -```text -bundle capability detected but not wired into OpenClaw yet: agents -``` + Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style + hook-pack directories (`HOOK.md` + `handler.ts`). -Current exceptions: + -- Claude `commands` is considered supported because it maps to skills -- Claude `settings` is considered supported because it maps to embedded Pi settings -- Cursor `commands` is considered supported because it maps to skills -- bundle MCP is considered supported because it maps into embedded Pi settings - and exposes supported stdio tools to embedded Pi -- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts + + Two detection modes: -## Format differences + - **Manifest-based:** `.claude-plugin/plugin.json` + - **Manifestless:** default Claude layout (`skills/`, `commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) -The formats are close, but not byte-for-byte identical. These are the practical -differences that matter in OpenClaw. + Claude-specific behavior: -### Codex + - `commands/` is treated as skill content + - `settings.json` is imported into embedded Pi settings (shell override keys are sanitized) + - `.mcp.json` exposes supported stdio tools to embedded Pi + - `hooks/hooks.json` is detected but not executed + - Custom component paths in the manifest are additive (they extend defaults, not replace them) -Typical markers: + -- `.codex-plugin/plugin.json` -- optional `skills/` -- optional `hooks/` -- optional `.mcp.json` -- optional `.app.json` + + Markers: `.cursor-plugin/plugin.json` -Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style -hook-pack directories. + Optional content: `skills/`, `.cursor/commands/`, `.cursor/agents/`, `.cursor/rules/`, `.cursor/hooks.json`, `.mcp.json` -### Claude + - `.cursor/commands/` is treated as skill content + - `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are detect-only -OpenClaw supports both: + + -- manifest-based Claude bundles: `.claude-plugin/plugin.json` -- manifestless Claude bundles that use the default Claude layout +## Detection precedence -Default Claude layout markers OpenClaw recognizes: +OpenClaw checks for native plugin format first: -- `skills/` -- `commands/` -- `agents/` -- `hooks/hooks.json` -- `.mcp.json` -- `.lsp.json` -- `settings.json` +1. `openclaw.plugin.json` or valid `package.json` with `openclaw.extensions` — treated as **native plugin** +2. Bundle markers (`.codex-plugin/`, `.claude-plugin/`, or default Claude/Cursor layout) — treated as **bundle** -Claude-specific notes: +If a directory contains both, OpenClaw uses the native path. This prevents +dual-format packages from being partially installed as bundles. -- `commands/` is treated like skill content -- `settings.json` is imported into embedded Pi settings -- `.mcp.json` and manifest `mcpServers` can expose supported stdio tools to - embedded Pi -- `hooks/hooks.json` is detected, but not executed as Claude automation +## Security -### Cursor +Bundles have a narrower trust boundary than native plugins: -Typical markers: +- OpenClaw does **not** load arbitrary bundle runtime modules in-process +- Skills and hook-pack paths must stay inside the plugin root (boundary-checked) +- Settings files are read with the same boundary checks +- Supported stdio MCP servers may be launched as subprocesses -- `.cursor-plugin/plugin.json` -- optional `skills/` -- optional `.cursor/commands/` -- optional `.cursor/agents/` -- optional `.cursor/rules/` -- optional `.cursor/hooks.json` -- optional `.mcp.json` - -Cursor-specific notes: - -- `.cursor/commands/` is treated like skill content -- `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are - detect-only today - -## Claude custom paths - -Claude bundle manifests can declare custom component paths. OpenClaw treats -those paths as **additive**, not replacing defaults. - -Currently recognized custom path keys: - -- `skills` -- `commands` -- `agents` -- `hooks` -- `mcpServers` -- `lspServers` -- `outputStyles` - -Examples: - -- default `commands/` plus manifest `commands: "extra-commands"` => - OpenClaw scans both -- default `skills/` plus manifest `skills: ["team-skills"]` => - OpenClaw scans both - -## Security model - -Bundle support is intentionally narrower than native plugin support. - -Current behavior: - -- bundle discovery reads files inside the plugin root with boundary checks -- skills and hook-pack paths must stay inside the plugin root -- bundle settings files are read with the same boundary checks -- supported stdio bundle MCP servers may be launched as subprocesses for - embedded Pi tool calls -- OpenClaw does not load arbitrary bundle runtime modules in-process - -This makes bundle support safer by default than native plugin modules, but you -should still treat third-party bundles as trusted content for the features they -do expose. - -## Install examples - -```bash -openclaw plugins install ./my-codex-bundle -openclaw plugins install ./my-claude-bundle -openclaw plugins install ./my-cursor-bundle -openclaw plugins install ./my-bundle.tgz -openclaw plugins marketplace list -openclaw plugins install @ -openclaw plugins inspect my-bundle -``` - -If the directory is a native OpenClaw plugin/package, the native install path -still wins. - -For Claude marketplace names, OpenClaw reads the local Claude known-marketplace -registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries -can resolve to bundle-compatible directories/archives or to native plugin -sources; after resolution, the normal install rules still apply. +This makes bundles safer by default, but you should still treat third-party +bundles as trusted content for the features they do expose. ## Troubleshooting -### Bundle is detected but capabilities do not run + + + Run `openclaw plugins inspect `. If a capability is listed but marked as + not wired, that is a product limit — not a broken install. + -Check `openclaw plugins inspect `. + + Make sure the bundle is enabled and the markdown files are inside a detected + `commands/` or `skills/` root. + -If the capability is listed but OpenClaw says it is not wired yet, that is a -real product limit, not a broken install. + + Only embedded Pi settings from `settings.json` are supported. OpenClaw does + not treat bundle settings as raw config patches. + -### Claude command files do not appear + + `hooks/hooks.json` is detect-only. If you need runnable hooks, use the + OpenClaw hook-pack layout or ship a native plugin. + + -Make sure the bundle is enabled and the markdown files are inside a detected -`commands` root or `skills` root. +## Related -### Claude settings do not apply - -Current support is limited to embedded Pi settings from `settings.json`. -OpenClaw does not treat bundle settings as raw OpenClaw config patches. - -### Claude hooks do not execute - -`hooks/hooks.json` is only detected today. - -If you need runnable bundle hooks today, use the normal OpenClaw hook-pack -layout through a supported Codex hook root or ship a native OpenClaw plugin. +- [Install and Configure Plugins](/tools/plugin) +- [Building Plugins](/plugins/building-plugins) — create a native plugin +- [Plugin Manifest](/plugins/manifest) — native manifest schema diff --git a/docs/plugins/community.md b/docs/plugins/community.md index 12df6c3eee0..d6cbcd76301 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -1,60 +1,128 @@ --- -summary: "Community plugins: quality bar, hosting requirements, and PR submission path" +summary: "Community-maintained OpenClaw plugins: browse, install, and submit your own" read_when: - - You want to publish a third-party OpenClaw plugin - - You want to propose a plugin for docs listing -title: "Community plugins" + - You want to find third-party OpenClaw plugins + - You want to publish or list your own plugin +title: "Community Plugins" --- -# Community plugins +# Community Plugins -This page tracks high-quality **community-maintained plugins** for OpenClaw. +Community plugins are third-party packages that extend OpenClaw with new +channels, tools, providers, or other capabilities. They are built and maintained +by the community, published on npm, and installable with a single command. -We accept PRs that add community plugins here when they meet the quality bar. - -## Required for listing - -- Plugin package is published on npmjs (installable via `openclaw plugins install `). -- Source code is hosted on GitHub (public repository). -- Repository includes setup/use docs and an issue tracker. -- Plugin has a clear maintenance signal (active maintainer, recent updates, or responsive issue handling). - -## How to submit - -Open a PR that adds your plugin to this page with: - -- Plugin name -- npm package name -- GitHub repository URL -- One-line description -- Install command - -## Review bar - -We prefer plugins that are useful, documented, and safe to operate. -Low-effort wrappers, unclear ownership, or unmaintained packages may be declined. - -## Candidate format - -Use this format when adding entries: - -- **Plugin Name** — short description - npm: `@scope/package` - repo: `https://github.com/org/repo` - install: `openclaw plugins install @scope/package` +```bash +openclaw plugins install +``` ## Listed plugins -- **openclaw-dingtalk** — The OpenClaw DingTalk channel plugin enables the integration of enterprise robots using the Stream mode. It supports text, images and file messages via any DingTalk client. - npm: `@largezhou/ddingtalk` - repo: `https://github.com/largezhou/openclaw-dingtalk` - install: `openclaw plugins install @largezhou/ddingtalk` -- **QQbot** — Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group mentions, channel messages, and rich media including voice, images, videos, and files. - npm: `@sliverp/qqbot` - repo: `https://github.com/sliverp/qqbot` - install: `openclaw plugins install @sliverp/qqbot` +### Codex App Server Bridge -- **WeChat** — Connect OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol). Supports text, image, and file exchange with keyword-triggered conversations. - npm: `@icesword760/openclaw-wechat` - repo: `https://github.com/icesword0760/openclaw-wechat` - install: `openclaw plugins install @icesword760/openclaw-wechat` +Independent OpenClaw bridge for Codex App Server conversations. Bind a chat to +a Codex thread, talk to it with plain text, and control it with chat-native +commands for resume, planning, review, model selection, compaction, and more. + +- **npm:** `openclaw-codex-app-server` +- **repo:** [github.com/pwrdrvr/openclaw-codex-app-server](https://github.com/pwrdrvr/openclaw-codex-app-server) + +```bash +openclaw plugins install openclaw-codex-app-server +``` + +### DingTalk + +Enterprise robot integration using Stream mode. Supports text, images, and +file messages via any DingTalk client. + +- **npm:** `@largezhou/ddingtalk` +- **repo:** [github.com/largezhou/openclaw-dingtalk](https://github.com/largezhou/openclaw-dingtalk) + +```bash +openclaw plugins install @largezhou/ddingtalk +``` + +### Lossless Claw (LCM) + +Lossless Context Management plugin for OpenClaw. DAG-based conversation +summarization with incremental compaction — preserves full context fidelity +while reducing token usage. + +- **npm:** `@martian-engineering/lossless-claw` +- **repo:** [github.com/Martian-Engineering/lossless-claw](https://github.com/Martian-Engineering/lossless-claw) + +```bash +openclaw plugins install @martian-engineering/lossless-claw +``` + +### Opik + +Official plugin that exports agent traces to Opik. Monitor agent behavior, +cost, tokens, errors, and more. + +- **npm:** `@opik/opik-openclaw` +- **repo:** [github.com/comet-ml/opik-openclaw](https://github.com/comet-ml/opik-openclaw) + +```bash +openclaw plugins install @opik/opik-openclaw +``` + +### QQbot + +Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group +mentions, channel messages, and rich media including voice, images, videos, +and files. + +- **npm:** `@sliverp/qqbot` +- **repo:** [github.com/sliverp/qqbot](https://github.com/sliverp/qqbot) + +```bash +openclaw plugins install @sliverp/qqbot +``` + +## Submit your plugin + +We welcome community plugins that are useful, documented, and safe to operate. + + + + Your plugin must be installable via `openclaw plugins install \`. + See [Building Plugins](/plugins/building-plugins) for the full guide. + + + + + Source code must be in a public repository with setup docs and an issue + tracker. + + + + + Add your plugin to this page with: + + - Plugin name + - npm package name + - GitHub repository URL + - One-line description + - Install command + + + + +## Quality bar + +| Requirement | Why | +| -------------------- | --------------------------------------------- | +| Published on npm | Users need `openclaw plugins install` to work | +| Public GitHub repo | Source review, issue tracking, transparency | +| Setup and usage docs | Users need to know how to configure it | +| Active maintenance | Recent updates or responsive issue handling | + +Low-effort wrappers, unclear ownership, or unmaintained packages may be declined. + +## Related + +- [Install and Configure Plugins](/tools/plugin) — how to install any plugin +- [Building Plugins](/plugins/building-plugins) — create your own +- [Plugin Manifest](/plugins/manifest) — manifest schema diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md new file mode 100644 index 00000000000..52501f5b9c7 --- /dev/null +++ b/docs/plugins/sdk-migration.md @@ -0,0 +1,168 @@ +--- +title: "Plugin SDK Migration" +sidebarTitle: "SDK Migration" +summary: "Migrate from the legacy backwards-compatibility layer to the modern plugin SDK" +read_when: + - You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning + - You see the OPENCLAW_EXTENSION_API_DEPRECATED warning + - You are updating a plugin to the modern plugin architecture + - You maintain an external OpenClaw plugin +--- + +# Plugin SDK Migration + +OpenClaw has moved from a broad backwards-compatibility layer to a modern plugin +architecture with focused, documented imports. If your plugin was built before +the new architecture, this guide helps you migrate. + +## What is changing + +The old plugin system provided two wide-open surfaces that let plugins import +anything they needed from a single entry point: + +- **`openclaw/plugin-sdk/compat`** — a single import that re-exported dozens of + helpers. It was introduced to keep older hook-based plugins working while the + new plugin architecture was being built. +- **`openclaw/extension-api`** — a bridge that gave plugins direct access to + host-side helpers like the embedded agent runner. + +Both surfaces are now **deprecated**. They still work at runtime, but new +plugins must not use them, and existing plugins should migrate before the next +major release removes them. + + + The backwards-compatibility layer will be removed in a future major release. + Plugins that still import from these surfaces will break when that happens. + + +## Why this changed + +The old approach caused problems: + +- **Slow startup** — importing one helper loaded dozens of unrelated modules +- **Circular dependencies** — broad re-exports made it easy to create import cycles +- **Unclear API surface** — no way to tell which exports were stable vs internal + +The modern plugin SDK fixes this: each import path (`openclaw/plugin-sdk/\`) +is a small, self-contained module with a clear purpose and documented contract. + +## How to migrate + + + + Search your plugin for imports from either deprecated surface: + + ```bash + grep -r "plugin-sdk/compat" my-plugin/ + grep -r "openclaw/extension-api" my-plugin/ + ``` + + + + + Each export from the old surface maps to a specific modern import path: + + ```typescript + // Before (deprecated backwards-compatibility layer) + import { + createChannelReplyPipeline, + createPluginRuntimeStore, + resolveControlCommandGate, + } from "openclaw/plugin-sdk/compat"; + + // After (modern focused imports) + import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; + import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; + import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; + ``` + + For host-side helpers, use the injected plugin runtime instead of importing + directly: + + ```typescript + // Before (deprecated extension-api bridge) + import { runEmbeddedPiAgent } from "openclaw/extension-api"; + const result = await runEmbeddedPiAgent({ sessionId, prompt }); + + // After (injected runtime) + const result = await api.runtime.agent.runEmbeddedPiAgent({ sessionId, prompt }); + ``` + + The same pattern applies to other legacy bridge helpers: + + | Old import | Modern equivalent | + | --- | --- | + | `resolveAgentDir` | `api.runtime.agent.resolveAgentDir` | + | `resolveAgentWorkspaceDir` | `api.runtime.agent.resolveAgentWorkspaceDir` | + | `resolveAgentIdentity` | `api.runtime.agent.resolveAgentIdentity` | + | `resolveThinkingDefault` | `api.runtime.agent.resolveThinkingDefault` | + | `resolveAgentTimeoutMs` | `api.runtime.agent.resolveAgentTimeoutMs` | + | `ensureAgentWorkspace` | `api.runtime.agent.ensureAgentWorkspace` | + | session store helpers | `api.runtime.agent.session.*` | + + + + + ```bash + pnpm build + pnpm test -- my-plugin/ + ``` + + + +## Import path reference + + + | Import path | Purpose | Key exports | + | --- | --- | --- | + | `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` | + | `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` | + | `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | + | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` | + | `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` | + | `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types | + | `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` | + | `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` | + | `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities | + | `plugin-sdk/channel-send-result` | Send result types | Reply result types | + | `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` | + | `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase` | + | `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` | + | `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` | + | `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers | + | `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities | + | `plugin-sdk/reply-payload` | Message reply types | Reply payload types | + | `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers | + | `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` | + | `plugin-sdk/testing` | Test utilities | Test helpers and mocks | + + +Use the narrowest import that matches the job. If you cannot find an export, +check the source at `src/plugin-sdk/` or ask in Discord. + +## Removal timeline + +| When | What happens | +| ---------------------- | ----------------------------------------------------------------------- | +| **Now** | Deprecated surfaces emit runtime warnings | +| **Next major release** | Deprecated surfaces will be removed; plugins still using them will fail | + +All core plugins have already been migrated. External plugins should migrate +before the next major release. + +## Suppressing the warnings temporarily + +Set these environment variables while you work on migrating: + +```bash +OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run +OPENCLAW_SUPPRESS_EXTENSION_API_WARNING=1 openclaw gateway run +``` + +This is a temporary escape hatch, not a permanent solution. + +## Related + +- [Building Plugins](/plugins/building-plugins) +- [Plugin Internals](/plugins/architecture) +- [Plugin Manifest](/plugins/manifest) diff --git a/docs/providers/xai.md b/docs/providers/xai.md index ec491735e50..271eae0bc57 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -34,8 +34,7 @@ OpenClaw now includes these xAI model families out of the box: - `grok-4`, `grok-4-0709` - `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning` - `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning` -- `grok-4.20-experimental-beta-0304-reasoning` -- `grok-4.20-experimental-beta-0304-non-reasoning` +- `grok-4.20-reasoning`, `grok-4.20-non-reasoning` - `grok-code-fast-1` The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when diff --git a/docs/reference/test.md b/docs/reference/test.md index e337e963e1d..08ebb2af3fc 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -11,8 +11,9 @@ 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` on Node 22, 23, and 24 uses Vitest `vmForks` by default for local runs with enough memory. CI stays on `forks` unless explicitly overridden. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`. - `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. +- Files marked `singletonIsolated` no longer spawn one fresh Vitest process each by default. The wrapper batches them into dedicated `forks` lanes with `maxWorkers=1`, which preserves isolation from `unit-fast` while cutting process startup overhead. Tune lane count with `OPENCLAW_TEST_SINGLETON_ISOLATED_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`. diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 7e530f769b5..754957a96d6 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -164,9 +164,9 @@ Use these hubs to discover every page, including deep dives and reference docs t ## Extensions + plugins - [Plugins overview](/tools/plugin) -- [Building extensions](/plugins/building-extensions) +- [Building plugins](/plugins/building-plugins) - [Plugin manifest](/plugins/manifest) -- [Agent tools](/plugins/agent-tools) +- [Agent tools](/plugins/building-plugins#registering-agent-tools) - [Plugin bundles](/plugins/bundles) - [Community plugins](/plugins/community) - [Capability cookbook](/tools/capability-cookbook) diff --git a/docs/tools/agent-send.md b/docs/tools/agent-send.md index e301feeea12..153a1e9b3c6 100644 --- a/docs/tools/agent-send.md +++ b/docs/tools/agent-send.md @@ -1,53 +1,100 @@ --- -summary: "Direct `openclaw agent` CLI runs (with optional delivery)" +summary: "Run agent turns from the CLI and optionally deliver replies to channels" read_when: - - Adding or modifying the agent CLI entrypoint + - You want to trigger agent runs from scripts or the command line + - You need to deliver agent replies to a chat channel programmatically title: "Agent Send" --- -# `openclaw agent` (direct agent runs) +# Agent Send -`openclaw agent` runs a single agent turn without needing an inbound chat message. -By default it goes **through the Gateway**; add `--local` to force the embedded -runtime on the current machine. +`openclaw agent` runs a single agent turn from the command line without needing +an inbound chat message. Use it for scripted workflows, testing, and +programmatic delivery. + +## Quick start + + + + ```bash + openclaw agent --message "What is the weather today?" + ``` + + This sends the message through the Gateway and prints the reply. + + + + + ```bash + # Target a specific agent + openclaw agent --agent ops --message "Summarize logs" + + # Target a phone number (derives session key) + openclaw agent --to +15555550123 --message "Status update" + + # Reuse an existing session + openclaw agent --session-id abc123 --message "Continue the task" + ``` + + + + + ```bash + # Deliver to WhatsApp (default channel) + openclaw agent --to +15555550123 --message "Report ready" --deliver + + # Deliver to Slack + openclaw agent --agent ops --message "Generate report" \ + --deliver --reply-channel slack --reply-to "#reports" + ``` + + + + +## Flags + +| Flag | Description | +| ----------------------------- | ----------------------------------------------------------- | +| `--message \` | Message to send (required) | +| `--to \` | Derive session key from a target (phone, chat id) | +| `--agent \` | Target a configured agent (uses its `main` session) | +| `--session-id \` | Reuse an existing session by id | +| `--local` | Force local embedded runtime (skip Gateway) | +| `--deliver` | Send the reply to a chat channel | +| `--channel \` | Delivery channel (whatsapp, telegram, discord, slack, etc.) | +| `--reply-to \` | Delivery target override | +| `--reply-channel \` | Delivery channel override | +| `--reply-account \` | Delivery account id override | +| `--thinking \` | Set thinking level (off, minimal, low, medium, high, xhigh) | +| `--verbose \` | Set verbose level | +| `--timeout \` | Override agent timeout | +| `--json` | Output structured JSON | ## Behavior -- Required: `--message ` -- Session selection: - - `--to ` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or** - - `--session-id ` reuses an existing session by id, **or** - - `--agent ` targets a configured agent directly (uses that agent's `main` session key) -- Runs the same embedded agent runtime as normal inbound replies. -- Thinking/verbose flags persist into the session store. -- Output: - - default: prints reply text (plus `MEDIA:` lines) - - `--json`: prints structured payload + metadata -- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `openclaw message --target`). -- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session. - -If the Gateway is unreachable, the CLI **falls back** to the embedded local run. +- By default, the CLI goes **through the Gateway**. Add `--local` to force the + embedded runtime on the current machine. +- If the Gateway is unreachable, the CLI **falls back** to the local embedded run. +- Session selection: `--to` derives the session key (group/channel targets + preserve isolation; direct chats collapse to `main`). +- Thinking and verbose flags persist into the session store. +- Output: plain text by default, or `--json` for structured payload + metadata. ## Examples ```bash -openclaw agent --to +15555550123 --message "status update" -openclaw agent --agent ops --message "Summarize logs" -openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium +# Simple turn with JSON output openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json -openclaw agent --to +15555550123 --message "Summon reply" --deliver -openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports" + +# Turn with thinking level +openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium + +# Deliver to a different channel than the session +openclaw agent --agent ops --message "Alert" --deliver --reply-channel telegram --reply-to "@admin" ``` -## Flags +## Related -- `--local`: run locally (requires model provider API keys in your shell) -- `--deliver`: send the reply to the chosen channel -- `--channel`: delivery channel (`whatsapp|telegram|discord|googlechat|slack|signal|imessage`, default: `whatsapp`) -- `--reply-to`: delivery target override -- `--reply-channel`: delivery channel override -- `--reply-account`: delivery account id override -- `--thinking `: persist thinking level (GPT-5.2 + Codex models only) -- `--verbose `: persist verbose level -- `--timeout `: override agent timeout -- `--json`: output structured JSON +- [Agent CLI reference](/cli/agent) +- [Sub-agents](/tools/subagents) — background sub-agent spawning +- [Sessions](/concepts/session) — how session keys work diff --git a/docs/tools/capability-cookbook.md b/docs/tools/capability-cookbook.md index f439c362e89..7a5ab50611a 100644 --- a/docs/tools/capability-cookbook.md +++ b/docs/tools/capability-cookbook.md @@ -1,13 +1,20 @@ --- -summary: "Cookbook for adding a new shared capability to OpenClaw" +summary: "Contributor guide for adding a new shared capability to the OpenClaw plugin system" read_when: - Adding a new core capability and plugin registration surface - Deciding whether code belongs in core, a vendor plugin, or a feature plugin - Wiring a new runtime helper for channels or tools -title: "Capability Cookbook" +title: "Adding Capabilities (Contributor Guide)" +sidebarTitle: "Adding Capabilities" --- -# Capability Cookbook +# Adding Capabilities + + + This is a **contributor guide** for OpenClaw core developers. If you are + building an external plugin, see [Building Plugins](/plugins/building-plugins) + instead. + Use this when OpenClaw needs a new domain such as image generation, video generation, or some future vendor-backed feature area. diff --git a/docs/tools/creating-skills.md b/docs/tools/creating-skills.md index 964165ad0a2..69024038efc 100644 --- a/docs/tools/creating-skills.md +++ b/docs/tools/creating-skills.md @@ -6,53 +6,112 @@ read_when: - You need a quick starter workflow for SKILL.md-based skills --- -# Creating Custom Skills 🛠 +# Creating Skills -OpenClaw is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant. +Skills teach the agent how and when to use tools. Each skill is a directory +containing a `SKILL.md` file with YAML frontmatter and markdown instructions. -## What is a Skill? +For how skills are loaded and prioritized, see [Skills](/tools/skills). -A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources. +## Create your first skill -## Step-by-Step: Your First Skill + + + Skills live in your workspace. Create a new folder: -### 1. Create the Directory + ```bash + mkdir -p ~/.openclaw/workspace/skills/hello-world + ``` -Skills live in your workspace, usually `~/.openclaw/workspace/skills/`. Create a new folder for your skill: + -```bash -mkdir -p ~/.openclaw/workspace/skills/hello-world -``` + + Create `SKILL.md` inside that directory. The frontmatter defines metadata, + and the markdown body contains instructions for the agent. -### 2. Define the `SKILL.md` + ```markdown + --- + name: hello_world + description: A simple skill that says hello. + --- -Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions. + # Hello World Skill -```markdown ---- -name: hello_world -description: A simple skill that says hello. ---- + When the user asks for a greeting, use the `echo` tool to say + "Hello from your custom skill!". + ``` -# Hello World Skill + -When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!". -``` + + You can define custom tool schemas in the frontmatter or instruct the agent + to use existing system tools (like `exec` or `browser`). Skills can also + ship inside plugins alongside the tools they document. -### 3. Add Tools (Optional) + -You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`). + + Start a new session so OpenClaw picks up the skill: -### 4. Refresh OpenClaw + ```bash + # From chat + /new -Ask your agent to "refresh skills" or restart the gateway. OpenClaw will discover the new directory and index the `SKILL.md`. + # Or restart the gateway + openclaw gateway restart + ``` -## Best Practices + Verify the skill loaded: -- **Be Concise**: Instruct the model on _what_ to do, not how to be an AI. -- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input. -- **Test Locally**: Use `openclaw agent --message "use my new skill"` to test. + ```bash + openclaw skills list + ``` -## Shared Skills + -You can also browse and contribute skills to [ClawHub](https://clawhub.com). + + Send a message that should trigger the skill: + + ```bash + openclaw agent --message "give me a greeting" + ``` + + Or just chat with the agent and ask for a greeting. + + + + +## Skill metadata reference + +The YAML frontmatter supports these fields: + +| Field | Required | Description | +| ----------------------------------- | -------- | ------------------------------------------- | +| `name` | Yes | Unique identifier (snake_case) | +| `description` | Yes | One-line description shown to the agent | +| `metadata.openclaw.os` | No | OS filter (`["darwin"]`, `["linux"]`, etc.) | +| `metadata.openclaw.requires.bins` | No | Required binaries on PATH | +| `metadata.openclaw.requires.config` | No | Required config keys | + +## Best practices + +- **Be concise** — instruct the model on _what_ to do, not how to be an AI +- **Safety first** — if your skill uses `exec`, ensure prompts don't allow arbitrary command injection from untrusted input +- **Test locally** — use `openclaw agent --message "..."` to test before sharing +- **Use ClawHub** — browse and contribute skills at [ClawHub](https://clawhub.com) + +## Where skills live + +| Location | Precedence | Scope | +| ------------------------------- | ---------- | --------------------- | +| `\/skills/` | Highest | Per-agent | +| `~/.openclaw/skills/` | Medium | Shared (all agents) | +| Bundled (shipped with OpenClaw) | Lowest | Global | +| `skills.load.extraDirs` | Lowest | Custom shared folders | + +## Related + +- [Skills reference](/tools/skills) — loading, precedence, and gating rules +- [Skills config](/tools/skills-config) — `skills.*` config schema +- [ClawHub](/tools/clawhub) — public skill registry +- [Building Plugins](/plugins/building-plugins) — plugins can ship skills diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index c10b955ce2d..96a574f6fc9 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -1,63 +1,114 @@ --- -summary: "Elevated exec mode and /elevated directives" +summary: "Elevated exec mode: run commands on the gateway host from a sandboxed agent" read_when: - Adjusting elevated mode defaults, allowlists, or slash command behavior + - Understanding how sandboxed agents can access the host title: "Elevated Mode" --- -# Elevated Mode (/elevated directives) +# Elevated Mode -## What it does +When an agent runs inside a sandbox, its `exec` commands are confined to the +sandbox environment. **Elevated mode** lets the agent break out and run commands +on the gateway host instead, with configurable approval gates. -- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`). -- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals). -- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`). -- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies. -- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host). -- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`. -- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state. + + Elevated mode only changes behavior when the agent is **sandboxed**. For + unsandboxed agents, exec already runs on the host. + -## What it controls (and what it does not) +## Directives -- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). -- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key. -- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only. -- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. -- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`. -- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require. -- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status. -- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used. -- **Separate from `/exec`**: `/exec` adjusts per-session defaults for authorized senders and does not require elevated. +Control elevated mode per-session with slash commands: + +| Directive | What it does | +| ---------------- | --------------------------------------------------- | +| `/elevated on` | Run on the gateway host, keep exec approvals | +| `/elevated ask` | Same as `on` (alias) | +| `/elevated full` | Run on the gateway host **and** skip exec approvals | +| `/elevated off` | Return to sandbox-confined execution | + +Also available as `/elev on|off|ask|full`. + +Send `/elevated` with no argument to see the current level. + +## How it works + + + + Elevated must be enabled in config and the sender must be on the allowlist: + + ```json5 + { + tools: { + elevated: { + enabled: true, + allowFrom: { + discord: ["user-id-123"], + whatsapp: ["+15555550123"], + }, + }, + }, + } + ``` + + + + + Send a directive-only message to set the session default: + + ``` + /elevated full + ``` + + Or use it inline (applies to that message only): + + ``` + /elevated on run the deployment script + ``` + + + + + With elevated active, `exec` calls route to the gateway host instead of the + sandbox. In `full` mode, exec approvals are skipped. In `on`/`ask` mode, + configured approval rules still apply. + + ## Resolution order -1. Inline directive on the message (applies only to that message). -2. Session override (set by sending a directive-only message). -3. Global default (`agents.defaults.elevatedDefault` in config). +1. **Inline directive** on the message (applies only to that message) +2. **Session override** (set by sending a directive-only message) +3. **Global default** (`agents.defaults.elevatedDefault` in config) -## Setting a session default +## Availability and allowlists -- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`. -- Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`). -- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state. -- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. +- **Global gate**: `tools.elevated.enabled` (must be `true`) +- **Sender allowlist**: `tools.elevated.allowFrom` with per-channel lists +- **Per-agent gate**: `agents.list[].tools.elevated.enabled` (can only further restrict) +- **Per-agent allowlist**: `agents.list[].tools.elevated.allowFrom` (sender must match both global + per-agent) +- **Discord fallback**: if `tools.elevated.allowFrom.discord` is omitted, `channels.discord.allowFrom` is used as fallback +- **All gates must pass**; otherwise elevated is treated as unavailable -## Availability + allowlists +Allowlist entry formats: -- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it). -- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`). -- Unprefixed allowlist entries match sender-scoped identity values only (`SenderId`, `SenderE164`, `From`); recipient routing fields are never used for elevated authorization. -- Mutable sender metadata requires explicit prefixes: - - `name:` matches `SenderName` - - `username:` matches `SenderUsername` - - `tag:` matches `SenderTag` - - `id:`, `from:`, `e164:` are available for explicit identity targeting -- Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict). -- Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists). -- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `channels.discord.allowFrom` list is used as a fallback (legacy: `channels.discord.dm.allowFrom`). Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback. -- All gates must pass; otherwise elevated is treated as unavailable. +| Prefix | Matches | +| ----------------------- | ------------------------------- | +| (none) | Sender ID, E.164, or From field | +| `name:` | Sender display name | +| `username:` | Sender username | +| `tag:` | Sender tag | +| `id:`, `from:`, `e164:` | Explicit identity targeting | -## Logging + status +## What elevated does not control -- Elevated exec calls are logged at info level. -- Session status includes elevated mode (e.g. `elevated=ask`, `elevated=full`). +- **Tool policy**: if `exec` is denied by tool policy, elevated cannot override it +- **Separate from `/exec`**: the `/exec` directive adjusts per-session exec defaults for authorized senders and does not require elevated mode + +## Related + +- [Exec tool](/tools/exec) — shell command execution +- [Exec approvals](/tools/exec-approvals) — approval and allowlist system +- [Sandboxing](/gateway/sandboxing) — sandbox configuration +- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) diff --git a/docs/tools/index.md b/docs/tools/index.md index 075971d6877..77f334e826c 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -1,96 +1,129 @@ --- -summary: "Agent tool surface for OpenClaw (browser, canvas, nodes, message, cron) replacing legacy `openclaw-*` skills" +summary: "OpenClaw tools and plugins overview: what the agent can do and how to extend it" read_when: - - Adding or modifying agent tools - - Retiring or changing `openclaw-*` skills -title: "Tools" + - You want to understand what tools OpenClaw provides + - You need to configure, allow, or deny tools + - You are deciding between built-in tools, skills, and plugins +title: "Tools and Plugins" --- -# Tools (OpenClaw) +# Tools and Plugins -OpenClaw exposes **first-class agent tools** for browser, canvas, nodes, and cron. -These replace the old `openclaw-*` skills: the tools are typed, no shelling, -and the agent should rely on them directly. +Everything the agent does beyond generating text happens through **tools**. +Tools are how the agent reads files, runs commands, browses the web, sends +messages, and interacts with devices. -## Disabling tools +## Tools, skills, and plugins -You can globally allow/deny tools via `tools.allow` / `tools.deny` in `openclaw.json` -(deny wins). This prevents disallowed tools from being sent to model providers. +OpenClaw has three layers that work together: + + + + A tool is a typed function the agent can invoke (e.g. `exec`, `browser`, + `web_search`, `message`). OpenClaw ships a set of **built-in tools** and + plugins can register additional ones. + + The agent sees tools as structured function definitions sent to the model API. + + + + + A skill is a markdown file (`SKILL.md`) injected into the system prompt. + Skills give the agent context, constraints, and step-by-step guidance for + using tools effectively. Skills live in your workspace, in shared folders, + or ship inside plugins. + + [Skills reference](/tools/skills) | [Creating skills](/tools/creating-skills) + + + + + A plugin is a package that can register any combination of capabilities: + channels, model providers, tools, skills, speech, image generation, and more. + Some plugins are **core** (shipped with OpenClaw), others are **external** + (published on npm by the community). + + [Install and configure plugins](/tools/plugin) | [Build your own](/plugins/building-plugins) + + + + +## Built-in tools + +These tools ship with OpenClaw and are available without installing any plugins: + +| Tool | What it does | Page | +| ---------------------------- | -------------------------------------------------------- | --------------------------------- | +| `exec` / `process` | Run shell commands, manage background processes | [Exec](/tools/exec) | +| `browser` | Control a Chromium browser (navigate, click, screenshot) | [Browser](/tools/browser) | +| `web_search` / `web_fetch` | Search the web, fetch page content | [Web](/tools/web) | +| `read` / `write` / `edit` | File I/O in the workspace | | +| `apply_patch` | Multi-hunk file patches | [Apply Patch](/tools/apply-patch) | +| `message` | Send messages across all channels | [Agent Send](/tools/agent-send) | +| `canvas` | Drive node Canvas (present, eval, snapshot) | | +| `nodes` | Discover and target paired devices | | +| `cron` / `gateway` | Manage scheduled jobs, restart gateway | | +| `image` / `image_generate` | Analyze or generate images | | +| `sessions_*` / `agents_list` | Session management, sub-agents | [Sub-agents](/tools/subagents) | + +### Plugin-provided tools + +Plugins can register additional tools. Some examples: + +- [Lobster](/tools/lobster) — typed workflow runtime with resumable approvals +- [LLM Task](/tools/llm-task) — JSON-only LLM step for structured output +- [Diffs](/tools/diffs) — diff viewer and renderer +- [OpenProse](/prose) — markdown-first workflow orchestration + +## Tool configuration + +### Allow and deny lists + +Control which tools the agent can call via `tools.allow` / `tools.deny` in +config. Deny always wins over allow. ```json5 { - tools: { deny: ["browser"] }, + tools: { + allow: ["group:fs", "browser", "web_search"], + deny: ["exec"], + }, } ``` -Notes: +### Tool profiles -- Matching is case-insensitive. -- `*` wildcards are supported (`"*"` means all tools). -- If `tools.allow` only references unknown or unloaded plugin tool names, OpenClaw logs a warning and ignores the allowlist so core tools stay available. - -## Tool profiles (base allowlist) - -`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`. +`tools.profile` sets a base allowlist before `allow`/`deny` is applied. Per-agent override: `agents.list[].tools.profile`. -Profiles: +| Profile | What it includes | +| ----------- | ------------------------------------------- | +| `full` | All tools (default) | +| `coding` | File I/O, runtime, sessions, memory, image | +| `messaging` | Messaging, session list/history/send/status | +| `minimal` | `session_status` only | -- `minimal`: `session_status` only -- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image` -- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` -- `full`: no restriction (same as unset) +### Tool groups -Example (messaging-only by default, allow Slack + Discord tools too): +Use `group:*` shorthands in allow/deny lists: -```json5 -{ - tools: { - profile: "messaging", - allow: ["slack", "discord"], - }, -} -``` +| Group | Tools | +| ------------------ | ------------------------------------------------------------------------------ | +| `group:runtime` | exec, bash, process | +| `group:fs` | read, write, edit, apply_patch | +| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, session_status | +| `group:memory` | memory_search, memory_get | +| `group:web` | web_search, web_fetch | +| `group:ui` | browser, canvas | +| `group:automation` | cron, gateway | +| `group:messaging` | message | +| `group:nodes` | nodes | +| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) | -Example (coding profile, but deny exec/process everywhere): +### Provider-specific restrictions -```json5 -{ - tools: { - profile: "coding", - deny: ["group:runtime"], - }, -} -``` - -Example (global coding profile, messaging-only support agent): - -```json5 -{ - tools: { profile: "coding" }, - agents: { - list: [ - { - id: "support", - tools: { profile: "messaging", allow: ["slack"] }, - }, - ], - }, -} -``` - -## Provider-specific tool policy - -Use `tools.byProvider` to **further restrict** tools for specific providers -(or a single `provider/model`) without changing your global defaults. -Per-agent override: `agents.list[].tools.byProvider`. - -This is applied **after** the base tool profile and **before** allow/deny lists, -so it can only narrow the tool set. -Provider keys accept either `provider` (e.g. `google-antigravity`) or -`provider/model` (e.g. `openai/gpt-5.2`). - -Example (keep global coding profile, but minimal tools for Google Antigravity): +Use `tools.byProvider` to restrict tools for specific providers without +changing global defaults: ```json5 { @@ -102,515 +135,3 @@ Example (keep global coding profile, but minimal tools for Google Antigravity): }, } ``` - -Example (provider/model-specific allowlist for a flaky endpoint): - -```json5 -{ - tools: { - allow: ["group:fs", "group:runtime", "sessions_list"], - byProvider: { - "openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] }, - }, - }, -} -``` - -Example (agent-specific override for a single provider): - -```json5 -{ - agents: { - list: [ - { - id: "support", - tools: { - byProvider: { - "google-antigravity": { allow: ["message", "sessions_list"] }, - }, - }, - }, - ], - }, -} -``` - -## Tool groups (shorthands) - -Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools. -Use these in `tools.allow` / `tools.deny`. - -Available groups: - -- `group:runtime`: `exec`, `bash`, `process` -- `group:fs`: `read`, `write`, `edit`, `apply_patch` -- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` -- `group:memory`: `memory_search`, `memory_get` -- `group:web`: `web_search`, `web_fetch` -- `group:ui`: `browser`, `canvas` -- `group:automation`: `cron`, `gateway` -- `group:messaging`: `message` -- `group:nodes`: `nodes` -- `group:openclaw`: all built-in OpenClaw tools (excludes provider plugins) - -Example (allow only file tools + browser): - -```json5 -{ - tools: { - allow: ["group:fs", "browser"], - }, -} -``` - -## Plugins + tools - -Plugins can register **additional tools** (and CLI commands) beyond the core set. -See [Plugins](/tools/plugin) for install + config, and [Skills](/tools/skills) for how -tool usage guidance is injected into prompts. Some plugins ship their own skills -alongside tools (for example, the voice-call plugin). - -Optional plugin tools: - -- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host). -- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation). -- [Diffs](/tools/diffs): read-only diff viewer and PNG or PDF file renderer for before/after text or unified patches. - -## Tool inventory - -### `apply_patch` - -Apply structured patches across one or more files. Use for multi-hunk edits. -Experimental: enable via `tools.exec.applyPatch.enabled` (OpenAI models only). -`tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory. - -### `exec` - -Run shell commands in the workspace. - -Core parameters: - -- `command` (required) -- `yieldMs` (auto-background after timeout, default 10000) -- `background` (immediate background) -- `timeout` (seconds; kills the process if exceeded, default 1800) -- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed) -- `host` (`sandbox | gateway | node`) -- `security` (`deny | allowlist | full`) -- `ask` (`off | on-miss | always`) -- `node` (node id/name for `host=node`) -- Need a real TTY? Set `pty: true`. - -Notes: - -- Returns `status: "running"` with a `sessionId` when backgrounded. -- Use `process` to poll/log/write/kill/clear background sessions. -- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. -- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`. -- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). -- `host=node` can target a macOS companion app or a headless node host (`openclaw node run`). -- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals). - -### `process` - -Manage background exec sessions. - -Core actions: - -- `list`, `poll`, `log`, `write`, `kill`, `clear`, `remove` - -Notes: - -- `poll` returns new output and exit status when complete. -- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines). -- `process` is scoped per agent; sessions from other agents are not visible. - -### `loop-detection` (tool-call loop guardrails) - -OpenClaw tracks recent tool-call history and blocks or warns when it detects repetitive no-progress loops. -Enable with `tools.loopDetection.enabled: true` (default is `false`). - -```json5 -{ - tools: { - loopDetection: { - enabled: true, - warningThreshold: 10, - criticalThreshold: 20, - globalCircuitBreakerThreshold: 30, - historySize: 30, - detectors: { - genericRepeat: true, - knownPollNoProgress: true, - pingPong: true, - }, - }, - }, -} -``` - -- `genericRepeat`: repeated same tool + same params call pattern. -- `knownPollNoProgress`: repeating poll-like tools with identical outputs. -- `pingPong`: alternating `A/B/A/B` no-progress patterns. -- Per-agent override: `agents.list[].tools.loopDetection`. - -### `web_search` - -Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, Perplexity, or Tavily. - -Core parameters: - -- `query` (required) -- `count` (1–10; default from `tools.web.search.maxResults`) - -Notes: - -- Requires an API key for the chosen provider (recommended: `openclaw configure --section web`). -- Enable via `tools.web.search.enabled`. -- Responses are cached (default 15 min). -- See [Web tools](/tools/web) for setup. - -### `web_fetch` - -Fetch and extract readable content from a URL (HTML → markdown/text). - -Core parameters: - -- `url` (required) -- `extractMode` (`markdown` | `text`) -- `maxChars` (truncate long pages) - -Notes: - -- Enable via `tools.web.fetch.enabled`. -- `maxChars` is clamped by `tools.web.fetch.maxCharsCap` (default 50000). -- Responses are cached (default 15 min). -- For JS-heavy sites, prefer the browser tool. -- See [Web tools](/tools/web) for setup. -- See [Firecrawl](/tools/firecrawl) for the optional anti-bot fallback. - -### `browser` - -Control the dedicated OpenClaw-managed browser. - -Core actions: - -- `status`, `start`, `stop`, `tabs`, `open`, `focus`, `close` -- `snapshot` (aria/ai) -- `screenshot` (returns image block + `MEDIA:`) -- `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate) -- `navigate`, `console`, `pdf`, `upload`, `dialog` - -Profile management: - -- `profiles` — list all browser profiles with status -- `create-profile` — create new profile with auto-allocated port (or `cdpUrl`) -- `delete-profile` — stop browser, delete user data, remove from config (local only) -- `reset-profile` — kill orphan process on profile's port (local only) - -Common parameters: - -- `profile` (optional; defaults to `browser.defaultProfile`) -- `target` (`sandbox` | `host` | `node`) -- `node` (optional; picks a specific node id/name) - Notes: -- Requires `browser.enabled=true` (default is `true`; set `false` to disable). -- All actions accept optional `profile` parameter for multi-instance support. -- Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`). -- Use `profile="user"` for the real local host browser when existing logins/cookies matter and the user is present to click/approve any attach prompt. -- `profile="user"` is host-only; do not combine it with sandbox/node targets. -- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`). -- Profile names: lowercase alphanumeric + hyphens only (max 64 chars). -- Port range: 18800-18899 (~100 profiles max). -- Remote profiles are attach-only (no start/stop/reset). -- If a browser-capable node is connected, the tool may auto-route to it (unless you pin `target`). -- `snapshot` defaults to `ai` when Playwright is installed; use `aria` for the accessibility tree. -- `snapshot` also supports role-snapshot options (`interactive`, `compact`, `depth`, `selector`) which return refs like `e12`. -- `act` requires `ref` from `snapshot` (numeric `12` from AI snapshots, or `e12` from role snapshots); use `evaluate` for rare CSS selector needs. -- Avoid `act` → `wait` by default; use it only in exceptional cases (no reliable UI state to wait on). -- `upload` can optionally pass a `ref` to auto-click after arming. -- `upload` also supports `inputRef` (aria ref) or `element` (CSS selector) to set `` directly. - -### `canvas` - -Drive the node Canvas (present, eval, snapshot, A2UI). - -Core actions: - -- `present`, `hide`, `navigate`, `eval` -- `snapshot` (returns image block + `MEDIA:`) -- `a2ui_push`, `a2ui_reset` - -Notes: - -- Uses gateway `node.invoke` under the hood. -- If no `node` is provided, the tool picks a default (single connected node or local mac node). -- A2UI is v0.8 only (no `createSurface`); the CLI rejects v0.9 JSONL with line errors. -- Quick smoke: `openclaw nodes canvas a2ui push --node --text "Hello from A2UI"`. - -### `nodes` - -Discover and target paired nodes; send notifications; capture camera/screen. - -Core actions: - -- `status`, `describe` -- `pending`, `approve`, `reject` (pairing) -- `notify` (macOS `system.notify`) -- `run` (macOS `system.run`) -- `camera_list`, `camera_snap`, `camera_clip`, `screen_record` -- `location_get`, `notifications_list`, `notifications_action` -- `device_status`, `device_info`, `device_permissions`, `device_health` - -Notes: - -- Camera/screen commands require the node app to be foregrounded. -- Images return image blocks + `MEDIA:`. -- Videos return `FILE:` (mp4). -- Location returns a JSON payload (lat/lon/accuracy/timestamp). -- `run` params: `command` argv array; optional `cwd`, `env` (`KEY=VAL`), `commandTimeoutMs`, `invokeTimeoutMs`, `needsScreenRecording`. - -Example (`run`): - -```json -{ - "action": "run", - "node": "office-mac", - "command": ["echo", "Hello"], - "env": ["FOO=bar"], - "commandTimeoutMs": 12000, - "invokeTimeoutMs": 45000, - "needsScreenRecording": false -} -``` - -### `image` - -Analyze an image with the configured image model. - -Core parameters: - -- `image` (required path or URL) -- `prompt` (optional; defaults to "Describe the image.") -- `model` (optional override) -- `maxBytesMb` (optional size cap) - -Notes: - -- Only available when `agents.defaults.imageModel` is configured (primary or fallbacks), or when an implicit image model can be inferred from your default model + configured auth (best-effort pairing). -- Uses the image model directly (independent of the main chat model). - -### `image_generate` - -Generate one or more images with the configured or inferred image-generation model. - -Core parameters: - -- `action` (optional: `generate` or `list`; default `generate`) -- `prompt` (required) -- `image` or `images` (optional reference image path/URL for edit mode) -- `model` (optional provider/model override) -- `size` (optional size hint) -- `resolution` (optional `1K|2K|4K` hint) -- `count` (optional, `1-4`, default `1`) - -Notes: - -- Available when `agents.defaults.imageGenerationModel` is configured, or when OpenClaw can infer a compatible image-generation default from your enabled providers plus available auth. -- Explicit `agents.defaults.imageGenerationModel` still wins over any inferred default. -- Use `action: "list"` to inspect registered providers, default models, supported model ids, sizes, resolutions, and edit support. -- Returns local `MEDIA:` lines so channels can deliver the generated files directly. -- Uses the image-generation model directly (independent of the main chat model). -- Google-backed flows, including `google/gemini-3-pro-image-preview` for the native Nano Banana-style path, support reference-image edits plus explicit `1K|2K|4K` resolution hints. -- When editing and `resolution` is omitted, OpenClaw infers a draft/final resolution from the input image size. -- This is the built-in replacement for the old `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation. - -Native example: - -```json5 -{ - agents: { - defaults: { - imageGenerationModel: { - primary: "google/gemini-3-pro-image-preview", // native Nano Banana path - fallbacks: ["fal/fal-ai/flux/dev"], - }, - }, - }, -} -``` - -### `pdf` - -Analyze one or more PDF documents. - -For full behavior, limits, config, and examples, see [PDF tool](/tools/pdf). - -### `message` - -Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/Microsoft Teams. - -Core actions: - -- `send` (text + optional media; Microsoft Teams also supports `card` for Adaptive Cards) -- `poll` (WhatsApp/Discord/Microsoft Teams polls) -- `react` / `reactions` / `read` / `edit` / `delete` -- `pin` / `unpin` / `list-pins` -- `permissions` -- `thread-create` / `thread-list` / `thread-reply` -- `search` -- `sticker` -- `member-info` / `role-info` -- `emoji-list` / `emoji-upload` / `sticker-upload` -- `role-add` / `role-remove` -- `channel-info` / `channel-list` -- `voice-status` -- `event-list` / `event-create` -- `timeout` / `kick` / `ban` - -Notes: - -- `send` routes WhatsApp via the Gateway; other channels go direct. -- `poll` uses the Gateway for WhatsApp and Microsoft Teams; Discord polls go direct. -- When a message tool call is bound to an active chat session, sends are constrained to that session’s target to avoid cross-context leaks. - -### `cron` - -Manage Gateway cron jobs and wakeups. - -Core actions: - -- `status`, `list` -- `add`, `update`, `remove`, `run`, `runs` -- `wake` (enqueue system event + optional immediate heartbeat) - -Notes: - -- `add` expects a full cron job object (same schema as `cron.add` RPC). -- `update` uses `{ jobId, patch }` (`id` accepted for compatibility). - -### `gateway` - -Restart or apply updates to the running Gateway process (in-place). - -Core actions: - -- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `openclaw gateway` restart in-place) -- `config.schema.lookup` (inspect one config path at a time without loading the full schema into prompt context) -- `config.get` -- `config.apply` (validate + write config + restart + wake) -- `config.patch` (merge partial update + restart + wake) -- `update.run` (run update + restart + wake) - -Notes: - -- `config.schema.lookup` expects a targeted config path such as `gateway.auth` or `agents.list.*.heartbeat`. -- Paths may include slash-delimited plugin ids when addressing `plugins.entries.`, for example `plugins.entries.pack/one.config`. -- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. -- `config.schema` remains available to internal Control UI flows and is not exposed through the agent `gateway` tool. -- `restart` is enabled by default; set `commands.restart: false` to disable it. - -### `sessions_list` / `sessions_history` / `sessions_send` / `sessions_spawn` / `session_status` - -List sessions, inspect transcript history, or send to another session. - -Core parameters: - -- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none) -- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?` -- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget) -- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `streamTo?`, `attachments?`, `attachAs?` -- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override) - -Notes: - -- `main` is the canonical direct-chat key; global/unknown are hidden. -- `messageLimit > 0` fetches last N messages per session (tool messages filtered). -- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing. -- `sessions_send` waits for final completion when `timeoutSeconds > 0`. -- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered. -- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents). -- For ACP runtime, `streamTo: "parent"` routes initial-run progress summaries back to the requester session as system events instead of direct child delivery. -- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat. - - Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`). - - If `thread: true` and `mode` is omitted, mode defaults to `session`. - - `mode: "session"` requires `thread: true`. - - If `runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise timeout defaults to `0` (no timeout). - - Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`. - - Reply format includes `Status`, `Result`, and compact stats. - - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback. -- Manual completion-mode spawns send directly first, with queue fallback and retry on transient failures (`status: "ok"` means run finished, not that announce delivered). -- `sessions_spawn` supports inline file attachments for subagent runtime only (ACP rejects them). Each attachment has `name`, `content`, and optional `encoding` (`utf8` or `base64`) and `mimeType`. Files are materialized into the child workspace at `.openclaw/attachments//` with a `.manifest.json` metadata file. The tool returns a receipt with `count`, `totalBytes`, per file `sha256`, and `relDir`. Attachment content is automatically redacted from transcript persistence. - - Configure limits via `tools.sessions_spawn.attachments` (`enabled`, `maxTotalBytes`, `maxFiles`, `maxFileBytes`, `retainOnSessionKeep`). - - `attachAs.mountPath` is a reserved hint for future mount implementations. -- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately. -- ACP `streamTo: "parent"` responses may include `streamLogPath` (session-scoped `*.acp-stream.jsonl`) for tailing progress history. -- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5). -- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement. -- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`. - -### `agents_list` - -List agent ids that the current session may target with `sessions_spawn`. - -Notes: - -- Result is restricted to per-agent allowlists (`agents.list[].subagents.allowAgents`). -- When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`. - -## Parameters (common) - -Gateway-backed tools (`canvas`, `nodes`, `cron`): - -- `gatewayUrl` (default `ws://127.0.0.1:18789`) -- `gatewayToken` (if auth enabled) -- `timeoutMs` - -Note: when `gatewayUrl` is set, include `gatewayToken` explicitly. Tools do not inherit config -or environment credentials for overrides, and missing explicit credentials is an error. - -Browser tool: - -- `profile` (optional; defaults to `browser.defaultProfile`) -- `target` (`sandbox` | `host` | `node`) -- `node` (optional; pin a specific node id/name) -- Troubleshooting guides: - - Linux startup/CDP issues: [Browser troubleshooting (Linux)](/tools/browser-linux-troubleshooting) - - WSL2 Gateway + Windows remote Chrome CDP: [WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting) - -## Recommended agent flows - -Browser automation: - -1. `browser` → `status` / `start` -2. `snapshot` (ai or aria) -3. `act` (click/type/press) -4. `screenshot` if you need visual confirmation - -Canvas render: - -1. `canvas` → `present` -2. `a2ui_push` (optional) -3. `snapshot` - -Node targeting: - -1. `nodes` → `status` -2. `describe` on the chosen node -3. `notify` / `run` / `camera_snap` / `screen_record` - -## Safety - -- Avoid direct `system.run`; use `nodes` → `run` only with explicit user consent. -- Respect user consent for camera/screen capture. -- Use `status/describe` to ensure permissions before invoking media commands. - -## How tools are presented to the agent - -Tools are exposed in two parallel channels: - -1. **System prompt text**: a human-readable list + guidance. -2. **Tool schema**: the structured function definitions sent to the model API. - -That means the agent sees both “what tools exist” and “how to call them.” If a tool -doesn’t appear in the system prompt or the schema, the model cannot call it. diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md index 6e502c09c19..fd8e4c5eb92 100644 --- a/docs/tools/lobster.md +++ b/docs/tools/lobster.md @@ -330,7 +330,7 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, ## Learn more - [Plugins](/tools/plugin) -- [Plugin tool authoring](/plugins/agent-tools) +- [Plugin tool authoring](/plugins/building-plugins#registering-agent-tools) ## Case study: community workflows diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7f1ba0fade4..3ede326f0aa 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1,69 +1,64 @@ --- -summary: "OpenClaw plugins/extensions: discovery, config, and safety" +summary: "Install, configure, and manage OpenClaw plugins" read_when: - - Adding or modifying plugins/extensions - - Documenting plugin install or load rules + - Installing or configuring plugins + - Understanding plugin discovery and load rules - Working with Codex/Claude-compatible plugin bundles title: "Plugins" +sidebarTitle: "Install and Configure" --- -# Plugins (Extensions) +# Plugins + +Plugins extend OpenClaw with new capabilities: channels, model providers, tools, +skills, speech, image generation, and more. Some plugins are **core** (shipped +with OpenClaw), others are **external** (published on npm by the community). ## Quick start -A plugin is either: + + + ```bash + openclaw plugins list + ``` + -- a native **OpenClaw plugin** (`openclaw.plugin.json` + runtime module), or -- a compatible **bundle** (`.codex-plugin/plugin.json` or `.claude-plugin/plugin.json`) + + ```bash + # From npm + openclaw plugins install @openclaw/voice-call -Both show up under `openclaw plugins`, but only native OpenClaw plugins execute -runtime code in-process. + # From a local directory or archive + openclaw plugins install ./my-plugin + openclaw plugins install ./my-plugin.tgz + ``` -1. See what is already loaded: + -```bash -openclaw plugins list -``` + + ```bash + openclaw gateway restart + ``` -2. Install an official plugin (example: Voice Call): + Then configure under `plugins.entries.\.config` in your config file. -```bash -openclaw plugins install @openclaw/voice-call -``` + + -Npm specs are registry-only. See [install rules](/cli/plugins#install) for -details on pinning, prerelease gating, and supported spec formats. +## Plugin types -3. Restart the Gateway, then configure under `plugins.entries..config`. +OpenClaw recognizes two plugin formats: -See [Voice Call](/plugins/voice-call) for a concrete example plugin. -Looking for third-party listings? See [Community plugins](/plugins/community). -Need the bundle compatibility details? See [Plugin bundles](/plugins/bundles). +| Format | How it works | Examples | +| ---------- | ------------------------------------------------------------------ | ------------------------------------------------------ | +| **Native** | `openclaw.plugin.json` + runtime module; executes in-process | Official plugins, community npm packages | +| **Bundle** | Codex/Claude/Cursor-compatible layout; mapped to OpenClaw features | `.codex-plugin/`, `.claude-plugin/`, `.cursor-plugin/` | -For compatible bundles, install from a local directory or archive: +Both show up under `openclaw plugins list`. See [Plugin Bundles](/plugins/bundles) for bundle details. -```bash -openclaw plugins install ./my-bundle -openclaw plugins install ./my-bundle.tgz -``` +## Official plugins -For Claude marketplace installs, list the marketplace first, then install by -marketplace entry name: - -```bash -openclaw plugins marketplace list -openclaw plugins install @ -``` - -OpenClaw resolves known Claude marketplace names from -`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit -marketplace source with `--marketplace`. - -## Available plugins (official) - -### Installable plugins - -These are published to npm and installed with `openclaw plugins install`: +### Installable (npm) | Plugin | Package | Docs | | --------------- | ---------------------- | ------------------------------------ | @@ -74,51 +69,34 @@ These are published to npm and installed with `openclaw plugins install`: | Zalo | `@openclaw/zalo` | [Zalo](/channels/zalo) | | Zalo Personal | `@openclaw/zalouser` | [Zalo Personal](/plugins/zalouser) | -Microsoft Teams is plugin-only as of 2026.1.15. +### Core (shipped with OpenClaw) -Packaged installs also ship install-on-demand metadata for heavyweight official -plugins. Today that includes WhatsApp and `memory-lancedb`: onboarding, -`openclaw channels add`, `openclaw channels login --channel whatsapp`, and -other channel setup flows prompt to install them when first used instead of -shipping their full runtime trees inside the main npm tarball. + + + `anthropic`, `byteplus`, `cloudflare-ai-gateway`, `github-copilot`, `google`, + `huggingface`, `kilocode`, `kimi-coding`, `minimax`, `mistral`, `modelstudio`, + `moonshot`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, + `qianfan`, `qwen-portal-auth`, `synthetic`, `together`, `venice`, + `vercel-ai-gateway`, `volcengine`, `xiaomi`, `zai` + -### Bundled plugins + + - `memory-core` — bundled memory search (default via `plugins.slots.memory`) + - `memory-lancedb` — install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`) + -These ship with OpenClaw and are enabled by default unless noted. + + `elevenlabs`, `microsoft` + -**Memory:** + + - `copilot-proxy` — VS Code Copilot Proxy bridge (disabled by default) + + -- `memory-core` -- bundled memory search (default via `plugins.slots.memory`) -- `memory-lancedb` -- install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`) +Looking for third-party plugins? See [Community Plugins](/plugins/community). -**Model providers** (all enabled by default): - -`anthropic`, `byteplus`, `cloudflare-ai-gateway`, `github-copilot`, `google`, `huggingface`, `kilocode`, `kimi-coding`, `minimax`, `mistral`, `modelstudio`, `moonshot`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, `qianfan`, `qwen-portal-auth`, `synthetic`, `together`, `venice`, `vercel-ai-gateway`, `volcengine`, `xiaomi`, `zai` - -**Speech providers** (enabled by default): - -`elevenlabs`, `microsoft` - -**Other bundled:** - -- `copilot-proxy` -- VS Code Copilot Proxy bridge (disabled by default) - -## Compatible bundles - -OpenClaw also recognizes compatible external bundle layouts: - -- Codex-style bundles: `.codex-plugin/plugin.json` -- Claude-style bundles: `.claude-plugin/plugin.json` or the default Claude - component layout without a manifest -- Cursor-style bundles: `.cursor-plugin/plugin.json` - -They are shown in the plugin list as `format=bundle`, with a subtype of -`codex`, `claude`, or `cursor` in verbose/inspect output. - -See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping -behavior, and current support matrix. - -## Config +## Configuration ```json5 { @@ -134,204 +112,140 @@ behavior, and current support matrix. } ``` -Fields: +| Field | Description | +| ---------------- | --------------------------------------------------------- | +| `enabled` | Master toggle (default: `true`) | +| `allow` | Plugin allowlist (optional) | +| `deny` | Plugin denylist (optional; deny wins) | +| `load.paths` | Extra plugin files/directories | +| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) | +| `entries.\` | Per-plugin toggles + config | -- `enabled`: master toggle (default: true) -- `allow`: allowlist (optional) -- `deny`: denylist (optional; deny wins) -- `load.paths`: extra plugin files/dirs -- `slots`: exclusive slot selectors such as `memory` and `contextEngine` -- `entries.`: per-plugin toggles + config +Config changes **require a gateway restart**. -Config changes **require a gateway restart**. See -[Configuration reference](/configuration) for the full config schema. - -Validation rules (strict): - -- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**. -- Unknown `channels.` keys are **errors** unless a plugin manifest declares - the channel id. -- Native plugin config is validated using the JSON Schema embedded in - `openclaw.plugin.json` (`configSchema`). -- Compatible bundles currently do not expose native OpenClaw config schemas. -- If a plugin is disabled, its config is preserved and a **warning** is emitted. - -### Disabled vs missing vs invalid - -These states are intentionally different: - -- **disabled**: plugin exists, but enablement rules turned it off -- **missing**: config references a plugin id that discovery did not find -- **invalid**: plugin exists, but its config does not match the declared schema - -OpenClaw preserves config for disabled plugins so toggling them back on is not -destructive. + + - **Disabled**: plugin exists but enablement rules turned it off. Config is preserved. + - **Missing**: config references a plugin id that discovery did not find. + - **Invalid**: plugin exists but its config does not match the declared schema. + ## Discovery and precedence -OpenClaw scans, in order: +OpenClaw scans for plugins in this order (first match wins): -1. Config paths + + + `plugins.load.paths` — explicit file or directory paths. + -- `plugins.load.paths` (file or directory) + + `\/.openclaw/extensions/*.ts` and `\/.openclaw/extensions/*/index.ts`. + -2. Workspace extensions + + `~/.openclaw/extensions/*.ts` and `~/.openclaw/extensions/*/index.ts`. + -- `/.openclaw/extensions/*.ts` -- `/.openclaw/extensions/*/index.ts` - -3. Global extensions - -- `~/.openclaw/extensions/*.ts` -- `~/.openclaw/extensions/*/index.ts` - -4. Bundled extensions (shipped with OpenClaw; mixed default-on/default-off) - -- `/dist/extensions/*` in packaged installs -- `/dist-runtime/extensions/*` in local built checkouts -- `/extensions/*` in source/Vitest workflows - -Many bundled provider plugins are enabled by default so model catalogs/runtime -hooks stay available without extra setup. Others still require explicit -enablement via `plugins.entries..enabled` or -`openclaw plugins enable `. - -Bundled plugin runtime dependencies are owned by each plugin package. Packaged -builds stage opted-in bundled dependencies under -`dist/extensions//node_modules` instead of requiring mirrored copies in the -root package. Very large official plugins can ship as metadata-only bundled -entries and install their runtime package on demand. npm artifacts ship the -built `dist/extensions/*` tree; source `extensions/*` directories stay in source -checkouts only. - -Installed plugins are enabled by default, but can be disabled the same way. - -Workspace plugins are **disabled by default** unless you explicitly enable them -or allowlist them. This is intentional: a checked-out repo should not silently -become production gateway code. - -If multiple plugins resolve to the same id, the first match in the order above -wins and lower-precedence copies are ignored. + + Shipped with OpenClaw. Many are enabled by default (model providers, speech). + Others require explicit enablement. + + ### Enablement rules -Enablement is resolved after discovery: - - `plugins.enabled: false` disables all plugins -- `plugins.deny` always wins -- `plugins.entries..enabled: false` disables that plugin -- workspace-origin plugins are disabled by default -- allowlists restrict the active set when `plugins.allow` is non-empty -- allowlists are **id-based**, not source-based -- bundled plugins are disabled by default unless: - - the bundled id is in the built-in default-on set, or - - you explicitly enable it, or - - channel config implicitly enables the bundled channel plugin -- exclusive slots can force-enable the selected plugin for that slot +- `plugins.deny` always wins over allow +- `plugins.entries.\.enabled: false` disables that plugin +- Workspace-origin plugins are **disabled by default** (must be explicitly enabled) +- Bundled plugins follow the built-in default-on set unless overridden +- Exclusive slots can force-enable the selected plugin for that slot ## Plugin slots (exclusive categories) -Some plugin categories are **exclusive** (only one active at a time). Use -`plugins.slots` to select which plugin owns the slot: +Some categories are exclusive (only one active at a time): ```json5 { plugins: { slots: { - memory: "memory-core", // or "none" to disable memory plugins - contextEngine: "legacy", // or a plugin id such as "lossless-claw" + memory: "memory-core", // or "none" to disable + contextEngine: "legacy", // or a plugin id }, }, } ``` -Supported exclusive slots: +| Slot | What it controls | Default | +| --------------- | --------------------- | ------------------- | +| `memory` | Active memory plugin | `memory-core` | +| `contextEngine` | Active context engine | `legacy` (built-in) | -- `memory`: active memory plugin (`"none"` disables memory plugins) -- `contextEngine`: active context engine plugin (`"legacy"` is the built-in default) - -If multiple plugins declare `kind: "memory"` or `kind: "context-engine"`, only -the selected plugin loads for that slot. Others are disabled with diagnostics. -Declare `kind` in your [plugin manifest](/plugins/manifest). - -## Plugin IDs - -Default plugin ids: - -- Package packs: `package.json` `name` -- Standalone file: file base name (`~/.../voice-call.ts` -> `voice-call`) - -If a plugin exports `id`, OpenClaw uses it but warns when it does not match the -configured id. - -## Inspection +## CLI reference ```bash -openclaw plugins inspect openai # deep detail on one plugin -openclaw plugins inspect openai --json # machine-readable -openclaw plugins list # compact inventory -openclaw plugins status # operational summary -openclaw plugins doctor # issue-focused diagnostics -``` +openclaw plugins list # compact inventory +openclaw plugins inspect # deep detail +openclaw plugins inspect --json # machine-readable +openclaw plugins status # operational summary +openclaw plugins doctor # diagnostics -## CLI +openclaw plugins install # install from npm +openclaw plugins install # install from local path +openclaw plugins install -l # link (no copy) for dev +openclaw plugins update # update one plugin +openclaw plugins update --all # update all -```bash -openclaw plugins list -openclaw plugins inspect -openclaw plugins install # copy a local file/dir into ~/.openclaw/extensions/ -openclaw plugins install ./extensions/voice-call # relative path ok -openclaw plugins install ./plugin.tgz # install from a local tarball -openclaw plugins install ./plugin.zip # install from a local zip -openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev -openclaw plugins install @openclaw/voice-call # install from npm -openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version -openclaw plugins update -openclaw plugins update --all openclaw plugins enable openclaw plugins disable -openclaw plugins doctor ``` -See [`openclaw plugins` CLI reference](/cli/plugins) for full details on each -command (install rules, inspect output, marketplace installs, uninstall). +See [`openclaw plugins` CLI reference](/cli/plugins) for full details. -Plugins may also register their own top-level commands (example: -`openclaw voicecall`). +## Plugin API overview -## Plugin API (overview) +Plugins export either a function or an object with `register(api)`: -Plugins export either: +```typescript +export default definePluginEntry({ + id: "my-plugin", + name: "My Plugin", + register(api) { + api.registerProvider({ + /* ... */ + }); + api.registerTool({ + /* ... */ + }); + api.registerChannel({ + /* ... */ + }); + }, +}); +``` -- A function: `(api) => { ... }` -- An object: `{ id, name, configSchema, register(api) { ... } }` +Common registration methods: -`register(api)` is where plugins attach behavior. Common registrations include: +| Method | What it registers | +| ------------------------------------ | -------------------- | +| `registerProvider` | Model provider (LLM) | +| `registerChannel` | Chat channel | +| `registerTool` | Agent tool | +| `registerHook` / `on(...)` | Lifecycle hooks | +| `registerSpeechProvider` | Text-to-speech / STT | +| `registerMediaUnderstandingProvider` | Image/audio analysis | +| `registerImageGenerationProvider` | Image generation | +| `registerWebSearchProvider` | Web search | +| `registerHttpRoute` | HTTP endpoint | +| `registerCommand` / `registerCli` | CLI commands | +| `registerContextEngine` | Context engine | +| `registerService` | Background service | -- `registerTool` -- `registerHook` -- `on(...)` for typed lifecycle hooks -- `registerChannel` -- `registerProvider` -- `registerSpeechProvider` -- `registerMediaUnderstandingProvider` -- `registerWebSearchProvider` -- `registerHttpRoute` -- `registerCommand` -- `registerCli` -- `registerContextEngine` -- `registerService` +## Related -See [Plugin manifest](/plugins/manifest) for the manifest file format. - -## Further reading - -- [Plugin architecture and internals](/plugins/architecture) -- capability model, - ownership model, contracts, load pipeline, runtime helpers, and developer API - reference -- [Building extensions](/plugins/building-extensions) -- [Plugin bundles](/plugins/bundles) -- [Plugin manifest](/plugins/manifest) -- [Plugin agent tools](/plugins/agent-tools) -- [Capability Cookbook](/tools/capability-cookbook) -- [Community plugins](/plugins/community) +- [Building Plugins](/plugins/building-plugins) — create your own plugin +- [Plugin Bundles](/plugins/bundles) — Codex/Claude/Cursor bundle compatibility +- [Plugin Manifest](/plugins/manifest) — manifest schema +- [Registering Tools](/plugins/building-plugins#registering-agent-tools) — add agent tools in a plugin +- [Plugin Internals](/plugins/architecture) — capability model and load pipeline +- [Community Plugins](/plugins/community) — third-party listings diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index 17f9cfbb7f9..56d6b5942e7 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -1,23 +1,64 @@ --- -summary: "Reaction semantics shared across channels" +summary: "Reaction tool semantics across all supported channels" read_when: - Working on reactions in any channel + - Understanding how emoji reactions differ across platforms title: "Reactions" --- -# Reaction tooling +# Reactions -Shared reaction semantics across channels: +The agent can add and remove emoji reactions on messages using the `message` +tool with the `react` action. Reaction behavior varies by channel. + +## How it works + +```json +{ + "action": "react", + "messageId": "msg-123", + "emoji": "thumbsup" +} +``` - `emoji` is required when adding a reaction. -- `emoji=""` removes the bot's reaction(s) when supported. -- `remove: true` removes the specified emoji when supported (requires `emoji`). +- Set `emoji` to an empty string (`""`) to remove the bot's reaction(s). +- Set `remove: true` to remove a specific emoji (requires non-empty `emoji`). -Channel notes: +## Channel behavior -- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji. -- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji. -- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation. -- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`). -- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction. -- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled. + + + - Empty `emoji` removes all of the bot's reactions on the message. + - `remove: true` removes just the specified emoji. + + + + - Empty `emoji` removes the app's reactions on the message. + - `remove: true` removes just the specified emoji. + + + + - Empty `emoji` removes the bot's reactions. + - `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation. + + + + - Empty `emoji` removes the bot reaction. + - `remove: true` maps to empty emoji internally (still requires `emoji` in the tool call). + + + + - Requires non-empty `emoji`. + - `remove: true` removes that specific emoji reaction. + + + + - Inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled. + + + +## Related + +- [Agent Send](/tools/agent-send) — the `message` tool that includes `react` +- [Channels](/channels) — channel-specific configuration diff --git a/extensions/acpx/runtime-api.ts b/extensions/acpx/runtime-api.ts index 8d1d125f226..7a8a555a9a7 100644 --- a/extensions/acpx/runtime-api.ts +++ b/extensions/acpx/runtime-api.ts @@ -1 +1,38 @@ -export * from "openclaw/plugin-sdk/acpx"; +export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime"; +export { + AcpRuntimeError, + registerAcpRuntimeBackend, + unregisterAcpRuntimeBackend, +} from "openclaw/plugin-sdk/acp-runtime"; +export type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeDoctorReport, + AcpRuntimeEnsureInput, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeStatus, + AcpRuntimeTurnInput, + AcpSessionUpdateTag, +} from "openclaw/plugin-sdk/acp-runtime"; +export type { + OpenClawPluginApi, + OpenClawPluginConfigSchema, + OpenClawPluginService, + OpenClawPluginServiceContext, + PluginLogger, +} from "openclaw/plugin-sdk/core"; +export type { + WindowsSpawnProgram, + WindowsSpawnProgramCandidate, + WindowsSpawnResolution, +} from "openclaw/plugin-sdk/windows-spawn"; +export { + applyWindowsSpawnProgramPolicy, + materializeWindowsSpawnProgram, + resolveWindowsSpawnProgramCandidate, +} from "openclaw/plugin-sdk/windows-spawn"; +export { + listKnownProviderAuthEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "openclaw/plugin-sdk/provider-env-vars"; diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 02cda25b5bc..677e1ae9703 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -1,4 +1,3 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { bluebubblesMessageActions } from "./actions.js"; import { sendBlueBubblesAttachment } from "./attachments.js"; @@ -6,6 +5,7 @@ import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js"; import { resolveBlueBubblesMessageId } from "./monitor.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { sendBlueBubblesReaction } from "./reactions.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; vi.mock("./accounts.js", async () => { diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index cb40ca810e3..0b5ee8bbf02 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -1,8 +1,8 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import "./test-mocks.js"; import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; +import "./test-mocks.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import type { PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; import { BLUE_BUBBLES_PRIVATE_API_STATUS, diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 4d4b411a639..5719b12e22b 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -4,15 +4,15 @@ 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-pairing"; import { createOpenGroupPolicyRestrictSendersWarningCollector, projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; -import { - createAttachedChannelResultAdapter, - createPairingPrefixStripper, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, diff --git a/extensions/bluebubbles/src/media-send.test.ts b/extensions/bluebubbles/src/media-send.test.ts index 59fe82cbeae..ad1523c7863 100644 --- a/extensions/bluebubbles/src/media-send.test.ts +++ b/extensions/bluebubbles/src/media-send.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sendBlueBubblesMedia } from "./media-send.js"; +import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; const sendBlueBubblesAttachmentMock = vi.hoisted(() => vi.fn()); diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 17467465d82..5ff26e2dc96 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -1,6 +1,5 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; @@ -12,6 +11,7 @@ import { resolveBlueBubblesMessageId, _resetBlueBubblesShortIdState, } from "./monitor.js"; +import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; // Mock dependencies diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index 8d98b0c45eb..aacbb437841 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -1,6 +1,5 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; @@ -11,6 +10,7 @@ import { resolveBlueBubblesMessageId, _resetBlueBubblesShortIdState, } from "./monitor.js"; +import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; // Mock dependencies diff --git a/extensions/bluebubbles/src/monitor.webhook-route.test.ts b/extensions/bluebubbles/src/monitor.webhook-route.test.ts index fc48606b8ed..cb30d9edb01 100644 --- a/extensions/bluebubbles/src/monitor.webhook-route.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-route.test.ts @@ -1,9 +1,9 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { WebhookTarget } from "./monitor-shared.js"; import { registerBlueBubblesWebhookTarget } from "./monitor.js"; +import type { OpenClawConfig } from "./runtime-api.js"; function createTarget(): WebhookTarget { return { diff --git a/extensions/bluebubbles/src/runtime-api.ts b/extensions/bluebubbles/src/runtime-api.ts index 23c09660d96..4faebbed877 100644 --- a/extensions/bluebubbles/src/runtime-api.ts +++ b/extensions/bluebubbles/src/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/bluebubbles"; +export * from "../../../src/plugin-sdk/bluebubbles.js"; diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index ecb8b1f68e0..7d79f475a56 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,7 +1,7 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import "./test-mocks.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import "./test-mocks.js"; +import type { PluginRuntime } from "./runtime-api.js"; import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js"; import { diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index 605c5cecc76..833ac88522e 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -5,7 +5,7 @@ import { type ParsedChatTarget, resolveServicePrefixedAllowTarget, resolveServicePrefixedTarget, -} from "openclaw/plugin-sdk/imessage-core"; +} from "../../imessage/api.js"; export type BlueBubblesService = "imessage" | "sms" | "auto"; diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts index b715ad46c5a..de70c603e23 100644 --- a/extensions/chutes/index.ts +++ b/extensions/chutes/index.ts @@ -1,11 +1,11 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { - buildOauthProviderAuthResult, createProviderApiKeyAuthMethod, resolveOAuthApiKeyMarker, type ProviderAuthContext, type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { loginChutes } from "openclaw/plugin-sdk/provider-auth-login"; import { CHUTES_DEFAULT_MODEL_REF, diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts index 299ad90f05d..eb4001b8a91 100644 --- a/extensions/device-pair/api.ts +++ b/extensions/device-pair/api.ts @@ -1 +1,8 @@ -export * from "openclaw/plugin-sdk/device-pair"; +export { + approveDevicePairing, + issueDeviceBootstrapToken, + listDevicePairing, +} from "openclaw/plugin-sdk/device-bootstrap"; +export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core"; +export { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/sandbox"; diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index 7e0a28ec7fd..994245461ed 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -2,7 +2,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco import { hasConfiguredSecretInput, normalizeSecretInputString, -} from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/secret-input"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts index e63d00f23ec..fcb3cf530b6 100644 --- a/extensions/discord/src/actions/handle-action.guild-admin.ts +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -5,7 +5,7 @@ import { readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { handleDiscordAction } from "./runtime.js"; import { isDiscordModerationAction, diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 9726b07cdda..e0f91daa668 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -5,8 +5,8 @@ import { readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; -import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 1c6b9b5c70f..51fb193b58e 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -1,12 +1,12 @@ import { createUnionActionGate, listTokenSourcedAccounts, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-actions"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-contract"; import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; import { handleDiscordMessageAction } from "./actions/handle-action.js"; diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index b5f2224b1dd..152223f12a9 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -1,13 +1,13 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChannelAccountSnapshot, ChannelGatewayContext, - OpenClawConfig, - PluginRuntime, -} from "openclaw/plugin-sdk/discord"; -import { afterEach, describe, expect, it, vi } from "vitest"; +} from "../../../src/channels/plugins/types.js"; +import type { PluginRuntime } from "../../../src/plugins/runtime/types.js"; import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ResolvedDiscordAccount } from "./accounts.js"; import { discordPlugin } from "./channel.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { setDiscordRuntime } from "./runtime.js"; const probeDiscordMock = vi.hoisted(() => vi.fn()); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 0ddb5c9e19f..63f11ede836 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -5,20 +5,29 @@ import { 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 { - createAttachedChannelResultAdapter, - createChannelDirectoryAdapter, createPairingPrefixStripper, - createTopLevelChannelReplyToModeResolver, - createRuntimeDirectoryLiveAdapter, createTextPairingAdapter, - normalizeMessageChannel, +} from "openclaw/plugin-sdk/channel-pairing"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/channel-targets"; +import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { + createChannelDirectoryAdapter, + createRuntimeDirectoryLiveAdapter, +} from "openclaw/plugin-sdk/directory-runtime"; +import { + createRuntimeOutboundDelegates, 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"; +} from "openclaw/plugin-sdk/infra-runtime"; +import { + buildOutboundBaseSessionKey, + normalizeMessageChannel, + normalizeOutboundThreadId, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk/routing"; import { listDiscordAccountIds, resolveDiscordAccount, diff --git a/extensions/discord/src/config-schema.ts b/extensions/discord/src/config-schema.ts index a6866fc092d..6498c77a9fb 100644 --- a/extensions/discord/src/config-schema.ts +++ b/extensions/discord/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core"; +import { buildChannelConfigSchema, DiscordConfigSchema } from "./runtime-api.js"; export const DiscordChannelConfigSchema = buildChannelConfigSchema(DiscordConfigSchema); diff --git a/extensions/discord/src/directory-live.ts b/extensions/discord/src/directory-live.ts index 6bd38204a0a..67a8e908f7c 100644 --- a/extensions/discord/src/directory-live.ts +++ b/extensions/discord/src/directory-live.ts @@ -1,5 +1,7 @@ -import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelDirectoryEntry, + DirectoryConfigParams, +} from "openclaw/plugin-sdk/directory-runtime"; import { resolveDiscordAccount } from "./accounts.js"; import { fetchDiscord } from "./api.js"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; diff --git a/extensions/discord/src/draft-stream.ts b/extensions/discord/src/draft-stream.ts index a12348334bc..ab49b13fbc3 100644 --- a/extensions/discord/src/draft-stream.ts +++ b/extensions/discord/src/draft-stream.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-runtime"; +import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-lifecycle"; /** Discord messages cap at 2000 characters. */ const DISCORD_STREAM_MAX_CHARS = 2000; diff --git a/extensions/discord/src/group-policy.ts b/extensions/discord/src/group-policy.ts index a5a8ebac5eb..9394e319818 100644 --- a/extensions/discord/src/group-policy.ts +++ b/extensions/discord/src/group-policy.ts @@ -1,9 +1,9 @@ +import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { resolveToolsBySender, type GroupToolPolicyBySenderConfig, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; -import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core"; import type { DiscordConfig } from "./runtime-api.js"; diff --git a/extensions/discord/src/monitor/agent-components-helpers.ts b/extensions/discord/src/monitor/agent-components-helpers.ts index eecbe73c351..b7c247d1f07 100644 --- a/extensions/discord/src/monitor/agent-components-helpers.ts +++ b/extensions/discord/src/monitor/agent-components-helpers.ts @@ -11,7 +11,7 @@ import { import type { APIStringSelectComponent } from "discord-api-types/v10"; import { ChannelType } from "discord-api-types/v10"; import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 0fa42d0e23c..429b575b140 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -19,8 +19,11 @@ import { import type { APIStringSelectComponent } from "discord-api-types/v10"; import { ButtonStyle, ChannelType } from "discord-api-types/v10"; import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/channel-inbound"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; @@ -31,6 +34,7 @@ import { parsePluginBindingApprovalCustomId, resolvePluginConversationBindingApproval, } from "openclaw/plugin-sdk/conversation-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { @@ -38,10 +42,6 @@ import { type PluginInteractiveDiscordHandlerContext, } from "openclaw/plugin-sdk/plugin-runtime"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/discord/src/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts index 31d95f2f45b..37508b9a092 100644 --- a/extensions/discord/src/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -1,11 +1,11 @@ import type { Guild, User } from "@buape/carbon"; -import type { AllowlistMatch } from "openclaw/plugin-sdk/channel-runtime"; +import type { AllowlistMatch } from "openclaw/plugin-sdk/allow-from"; import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, resolveChannelMatchConfig, type ChannelMatchSource, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-targets"; import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { formatDiscordUserTag } from "./format.js"; diff --git a/extensions/discord/src/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts index 1e8f1afbb4b..f668545f733 100644 --- a/extensions/discord/src/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -1,4 +1,4 @@ -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index 607d5088ad1..c30d0c082e9 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -10,7 +10,6 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; -import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; @@ -24,7 +23,11 @@ import type { ExecApprovalRequest, ExecApprovalResolved, } from "openclaw/plugin-sdk/infra-runtime"; -import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; +import { + normalizeAccountId, + normalizeMessageChannel, + resolveAgentIdFromSessionKey, +} from "openclaw/plugin-sdk/routing"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime"; import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/discord/src/monitor/inbound-worker.ts b/extensions/discord/src/monitor/inbound-worker.ts index 33986e458a3..c00b7dc1c1d 100644 --- a/extensions/discord/src/monitor/inbound-worker.ts +++ b/extensions/discord/src/monitor/inbound-worker.ts @@ -1,4 +1,4 @@ -import { createRunStateMachine } from "openclaw/plugin-sdk/channel-runtime"; +import { createRunStateMachine } from "openclaw/plugin-sdk/channel-lifecycle"; import { formatDurationSeconds } from "openclaw/plugin-sdk/infra-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { danger } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 9094cabb645..55822830cd5 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -1,9 +1,15 @@ import { ChannelType, MessageType, type Message, type User } from "@buape/carbon"; import { Routes, type APIMessage } from "discord-api-types/v10"; -import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; +import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from"; +import { + buildMentionRegexes, + logInboundDrop, + matchesMentionWithExplicit, + resolveMentionGatingWithBypass, +} from "openclaw/plugin-sdk/channel-inbound"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; +import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-auth"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { @@ -18,13 +24,10 @@ import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { shouldHandleTextCommands } from "openclaw/plugin-sdk/reply-runtime"; import { recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts index 368352e1551..575d8ee165b 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -1,7 +1,7 @@ import type { ChannelType, Client, User } from "@buape/carbon"; import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js"; import type { DiscordChannelInfo } from "./message-utils.js"; diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 42f2011d62a..b381013349e 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -1,31 +1,32 @@ import { ChannelType, type RequestClient } from "@buape/carbon"; import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { shouldAckReaction as shouldAckReactionGate } from "openclaw/plugin-sdk/channel-runtime"; -import { logTypingFailure, logAckFailure } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; import { createStatusReactionController, DEFAULT_TIMING, + logAckFailure, + logTypingFailure, + shouldAckReaction as shouldAckReactionGate, type StatusReactionAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-feedback"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/channel-inbound"; +import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-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 { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; +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 { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/discord/src/monitor/message-handler.ts b/extensions/discord/src/monitor/message-handler.ts index 400f35a2529..e17dcc906af 100644 --- a/extensions/discord/src/monitor/message-handler.ts +++ b/extensions/discord/src/monitor/message-handler.ts @@ -2,7 +2,7 @@ import type { Client } from "@buape/carbon"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-inbound"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import { danger } from "openclaw/plugin-sdk/runtime-env"; import { buildDiscordInboundJob } from "./inbound-job.js"; diff --git a/extensions/discord/src/monitor/message-utils.ts b/extensions/discord/src/monitor/message-utils.ts index 4e84f4b3827..e0eb58c9266 100644 --- a/extensions/discord/src/monitor/message-utils.ts +++ b/extensions/discord/src/monitor/message-utils.ts @@ -1,9 +1,9 @@ import type { ChannelType, Client, Message } from "@buape/carbon"; import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; -import { buildMediaPayload } from "openclaw/plugin-sdk/channel-runtime"; import type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { fetchRemoteMedia, type FetchLike } from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; const DISCORD_CDN_HOSTNAMES = [ diff --git a/extensions/discord/src/monitor/model-picker.test-utils.ts b/extensions/discord/src/monitor/model-picker.test-utils.ts index 56dcd7480c1..60b1c41e8ba 100644 --- a/extensions/discord/src/monitor/model-picker.test-utils.ts +++ b/extensions/discord/src/monitor/model-picker.test-utils.ts @@ -1,4 +1,4 @@ -import type { ModelsProviderData } from "openclaw/plugin-sdk/reply-runtime"; +import type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth"; export function createModelsProviderData( entries: Record, diff --git a/extensions/discord/src/monitor/model-picker.ts b/extensions/discord/src/monitor/model-picker.ts index ec067ede2dd..47313af5801 100644 --- a/extensions/discord/src/monitor/model-picker.ts +++ b/extensions/discord/src/monitor/model-picker.ts @@ -12,11 +12,8 @@ import { import type { APISelectMenuOption } from "discord-api-types/v10"; import { ButtonStyle } from "discord-api-types/v10"; import { normalizeProviderId } from "openclaw/plugin-sdk/agent-runtime"; +import { buildModelsProviderData, type ModelsProviderData } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - buildModelsProviderData, - type ModelsProviderData, -} from "openclaw/plugin-sdk/reply-runtime"; export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk"; export const DISCORD_CUSTOM_ID_MAX_CHARS = 100; diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 158336d2435..27e129b0bee 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -117,8 +117,8 @@ vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (import }; }); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index 07dc0bf0a76..81b97bede15 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -1,4 +1,4 @@ -import type { CommandArgs } from "openclaw/plugin-sdk/reply-runtime"; +import type { CommandArgs } from "openclaw/plugin-sdk/command-auth"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js"; import { buildDiscordInboundAccessContext } from "./inbound-context.js"; diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 5c31e81ed8f..314c31f11bf 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -11,22 +11,20 @@ import { type StringSelectMenuInteraction, } from "@buape/carbon"; import { ButtonStyle } from "discord-api-types/v10"; -import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { buildCommandTextFromArgs, findCommandByNativeName, listChatCommands, resolveCommandArgChoices, + resolveStoredModelOverride, serializeCommandArgs, -} from "openclaw/plugin-sdk/reply-runtime"; -import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime"; -import type { - ChatCommandDefinition, - CommandArgDefinition, - CommandArgValues, - CommandArgs, -} from "openclaw/plugin-sdk/reply-runtime"; + type ChatCommandDefinition, + type CommandArgDefinition, + type CommandArgValues, + type CommandArgs, +} from "openclaw/plugin-sdk/command-auth"; +import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { chunkItems, withTimeout } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 315e87b7e6f..d00fab280f0 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -13,8 +13,24 @@ import { import { ApplicationCommandOptionType } from "discord-api-types/v10"; import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime"; +import { + resolveCommandAuthorizedFromAuthorizers, + resolveNativeCommandSessionTargets, +} from "openclaw/plugin-sdk/command-auth"; +import { + buildCommandTextFromArgs, + findCommandByNativeName, + listChatCommands, + parseCommandArgs, + resolveCommandArgChoices, + resolveCommandArgMenu, + serializeCommandArgs, + type ChatCommandDefinition, + type CommandArgDefinition, + type CommandArgValues, + type CommandArgs, + type NativeCommandSpec, +} from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; @@ -30,22 +46,6 @@ import { resolveTextChunksWithFallback, } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import type { - ChatCommandDefinition, - CommandArgDefinition, - CommandArgValues, - CommandArgs, - NativeCommandSpec, -} from "openclaw/plugin-sdk/reply-runtime"; -import { - buildCommandTextFromArgs, - findCommandByNativeName, - listChatCommands, - parseCommandArgs, - resolveCommandArgChoices, - resolveCommandArgMenu, - serializeCommandArgs, -} from "openclaw/plugin-sdk/reply-runtime"; import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/provider.allowlist.ts b/extensions/discord/src/monitor/provider.allowlist.ts index ac6c89dd9f8..8cd945da823 100644 --- a/extensions/discord/src/monitor/provider.allowlist.ts +++ b/extensions/discord/src/monitor/provider.allowlist.ts @@ -4,7 +4,7 @@ import { canonicalizeAllowlistWithResolvedIds, patchAllowlistUsersInConfigEntries, summarizeMapping, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/allow-from"; import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index b2a9e8a6019..884a0bded57 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -1,6 +1,6 @@ import type { Client } from "@buape/carbon"; import type { GatewayPlugin } from "@buape/carbon/gateway"; -import { createArmableStallWatchdog } from "openclaw/plugin-sdk/channel-runtime"; +import { createArmableStallWatchdog } from "openclaw/plugin-sdk/channel-lifecycle"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 8dbb6df29f5..523f7c54c36 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -14,10 +14,10 @@ import { Routes } from "discord-api-types/v10"; import { getAcpSessionManager } from "openclaw/plugin-sdk/acp-runtime"; import { isAcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime"; import { - resolveThreadBindingIdleTimeoutMs, - resolveThreadBindingMaxAgeMs, - resolveThreadBindingsEnabled, -} from "openclaw/plugin-sdk/channel-runtime"; + listNativeCommandSpecsForConfig, + listSkillCommandsForAgents, + type NativeCommandSpec, +} from "openclaw/plugin-sdk/command-auth"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, @@ -32,14 +32,16 @@ import { resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingMaxAgeMs, + resolveThreadBindingsEnabled, +} from "openclaw/plugin-sdk/conversation-runtime"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import type { NativeCommandSpec } from "openclaw/plugin-sdk/reply-runtime"; -import { listNativeCommandSpecsForConfig } from "openclaw/plugin-sdk/reply-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; -import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime"; import { danger, isVerbose, diff --git a/extensions/discord/src/monitor/thread-bindings.config.ts b/extensions/discord/src/monitor/thread-bindings.config.ts index 701defcfbe1..a6520c5e868 100644 --- a/extensions/discord/src/monitor/thread-bindings.config.ts +++ b/extensions/discord/src/monitor/thread-bindings.config.ts @@ -1,9 +1,9 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveThreadBindingIdleTimeoutMs, resolveThreadBindingMaxAgeMs, resolveThreadBindingsEnabled, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/conversation-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; export { diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index 5c37ac4bbf0..0fa8f09aac0 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -1,8 +1,8 @@ import { Routes } from "discord-api-types/v10"; -import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; import { getRuntimeConfigSnapshot, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { registerSessionBindingAdapter, + resolveThreadBindingConversationIdFromBindingId, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, diff --git a/extensions/discord/src/monitor/thread-bindings.messages.ts b/extensions/discord/src/monitor/thread-bindings.messages.ts index 043e888b7fc..1e0a1f3cbb2 100644 --- a/extensions/discord/src/monitor/thread-bindings.messages.ts +++ b/extensions/discord/src/monitor/thread-bindings.messages.ts @@ -1,6 +1,6 @@ export { - formatThreadBindingDurationLabel, resolveThreadBindingFarewellText, resolveThreadBindingIntroText, resolveThreadBindingThreadName, -} from "openclaw/plugin-sdk/channel-runtime"; + formatThreadBindingDurationLabel, +} from "openclaw/plugin-sdk/conversation-runtime"; diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 8b18fffec90..471cf841aa8 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -1,16 +1,15 @@ +import { + attachChannelToResult, + type ChannelOutboundAdapter, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveOutboundSendDep, type OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import { resolvePayloadMediaUrls, 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"; +} from "openclaw/plugin-sdk/reply-payload"; import type { DiscordComponentMessageSpec } from "./components.js"; import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js"; import { normalizeDiscordOutboundTarget } from "./normalize.js"; diff --git a/extensions/discord/src/probe.ts b/extensions/discord/src/probe.ts index f84b4aad10a..cdd662718eb 100644 --- a/extensions/discord/src/probe.ts +++ b/extensions/discord/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; import { normalizeDiscordToken } from "./token.js"; diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index 0d355ab506f..7d9bc355184 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -4,7 +4,7 @@ export { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "../../../src/plugin-sdk/discord.js"; export { buildChannelConfigSchema, getChatChannelMeta, @@ -19,15 +19,15 @@ export { type DiscordActionConfig, type DiscordConfig, type OpenClawConfig, -} from "openclaw/plugin-sdk/discord-core"; -export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core"; +} from "../../../src/plugin-sdk/discord-core.js"; +export { DiscordConfigSchema } from "../../../src/plugin-sdk/discord-core.js"; export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; export { assertMediaNotDataUrl, parseAvailableTags, readReactionParams, withNormalizedTimestamp, -} from "openclaw/plugin-sdk/discord-core"; +} from "../../../src/plugin-sdk/discord-core.js"; export { createHybridChannelConfigAdapter, createScopedChannelConfigAdapter, @@ -44,9 +44,9 @@ export { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; export type { ChannelMessageActionAdapter, ChannelMessageActionName, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-contract"; export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/secret-input"; diff --git a/extensions/discord/src/session-key-normalization.ts b/extensions/discord/src/session-key-normalization.ts index 06164d6aba5..f63524428c0 100644 --- a/extensions/discord/src/session-key-normalization.ts +++ b/extensions/discord/src/session-key-normalization.ts @@ -1,4 +1,4 @@ -import { normalizeChatType } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeChatType } from "openclaw/plugin-sdk/account-resolution"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; export function normalizeExplicitDiscordSessionKey( diff --git a/extensions/discord/src/setup-account-state.ts b/extensions/discord/src/setup-account-state.ts index 725e6e4037e..2adbcacb424 100644 --- a/extensions/discord/src/setup-account-state.ts +++ b/extensions/discord/src/setup-account-state.ts @@ -1,9 +1,9 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { hasConfiguredSecretInput, normalizeSecretInputString, - type OpenClawConfig, -} from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/secret-input"; import type { DiscordAccountConfig } from "./runtime-api.js"; import { resolveDiscordToken } from "./token.js"; diff --git a/extensions/discord/src/status-issues.ts b/extensions/discord/src/status-issues.ts index 4fa26fd011b..f095221483e 100644 --- a/extensions/discord/src/status-issues.ts +++ b/extensions/discord/src/status-issues.ts @@ -1,13 +1,13 @@ +import type { + ChannelAccountSnapshot, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; import { appendMatchMetadata, asString, isRecord, resolveEnabledConfiguredAccountId, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { - ChannelAccountSnapshot, - ChannelStatusIssue, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/status-helpers"; type DiscordIntentSummary = { messageContent?: "enabled" | "limited" | "disabled"; diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index a05db63043a..927ae73b0d3 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getRequiredHookHandler, diff --git a/extensions/discord/src/targets.ts b/extensions/discord/src/targets.ts index 3660f75921e..cb04a96d914 100644 --- a/extensions/discord/src/targets.ts +++ b/extensions/discord/src/targets.ts @@ -1,4 +1,3 @@ -import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; import { buildMessagingTarget, parseMentionPrefixOrAtUserTarget, @@ -6,7 +5,8 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-targets"; +import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js"; diff --git a/extensions/discord/src/token.ts b/extensions/discord/src/token.ts index 2a979ca4b3b..b9614e59794 100644 --- a/extensions/discord/src/token.ts +++ b/extensions/discord/src/token.ts @@ -1,7 +1,7 @@ -import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; export type DiscordTokenSource = "env" | "config" | "none"; diff --git a/extensions/discord/src/voice/command.ts b/extensions/discord/src/voice/command.ts index 3ed7aa2ccdb..0d9bf5124d6 100644 --- a/extensions/discord/src/voice/command.ts +++ b/extensions/discord/src/voice/command.ts @@ -10,7 +10,7 @@ import { ChannelType as DiscordChannelType, type APIApplicationCommandChannelOption, } from "discord-api-types/v10"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts index 1257d4a7f00..cde6bbf5569 100644 --- a/extensions/feishu/runtime-api.ts +++ b/extensions/feishu/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/feishu"; +// Private runtime barrel for the bundled Feishu extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/feishu.js"; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 97fd5dd068d..4eac10cc0cd 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,21 +1,23 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions"; import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-contract"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; 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"; +} from "openclaw/plugin-sdk/directory-runtime"; +import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "../runtime-api.js"; import { diff --git a/extensions/feishu/src/thread-bindings.ts b/extensions/feishu/src/thread-bindings.ts index cfae8fb2058..842374155b3 100644 --- a/extensions/feishu/src/thread-bindings.ts +++ b/extensions/feishu/src/thread-bindings.ts @@ -1,11 +1,9 @@ -import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { registerSessionBindingAdapter, + resolveThreadBindingConversationIdFromBindingId, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, diff --git a/extensions/firecrawl/src/config.ts b/extensions/firecrawl/src/config.ts index 3f2d6a82f8a..3c2c2f3c25d 100644 --- a/extensions/firecrawl/src/config.ts +++ b/extensions/firecrawl/src/config.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { normalizeSecretInput } from "openclaw/plugin-sdk/provider-auth"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30; diff --git a/extensions/firecrawl/src/firecrawl-client.ts b/extensions/firecrawl/src/firecrawl-client.ts index 565e1d6aac3..fa38c5bdabe 100644 --- a/extensions/firecrawl/src/firecrawl-client.ts +++ b/extensions/firecrawl/src/firecrawl-client.ts @@ -1,11 +1,10 @@ import { markdownToText, truncateText } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { withTrustedWebToolsEndpoint } from "openclaw/plugin-sdk/provider-web-search"; import { DEFAULT_CACHE_TTL_MINUTES, normalizeCacheKey, + postTrustedWebToolsJson, readCache, - readResponseText, resolveCacheTtlMs, writeCache, } from "openclaw/plugin-sdk/provider-web-search"; @@ -29,7 +28,6 @@ const SCRAPE_CACHE = new Map< >(); const DEFAULT_SEARCH_COUNT = 5; const DEFAULT_SCRAPE_MAX_CHARS = 50_000; -const DEFAULT_ERROR_MAX_BYTES = 64_000; type FirecrawlSearchItem = { title: string; @@ -88,51 +86,6 @@ function resolveSiteName(urlRaw: string): string | undefined { } } -async function postFirecrawlJson(params: { - baseUrl: string; - pathname: "/v2/search" | "/v2/scrape"; - apiKey: string; - body: Record; - timeoutSeconds: number; - errorLabel: string; -}): Promise> { - const endpoint = resolveEndpoint(params.baseUrl, params.pathname); - return await withTrustedWebToolsEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(params.body), - }, - }, - async ({ response }) => { - if (!response.ok) { - const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES }); - throw new Error( - `${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`, - ); - } - const payload = (await response.json()) as Record; - if (payload.success === false) { - const error = - typeof payload.error === "string" - ? payload.error - : typeof payload.message === "string" - ? payload.message - : "unknown error"; - throw new Error(`${params.errorLabel} API error: ${error}`); - } - return payload; - }, - ); -} - function resolveSearchItems(payload: Record): FirecrawlSearchItem[] { const candidates = [ payload.data, @@ -279,14 +232,28 @@ export async function runFirecrawlSearch( } const start = Date.now(); - const payload = await postFirecrawlJson({ - baseUrl, - pathname: "/v2/search", - apiKey, - body, - timeoutSeconds, - errorLabel: "Firecrawl Search", - }); + const payload = await postTrustedWebToolsJson( + { + url: resolveEndpoint(baseUrl, "/v2/search"), + timeoutSeconds, + apiKey, + body, + errorLabel: "Firecrawl Search", + }, + async (response) => { + const payload = (await response.json()) as Record; + if (payload.success === false) { + const error = + typeof payload.error === "string" + ? payload.error + : typeof payload.message === "string" + ? payload.message + : "unknown error"; + throw new Error(`Firecrawl Search API error: ${error}`); + } + return payload; + }, + ); const result = buildSearchPayload({ query: params.query, provider: "firecrawl", @@ -409,22 +376,24 @@ export async function runFirecrawlScrape( return { ...cached.value, cached: true }; } - const payload = await postFirecrawlJson({ - baseUrl, - pathname: "/v2/scrape", - apiKey, - timeoutSeconds, - errorLabel: "Firecrawl", - body: { - url: params.url, - formats: ["markdown"], - onlyMainContent, - timeout: timeoutSeconds * 1000, - maxAge: maxAgeMs, - proxy, - storeInCache, + const payload = await postTrustedWebToolsJson( + { + url: resolveEndpoint(baseUrl, "/v2/scrape"), + timeoutSeconds, + apiKey, + errorLabel: "Firecrawl", + body: { + url: params.url, + formats: ["markdown"], + onlyMainContent, + timeout: timeoutSeconds * 1000, + maxAge: maxAgeMs, + proxy, + storeInCache, + }, }, - }); + async (response) => (await response.json()) as Record, + ); const result = parseFirecrawlScrapePayload({ payload, url: params.url, diff --git a/extensions/google/runtime-api.ts b/extensions/google/runtime-api.ts index 7deb5b38f92..9b2b8047998 100644 --- a/extensions/google/runtime-api.ts +++ b/extensions/google/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/google"; +export { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/provider-google"; diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 9eecea28139..cd47c0e56c7 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Google Chat extension. -// Keep this barrel thin and aligned with the curated plugin-sdk/googlechat surface. +// Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/googlechat"; +export * from "../../src/plugin-sdk/googlechat.js"; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index fc4cf489928..e8917d13c04 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -3,19 +3,17 @@ import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing"; import { composeWarningCollectors, createAllowlistProviderGroupPolicyWarningCollector, createConditionalWarningCollector, createAllowlistProviderOpenWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; import { - createAttachedChannelResultAdapter, createChannelDirectoryAdapter, - createTopLevelChannelReplyToModeResolver, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; -import { listResolvedDirectoryGroupEntriesFromMapKeys, listResolvedDirectoryUserEntriesFromAllowFrom, } from "openclaw/plugin-sdk/directory-runtime"; diff --git a/extensions/imessage/runtime-api.ts b/extensions/imessage/runtime-api.ts index aa6d55c75e5..22b1e4a21ba 100644 --- a/extensions/imessage/runtime-api.ts +++ b/extensions/imessage/runtime-api.ts @@ -13,7 +13,7 @@ export { IMessageConfigSchema, type ChannelPlugin, type IMessageAccountConfig, -} from "openclaw/plugin-sdk/imessage"; +} from "../../src/plugin-sdk/imessage.js"; export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, diff --git a/extensions/imessage/src/channel.runtime.ts b/extensions/imessage/src/channel.runtime.ts index 32cd39a1d64..5ee80d614d6 100644 --- a/extensions/imessage/src/channel.runtime.ts +++ b/extensions/imessage/src/channel.runtime.ts @@ -1,4 +1,4 @@ -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "../runtime-api.js"; import type { ResolvedIMessageAccount } from "./accounts.js"; import { monitorIMessageProvider } from "./monitor.js"; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index d084ee92a15..5257e32f349 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,12 +1,9 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { - createAttachedChannelResultAdapter, - resolveOutboundSendDep, -} from "openclaw/plugin-sdk/channel-runtime"; -import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import { type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, diff --git a/extensions/imessage/src/config-schema.ts b/extensions/imessage/src/config-schema.ts index dc960ccdb0e..230c31ce089 100644 --- a/extensions/imessage/src/config-schema.ts +++ b/extensions/imessage/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, IMessageConfigSchema } from "openclaw/plugin-sdk/imessage-core"; +import { buildChannelConfigSchema, IMessageConfigSchema } from "../runtime-api.js"; export const IMessageChannelConfigSchema = buildChannelConfigSchema(IMessageConfigSchema); diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 531a8324dfd..358ecf26f17 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -1,24 +1,25 @@ -import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; +import { + buildMentionRegexes, + type EnvelopeFormatOptions, + formatInboundEnvelope, + formatInboundFromLabel, + logInboundDrop, + matchesMentionPatterns, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/channel-inbound"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; +import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "openclaw/plugin-sdk/config-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { - formatInboundEnvelope, - formatInboundFromLabel, - resolveEnvelopeFormatOptions, - type EnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { DM_GROUP_ACCESS_REASON, diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 651926616c6..f5524a12f85 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -1,12 +1,11 @@ import fs from "node:fs/promises"; import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; -import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-inbound"; +import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; +import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveOpenProviderRuntimeGroupPolicy, @@ -18,6 +17,7 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "openclaw/plugin-sdk/conversation-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/infra-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; import { @@ -26,13 +26,13 @@ import { resolveIMessageRemoteAttachmentRoots, } from "openclaw/plugin-sdk/media-runtime"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; -import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; diff --git a/extensions/imessage/src/outbound-adapter.ts b/extensions/imessage/src/outbound-adapter.ts index cd961c30bfa..0b023fa2b02 100644 --- a/extensions/imessage/src/outbound-adapter.ts +++ b/extensions/imessage/src/outbound-adapter.ts @@ -1,8 +1,8 @@ +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime"; import { - createScopedChannelMediaMaxBytesResolver, createDirectTextMediaOutbound, -} from "openclaw/plugin-sdk/channel-runtime"; -import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; + createScopedChannelMediaMaxBytesResolver, +} from "openclaw/plugin-sdk/media-runtime"; import { sendMessageIMessage } from "./send.js"; function resolveIMessageSender(deps: OutboundSendDeps | undefined) { diff --git a/extensions/imessage/src/probe.ts b/extensions/imessage/src/probe.ts index 7ae049f02eb..1609ec2f657 100644 --- a/extensions/imessage/src/probe.ts +++ b/extensions/imessage/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index 8c68eb5406e..71281cbcf4d 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -1,8 +1,8 @@ import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/core"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]); diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 27571c92d35..69fdc07a79f 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -3,17 +3,17 @@ import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing"; import { composeWarningCollectors, createAllowlistProviderOpenWarningCollector, createConditionalWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { - createAttachedChannelResultAdapter, createChannelDirectoryAdapter, - createTextPairingAdapter, listResolvedDirectoryEntriesFromSources, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/directory-runtime"; import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared"; import { listIrcAccountIds, diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts index 93214aeda45..96e4bdbbe90 100644 --- a/extensions/irc/src/runtime-api.ts +++ b/extensions/irc/src/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/irc"; +// Private runtime barrel for the bundled IRC extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../../src/plugin-sdk/irc.js"; diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 8e3a347e35a..f2e83e9838f 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -1,5 +1,4 @@ -import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { diff --git a/extensions/line/api.ts b/extensions/line/api.ts index 5fdc62bdfb4..3fd34872f05 100644 --- a/extensions/line/api.ts +++ b/extensions/line/api.ts @@ -1,2 +1,41 @@ -export * from "openclaw/plugin-sdk/line"; +export type { + ChannelPlugin, + OpenClawConfig, + OpenClawPluginApi, + PluginRuntime, +} from "openclaw/plugin-sdk/core"; +export { buildChannelConfigSchema, clearAccountEntryFields } from "openclaw/plugin-sdk/core"; +export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/testing"; +export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract"; +export { + buildComputedAccountStatusSnapshot, + buildTokenChannelStatusSummary, +} from "openclaw/plugin-sdk/status-helpers"; +export type { + CardAction, + LineChannelData, + LineConfig, + ListItem, + ResolvedLineAccount, +} from "./runtime-api.js"; +export { + createActionCard, + createImageCard, + createInfoCard, + createListCard, + createReceiptCard, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + LineConfigSchema, + listLineAccountIds, + normalizeAccountId, + processLineMessage, + resolveDefaultLineAccountId, + resolveExactLineGroupConfigKey, + resolveLineAccount, + setSetupChannelEnabled, + splitSetupEntries, +} from "./runtime-api.js"; +export * from "./runtime-api.js"; export * from "./setup-api.js"; diff --git a/extensions/line/src/channel-shared.ts b/extensions/line/src/channel-shared.ts new file mode 100644 index 00000000000..593824f3070 --- /dev/null +++ b/extensions/line/src/channel-shared.ts @@ -0,0 +1,66 @@ +import type { ChannelPlugin } from "../api.js"; +import { + resolveLineAccount, + type OpenClawConfig, + type ResolvedLineAccount, +} from "../runtime-api.js"; +import { lineConfigAdapter } from "./config-adapter.js"; +import { LineChannelConfigSchema } from "./config-schema.js"; + +export const lineChannelMeta = { + id: "line", + label: "LINE", + selectionLabel: "LINE (Messaging API)", + detailLabel: "LINE Bot", + docsPath: "/channels/line", + docsLabel: "line", + blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", + systemImage: "message.fill", +} as const; + +export const lineChannelPluginCommon = { + meta: { + ...lineChannelMeta, + quickstartAllowFrom: true, + }, + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + threads: false, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.line"] }, + configSchema: LineChannelConfigSchema, + config: { + ...lineConfigAdapter, + isConfigured: (account: ResolvedLineAccount) => + Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + describeAccount: (account: ResolvedLineAccount) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + tokenSource: account.tokenSource ?? undefined, + }), + }, +} satisfies Pick< + ChannelPlugin, + "meta" | "capabilities" | "reload" | "configSchema" | "config" +>; + +export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const resolved = resolveLineAccount({ cfg, accountId }); + return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); +} + +export function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../runtime-api.js"; diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts index bae717a205d..cbd36f44446 100644 --- a/extensions/line/src/channel.setup.ts +++ b/extensions/line/src/channel.setup.ts @@ -1,52 +1,11 @@ -import { - buildChannelConfigSchema, - LineConfigSchema, - type ChannelPlugin, - type ResolvedLineAccount, -} from "../api.js"; -import { lineConfigAdapter } from "./config-adapter.js"; +import { type ChannelPlugin, type ResolvedLineAccount } from "../api.js"; +import { lineChannelPluginCommon } from "./channel-shared.js"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; -const meta = { - id: "line", - label: "LINE", - selectionLabel: "LINE (Messaging API)", - detailLabel: "LINE Bot", - docsPath: "/channels/line", - docsLabel: "line", - blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", - systemImage: "message.fill", -} as const; - export const lineSetupPlugin: ChannelPlugin = { id: "line", - meta: { - ...meta, - quickstartAllowFrom: true, - }, - capabilities: { - chatTypes: ["direct", "group"], - reactions: false, - threads: false, - media: true, - nativeCommands: false, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.line"] }, - configSchema: buildChannelConfigSchema(LineConfigSchema), - config: { - ...lineConfigAdapter, - isConfigured: (account) => - Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), - tokenSource: account.tokenSource ?? undefined, - }), - }, + ...lineChannelPluginCommon, setupWizard: lineSetupWizard, setup: lineSetupAdapter, }; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index d983d2a0172..fd81a4c8f8a 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,20 +1,20 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createAttachedChannelResultAdapter, - createEmptyChannelDirectoryAdapter, createEmptyChannelResult, - createPairingPrefixStripper, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-send-result"; +import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; import { - buildChannelConfigSchema, buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, - LineConfigSchema, processLineMessage, type ChannelPlugin, type ChannelStatusIssue, @@ -23,24 +23,12 @@ import { type OpenClawConfig, type ResolvedLineAccount, } from "../api.js"; -import { lineConfigAdapter } from "./config-adapter.js"; +import { lineChannelPluginCommon } from "./channel-shared.js"; import { resolveLineGroupRequireMention } from "./group-policy.js"; import { getLineRuntime } from "./runtime.js"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; -// LINE channel metadata -const meta = { - id: "line", - label: "LINE", - selectionLabel: "LINE (Messaging API)", - detailLabel: "LINE Bot", - docsPath: "/channels/line", - docsLabel: "line", - blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", - systemImage: "message.fill", -}; - const resolveLineDmPolicy = createScopedDmSecurityResolver({ channelKey: "line", resolvePolicy: (account) => account.config.dmPolicy, @@ -63,10 +51,7 @@ const collectLineSecurityWarnings = export const linePlugin: ChannelPlugin = { id: "line", - meta: { - ...meta, - quickstartAllowFrom: true, - }, + ...lineChannelPluginCommon, pairing: createTextPairingAdapter({ idLabel: "lineUserId", message: "OpenClaw: your access has been approved.", @@ -83,29 +68,7 @@ export const linePlugin: ChannelPlugin = { }); }, }), - capabilities: { - chatTypes: ["direct", "group"], - reactions: false, - threads: false, - media: true, - nativeCommands: false, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.line"] }, - configSchema: buildChannelConfigSchema(LineConfigSchema), setupWizard: lineSetupWizard, - config: { - ...lineConfigAdapter, - isConfigured: (account) => - Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), - tokenSource: account.tokenSource ?? undefined, - }), - }, security: { resolveDmPolicy: resolveLineDmPolicy, collectWarnings: collectLineSecurityWarnings, diff --git a/extensions/lobster/runtime-api.ts b/extensions/lobster/runtime-api.ts index 7ab2351b77d..d883e0853b3 100644 --- a/extensions/lobster/runtime-api.ts +++ b/extensions/lobster/runtime-api.ts @@ -1 +1,12 @@ -export * from "openclaw/plugin-sdk/lobster"; +export { definePluginEntry } from "openclaw/plugin-sdk/core"; +export type { + AnyAgentTool, + OpenClawPluginApi, + OpenClawPluginToolContext, + OpenClawPluginToolFactory, +} from "openclaw/plugin-sdk/core"; +export { + applyWindowsSpawnProgramPolicy, + materializeWindowsSpawnProgram, + resolveWindowsSpawnProgramCandidate, +} from "openclaw/plugin-sdk/windows-spawn"; diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index 1aaee387fc8..e3fc7f732e1 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -3,3 +3,27 @@ // matrix-js-sdk during plain runtime-api import. export * from "./src/auth-precedence.js"; export * from "./helper-api.js"; +export { + assertHttpUrlTargetsPrivateNetwork, + closeDispatcher, + createPinnedDispatcher, + resolvePinnedHostnameWithPolicy, + ssrfPolicyFromAllowPrivateNetwork, + type LookupFn, + type SsrFPolicy, +} from "openclaw/plugin-sdk/infra-runtime"; +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "./thread-bindings-runtime.js"; +export { writeJsonFileAtomically } from "../../src/plugin-sdk/json-store.js"; +export type { + ChannelDirectoryEntry, + ChannelMessageActionContext, + OpenClawConfig, + PluginRuntime, + RuntimeLogger, + RuntimeEnv, + WizardPrompter, +} from "../../src/plugin-sdk/matrix.js"; +export { formatZonedTimestamp } from "../../src/plugin-sdk/matrix.js"; diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts index 12dfea963f3..eaa2be533b0 100644 --- a/extensions/matrix/src/actions.account-propagation.test.ts +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -1,5 +1,5 @@ -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelMessageActionContext } from "../runtime-api.js"; import type { CoreConfig } from "./types.js"; const mocks = vi.hoisted(() => ({ diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index 5e657bb4603..6750f7d9fb7 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it } from "vitest"; +import type { PluginRuntime } from "../runtime-api.js"; import { matrixMessageActions } from "./actions.js"; import { setMatrixRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 8f79f592db8..2c4c8a254bf 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { matrixPlugin } from "./channel.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { resolveMatrixConfigForAccount } from "./matrix/client/config.js"; diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts index ecafd4819f6..ba065fba792 100644 --- a/extensions/matrix/src/channel.setup.test.ts +++ b/extensions/matrix/src/channel.setup.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js"; const verificationMocks = vi.hoisted(() => ({ bootstrapMatrixVerification: vi.fn(), diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index ca028d8d99d..bef357c3bdd 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -2,20 +2,22 @@ import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderOpenWarningCollector, projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; import { createChannelDirectoryAdapter, - createPairingPrefixStripper, - createScopedAccountReplyToModeResolver, createRuntimeDirectoryLiveAdapter, - createRuntimeOutboundDelegates, - createTextPairingAdapter, listResolvedDirectoryEntriesFromSources, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/directory-runtime"; import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared"; +import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index da10215f435..318db978f6b 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; -import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatZonedTimestamp } from "../runtime-api.js"; const bootstrapMatrixVerificationMock = vi.fn(); const getMatrixRoomKeyBackupStatusMock = vi.fn(); diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index e1b8c78c56f..4e6882bc20b 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { LookupFn } from "../runtime-api.js"; +import type { LookupFn } from "../../runtime-api.js"; import type { CoreConfig } from "../types.js"; import { getMatrixScopedEnvVarNames, diff --git a/extensions/matrix/src/matrix/client/storage.test.ts b/extensions/matrix/src/matrix/client/storage.test.ts index 923f686df67..f0749dd5bef 100644 --- a/extensions/matrix/src/matrix/client/storage.test.ts +++ b/extensions/matrix/src/matrix/client/storage.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveMatrixAccountStorageRoot } from "openclaw/plugin-sdk/matrix"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; const createBackupArchiveMock = vi.hoisted(() => diff --git a/extensions/matrix/src/matrix/format.ts b/extensions/matrix/src/matrix/format.ts index 31bddcc5292..efb81ebff2a 100644 --- a/extensions/matrix/src/matrix/format.ts +++ b/extensions/matrix/src/matrix/format.ts @@ -1,4 +1,5 @@ import MarkdownIt from "markdown-it"; +import { isAutoLinkedFileRef } from "openclaw/plugin-sdk/text-runtime"; const md = new MarkdownIt({ html: false, @@ -10,38 +11,6 @@ const md = new MarkdownIt({ md.enable("strikethrough"); const { escapeHtml } = md.utils; - -/** - * Keep bare file references like README.md from becoming external http:// links. - * Telegram already hardens this path; Matrix should not turn common code/docs - * filenames into clickable registrar-style URLs either. - */ -const FILE_EXTENSIONS_WITH_TLD = new Set(["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"]); - -function isAutoLinkedFileRef(href: string, label: string): boolean { - const stripped = href.replace(/^https?:\/\//i, ""); - if (stripped !== label) { - return false; - } - const dotIndex = label.lastIndexOf("."); - if (dotIndex < 1) { - return false; - } - const ext = label.slice(dotIndex + 1).toLowerCase(); - if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) { - return false; - } - const segments = label.split("/"); - if (segments.length > 1) { - for (let i = 0; i < segments.length - 1; i += 1) { - if (segments[i]?.includes(".")) { - return false; - } - } - } - return true; -} - function shouldSuppressAutoLink( tokens: Parameters>[0], idx: number, diff --git a/extensions/matrix/src/matrix/monitor/auto-join.test.ts b/extensions/matrix/src/matrix/monitor/auto-join.test.ts index 07dc83fe2a6..9aa8914777e 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.test.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixConfig } from "../../types.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; @@ -48,7 +48,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -67,7 +67,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); expect(getInviteHandler()).toBeNull(); @@ -88,7 +88,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -112,7 +112,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -135,7 +135,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -161,7 +161,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error, - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -187,7 +187,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -210,7 +210,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); diff --git a/extensions/matrix/src/matrix/monitor/config.test.ts b/extensions/matrix/src/matrix/monitor/config.test.ts index f2a146879f7..0b85ef811d5 100644 --- a/extensions/matrix/src/matrix/monitor/config.test.ts +++ b/extensions/matrix/src/matrix/monitor/config.test.ts @@ -1,5 +1,5 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../../../runtime-api.js"; import type { CoreConfig, MatrixRoomConfig } from "../../types.js"; import { resolveMatrixMonitorConfig } from "./config.js"; diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts index 45c7484d3ca..58b78ff306c 100644 --- a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; import type { MatrixRawEvent } from "./types.js"; diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index 3aa13a735a0..585ce851b0a 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -52,16 +52,28 @@ type MatrixHandlerTestHarnessOptions = { resolveEnvelopeFormatOptions?: () => Record; formatAgentEnvelope?: ({ body }: { body: string }) => string; finalizeInboundContext?: (ctx: unknown) => unknown; - createReplyDispatcherWithTyping?: () => { + createReplyDispatcherWithTyping?: (params?: { + onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void; + }) => { dispatcher: Record; replyOptions: Record; markDispatchIdle: () => void; + markRunComplete: () => void; }; resolveHumanDelayConfig?: () => undefined; dispatchReplyFromConfig?: () => Promise<{ queuedFinal: boolean; counts: { final: number; block: number; tool: number }; }>; + withReplyDispatcher?: (params: { + dispatcher: { + markComplete?: () => void; + waitForIdle?: () => Promise; + }; + run: () => Promise; + onSettled?: () => void | Promise; + }) => Promise; + inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"]; shouldAckReaction?: () => boolean; enqueueSystemEvent?: (...args: unknown[]) => void; getRoomInfo?: MatrixMonitorHandlerParams["getRoomInfo"]; @@ -138,9 +150,32 @@ export function createMatrixHandlerTestHarness( dispatcher: {}, replyOptions: {}, markDispatchIdle: () => {}, + markRunComplete: () => {}, })), resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined), dispatchReplyFromConfig, + withReplyDispatcher: + options.withReplyDispatcher ?? + (async (params: { + dispatcher: { + markComplete?: () => void; + waitForIdle?: () => Promise; + }; + run: () => Promise; + onSettled?: () => void | Promise; + }) => { + const { dispatcher, run, onSettled } = params; + try { + return await run(); + } finally { + dispatcher.markComplete?.(); + try { + await dispatcher.waitForIdle?.(); + } finally { + await onSettled?.(); + } + } + }), }, reactions: { shouldAckReaction: options.shouldAckReaction ?? (() => false), @@ -179,6 +214,7 @@ export function createMatrixHandlerTestHarness( startupMs: options.startupMs ?? 0, startupGraceMs: options.startupGraceMs ?? 0, dropPreStartupMessages: options.dropPreStartupMessages ?? true, + inboundDeduper: options.inboundDeduper, directTracker: { isDirectMessage: async () => options.isDirectMessage ?? true, }, diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 289623631fa..8e842e38baa 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -720,12 +720,36 @@ describe("matrix monitor handler pairing account scope", () => { dispatcher: {}, replyOptions: {}, markDispatchIdle: () => {}, + markRunComplete: () => {}, }), resolveHumanDelayConfig: () => undefined, dispatchReplyFromConfig: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), + withReplyDispatcher: async ({ + dispatcher, + run, + onSettled, + }: { + dispatcher: { + markComplete?: () => void; + waitForIdle?: () => Promise; + }; + run: () => Promise; + onSettled?: () => void | Promise; + }) => { + try { + return await run(); + } finally { + dispatcher.markComplete?.(); + try { + await dispatcher.waitForIdle?.(); + } finally { + await onSettled?.(); + } + } + }, }, reactions: { shouldAckReaction: () => false, @@ -989,3 +1013,282 @@ describe("matrix monitor handler pairing account scope", () => { expect(resolveAgentRoute).toHaveBeenCalledTimes(1); }); }); + +describe("matrix monitor handler durable inbound dedupe", () => { + it("skips replayed inbound events before session recording", async () => { + const inboundDeduper = { + claimEvent: vi.fn(() => false), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + }; + const { handler, recordInboundSession } = createMatrixHandlerTestHarness({ + inboundDeduper, + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + })), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$dup", + body: "hello", + }), + ); + + expect(inboundDeduper.claimEvent).toHaveBeenCalledWith({ + roomId: "!room:example.org", + eventId: "$dup", + }); + expect(recordInboundSession).not.toHaveBeenCalled(); + expect(inboundDeduper.commitEvent).not.toHaveBeenCalled(); + expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled(); + }); + + it("commits inbound events only after queued replies finish delivering", async () => { + const callOrder: string[] = []; + const inboundDeduper = { + claimEvent: vi.fn(() => { + callOrder.push("claim"); + return true; + }), + commitEvent: vi.fn(async () => { + callOrder.push("commit"); + }), + releaseEvent: vi.fn(() => { + callOrder.push("release"); + }), + }; + const recordInboundSession = vi.fn(async () => { + callOrder.push("record"); + }); + const dispatchReplyFromConfig = vi.fn(async () => { + callOrder.push("dispatch"); + return { + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + }; + }); + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + recordInboundSession, + dispatchReplyFromConfig, + createReplyDispatcherWithTyping: () => ({ + dispatcher: { + markComplete: () => { + callOrder.push("mark-complete"); + }, + waitForIdle: async () => { + callOrder.push("wait-for-idle"); + }, + }, + replyOptions: {}, + markDispatchIdle: () => { + callOrder.push("dispatch-idle"); + }, + markRunComplete: () => { + callOrder.push("run-complete"); + }, + }), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$commit-order", + body: "hello", + }), + ); + + expect(callOrder).toEqual([ + "claim", + "record", + "dispatch", + "run-complete", + "mark-complete", + "wait-for-idle", + "dispatch-idle", + "commit", + ]); + expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled(); + }); + + it("releases a claimed event when reply dispatch fails before completion", async () => { + const inboundDeduper = { + claimEvent: vi.fn(() => true), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + }; + const runtime = { + error: vi.fn(), + }; + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + runtime: runtime as never, + recordInboundSession: vi.fn(async () => { + throw new Error("disk failed"); + }), + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + })), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$release-on-error", + body: "hello", + }), + ); + + expect(inboundDeduper.commitEvent).not.toHaveBeenCalled(); + expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({ + roomId: "!room:example.org", + eventId: "$release-on-error", + }); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("matrix handler failed")); + }); + + it("releases a claimed event when queued final delivery fails", async () => { + const inboundDeduper = { + claimEvent: vi.fn(() => true), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + }; + const runtime = { + error: vi.fn(), + }; + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + runtime: runtime as never, + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + })), + createReplyDispatcherWithTyping: (params) => ({ + dispatcher: { + markComplete: () => {}, + waitForIdle: async () => { + params?.onError?.(new Error("send failed"), { kind: "final" }); + }, + }, + replyOptions: {}, + markDispatchIdle: () => {}, + markRunComplete: () => {}, + }), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$release-on-final-delivery-error", + body: "hello", + }), + ); + + expect(inboundDeduper.commitEvent).not.toHaveBeenCalled(); + expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({ + roomId: "!room:example.org", + eventId: "$release-on-final-delivery-error", + }); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("matrix final reply failed"), + ); + }); + + it.each(["tool", "block"] as const)( + "releases a claimed event when queued %s delivery fails and no final reply exists", + async (kind) => { + const inboundDeduper = { + claimEvent: vi.fn(() => true), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + }; + const runtime = { + error: vi.fn(), + }; + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + runtime: runtime as never, + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: false, + counts: { + final: 0, + block: kind === "block" ? 1 : 0, + tool: kind === "tool" ? 1 : 0, + }, + })), + createReplyDispatcherWithTyping: (params) => ({ + dispatcher: { + markComplete: () => {}, + waitForIdle: async () => { + params?.onError?.(new Error("send failed"), { kind }); + }, + }, + replyOptions: {}, + markDispatchIdle: () => {}, + markRunComplete: () => {}, + }), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: `$release-on-${kind}-delivery-error`, + body: "hello", + }), + ); + + expect(inboundDeduper.commitEvent).not.toHaveBeenCalled(); + expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({ + roomId: "!room:example.org", + eventId: `$release-on-${kind}-delivery-error`, + }); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining(`matrix ${kind} reply failed`), + ); + }, + ); + + it("commits a claimed event when dispatch completes without a final reply", async () => { + const callOrder: string[] = []; + const inboundDeduper = { + claimEvent: vi.fn(() => { + callOrder.push("claim"); + return true; + }), + commitEvent: vi.fn(async () => { + callOrder.push("commit"); + }), + releaseEvent: vi.fn(() => { + callOrder.push("release"); + }), + }; + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + recordInboundSession: vi.fn(async () => { + callOrder.push("record"); + }), + dispatchReplyFromConfig: vi.fn(async () => { + callOrder.push("dispatch"); + return { + queuedFinal: false, + counts: { final: 0, block: 0, tool: 0 }, + }; + }), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$no-final", + body: "hello", + }), + ); + + expect(callOrder).toEqual(["claim", "record", "dispatch", "commit"]); + expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts index 51f5a07bdd0..aea230f3afc 100644 --- a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index b7295009bcd..40c386e3820 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -30,6 +30,7 @@ import { } from "../send.js"; import { resolveMatrixMonitorAccessState } from "./access-state.js"; import { resolveMatrixAckReactionConfig } from "./ack-config.js"; +import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js"; import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; import { downloadMatrixMedia } from "./media.js"; import { resolveMentions } from "./mentions.js"; @@ -72,6 +73,7 @@ export type MatrixMonitorHandlerParams = { startupMs: number; startupGraceMs: number; dropPreStartupMessages: boolean; + inboundDeduper?: Pick; directTracker: { isDirectMessage: (params: { roomId: string; @@ -163,6 +165,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam startupMs, startupGraceMs, dropPreStartupMessages, + inboundDeduper, directTracker, getRoomInfo, getMemberDisplayName, @@ -219,6 +222,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }; return async (roomId: string, event: MatrixRawEvent) => { + const eventId = typeof event.event_id === "string" ? event.event_id.trim() : ""; + let claimedInboundEvent = false; try { const eventType = event.type; if (eventType === EventType.RoomMessageEncrypted) { @@ -256,6 +261,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const eventTs = event.origin_server_ts; const eventAge = event.unsigned?.age; + const commitInboundEventIfClaimed = async () => { + if (!claimedInboundEvent || !inboundDeduper || !eventId) { + return; + } + await inboundDeduper.commitEvent({ roomId, eventId }); + claimedInboundEvent = false; + }; if (dropPreStartupMessages) { if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { return; @@ -293,6 +305,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } } + if (eventId && inboundDeduper) { + claimedInboundEvent = inboundDeduper.claimEvent({ roomId, eventId }); + if (!claimedInboundEvent) { + logVerboseMessage(`matrix: skip duplicate inbound event room=${roomId} id=${eventId}`); + return; + } + } const isDirectMessage = await directTracker.isDirectMessage({ roomId, @@ -302,6 +321,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const isRoom = !isDirectMessage; if (isRoom && groupPolicy === "disabled") { + await commitInboundEventIfClaimed(); return; } @@ -332,20 +352,24 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage( `matrix: drop configured bot sender=${senderId} (allowBots=false${isDirectMessage ? "" : `, ${roomMatchMeta}`})`, ); + await commitInboundEventIfClaimed(); return; } if (isRoom && roomConfig && !roomConfigInfo?.allowed) { logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); + await commitInboundEventIfClaimed(); return; } if (isRoom && groupPolicy === "allowlist") { if (!roomConfigInfo?.allowlistConfigured) { logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); + await commitInboundEventIfClaimed(); return; } if (!roomConfig) { logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); + await commitInboundEventIfClaimed(); return; } } @@ -378,6 +402,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") { + await commitInboundEventIfClaimed(); return; } if (dmPolicy !== "open") { @@ -414,19 +439,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam accountId, }, ); + await commitInboundEventIfClaimed(); } catch (err) { logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + return; } } else { logVerboseMessage( `matrix pairing reminder suppressed sender=${senderId} (cooldown)`, ); + await commitInboundEventIfClaimed(); } } if (isReactionEvent || dmPolicy !== "pairing") { logVerboseMessage( `matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, ); + await commitInboundEventIfClaimed(); } return; } @@ -439,6 +468,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam roomUserMatch, )})`, ); + await commitInboundEventIfClaimed(); return; } if ( @@ -453,6 +483,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam groupAllowMatch, )})`, ); + await commitInboundEventIfClaimed(); return; } } @@ -475,6 +506,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam isDirectMessage, logVerboseMessage, }); + await commitInboundEventIfClaimed(); return; } @@ -491,6 +523,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam : undefined; const mediaUrl = contentUrl ?? contentFile?.url; if (!mentionPrecheckText && !mediaUrl && !isPollEvent) { + await commitInboundEventIfClaimed(); return; } @@ -509,6 +542,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage( `matrix: drop configured bot sender=${senderId} (allowBots=mentions, missing mention, ${roomMatchMeta})`, ); + await commitInboundEventIfClaimed(); return; } const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ @@ -534,6 +568,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam reason: "control command (unauthorized)", target: senderId, }); + await commitInboundEventIfClaimed(); return; } const shouldRequireMention = isRoom @@ -556,6 +591,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { logger.info("skipping room message", { roomId, reason: "no-mention" }); + await commitInboundEventIfClaimed(); return; } @@ -631,6 +667,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam mediaDownloadFailed, }); if (!bodyText) { + await commitInboundEventIfClaimed(); return; } const senderName = await getSenderName(); @@ -799,6 +836,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam accountId: route.accountId, }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); + let finalReplyDeliveryFailed = false; + let nonFinalReplyDeliveryFailed = false; const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg, agentId: route.agentId, @@ -827,7 +866,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); }, }); - const { dispatcher, replyOptions, markDispatchIdle } = + const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), @@ -847,32 +886,66 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); }, onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => { + if (info.kind === "final") { + finalReplyDeliveryFailed = true; + } else { + nonFinalReplyDeliveryFailed = true; + } runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); }, onReplyStart: typingCallbacks.onReplyStart, onIdle: typingCallbacks.onIdle, }); - const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, + const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ dispatcher, - replyOptions: { - ...replyOptions, - skillFilter: roomConfig?.skills, - onModelSelected, + onSettled: () => { + markDispatchIdle(); + }, + run: async () => { + try { + return await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + skillFilter: roomConfig?.skills, + onModelSelected, + }, + }); + } finally { + markRunComplete(); + } }, }); - markDispatchIdle(); + if (finalReplyDeliveryFailed) { + logVerboseMessage( + `matrix: final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`, + ); + return; + } + if (!queuedFinal && nonFinalReplyDeliveryFailed) { + logVerboseMessage( + `matrix: non-final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`, + ); + return; + } if (!queuedFinal) { + await commitInboundEventIfClaimed(); return; } const finalCount = counts.final; logVerboseMessage( `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); + await commitInboundEventIfClaimed(); } catch (err) { runtime.error?.(`matrix handler failed: ${String(err)}`); + } finally { + if (claimedInboundEvent && inboundDeduper && eventId) { + inboundDeduper.releaseEvent({ roomId, eventId }); + } } }; } diff --git a/extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts b/extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts new file mode 100644 index 00000000000..e0ad423c1f1 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts @@ -0,0 +1,146 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMatrixInboundEventDeduper } from "./inbound-dedupe.js"; + +describe("Matrix inbound event dedupe", () => { + const tempDirs: string[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function createStoragePath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-inbound-dedupe-")); + tempDirs.push(dir); + return path.join(dir, "inbound-dedupe.json"); + } + + const auth = { + accountId: "ops", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + deviceId: "DEVICE", + } as const; + + it("persists committed events across restarts", async () => { + const storagePath = createStoragePath(); + const first = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + }); + + expect(first.claimEvent({ roomId: "!room:example.org", eventId: "$event-1" })).toBe(true); + await first.commitEvent({ + roomId: "!room:example.org", + eventId: "$event-1", + }); + await first.stop(); + + const second = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + }); + expect(second.claimEvent({ roomId: "!room:example.org", eventId: "$event-1" })).toBe(false); + }); + + it("does not persist released pending claims", async () => { + const storagePath = createStoragePath(); + const first = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + }); + + expect(first.claimEvent({ roomId: "!room:example.org", eventId: "$event-2" })).toBe(true); + first.releaseEvent({ roomId: "!room:example.org", eventId: "$event-2" }); + await first.stop(); + + const second = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + }); + expect(second.claimEvent({ roomId: "!room:example.org", eventId: "$event-2" })).toBe(true); + }); + + it("prunes expired and overflowed entries on load", async () => { + const storagePath = createStoragePath(); + fs.writeFileSync( + storagePath, + JSON.stringify({ + version: 1, + entries: [ + { key: "!room:example.org|$old", ts: 10 }, + { key: "!room:example.org|$keep-1", ts: 90 }, + { key: "!room:example.org|$keep-2", ts: 95 }, + { key: "!room:example.org|$keep-3", ts: 100 }, + ], + }), + "utf8", + ); + + const deduper = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + ttlMs: 20, + maxEntries: 2, + nowMs: () => 100, + }); + + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$old" })).toBe(true); + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$keep-1" })).toBe(true); + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$keep-2" })).toBe(false); + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$keep-3" })).toBe(false); + }); + + it("retains replayed backlog events based on processing time", async () => { + const storagePath = createStoragePath(); + let now = 100; + const first = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + ttlMs: 20, + nowMs: () => now, + }); + + expect(first.claimEvent({ roomId: "!room:example.org", eventId: "$backlog" })).toBe(true); + await first.commitEvent({ + roomId: "!room:example.org", + eventId: "$backlog", + }); + await first.stop(); + + now = 110; + const second = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + ttlMs: 20, + nowMs: () => now, + }); + expect(second.claimEvent({ roomId: "!room:example.org", eventId: "$backlog" })).toBe(false); + }); + + it("treats stop persistence failures as best-effort cleanup", async () => { + const blockingPath = createStoragePath(); + fs.writeFileSync(blockingPath, "blocking file", "utf8"); + const deduper = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath: path.join(blockingPath, "nested", "inbound-dedupe.json"), + }); + + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$persist-fail" })).toBe( + true, + ); + await deduper.commitEvent({ + roomId: "!room:example.org", + eventId: "$persist-fail", + }); + + await expect(deduper.stop()).resolves.toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/inbound-dedupe.ts b/extensions/matrix/src/matrix/monitor/inbound-dedupe.ts new file mode 100644 index 00000000000..2e2b3b8461d --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/inbound-dedupe.ts @@ -0,0 +1,285 @@ +import path from "node:path"; +import { readJsonFileWithFallback, writeJsonFileAtomically } from "../../runtime-api.js"; +import { resolveMatrixStoragePaths } from "../client/storage.js"; +import type { MatrixAuth } from "../client/types.js"; +import { LogService } from "../sdk/logger.js"; + +const INBOUND_DEDUPE_FILENAME = "inbound-dedupe.json"; +const STORE_VERSION = 1; +const DEFAULT_MAX_ENTRIES = 20_000; +const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const PERSIST_DEBOUNCE_MS = 250; + +type StoredMatrixInboundDedupeEntry = { + key: string; + ts: number; +}; + +type StoredMatrixInboundDedupeState = { + version: number; + entries: StoredMatrixInboundDedupeEntry[]; +}; + +export type MatrixInboundEventDeduper = { + claimEvent: (params: { roomId: string; eventId: string }) => boolean; + commitEvent: (params: { roomId: string; eventId: string }) => Promise; + releaseEvent: (params: { roomId: string; eventId: string }) => void; + flush: () => Promise; + stop: () => Promise; +}; + +function createAsyncLock() { + let lock: Promise = Promise.resolve(); + return async function withLock(fn: () => Promise): Promise { + const previous = lock; + let release: (() => void) | undefined; + lock = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await fn(); + } finally { + release?.(); + } + }; +} + +function normalizeEventPart(value: string): string { + return value.trim(); +} + +function buildEventKey(params: { roomId: string; eventId: string }): string { + const roomId = normalizeEventPart(params.roomId); + const eventId = normalizeEventPart(params.eventId); + return roomId && eventId ? `${roomId}|${eventId}` : ""; +} + +function resolveInboundDedupeStatePath(params: { + auth: MatrixAuth; + env?: NodeJS.ProcessEnv; + stateDir?: string; +}): string { + const storagePaths = resolveMatrixStoragePaths({ + homeserver: params.auth.homeserver, + userId: params.auth.userId, + accessToken: params.auth.accessToken, + accountId: params.auth.accountId, + deviceId: params.auth.deviceId, + env: params.env, + stateDir: params.stateDir, + }); + return path.join(storagePaths.rootDir, INBOUND_DEDUPE_FILENAME); +} + +function normalizeTimestamp(raw: unknown): number | null { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return null; + } + return Math.max(0, Math.floor(raw)); +} + +function pruneSeenEvents(params: { + seen: Map; + ttlMs: number; + maxEntries: number; + nowMs: number; +}) { + const { seen, ttlMs, maxEntries, nowMs } = params; + if (ttlMs > 0) { + const cutoff = nowMs - ttlMs; + for (const [key, ts] of seen) { + if (ts < cutoff) { + seen.delete(key); + } + } + } + const max = Math.max(0, Math.floor(maxEntries)); + if (max <= 0) { + seen.clear(); + return; + } + while (seen.size > max) { + const oldestKey = seen.keys().next().value; + if (typeof oldestKey !== "string") { + break; + } + seen.delete(oldestKey); + } +} + +function toStoredState(params: { + seen: Map; + ttlMs: number; + maxEntries: number; + nowMs: number; +}): StoredMatrixInboundDedupeState { + pruneSeenEvents(params); + return { + version: STORE_VERSION, + entries: Array.from(params.seen.entries()).map(([key, ts]) => ({ key, ts })), + }; +} + +async function readStoredState( + storagePath: string, +): Promise { + const { value } = await readJsonFileWithFallback( + storagePath, + null, + ); + if (value?.version !== STORE_VERSION || !Array.isArray(value.entries)) { + return null; + } + return value; +} + +export async function createMatrixInboundEventDeduper(params: { + auth: MatrixAuth; + env?: NodeJS.ProcessEnv; + stateDir?: string; + storagePath?: string; + ttlMs?: number; + maxEntries?: number; + nowMs?: () => number; +}): Promise { + const nowMs = params.nowMs ?? (() => Date.now()); + const ttlMs = + typeof params.ttlMs === "number" && Number.isFinite(params.ttlMs) + ? Math.max(0, Math.floor(params.ttlMs)) + : DEFAULT_TTL_MS; + const maxEntries = + typeof params.maxEntries === "number" && Number.isFinite(params.maxEntries) + ? Math.max(0, Math.floor(params.maxEntries)) + : DEFAULT_MAX_ENTRIES; + const storagePath = + params.storagePath ?? + resolveInboundDedupeStatePath({ + auth: params.auth, + env: params.env, + stateDir: params.stateDir, + }); + + const seen = new Map(); + const pending = new Set(); + const persistLock = createAsyncLock(); + + try { + const stored = await readStoredState(storagePath); + for (const entry of stored?.entries ?? []) { + if (!entry || typeof entry.key !== "string") { + continue; + } + const key = entry.key.trim(); + const ts = normalizeTimestamp(entry.ts); + if (!key || ts === null) { + continue; + } + seen.set(key, ts); + } + pruneSeenEvents({ seen, ttlMs, maxEntries, nowMs: nowMs() }); + } catch (err) { + LogService.warn("MatrixInboundDedupe", "Failed loading Matrix inbound dedupe store:", err); + } + + let dirty = false; + let persistTimer: NodeJS.Timeout | null = null; + let persistPromise: Promise | null = null; + + const persist = async () => { + dirty = false; + const payload = toStoredState({ + seen, + ttlMs, + maxEntries, + nowMs: nowMs(), + }); + try { + await persistLock(async () => { + await writeJsonFileAtomically(storagePath, payload); + }); + } catch (err) { + dirty = true; + throw err; + } + }; + + const flush = async (): Promise => { + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = null; + } + while (dirty || persistPromise) { + if (dirty && !persistPromise) { + persistPromise = persist().finally(() => { + persistPromise = null; + }); + } + await persistPromise; + } + }; + + const schedulePersist = () => { + dirty = true; + if (persistTimer) { + return; + } + persistTimer = setTimeout(() => { + persistTimer = null; + void flush().catch((err) => { + LogService.warn( + "MatrixInboundDedupe", + "Failed persisting Matrix inbound dedupe store:", + err, + ); + }); + }, PERSIST_DEBOUNCE_MS); + persistTimer.unref?.(); + }; + + return { + claimEvent: ({ roomId, eventId }) => { + const key = buildEventKey({ roomId, eventId }); + if (!key) { + return true; + } + pruneSeenEvents({ seen, ttlMs, maxEntries, nowMs: nowMs() }); + if (seen.has(key) || pending.has(key)) { + return false; + } + pending.add(key); + return true; + }, + commitEvent: async ({ roomId, eventId }) => { + const key = buildEventKey({ roomId, eventId }); + if (!key) { + return; + } + pending.delete(key); + const ts = nowMs(); + seen.delete(key); + seen.set(key, ts); + pruneSeenEvents({ seen, ttlMs, maxEntries, nowMs: nowMs() }); + schedulePersist(); + }, + releaseEvent: ({ roomId, eventId }) => { + const key = buildEventKey({ roomId, eventId }); + if (!key) { + return; + } + pending.delete(key); + }, + flush, + stop: async () => { + try { + await flush(); + } catch (err) { + LogService.warn( + "MatrixInboundDedupe", + "Failed to flush Matrix inbound dedupe store during stop():", + err, + ); + } + }, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index b7ddb8f9656..1e7db90d4df 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -5,9 +5,18 @@ const hoisted = vi.hoisted(() => { const state = { startClientError: null as Error | null, }; + const inboundDeduper = { + claimEvent: vi.fn(() => true), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + flush: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + }; const client = { id: "matrix-client", hasPersistedSyncState: vi.fn(() => false), + stopSyncWithoutPersist: vi.fn(), + drainPendingDecryptions: vi.fn(async () => undefined), }; const createMatrixRoomMessageHandler = vi.fn(() => vi.fn()); const resolveTextChunkLimit = vi.fn< @@ -26,7 +35,9 @@ const hoisted = vi.hoisted(() => { callOrder, client, createMatrixRoomMessageHandler, + inboundDeduper, logger, + registeredOnRoomMessage: null as null | ((roomId: string, event: unknown) => Promise), releaseSharedClientInstance, resolveTextChunkLimit, setActiveMatrixClient, @@ -35,7 +46,7 @@ const hoisted = vi.hoisted(() => { }; }); -vi.mock("openclaw/plugin-sdk/matrix", () => ({ +vi.mock("../../runtime-api.js", () => ({ GROUP_POLICY_BLOCKED_LABEL: { room: "room", }, @@ -181,15 +192,22 @@ vi.mock("./direct.js", () => ({ })); vi.mock("./events.js", () => ({ - registerMatrixMonitorEvents: vi.fn(() => { - hoisted.callOrder.push("register-events"); - }), + registerMatrixMonitorEvents: vi.fn( + (params: { onRoomMessage: (roomId: string, event: unknown) => Promise }) => { + hoisted.callOrder.push("register-events"); + hoisted.registeredOnRoomMessage = params.onRoomMessage; + }, + ), })); vi.mock("./handler.js", () => ({ createMatrixRoomMessageHandler: hoisted.createMatrixRoomMessageHandler, })); +vi.mock("./inbound-dedupe.js", () => ({ + createMatrixInboundEventDeduper: vi.fn(async () => hoisted.inboundDeduper), +})); + vi.mock("./legacy-crypto-restore.js", () => ({ maybeRestoreLegacyMatrixBackup: vi.fn(), })); @@ -214,9 +232,17 @@ describe("monitorMatrixProvider", () => { hoisted.state.startClientError = null; hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000); hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true); + hoisted.registeredOnRoomMessage = null; hoisted.setActiveMatrixClient.mockReset(); hoisted.stopThreadBindingManager.mockReset(); hoisted.client.hasPersistedSyncState.mockReset().mockReturnValue(false); + hoisted.client.stopSyncWithoutPersist.mockReset(); + hoisted.client.drainPendingDecryptions.mockReset().mockResolvedValue(undefined); + hoisted.inboundDeduper.claimEvent.mockReset().mockReturnValue(true); + hoisted.inboundDeduper.commitEvent.mockReset().mockResolvedValue(undefined); + hoisted.inboundDeduper.releaseEvent.mockReset(); + hoisted.inboundDeduper.flush.mockReset().mockResolvedValue(undefined); + hoisted.inboundDeduper.stop.mockReset().mockResolvedValue(undefined); hoisted.createMatrixRoomMessageHandler.mockReset().mockReturnValue(vi.fn()); Object.values(hoisted.logger).forEach((mock) => mock.mockReset()); }); @@ -278,4 +304,77 @@ describe("monitorMatrixProvider", () => { }), ); }); + + it("stops sync, drains decryptions, then waits for in-flight handlers before persisting", async () => { + const { monitorMatrixProvider } = await import("./index.js"); + const abortController = new AbortController(); + let resolveHandler: (() => void) | null = null; + + hoisted.createMatrixRoomMessageHandler.mockReturnValue( + vi.fn(() => { + hoisted.callOrder.push("handler-start"); + return new Promise((resolve) => { + resolveHandler = () => { + hoisted.callOrder.push("handler-done"); + resolve(); + }; + }); + }), + ); + hoisted.client.stopSyncWithoutPersist.mockImplementation(() => { + hoisted.callOrder.push("pause-client"); + }); + hoisted.client.drainPendingDecryptions.mockImplementation(async () => { + hoisted.callOrder.push("drain-decrypts"); + }); + hoisted.stopThreadBindingManager.mockImplementation(() => { + hoisted.callOrder.push("stop-manager"); + }); + hoisted.releaseSharedClientInstance.mockImplementation(async () => { + hoisted.callOrder.push("release-client"); + return true; + }); + hoisted.inboundDeduper.stop.mockImplementation(async () => { + hoisted.callOrder.push("stop-deduper"); + }); + + const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal }); + await vi.waitFor(() => { + expect(hoisted.callOrder).toContain("start-client"); + }); + const onRoomMessage = hoisted.registeredOnRoomMessage; + if (!onRoomMessage) { + throw new Error("expected room message handler to be registered"); + } + + const roomMessagePromise = onRoomMessage("!room:example.org", { event_id: "$event" }); + abortController.abort(); + await vi.waitFor(() => { + expect(hoisted.callOrder).toContain("pause-client"); + }); + expect(hoisted.callOrder).not.toContain("stop-deduper"); + + if (resolveHandler === null) { + throw new Error("expected in-flight handler to be pending"); + } + (resolveHandler as () => void)(); + await roomMessagePromise; + await monitorPromise; + + expect(hoisted.callOrder.indexOf("pause-client")).toBeLessThan( + hoisted.callOrder.indexOf("drain-decrypts"), + ); + expect(hoisted.callOrder.indexOf("drain-decrypts")).toBeLessThan( + hoisted.callOrder.indexOf("handler-done"), + ); + expect(hoisted.callOrder.indexOf("handler-done")).toBeLessThan( + hoisted.callOrder.indexOf("stop-manager"), + ); + expect(hoisted.callOrder.indexOf("stop-manager")).toBeLessThan( + hoisted.callOrder.indexOf("stop-deduper"), + ); + expect(hoisted.callOrder.indexOf("stop-deduper")).toBeLessThan( + hoisted.callOrder.indexOf("release-client"), + ); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 62ea41b0169..71efc539424 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -25,6 +25,7 @@ import { resolveMatrixMonitorConfig } from "./config.js"; import { createDirectRoomTracker } from "./direct.js"; import { registerMatrixMonitorEvents } from "./events.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; +import { createMatrixInboundEventDeduper } from "./inbound-dedupe.js"; import { createMatrixRoomInfoResolver } from "./room-info.js"; import { runMatrixStartupMaintenance } from "./startup.js"; @@ -136,15 +137,29 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi setActiveMatrixClient(client, auth.accountId); let cleanedUp = false; let threadBindingManager: { accountId: string; stop: () => void } | null = null; + const inboundDeduper = await createMatrixInboundEventDeduper({ + auth, + env: process.env, + }); + const inFlightRoomMessages = new Set>(); + const waitForInFlightRoomMessages = async () => { + while (inFlightRoomMessages.size > 0) { + await Promise.allSettled(Array.from(inFlightRoomMessages)); + } + }; const cleanup = async () => { if (cleanedUp) { return; } cleanedUp = true; try { + client.stopSyncWithoutPersist(); + await client.drainPendingDecryptions("matrix monitor shutdown"); + await waitForInFlightRoomMessages(); threadBindingManager?.stop(); - } finally { + await inboundDeduper.stop(); await releaseSharedClientInstance(client, "persist"); + } finally { setActiveMatrixClient(null, auth.accountId); } }; @@ -219,11 +234,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi startupMs, startupGraceMs, dropPreStartupMessages, + inboundDeduper, directTracker, getRoomInfo, getMemberDisplayName, needsRoomAliasesForConfig, }); + const trackRoomMessage = (roomId: string, event: Parameters[1]) => { + const task = Promise.resolve(handleRoomMessage(roomId, event)).finally(() => { + inFlightRoomMessages.delete(task); + }); + inFlightRoomMessages.add(task); + return task; + }; try { threadBindingManager = await createMatrixThreadBindingManager({ @@ -249,7 +272,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi warnedCryptoMissingRooms, logger, formatNativeDependencyHint: core.system.formatNativeDependencyHint, - onRoomMessage: handleRoomMessage, + onRoomMessage: trackRoomMessage, }); // Register Matrix thread bindings before the client starts syncing so threaded diff --git a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts index 887dd25624a..68e81a48e41 100644 --- a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveMatrixAccountStorageRoot } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "../../../../../test/helpers/temp-home.js"; +import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js"; import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js"; function createBackupStatus() { diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts index 19ee48cb57e..73abd2feb80 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; import { downloadMatrixMedia } from "./media.js"; diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 33ed0bba226..92146fa4901 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv } from "../../../runtime-api.js"; import type { MatrixClient } from "../sdk.js"; const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" })); diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 8b7330294e6..dd84a7f6eb2 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -684,6 +684,52 @@ describe("MatrixClient event bridge", () => { expect(delivered).toEqual(["m.room.message"]); }); + it("can drain pending decrypt retries after sync stops", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token"); + const delivered: string[] = []; + + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + encrypted.emit("decrypted", decrypted); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + client.stopSyncWithoutPersist(); + await client.drainPendingDecryptions("test shutdown"); + + expect(matrixJsClient.stopClient).toHaveBeenCalledTimes(1); + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(delivered).toEqual(["m.room.message"]); + }); + it("retries failed decryptions immediately on crypto key update signals", async () => { vi.useFakeTimers(); const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index f394974106a..4fb0b53389c 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -365,11 +365,21 @@ export class MatrixClient { await this.startSyncSession({ bootstrapCrypto: false }); } - stop(): void { + stopSyncWithoutPersist(): void { if (this.idbPersistTimer) { clearInterval(this.idbPersistTimer); this.idbPersistTimer = null; } + this.client.stopClient(); + this.started = false; + } + + async drainPendingDecryptions(reason = "matrix client shutdown"): Promise { + await this.decryptBridge.drainPendingDecryptions(reason); + } + + stop(): void { + this.stopSyncWithoutPersist(); this.decryptBridge.stop(); // Final persist on shutdown this.syncStore?.markCleanShutdown(); @@ -380,8 +390,6 @@ export class MatrixClient { }).catch(noop), this.syncStore?.flush().catch(noop), ]).then(() => undefined); - this.client.stopClient(); - this.started = false; } async stopAndPersist(): Promise { diff --git a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts index 1df9e8748bd..1ca35993e91 100644 --- a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts +++ b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts @@ -51,6 +51,8 @@ export class MatrixDecryptBridge { private readonly decryptedMessageDedupe = new Map(); private readonly decryptRetries = new Map(); private readonly failedDecryptionsNotified = new Set(); + private activeRetryRuns = 0; + private readonly retryIdleResolvers = new Set<() => void>(); private cryptoRetrySignalsBound = false; constructor( @@ -139,6 +141,22 @@ export class MatrixDecryptBridge { } } + async drainPendingDecryptions(reason: string): Promise { + for (let attempts = 0; attempts < MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS; attempts += 1) { + if (this.decryptRetries.size === 0) { + return; + } + this.retryPendingNow(reason); + await this.waitForActiveRetryRunsToFinish(); + const hasPendingRetryTimers = Array.from(this.decryptRetries.values()).some( + (state) => state.timer || state.inFlight, + ); + if (!hasPendingRetryTimers) { + return; + } + } + } + private handleEncryptedEventDecrypted(params: { roomId: string; encryptedEvent: MatrixEvent; @@ -246,9 +264,12 @@ export class MatrixDecryptBridge { state.inFlight = true; state.timer = null; + this.activeRetryRuns += 1; const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function"; if (!canDecrypt) { this.clearDecryptRetry(retryKey); + this.activeRetryRuns = Math.max(0, this.activeRetryRuns - 1); + this.resolveRetryIdleIfNeeded(); return; } @@ -260,8 +281,13 @@ export class MatrixDecryptBridge { // Retry with backoff until we hit the configured retry cap. } finally { state.inFlight = false; + this.activeRetryRuns = Math.max(0, this.activeRetryRuns - 1); + this.resolveRetryIdleIfNeeded(); } + if (this.decryptRetries.get(retryKey) !== state) { + return; + } if (isDecryptionFailure(state.event)) { this.scheduleDecryptRetry(state); return; @@ -304,4 +330,27 @@ export class MatrixDecryptBridge { this.decryptedMessageDedupe.delete(oldest); } } + + private async waitForActiveRetryRunsToFinish(): Promise { + if (this.activeRetryRuns === 0) { + return; + } + await new Promise((resolve) => { + this.retryIdleResolvers.add(resolve); + if (this.activeRetryRuns === 0) { + this.retryIdleResolvers.delete(resolve); + resolve(); + } + }); + } + + private resolveRetryIdleIfNeeded(): void { + if (this.activeRetryRuns !== 0) { + return; + } + for (const resolve of this.retryIdleResolvers) { + resolve(); + } + this.retryIdleResolvers.clear(); + } } diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 5b0f9ff8a07..20e5ba8fd67 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "../../runtime-api.js"; import { setMatrixRuntime } from "../runtime.js"; const loadWebMediaMock = vi.fn().mockResolvedValue({ diff --git a/extensions/matrix/src/matrix/thread-bindings-shared.ts b/extensions/matrix/src/matrix/thread-bindings-shared.ts index f8c9c2b9e3f..3d3a08dc0b9 100644 --- a/extensions/matrix/src/matrix/thread-bindings-shared.ts +++ b/extensions/matrix/src/matrix/thread-bindings-shared.ts @@ -2,6 +2,7 @@ import type { BindingTargetKind, SessionBindingRecord, } from "openclaw/plugin-sdk/conversation-runtime"; +import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/conversation-runtime"; export type MatrixThreadBindingTargetKind = "subagent" | "acp"; @@ -74,32 +75,7 @@ export function resolveEffectiveBindingExpiry(params: { expiresAt?: number; reason?: "idle-expired" | "max-age-expired"; } { - const idleTimeoutMs = - typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; - const maxAgeMs = - typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; - const inactivityExpiresAt = - idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; - const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; - - if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { - return inactivityExpiresAt <= maxAgeExpiresAt - ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } - : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - if (inactivityExpiresAt != null) { - return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; - } - if (maxAgeExpiresAt != null) { - return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - return {}; + return resolveThreadBindingLifecycle(params); } export function toSessionBindingRecord( diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index 2b447447c81..cd08c459171 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -1,12 +1,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getSessionBindingService, __testing, } from "../../../../src/infra/outbound/session-binding-service.js"; +import type { PluginRuntime } from "../../runtime-api.js"; import { setMatrixRuntime } from "../runtime.js"; import { resolveMatrixStoragePaths } from "./client/storage.js"; import { @@ -30,10 +30,9 @@ const writeJsonFileAtomicallyMock = vi.hoisted(() => vi.fn<(filePath: string, value: unknown) => Promise>(), ); -vi.mock("openclaw/plugin-sdk/matrix", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/matrix", - ); +vi.mock("../../runtime-api.js", async () => { + const actual = + await vi.importActual("../../runtime-api.js"); pluginSdkActual.writeJsonFileAtomically = actual.writeJsonFileAtomically; return { ...actual, diff --git a/extensions/matrix/src/onboarding.resolve.test.ts b/extensions/matrix/src/onboarding.resolve.test.ts index f1d610aa5d4..270343b7509 100644 --- a/extensions/matrix/src/onboarding.resolve.test.ts +++ b/extensions/matrix/src/onboarding.resolve.test.ts @@ -1,5 +1,5 @@ -import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js"; import type { CoreConfig } from "./types.js"; const resolveMatrixTargetsMock = vi.hoisted(() => diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index cb5fd1ef445..b27dbf8189f 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -1,5 +1,5 @@ -import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js"; import { matrixOnboardingAdapter } from "./onboarding.js"; import { setMatrixRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/matrix/src/outbound.test.ts b/extensions/matrix/src/outbound.test.ts index 8f695efec3a..29de2346868 100644 --- a/extensions/matrix/src/outbound.test.ts +++ b/extensions/matrix/src/outbound.test.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; const mocks = vi.hoisted(() => ({ sendMessageMatrix: vi.fn(), diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index 801d61f71f5..3f0eb8dfefe 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -1,5 +1,5 @@ -import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ChannelDirectoryEntry } from "../runtime-api.js"; import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index b23758626c0..39e38660028 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -1,4 +1,4 @@ -export * from "openclaw/plugin-sdk/matrix"; +export * from "../../../src/plugin-sdk/matrix.js"; export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts index e13fee5ad71..2bc65439262 100644 --- a/extensions/mattermost/runtime-api.ts +++ b/extensions/mattermost/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/mattermost"; +// Private runtime barrel for the bundled Mattermost extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/mattermost.js"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 94c5bbff092..476c2c2d19e 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -1,17 +1,19 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; +import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions"; import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-contract"; +import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; -import { - createAttachedChannelResultAdapter, - createChannelDirectoryAdapter, - createLoggedPairingApprovalNotifier, - createMessageToolButtonsSchema, - createScopedAccountReplyToModeResolver, - type ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; @@ -39,8 +41,6 @@ import { DEFAULT_ACCOUNT_ID, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - type ChannelMessageActionAdapter, - type ChannelMessageActionName, type ChannelPlugin, } from "./runtime-api.js"; import { getMattermostRuntime } from "./runtime.js"; diff --git a/extensions/mattermost/src/session-route.ts b/extensions/mattermost/src/session-route.ts index 14352708986..39f12e37127 100644 --- a/extensions/mattermost/src/session-route.ts +++ b/extensions/mattermost/src/session-route.ts @@ -1,11 +1,11 @@ import { buildChannelOutboundSessionRoute, - normalizeOutboundThreadId, resolveThreadSessionKeys, stripChannelTargetPrefix, stripTargetKindPrefix, type ChannelOutboundSessionRouteParams, } from "openclaw/plugin-sdk/core"; +import { normalizeOutboundThreadId } from "openclaw/plugin-sdk/routing"; export function resolveMattermostOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { let trimmed = stripChannelTargetPrefix(params.target, "mattermost"); diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 36954819fd5..14576f4f5d4 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -1,4 +1,4 @@ -import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup"; import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index e219ceec6a0..aca00927171 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -1,16 +1,16 @@ import { - buildOauthProviderAuthResult, definePluginEntry, type ProviderAuthContext, type ProviderAuthResult, type ProviderCatalogContext, -} from "openclaw/plugin-sdk/minimax-portal-auth"; +} from "openclaw/plugin-sdk/plugin-entry"; import { MINIMAX_OAUTH_MARKER, createProviderApiKeyAuthMethod, ensureAuthProfileStore, listProfilesForProvider, } from "openclaw/plugin-sdk/provider-auth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage"; import { minimaxMediaUnderstandingProvider, diff --git a/extensions/minimax/oauth.ts b/extensions/minimax/oauth.ts index fb405cd5559..818b29b0372 100644 --- a/extensions/minimax/oauth.ts +++ b/extensions/minimax/oauth.ts @@ -1,8 +1,5 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { - generatePkceVerifierChallenge, - toFormUrlEncoded, -} from "openclaw/plugin-sdk/minimax-portal-auth"; +import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth"; export type MiniMaxRegion = "cn" | "global"; diff --git a/extensions/msteams/runtime-api.ts b/extensions/msteams/runtime-api.ts index 1347e49a695..e2b75780399 100644 --- a/extensions/msteams/runtime-api.ts +++ b/extensions/msteams/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/msteams"; +// Private runtime barrel for the bundled Microsoft Teams extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/msteams.js"; diff --git a/extensions/msteams/src/channel.directory.test.ts b/extensions/msteams/src/channel.directory.test.ts index 955fdb334c4..5fbc0b52ab1 100644 --- a/extensions/msteams/src/channel.directory.test.ts +++ b/extensions/msteams/src/channel.directory.test.ts @@ -9,6 +9,29 @@ import { msteamsPlugin } from "./channel.js"; describe("msteams directory", () => { const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv; + describe("self()", () => { + it("returns bot identity when credentials are configured", async () => { + const cfg = { + channels: { + msteams: { + appId: "test-app-id-1234", + appPassword: "secret", + tenantId: "tenant-id-5678", + }, + }, + } as unknown as OpenClawConfig; + + const result = await msteamsPlugin.directory?.self?.({ cfg, runtime: runtimeEnv }); + expect(result).toEqual({ kind: "user", id: "test-app-id-1234", name: "test-app-id-1234" }); + }); + + it("returns null when credentials are not configured", async () => { + const cfg = { channels: {} } as unknown as OpenClawConfig; + const result = await msteamsPlugin.directory?.self?.({ cfg, runtime: runtimeEnv }); + expect(result).toBeNull(); + }); + }); + it("lists peers and groups from config", async () => { const cfg = { channels: { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 9d59b042167..8a4e66fab9c 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,22 +1,24 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions"; import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-contract"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; 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"; + listDirectoryEntriesFromSources, +} from "openclaw/plugin-sdk/directory-runtime"; +import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "../runtime-api.js"; import { @@ -217,6 +219,13 @@ export const msteamsPlugin: ChannelPlugin = { }, }, directory: createChannelDirectoryAdapter({ + self: async ({ cfg }) => { + const creds = resolveMSTeamsCredentials(cfg.channels?.msteams); + if (!creds) { + return null; + } + return { kind: "user" as const, id: creds.appId, name: creds.appId }; + }, listPeers: async ({ cfg, query, limit }) => listDirectoryEntriesFromSources({ kind: "user", diff --git a/extensions/msteams/src/conversation-store.ts b/extensions/msteams/src/conversation-store.ts index aa5bc405db9..a32bb717aff 100644 --- a/extensions/msteams/src/conversation-store.ts +++ b/extensions/msteams/src/conversation-store.ts @@ -25,6 +25,13 @@ export type StoredConversationReference = { serviceUrl?: string; /** Locale */ locale?: string; + /** + * Cached Graph API chat ID (format: `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces`). + * Bot Framework conversation IDs for personal DMs use a different format (`a:1xxx` or + * `8:orgid:xxx`) that the Graph API does not accept. This field caches the resolved + * Graph-native chat ID so we don't need to re-query the API on every send. + */ + graphChatId?: string; }; export type MSTeamsConversationStoreEntry = { diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index a41147840ec..45c736e2e1d 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; -import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js"; +import { resolveGraphChatId, uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js"; describe("graph upload helpers", () => { const tokenProvider = { @@ -100,3 +100,106 @@ describe("graph upload helpers", () => { ).rejects.toThrow("SharePoint upload response missing required fields"); }); }); + +describe("resolveGraphChatId", () => { + const tokenProvider = { + getAccessToken: vi.fn(async () => "graph-token"), + }; + + it("returns the ID directly when it already starts with 19:", async () => { + const fetchFn = vi.fn(); + const result = await resolveGraphChatId({ + botFrameworkConversationId: "19:abc123@thread.tacv2", + tokenProvider, + fetchFn, + }); + // Should short-circuit without making any API call + expect(fetchFn).not.toHaveBeenCalled(); + expect(result).toBe("19:abc123@thread.tacv2"); + }); + + it("resolves personal DM chat ID via Graph API using user AAD object ID", async () => { + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ value: [{ id: "19:dm-chat-id@unq.gbl.spaces" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await resolveGraphChatId({ + botFrameworkConversationId: "a:1abc_bot_framework_dm_id", + userAadObjectId: "user-aad-object-id-123", + tokenProvider, + fetchFn, + }); + + expect(fetchFn).toHaveBeenCalledWith( + expect.stringContaining("/me/chats"), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer graph-token" }), + }), + ); + // Should filter by user AAD object ID + const callUrl = (fetchFn.mock.calls[0] as unknown[])[0]; + expect(callUrl).toContain("user-aad-object-id-123"); + expect(result).toBe("19:dm-chat-id@unq.gbl.spaces"); + }); + + it("resolves personal DM chat ID without user AAD object ID (lists all 1:1 chats)", async () => { + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ value: [{ id: "19:fallback-chat@unq.gbl.spaces" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await resolveGraphChatId({ + botFrameworkConversationId: "8:orgid:user-object-id", + tokenProvider, + fetchFn, + }); + + expect(fetchFn).toHaveBeenCalledOnce(); + expect(result).toBe("19:fallback-chat@unq.gbl.spaces"); + }); + + it("returns null when Graph API returns no chats", async () => { + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ value: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await resolveGraphChatId({ + botFrameworkConversationId: "a:1unknown_dm", + userAadObjectId: "some-user", + tokenProvider, + fetchFn, + }); + + expect(result).toBeNull(); + }); + + it("returns null when Graph API call fails", async () => { + const fetchFn = vi.fn( + async () => + new Response("Unauthorized", { + status: 401, + headers: { "content-type": "text/plain" }, + }), + ); + + const result = await resolveGraphChatId({ + botFrameworkConversationId: "a:1some_dm_id", + userAadObjectId: "some-user", + tokenProvider, + fetchFn, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts index 9705b1a63a4..61303cf877b 100644 --- a/extensions/msteams/src/graph-upload.ts +++ b/extensions/msteams/src/graph-upload.ts @@ -264,6 +264,82 @@ export async function getDriveItemProperties(params: { }; } +/** + * Resolve the Graph API-native chat ID from a Bot Framework conversation ID. + * + * Bot Framework personal DM conversation IDs use formats like `a:1xxx@unq.gbl.spaces` + * or `8:orgid:xxx` that the Graph API does not accept. Graph API requires the + * `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format. + * + * This function looks up the matching Graph chat by querying the bot's chats filtered + * by the target user's AAD object ID. + * + * Returns the Graph chat ID if found, or null if resolution fails. + */ +export async function resolveGraphChatId(params: { + /** Bot Framework conversation ID (may be in non-Graph format for personal DMs) */ + botFrameworkConversationId: string; + /** AAD object ID of the user in the conversation (used for filtering chats) */ + userAadObjectId?: string; + tokenProvider: MSTeamsAccessTokenProvider; + fetchFn?: typeof fetch; +}): Promise { + const { botFrameworkConversationId, userAadObjectId, tokenProvider } = params; + const fetchFn = params.fetchFn ?? fetch; + + // If the conversation ID already looks like a valid Graph chat ID, return it directly. + // Graph chat IDs start with "19:" — Bot Framework group chat IDs already use this format. + if (botFrameworkConversationId.startsWith("19:")) { + return botFrameworkConversationId; + } + + // For personal DMs with non-Graph conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`), + // query the bot's chats to find the matching one. + const token = await tokenProvider.getAccessToken(GRAPH_SCOPE); + + // Build filter: if we have the user's AAD object ID, narrow the search to 1:1 chats + // with that member. Otherwise, fall back to listing all 1:1 chats. + let path: string; + if (userAadObjectId) { + const encoded = encodeURIComponent( + `chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq '${userAadObjectId}')`, + ); + path = `/me/chats?$filter=${encoded}&$select=id`; + } else { + // Fallback: list all 1:1 chats when no user ID is available. + // Only safe when the bot has exactly one 1:1 chat; returns null otherwise to + // avoid sending to the wrong person's chat. + path = `/me/chats?$filter=${encodeURIComponent("chatType eq 'oneOnOne'")}&$select=id`; + } + + const res = await fetchFn(`${GRAPH_ROOT}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + return null; + } + + const data = (await res.json()) as { + value?: Array<{ id?: string }>; + }; + + const chats = data.value ?? []; + + // When filtered by userAadObjectId, any non-empty result is the right 1:1 chat. + if (userAadObjectId && chats.length > 0 && chats[0]?.id) { + return chats[0].id; + } + + // Without a user ID we can only be certain when exactly one chat is returned; + // multiple results would be ambiguous and could route to the wrong person. + if (!userAadObjectId && chats.length === 1 && chats[0]?.id) { + return chats[0].id; + } + + return null; +} + /** * Get members of a Teams chat for per-user sharing. * Used to create sharing links scoped to only the chat participants. diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 2644092f127..92f161341de 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -50,9 +50,14 @@ const runtimeStub: PluginRuntime = createPluginRuntimeMock({ }, }); +const noopUpdateActivity = async () => {}; +const noopDeleteActivity = async () => {}; + const createNoopAdapter = (): MSTeamsAdapter => ({ continueConversation: async () => {}, process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, }); const createRecordedSendActivity = ( @@ -81,6 +86,8 @@ const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({ }); }, process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, }); describe("msteams messenger", () => { @@ -195,6 +202,8 @@ describe("msteams messenger", () => { }); }, process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, }; const ids = await sendMSTeamsMessages({ @@ -366,6 +375,8 @@ describe("msteams messenger", () => { await logic({ sendActivity: createRecordedSendActivity(attempts, 503) }); }, process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, }; const ids = await sendMSTeamsMessages({ diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index c2263a4975f..331760adfce 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -61,6 +61,8 @@ export type MSTeamsAdapter = { res: unknown, logic: (context: unknown) => Promise, ) => Promise; + updateActivity: (context: unknown, activity: object) => Promise; + deleteActivity: (context: unknown, reference: { activityId?: string }) => Promise; }; export type MSTeamsReplyRenderOptions = { @@ -319,8 +321,10 @@ async function buildActivity( if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) { // Non-image in group chat/channel with SharePoint site configured: - // Upload to SharePoint and use native file card attachment - const chatId = conversationRef.conversation?.id; + // Upload to SharePoint and use native file card attachment. + // Use the cached Graph-native chat ID when available — Bot Framework conversation IDs + // for personal DMs use a format (e.g. `a:1xxx`) that Graph API rejects. + const chatId = conversationRef.graphChatId ?? conversationRef.conversation?.id; // Upload to SharePoint const uploaded = await uploadAndShareSharePoint({ diff --git a/extensions/msteams/src/monitor-handler.file-consent.test.ts b/extensions/msteams/src/monitor-handler.file-consent.test.ts index 5e610bfcfa6..39b6ea1b1ff 100644 --- a/extensions/msteams/src/monitor-handler.file-consent.test.ts +++ b/extensions/msteams/src/monitor-handler.file-consent.test.ts @@ -42,6 +42,8 @@ function createDeps(): MSTeamsMessageHandlerDeps { const adapter: MSTeamsAdapter = { continueConversation: async () => {}, process: async () => {}, + updateActivity: async () => {}, + deleteActivity: async () => {}, }; const conversationStore: MSTeamsConversationStore = { upsert: async () => {}, @@ -82,6 +84,8 @@ function createActivityHandler(): MSTeamsActivityHandler { handler = { onMessage: () => handler, onMembersAdded: () => handler, + onReactionsAdded: () => handler, + onReactionsRemoved: () => handler, run: async () => {}, }; return handler; diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts index de586261568..4cda545bd02 100644 --- a/extensions/msteams/src/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -21,6 +21,12 @@ export type MSTeamsActivityHandler = { onMembersAdded: ( handler: (context: unknown, next: () => Promise) => Promise, ) => MSTeamsActivityHandler; + onReactionsAdded: ( + handler: (context: unknown, next: () => Promise) => Promise, + ) => MSTeamsActivityHandler; + onReactionsRemoved: ( + handler: (context: unknown, next: () => Promise) => Promise, + ) => MSTeamsActivityHandler; run?: (context: unknown) => Promise; }; diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 8f71e80bbf2..fe6751b94c3 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -177,10 +177,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { channelName, allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); - const senderGroupPolicy = resolveSenderScopedGroupPolicy({ - groupPolicy, - groupAllowFrom: effectiveGroupAllowFrom, - }); + // When a route-level (team/channel) allowlist is configured but the sender allowlist is + // empty, resolveSenderScopedGroupPolicy would otherwise downgrade the policy to "open", + // allowing any sender. To close this bypass (GHSA-g7cr-9h7q-4qxq), treat an empty sender + // allowlist as deny-all whenever the route allowlist is active. + const senderGroupPolicy = + channelGate.allowlistConfigured && effectiveGroupAllowFrom.length === 0 + ? groupPolicy + : resolveSenderScopedGroupPolicy({ + groupPolicy, + groupAllowFrom: effectiveGroupAllowFrom, + }); const access = resolveDmGroupAccessWithLists({ isGroup: !isDirectMessage, dmPolicy, diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index cf482825ed2..0e34f637736 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,5 +1,5 @@ -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { getMSTeamsRuntime } from "./runtime.js"; diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index 3e28cf8a8cb..a5145bebf0f 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -1,4 +1,4 @@ -import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk/allowlist-resolution"; +import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk/allow-from"; import { searchGraphUsers } from "./graph-users.js"; import { listChannelsForTeam, diff --git a/extensions/msteams/src/send-context.ts b/extensions/msteams/src/send-context.ts index 6b1b32fafa3..2dd3102ed24 100644 --- a/extensions/msteams/src/send-context.ts +++ b/extensions/msteams/src/send-context.ts @@ -9,6 +9,7 @@ import type { MSTeamsConversationStore, StoredConversationReference, } from "./conversation-store.js"; +import { resolveGraphChatId } from "./graph-upload.js"; import type { MSTeamsAdapter } from "./messenger.js"; import { getMSTeamsRuntime } from "./runtime.js"; import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js"; @@ -30,6 +31,13 @@ export type MSTeamsProactiveContext = { sharePointSiteId?: string; /** Resolved media max bytes from config (default: 100MB) */ mediaMaxBytes?: number; + /** + * Graph API-native chat ID for this conversation. + * Bot Framework personal DM IDs (`a:1xxx` / `8:orgid:xxx`) cannot be used directly + * with Graph chat endpoints. This field holds the resolved `19:xxx` format ID. + * Null if resolution failed or not applicable. + */ + graphChatId?: string | null; }; /** @@ -150,6 +158,45 @@ export async function resolveMSTeamsSendContext(params: { resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb, }); + // Resolve Graph API-native chat ID if needed for SharePoint per-user sharing. + // Bot Framework personal DM conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`) cannot + // be used directly with Graph /chats/{chatId} endpoints — the Graph API requires the + // `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format. + // We check the cached value first, then resolve via Graph API and cache for future sends. + let graphChatId: string | null | undefined = ref.graphChatId ?? undefined; + if (graphChatId === undefined && sharePointSiteId) { + // Only resolve when SharePoint is configured (the only place chatId matters currently) + try { + const resolved = await resolveGraphChatId({ + botFrameworkConversationId: conversationId, + userAadObjectId: ref.user?.aadObjectId, + tokenProvider, + }); + graphChatId = resolved; + + // Cache in the conversation store so subsequent sends skip the Graph lookup. + // NOTE: We intentionally do NOT cache null results. Transient Graph API failures + // (network, 401, rate limit) should be retried on subsequent sends rather than + // permanently blocking file uploads for this conversation. + if (resolved) { + await store.upsert(conversationId, { ...ref, graphChatId: resolved }); + } else { + log.warn?.("could not resolve Graph chat ID; file uploads may fail for this conversation", { + conversationId, + }); + } + } catch (err) { + log.warn?.( + "failed to resolve Graph chat ID; file uploads may fall back to Bot Framework ID", + { + conversationId, + error: String(err), + }, + ); + graphChatId = null; + } + } + return { appId: creds.appId, conversationId, @@ -160,5 +207,6 @@ export async function resolveMSTeamsSendContext(params: { tokenProvider, sharePointSiteId, mediaMaxBytes, + graphChatId, }; } diff --git a/extensions/msteams/src/send.test.ts b/extensions/msteams/src/send.test.ts index 332a00b65bb..0c15cc87f28 100644 --- a/extensions/msteams/src/send.test.ts +++ b/extensions/msteams/src/send.test.ts @@ -9,6 +9,9 @@ const mockState = vi.hoisted(() => ({ prepareFileConsentActivity: vi.fn(), extractFilename: vi.fn(async () => "fallback.bin"), sendMSTeamsMessages: vi.fn(), + uploadAndShareSharePoint: vi.fn(), + getDriveItemProperties: vi.fn(), + buildTeamsFileInfoCard: vi.fn(), })); vi.mock("../runtime-api.js", () => ({ @@ -45,6 +48,16 @@ vi.mock("./runtime.js", () => ({ }), })); +vi.mock("./graph-upload.js", () => ({ + uploadAndShareSharePoint: mockState.uploadAndShareSharePoint, + getDriveItemProperties: mockState.getDriveItemProperties, + uploadAndShareOneDrive: vi.fn(), +})); + +vi.mock("./graph-chat.js", () => ({ + buildTeamsFileInfoCard: mockState.buildTeamsFileInfoCard, +})); + describe("sendMessageMSTeams", () => { beforeEach(() => { mockState.loadOutboundMediaFromUrl.mockReset(); @@ -53,6 +66,9 @@ describe("sendMessageMSTeams", () => { mockState.prepareFileConsentActivity.mockReset(); mockState.extractFilename.mockReset(); mockState.sendMSTeamsMessages.mockReset(); + mockState.uploadAndShareSharePoint.mockReset(); + mockState.getDriveItemProperties.mockReset(); + mockState.buildTeamsFileInfoCard.mockReset(); mockState.extractFilename.mockResolvedValue("fallback.bin"); mockState.requiresFileConsent.mockReturnValue(false); @@ -106,4 +122,139 @@ describe("sendMessageMSTeams", () => { }), ); }); + + it("uses graphChatId instead of conversationId when uploading to SharePoint", async () => { + // Simulates a group chat where Bot Framework conversationId is valid but we have + // a resolved Graph chat ID cached from a prior send. + const graphChatId = "19:graph-native-chat-id@thread.tacv2"; + const botFrameworkConversationId = "19:bot-framework-id@thread.tacv2"; + + mockState.resolveMSTeamsSendContext.mockResolvedValue({ + adapter: { + continueConversation: vi.fn( + async ( + _id: string, + _ref: unknown, + fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise, + ) => fn({ sendActivity: () => ({ id: "msg-1" }) }), + ), + }, + appId: "app-id", + conversationId: botFrameworkConversationId, + graphChatId, + ref: {}, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + conversationType: "groupChat", + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + mediaMaxBytes: 8 * 1024 * 1024, + sharePointSiteId: "site-123", + }); + + const pdfBuffer = Buffer.alloc(100, "pdf"); + mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({ + buffer: pdfBuffer, + contentType: "application/pdf", + fileName: "doc.pdf", + kind: "file", + }); + mockState.requiresFileConsent.mockReturnValue(false); + mockState.uploadAndShareSharePoint.mockResolvedValue({ + itemId: "item-1", + webUrl: "https://sp.example.com/doc.pdf", + shareUrl: "https://sp.example.com/share/doc.pdf", + name: "doc.pdf", + }); + mockState.getDriveItemProperties.mockResolvedValue({ + eTag: '"{GUID-123},1"', + webDavUrl: "https://sp.example.com/dav/doc.pdf", + name: "doc.pdf", + }); + mockState.buildTeamsFileInfoCard.mockReturnValue({ + contentType: "application/vnd.microsoft.teams.card.file.info", + contentUrl: "https://sp.example.com/dav/doc.pdf", + name: "doc.pdf", + content: { uniqueId: "GUID-123", fileType: "pdf" }, + }); + + await sendMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: "conversation:19:bot-framework-id@thread.tacv2", + text: "here is a file", + mediaUrl: "https://example.com/doc.pdf", + }); + + // The Graph-native chatId must be passed to SharePoint upload, not the Bot Framework ID + expect(mockState.uploadAndShareSharePoint).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: graphChatId, + siteId: "site-123", + }), + ); + }); + + it("falls back to conversationId when graphChatId is not available", async () => { + const botFrameworkConversationId = "19:fallback-id@thread.tacv2"; + + mockState.resolveMSTeamsSendContext.mockResolvedValue({ + adapter: { + continueConversation: vi.fn( + async ( + _id: string, + _ref: unknown, + fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise, + ) => fn({ sendActivity: () => ({ id: "msg-1" }) }), + ), + }, + appId: "app-id", + conversationId: botFrameworkConversationId, + graphChatId: null, // resolution failed — must fall back + ref: {}, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + conversationType: "groupChat", + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + mediaMaxBytes: 8 * 1024 * 1024, + sharePointSiteId: "site-456", + }); + + const pdfBuffer = Buffer.alloc(50, "pdf"); + mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({ + buffer: pdfBuffer, + contentType: "application/pdf", + fileName: "report.pdf", + kind: "file", + }); + mockState.requiresFileConsent.mockReturnValue(false); + mockState.uploadAndShareSharePoint.mockResolvedValue({ + itemId: "item-2", + webUrl: "https://sp.example.com/report.pdf", + shareUrl: "https://sp.example.com/share/report.pdf", + name: "report.pdf", + }); + mockState.getDriveItemProperties.mockResolvedValue({ + eTag: '"{GUID-456},1"', + webDavUrl: "https://sp.example.com/dav/report.pdf", + name: "report.pdf", + }); + mockState.buildTeamsFileInfoCard.mockReturnValue({ + contentType: "application/vnd.microsoft.teams.card.file.info", + contentUrl: "https://sp.example.com/dav/report.pdf", + name: "report.pdf", + content: { uniqueId: "GUID-456", fileType: "pdf" }, + }); + + await sendMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: "conversation:19:fallback-id@thread.tacv2", + text: "report", + mediaUrl: "https://example.com/report.pdf", + }); + + // Falls back to conversationId when graphChatId is null + expect(mockState.uploadAndShareSharePoint).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: botFrameworkConversationId, + siteId: "site-456", + }), + ); + }); }); diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index aaf6a8b4cc9..2471b6f3c86 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -206,7 +206,9 @@ export async function sendMessageMSTeams( contentType: media.contentType, tokenProvider, siteId: sharePointSiteId, - chatId: conversationId, + // Use the Graph-native chat ID (19:xxx format) — the Bot Framework conversationId + // for personal DMs uses a different format that Graph API rejects. + chatId: ctx.graphChatId ?? conversationId, usePerUserSharing: conversationType === "groupChat", }); diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts index fc9283930bd..80bc1b1dc7b 100644 --- a/extensions/nextcloud-talk/runtime-api.ts +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/nextcloud-talk"; +// Private runtime barrel for the bundled Nextcloud Talk extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/nextcloud-talk.js"; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index ff316e3a533..880be995ab8 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -4,12 +4,12 @@ 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 { - createAttachedChannelResultAdapter, createLoggedPairingApprovalNotifier, createPairingPrefixStripper, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-pairing"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared"; import { buildBaseChannelStatusSummary, diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 6aaf7aafbe8..1059cd0a63a 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -1,5 +1,4 @@ -import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index 776a9a4fe3e..4aa27c91009 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -1,7 +1,7 @@ -import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup"; diff --git a/extensions/nostr/api.ts b/extensions/nostr/api.ts index 3f3d64cc3bf..6606fb316b4 100644 --- a/extensions/nostr/api.ts +++ b/extensions/nostr/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/nostr"; +export * from "./runtime-api.js"; diff --git a/extensions/nostr/runtime-api.ts b/extensions/nostr/runtime-api.ts index 3f3d64cc3bf..602b0ac81b7 100644 --- a/extensions/nostr/runtime-api.ts +++ b/extensions/nostr/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/nostr"; +// Private runtime barrel for the bundled Nostr extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/nostr.js"; diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index 9c7a1512624..bdcb2ca31bf 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -1,4 +1,4 @@ -import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 66d182a341f..36af1146758 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -3,13 +3,13 @@ import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; -import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, type OAuthCredential, } from "openclaw/plugin-sdk/provider-auth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { loginOpenAICodexOAuth } from "openclaw/plugin-sdk/provider-auth-login"; import { DEFAULT_CONTEXT_TOKENS, diff --git a/extensions/phone-control/runtime-api.ts b/extensions/phone-control/runtime-api.ts index 7db40d08280..940bc8fe2ba 100644 --- a/extensions/phone-control/runtime-api.ts +++ b/extensions/phone-control/runtime-api.ts @@ -2,6 +2,6 @@ export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; export type { OpenClawPluginApi, OpenClawPluginCommandDefinition, - OpenClawPluginService, PluginCommandContext, -} from "openclaw/plugin-sdk/core"; + OpenClawPluginService, +} from "openclaw/plugin-sdk/plugin-entry"; diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index e32eb8ef791..bcbc564dc33 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -1,9 +1,10 @@ -import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime"; -import { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js"; import { buildOauthProviderAuthResult, definePluginEntry, + ensureAuthProfileStore, + listProfilesForProvider, + QWEN_OAUTH_MARKER, refreshQwenPortalCredentials, type ProviderAuthContext, type ProviderCatalogContext, diff --git a/extensions/qwen-portal-auth/refresh.test.ts b/extensions/qwen-portal-auth/refresh.test.ts new file mode 100644 index 00000000000..2cbaeb65d27 --- /dev/null +++ b/extensions/qwen-portal-auth/refresh.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { refreshQwenPortalCredentials } from "./refresh.js"; + +function expiredCredentials() { + return { + type: "oauth" as const, + provider: "qwen-portal", + access: "expired-access", + refresh: "refresh-token", + expires: Date.now() - 60_000, + }; +} + +describe("refreshQwenPortalCredentials", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + const runRefresh = async () => await refreshQwenPortalCredentials(expiredCredentials()); + + it("refreshes oauth credentials and preserves existing refresh token when absent", async () => { + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + access_token: "new-access", + expires_in: 3600, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + const result = await runRefresh(); + + expect(result.access).toBe("new-access"); + expect(result.refresh).toBe("refresh-token"); + expect(result.expires).toBeGreaterThan(Date.now()); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://chat.qwen.ai/api/v1/oauth2/token", + expect.objectContaining({ + method: "POST", + body: expect.any(URLSearchParams), + }), + ); + }); + + it("replaces the refresh token when the server rotates it", async () => { + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + access_token: "new-access", + refresh_token: "rotated-refresh", + expires_in: 1200, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + const result = await runRefresh(); + + expect(result.refresh).toBe("rotated-refresh"); + }); + + it("rejects invalid expires_in payloads", async () => { + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + access_token: "new-access", + expires_in: 0, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + await expect(runRefresh()).rejects.toThrow( + "Qwen OAuth refresh response missing or invalid expires_in", + ); + }); + + it("turns 400 responses into a re-authenticate hint", async () => { + globalThis.fetch = vi.fn( + async () => new Response("bad refresh", { status: 400 }), + ) as typeof fetch; + + await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh token expired or invalid"); + }); + + it("requires a refresh token", async () => { + await expect( + refreshQwenPortalCredentials({ + type: "oauth", + provider: "qwen-portal", + access: "expired-access", + refresh: "", + expires: Date.now() - 60_000, + }), + ).rejects.toThrow("Qwen OAuth refresh token missing"); + }); + + it("rejects missing access tokens", async () => { + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + expires_in: 3600, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh response missing access token"); + }); + + it("surfaces non-400 refresh failures", async () => { + globalThis.fetch = vi.fn( + async () => new Response("gateway down", { status: 502 }), + ) as typeof fetch; + + await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh failed: gateway down"); + }); +}); diff --git a/src/providers/qwen-portal-oauth.ts b/extensions/qwen-portal-auth/refresh.ts similarity index 96% rename from src/providers/qwen-portal-oauth.ts rename to extensions/qwen-portal-auth/refresh.ts index 159942ef2a9..eee8421e011 100644 --- a/src/providers/qwen-portal-oauth.ts +++ b/extensions/qwen-portal-auth/refresh.ts @@ -1,5 +1,5 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; -import { formatCliCommand } from "../cli/command-format.js"; +import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools"; const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"; const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`; @@ -54,9 +54,9 @@ export async function refreshQwenPortalCredentials( return { ...credentials, - access: accessToken, // RFC 6749 section 6: new refresh token is optional; if present, replace old. refresh: newRefreshToken || refreshToken, + access: accessToken, expires: Date.now() + expiresIn * 1000, }; } diff --git a/extensions/qwen-portal-auth/runtime-api.ts b/extensions/qwen-portal-auth/runtime-api.ts index 232a2886110..5fbd1e571b4 100644 --- a/extensions/qwen-portal-auth/runtime-api.ts +++ b/extensions/qwen-portal-auth/runtime-api.ts @@ -1 +1,7 @@ -export * from "openclaw/plugin-sdk/qwen-portal-auth"; +export { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; +export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +export type { ProviderAuthContext, ProviderCatalogContext } from "openclaw/plugin-sdk/plugin-entry"; +export { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/provider-auth"; +export { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; +export { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth"; +export { refreshQwenPortalCredentials } from "./refresh.js"; diff --git a/extensions/shared/passive-monitor.ts b/extensions/shared/passive-monitor.ts index 435f934b123..f9cd2ed58ab 100644 --- a/extensions/shared/passive-monitor.ts +++ b/extensions/shared/passive-monitor.ts @@ -1,4 +1,4 @@ -import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-runtime"; +import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-lifecycle"; type StoppableMonitor = { stop: () => void; diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index 272b4612dc1..51bd1f7e96d 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -4,7 +4,7 @@ import { resolveAccountEntry, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; -import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal-core"; +import type { SignalAccountConfig } from "./runtime-api.js"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 6ba7fce6084..9612951c3b4 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,16 +1,17 @@ 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"; +} from "openclaw/plugin-sdk/channel-pairing"; +import { + attachChannelToResult, + attachChannelToResults, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; -import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; import { markdownToSignalTextChunks } from "./format.js"; import { diff --git a/extensions/signal/src/config-schema.ts b/extensions/signal/src/config-schema.ts index a4f2d054ffd..e67469e1499 100644 --- a/extensions/signal/src/config-schema.ts +++ b/extensions/signal/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, SignalConfigSchema } from "openclaw/plugin-sdk/signal-core"; +import { buildChannelConfigSchema, SignalConfigSchema } from "./runtime-api.js"; export const SignalChannelConfigSchema = buildChannelConfigSchema(SignalConfigSchema); diff --git a/extensions/signal/src/message-actions.ts b/extensions/signal/src/message-actions.ts index c6082848f02..2645908f3e9 100644 --- a/extensions/signal/src/message-actions.ts +++ b/extensions/signal/src/message-actions.ts @@ -1,11 +1,9 @@ -import { - createActionGate, - jsonResult, - readStringParam, - resolveReactionMessageId, - type ChannelMessageActionAdapter, - type ChannelMessageActionName, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createActionGate, jsonResult, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "openclaw/plugin-sdk/channel-contract"; import { listEnabledSignalAccounts, resolveSignalAccount } from "./accounts.js"; import { resolveSignalReactionLevel } from "./reaction-level.js"; import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js"; diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index b0e601fc01e..9aa32731b1d 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -9,6 +9,7 @@ import { 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 { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { deliverTextOrMediaReply, resolveSendableOutboundReplyParts, @@ -19,7 +20,6 @@ import { resolveChunkMode, resolveTextChunkLimit, } from "openclaw/plugin-sdk/reply-runtime"; -import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 23eb676ae82..58ff8d4f8d7 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -1,32 +1,33 @@ import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; +import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; import { + buildMentionRegexes, createChannelInboundDebouncer, - shouldDebounceTextInbound, -} from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; -import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/config-runtime"; -import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; -import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; -import { formatInboundEnvelope, formatInboundFromLabel, + matchesMentionPatterns, resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; + shouldDebounceTextInbound, +} from "openclaw/plugin-sdk/channel-inbound"; +import { + logInboundDrop, + resolveMentionGatingWithBypass, +} from "openclaw/plugin-sdk/channel-inbound"; +import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; +import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/config-runtime"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, recordPendingHistoryEntryIfEnabled, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -46,6 +47,7 @@ import { resolveSignalSender, type SignalSender, } from "../identity.js"; +import { normalizeSignalMessagingTarget } from "../runtime-api.js"; import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js"; import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js"; import type { diff --git a/extensions/signal/src/monitor/event-handler.types.ts b/extensions/signal/src/monitor/event-handler.types.ts index 82a96af73cc..4ccb85cde5d 100644 --- a/extensions/signal/src/monitor/event-handler.types.ts +++ b/extensions/signal/src/monitor/event-handler.types.ts @@ -4,7 +4,7 @@ import type { GroupPolicy, SignalReactionNotificationMode, } from "openclaw/plugin-sdk/config-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { SignalSender } from "../identity.js"; diff --git a/extensions/signal/src/outbound-adapter.ts b/extensions/signal/src/outbound-adapter.ts index 4471871b69b..08d54ddd052 100644 --- a/extensions/signal/src/outbound-adapter.ts +++ b/extensions/signal/src/outbound-adapter.ts @@ -1,12 +1,12 @@ -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 type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { attachChannelToResult, attachChannelToResults, createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime"; +import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/media-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { markdownToSignalTextChunks } from "./format.js"; import { sendMessageSignal } from "./send.js"; diff --git a/extensions/signal/src/probe.ts b/extensions/signal/src/probe.ts index ac7dce428e8..4fd26f12355 100644 --- a/extensions/signal/src/probe.ts +++ b/extensions/signal/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { signalCheck, signalRpcRequest } from "./client.js"; export type SignalProbe = BaseProbeResult & { diff --git a/extensions/signal/src/runtime-api.ts b/extensions/signal/src/runtime-api.ts index 93bce482026..172943641f8 100644 --- a/extensions/signal/src/runtime-api.ts +++ b/extensions/signal/src/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/signal"; +// Private runtime barrel for the bundled Signal extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../../src/plugin-sdk/signal.js"; diff --git a/extensions/slack/src/account-inspect.ts b/extensions/slack/src/account-inspect.ts index a1620cfe33b..f465ccf2d79 100644 --- a/extensions/slack/src/account-inspect.ts +++ b/extensions/slack/src/account-inspect.ts @@ -6,7 +6,7 @@ import { import { hasConfiguredSecretInput, normalizeSecretInputString, -} from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/secret-input"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { mergeSlackAccountConfig, diff --git a/extensions/slack/src/channel-actions.ts b/extensions/slack/src/channel-actions.ts index 3d9c2417306..4502ddb36a4 100644 --- a/extensions/slack/src/channel-actions.ts +++ b/extensions/slack/src/channel-actions.ts @@ -2,7 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { type ChannelMessageActionAdapter, type ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-contract"; import type { SlackActionContext } from "./action-runtime.js"; import { handleSlackAction } from "./action-runtime.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 691b6126557..e9659c14d7c 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1,7 +1,7 @@ -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"; +import type { OpenClawConfig } from "./runtime-api.js"; const handleSlackActionMock = vi.fn(); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 7a27e73aa8d..3a2646c0152 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -4,20 +4,29 @@ import { 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 { - createAttachedChannelResultAdapter, - createChannelDirectoryAdapter, createPairingPrefixStripper, - createScopedAccountReplyToModeResolver, - createRuntimeDirectoryLiveAdapter, createTextPairingAdapter, - resolveOutboundSendDep, - resolveTargetsWithOptionalToken, -} from "openclaw/plugin-sdk/channel-runtime"; -import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/channel-pairing"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/channel-targets"; +import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { + createChannelDirectoryAdapter, + createRuntimeDirectoryLiveAdapter, +} from "openclaw/plugin-sdk/directory-runtime"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; -import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { + createRuntimeOutboundDelegates, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/infra-runtime"; +import { + buildOutboundBaseSessionKey, + normalizeOutboundThreadId, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk/routing"; import { listEnabledSlackAccounts, resolveSlackAccount, diff --git a/extensions/slack/src/config-schema.ts b/extensions/slack/src/config-schema.ts index d5f28cf7905..5b2e38e1665 100644 --- a/extensions/slack/src/config-schema.ts +++ b/extensions/slack/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, SlackConfigSchema } from "openclaw/plugin-sdk/slack-core"; +import { buildChannelConfigSchema, SlackConfigSchema } from "./runtime-api.js"; export const SlackChannelConfigSchema = buildChannelConfigSchema(SlackConfigSchema); diff --git a/extensions/slack/src/directory-live.ts b/extensions/slack/src/directory-live.ts index 0a8bd04af22..93d83978268 100644 --- a/extensions/slack/src/directory-live.ts +++ b/extensions/slack/src/directory-live.ts @@ -1,5 +1,7 @@ -import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelDirectoryEntry, + DirectoryConfigParams, +} from "openclaw/plugin-sdk/directory-runtime"; import { resolveSlackAccount } from "./accounts.js"; import { createSlackWebClient } from "./client.js"; diff --git a/extensions/slack/src/draft-stream.ts b/extensions/slack/src/draft-stream.ts index f122e2664c5..c4840b938fe 100644 --- a/extensions/slack/src/draft-stream.ts +++ b/extensions/slack/src/draft-stream.ts @@ -1,4 +1,4 @@ -import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-runtime"; +import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle"; import { deleteSlackMessage, editSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; diff --git a/extensions/slack/src/group-policy.ts b/extensions/slack/src/group-policy.ts index d49138fb5f8..b77a63c7a81 100644 --- a/extensions/slack/src/group-policy.ts +++ b/extensions/slack/src/group-policy.ts @@ -1,9 +1,9 @@ +import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { resolveToolsBySender, type GroupToolPolicyBySenderConfig, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; -import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeHyphenSlug } from "openclaw/plugin-sdk/core"; import { inspectSlackAccount } from "./account-inspect.js"; diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 55576d9e822..372ae915700 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,9 +1,9 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; -import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/slack-core"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks } from "./blocks-render.js"; +import { readNumberParam, readStringParam } from "./runtime-api.js"; type SlackActionInvoke = ( action: Record, diff --git a/extensions/slack/src/message-actions.ts b/extensions/slack/src/message-actions.ts index 938659c9354..5eb3bdb9e76 100644 --- a/extensions/slack/src/message-actions.ts +++ b/extensions/slack/src/message-actions.ts @@ -1,9 +1,7 @@ import { createActionGate } from "openclaw/plugin-sdk/agent-runtime"; -import type { - ChannelMessageActionName, - ChannelToolSend, -} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelToolSend } from "openclaw/plugin-sdk/tool-send"; import { listEnabledSlackAccounts } from "./accounts.js"; export function listSlackMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { diff --git a/extensions/slack/src/monitor/allow-list.ts b/extensions/slack/src/monitor/allow-list.ts index 32fb7f40530..0ae6de23ec1 100644 --- a/extensions/slack/src/monitor/allow-list.ts +++ b/extensions/slack/src/monitor/allow-list.ts @@ -2,7 +2,7 @@ import { compileAllowlist, resolveCompiledAllowlistMatch, type AllowlistMatch, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/allow-from"; import { normalizeHyphenSlug, normalizeStringEntries, diff --git a/extensions/slack/src/monitor/channel-config.ts b/extensions/slack/src/monitor/channel-config.ts index 32ad0e6f022..4aca5fc1422 100644 --- a/extensions/slack/src/monitor/channel-config.ts +++ b/extensions/slack/src/monitor/channel-config.ts @@ -3,7 +3,7 @@ import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, type ChannelMatchSource, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-targets"; import type { SlackReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime"; import type { SlackMessageEvent } from "../types.js"; import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts index f39a92ce207..0d3f5706697 100644 --- a/extensions/slack/src/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -1,5 +1,5 @@ import type { App } from "@slack/bolt"; -import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; +import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from"; import type { OpenClawConfig, SlackReactionNotificationMode, @@ -7,7 +7,7 @@ import type { import { resolveSessionKey, type SessionScope } from "openclaw/plugin-sdk/config-runtime"; import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/slack/src/monitor/dm-auth.ts b/extensions/slack/src/monitor/dm-auth.ts index 75a0515bce7..0783fa17acf 100644 --- a/extensions/slack/src/monitor/dm-auth.ts +++ b/extensions/slack/src/monitor/dm-auth.ts @@ -1,5 +1,5 @@ +import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from"; import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { resolveSlackAllowListMatch } from "./allow-list.js"; import type { SlackMonitorContext } from "./context.js"; diff --git a/extensions/slack/src/monitor/events/channels.ts b/extensions/slack/src/monitor/events/channels.ts index e4940f80d9f..47fdc2647c4 100644 --- a/extensions/slack/src/monitor/events/channels.ts +++ b/extensions/slack/src/monitor/events/channels.ts @@ -1,5 +1,5 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers"; import { loadConfig, writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/slack/src/monitor/message-handler.ts b/extensions/slack/src/monitor/message-handler.ts index feaddff98df..fb700b78350 100644 --- a/extensions/slack/src/monitor/message-handler.ts +++ b/extensions/slack/src/monitor/message-handler.ts @@ -1,7 +1,7 @@ import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-inbound"; import type { ResolvedSlackAccount } from "../accounts.js"; import type { SlackMessageEvent } from "../types.js"; import { stripSlackMentionsForCommandDetection } from "./commands.js"; diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 2b31791284e..f3860c2f6bd 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -1,12 +1,15 @@ import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { + logAckFailure, + logTypingFailure, + removeAckReactionAfterReply, +} from "openclaw/plugin-sdk/channel-feedback"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { removeAckReactionAfterReply } from "openclaw/plugin-sdk/channel-runtime"; -import { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history"; 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"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts index 5d4020f1b46..e1cfc33088a 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts @@ -1,5 +1,5 @@ +import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound"; import { readSessionUpdatedAt } from "openclaw/plugin-sdk/config-runtime"; -import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; @@ -30,7 +30,7 @@ export async function resolveSlackThreadContextData(params: { storePath: string; sessionKey: string; envelopeOptions: ReturnType< - typeof import("openclaw/plugin-sdk/reply-runtime").resolveEnvelopeFormatOptions + typeof import("openclaw/plugin-sdk/channel-inbound").resolveEnvelopeFormatOptions >; effectiveDirectMedia: SlackMediaResult[] | null; }): Promise { diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index e6bc3a23446..1f36eef491c 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -2,26 +2,29 @@ import { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime"; import { shouldAckReaction as shouldAckReactionGate, type AckReactionScope, -} from "openclaw/plugin-sdk/channel-runtime"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveConversationLabel } from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; -import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; -import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { shouldHandleTextCommands } from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/channel-feedback"; import { + buildMentionRegexes, formatInboundEnvelope, + logInboundDrop, + matchesMentionWithExplicit, resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; + resolveMentionGatingWithBypass, +} from "openclaw/plugin-sdk/channel-inbound"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; +import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-auth"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { + recordInboundSession, + resolveConversationLabel, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime"; import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 5a382551b47..1af83676e93 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -6,7 +6,7 @@ import { mergeAllowlist, patchAllowlistUsersInConfigEntries, summarizeMapping, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/allow-from"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { @@ -15,15 +15,15 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/config-runtime"; import type { SessionScope } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { computeBackoff, sleepWithAbort } from "openclaw/plugin-sdk/infra-runtime"; import { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/infra-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-runtime"; import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; import { warn } from "openclaw/plugin-sdk/runtime-env"; import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { resolveSlackAccount } from "../accounts.js"; import { resolveSlackWebClientOptions } from "../client.js"; diff --git a/extensions/slack/src/monitor/slash-commands.runtime.ts b/extensions/slack/src/monitor/slash-commands.runtime.ts index aaae82a0602..6659ae61031 100644 --- a/extensions/slack/src/monitor/slash-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-commands.runtime.ts @@ -4,17 +4,17 @@ import { listNativeCommandSpecsForConfig as listNativeCommandSpecsForConfigImpl, parseCommandArgs as parseCommandArgsImpl, resolveCommandArgMenu as resolveCommandArgMenuImpl, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/command-auth"; type BuildCommandTextFromArgs = - typeof import("openclaw/plugin-sdk/reply-runtime").buildCommandTextFromArgs; + typeof import("openclaw/plugin-sdk/command-auth").buildCommandTextFromArgs; type FindCommandByNativeName = - typeof import("openclaw/plugin-sdk/reply-runtime").findCommandByNativeName; + typeof import("openclaw/plugin-sdk/command-auth").findCommandByNativeName; type ListNativeCommandSpecsForConfig = - typeof import("openclaw/plugin-sdk/reply-runtime").listNativeCommandSpecsForConfig; -type ParseCommandArgs = typeof import("openclaw/plugin-sdk/reply-runtime").parseCommandArgs; + typeof import("openclaw/plugin-sdk/command-auth").listNativeCommandSpecsForConfig; +type ParseCommandArgs = typeof import("openclaw/plugin-sdk/command-auth").parseCommandArgs; type ResolveCommandArgMenu = - typeof import("openclaw/plugin-sdk/reply-runtime").resolveCommandArgMenu; + typeof import("openclaw/plugin-sdk/command-auth").resolveCommandArgMenu; export function buildCommandTextFromArgs( ...args: Parameters diff --git a/extensions/slack/src/monitor/slash-dispatch.runtime.ts b/extensions/slack/src/monitor/slash-dispatch.runtime.ts index affa13c01dd..a9c7eaba1d3 100644 --- a/extensions/slack/src/monitor/slash-dispatch.runtime.ts +++ b/extensions/slack/src/monitor/slash-dispatch.runtime.ts @@ -1,8 +1,8 @@ +import { resolveMarkdownTableMode as resolveMarkdownTableModeImpl } from "openclaw/plugin-sdk/config-runtime"; import { recordInboundSessionMetaSafe as recordInboundSessionMetaSafeImpl, resolveConversationLabel as resolveConversationLabelImpl, -} from "openclaw/plugin-sdk/channel-runtime"; -import { resolveMarkdownTableMode as resolveMarkdownTableModeImpl } from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/conversation-runtime"; import { dispatchReplyWithDispatcher as dispatchReplyWithDispatcherImpl, finalizeInboundContext as finalizeInboundContextImpl, @@ -17,9 +17,9 @@ type FinalizeInboundContext = type DispatchReplyWithDispatcher = typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher; type ResolveConversationLabel = - typeof import("openclaw/plugin-sdk/channel-runtime").resolveConversationLabel; + typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConversationLabel; type RecordInboundSessionMetaSafe = - typeof import("openclaw/plugin-sdk/channel-runtime").recordInboundSessionMetaSafe; + typeof import("openclaw/plugin-sdk/conversation-runtime").recordInboundSessionMetaSafe; type ResolveMarkdownTableMode = typeof import("openclaw/plugin-sdk/config-runtime").resolveMarkdownTableMode; type ResolveAgentRoute = typeof import("openclaw/plugin-sdk/routing").resolveAgentRoute; diff --git a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts index ec25e104fec..926eb5a3932 100644 --- a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts @@ -1,7 +1,7 @@ -import { listSkillCommandsForAgents as listSkillCommandsForAgentsImpl } from "openclaw/plugin-sdk/reply-runtime"; +import { listSkillCommandsForAgents as listSkillCommandsForAgentsImpl } from "openclaw/plugin-sdk/command-auth"; type ListSkillCommandsForAgents = - typeof import("openclaw/plugin-sdk/reply-runtime").listSkillCommandsForAgents; + typeof import("openclaw/plugin-sdk/command-auth").listSkillCommandsForAgents; export function listSkillCommandsForAgents( ...args: Parameters diff --git a/extensions/slack/src/monitor/slash.test-harness.ts b/extensions/slack/src/monitor/slash.test-harness.ts index 48a11cf3460..f5618dde5be 100644 --- a/extensions/slack/src/monitor/slash.test-harness.ts +++ b/extensions/slack/src/monitor/slash.test-harness.ts @@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => ({ resolveAgentRouteMock: vi.fn(), finalizeInboundContextMock: vi.fn(), resolveConversationLabelMock: vi.fn(), - createReplyPrefixOptionsMock: vi.fn(), recordSessionMetaFromInboundMock: vi.fn(), resolveStorePathMock: vi.fn(), })); @@ -38,12 +37,11 @@ vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => { }; }); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveConversationLabel: (...args: unknown[]) => mocks.resolveConversationLabelMock(...args), - createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args), recordInboundSessionMetaSafe: (...args: unknown[]) => mocks.recordSessionMetaFromInboundMock(...args), }; @@ -64,7 +62,6 @@ type SlashHarnessMocks = { resolveAgentRouteMock: ReturnType; finalizeInboundContextMock: ReturnType; resolveConversationLabelMock: ReturnType; - createReplyPrefixOptionsMock: ReturnType; recordSessionMetaFromInboundMock: ReturnType; resolveStorePathMock: ReturnType; }; @@ -84,7 +81,6 @@ export function resetSlackSlashMocks() { }); mocks.finalizeInboundContextMock.mockReset().mockImplementation((ctx: unknown) => ctx); mocks.resolveConversationLabelMock.mockReset().mockReturnValue(undefined); - mocks.createReplyPrefixOptionsMock.mockReset().mockReturnValue({ onModelSelected: () => {} }); mocks.recordSessionMetaFromInboundMock.mockReset().mockResolvedValue(undefined); mocks.resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); } diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index e06b22d2e91..6ff790e42b2 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -1,12 +1,14 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime"; +import { + resolveCommandAuthorizedFromAuthorizers, + resolveNativeCommandSessionTargets, +} from "openclaw/plugin-sdk/command-auth"; +import { type ChatCommandDefinition, type CommandArgs } from "openclaw/plugin-sdk/command-auth"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, } from "openclaw/plugin-sdk/config-runtime"; -import { type ChatCommandDefinition, type CommandArgs } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { chunkItems } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index ed107d4c63f..ee3946dde9b 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -1,20 +1,19 @@ -import { - resolvePayloadMediaUrls, - 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, + type ChannelOutboundAdapter, createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; -import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveOutboundSendDep, type OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import { resolveInteractiveTextFallback, type InteractiveReply, } from "openclaw/plugin-sdk/interactive-runtime"; import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; +import { + resolvePayloadMediaUrls, + sendPayloadMediaSequenceAndFinalize, + sendTextMediaPayload, +} from "openclaw/plugin-sdk/reply-payload"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js"; import { sendMessageSlack, type SlackSendIdentity } from "./send.js"; diff --git a/extensions/slack/src/probe.ts b/extensions/slack/src/probe.ts index c370b11be9b..a0d698e54b5 100644 --- a/extensions/slack/src/probe.ts +++ b/extensions/slack/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { withTimeout } from "openclaw/plugin-sdk/text-runtime"; import { createSlackWebClient } from "./client.js"; diff --git a/extensions/slack/src/runtime-api.ts b/extensions/slack/src/runtime-api.ts index 5dac68be756..84f7b9d480b 100644 --- a/extensions/slack/src/runtime-api.ts +++ b/extensions/slack/src/runtime-api.ts @@ -9,7 +9,7 @@ export { type ChannelPlugin, type OpenClawConfig, type SlackAccountConfig, -} from "openclaw/plugin-sdk/slack"; +} from "../../../src/plugin-sdk/slack.js"; export { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, @@ -25,5 +25,5 @@ export { readStringParam, SlackConfigSchema, withNormalizedTimestamp, -} from "openclaw/plugin-sdk/slack-core"; +} from "../../../src/plugin-sdk/slack-core.js"; export { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; diff --git a/extensions/slack/src/targets.ts b/extensions/slack/src/targets.ts index 43162a447d5..356f990d600 100644 --- a/extensions/slack/src/targets.ts +++ b/extensions/slack/src/targets.ts @@ -6,7 +6,7 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-targets"; export type SlackTargetKind = MessagingTargetKind; diff --git a/extensions/slack/src/threading-tool-context.ts b/extensions/slack/src/threading-tool-context.ts index 30451be5b6b..a6b59189dee 100644 --- a/extensions/slack/src/threading-tool-context.ts +++ b/extensions/slack/src/threading-tool-context.ts @@ -1,7 +1,7 @@ import type { ChannelThreadingContext, ChannelThreadingToolContext, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js"; diff --git a/extensions/slack/src/token.ts b/extensions/slack/src/token.ts index 36f31c89383..03c8c653344 100644 --- a/extensions/slack/src/token.ts +++ b/extensions/slack/src/token.ts @@ -1,4 +1,4 @@ -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; export function normalizeSlackToken(raw?: unknown): string | undefined { return normalizeResolvedSecretInputString({ diff --git a/extensions/synology-chat/api.ts b/extensions/synology-chat/api.ts deleted file mode 100644 index 4ff5241bd49..00000000000 --- a/extensions/synology-chat/api.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "openclaw/plugin-sdk/synology-chat"; -export * from "./setup-api.js"; diff --git a/extensions/synology-chat/src/channel.test-mocks.ts b/extensions/synology-chat/src/channel.test-mocks.ts index 21859ba90e9..77c4a6d223f 100644 --- a/extensions/synology-chat/src/channel.test-mocks.ts +++ b/extensions/synology-chat/src/channel.test-mocks.ts @@ -27,20 +27,37 @@ async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise ({ - DEFAULT_ACCOUNT_ID: "default", - setAccountEnabledInConfigSection: vi.fn((_opts: unknown) => ({})), - registerPluginHttpRoute: registerPluginHttpRouteMock, - buildChannelConfigSchema: vi.fn((schema: unknown) => ({ schema })), - readRequestBodyWithLimit: vi.fn(readRequestBodyWithLimitForTest), - isRequestBodyLimitError: vi.fn(() => false), - requestBodyErrorToText: vi.fn(() => "Request body too large"), - createFixedWindowRateLimiter: vi.fn(() => ({ - isRateLimited: vi.fn(() => false), - size: vi.fn(() => 0), - clear: vi.fn(), - })), -})); +vi.mock("openclaw/plugin-sdk/setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/setup"); + return { + ...actual, + DEFAULT_ACCOUNT_ID: "default", + }; +}); + +vi.mock("openclaw/plugin-sdk/channel-config-schema", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/channel-config-schema"); + return { + ...actual, + buildChannelConfigSchema: vi.fn((schema: unknown) => ({ schema })), + }; +}); + +vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/webhook-ingress"); + return { + ...actual, + registerPluginHttpRoute: registerPluginHttpRouteMock, + readRequestBodyWithLimit: vi.fn(readRequestBodyWithLimitForTest), + isRequestBodyLimitError: vi.fn(() => false), + requestBodyErrorToText: vi.fn(() => "Request body too large"), + createFixedWindowRateLimiter: vi.fn(() => ({ + isRateLimited: vi.fn(() => false), + size: vi.fn(() => 0), + clear: vi.fn(), + })), + }; +}); vi.mock("./client.js", () => ({ sendMessage: vi.fn().mockResolvedValue(true), diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 9617dc129ae..e4ae0bc857d 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -8,17 +8,17 @@ import { createHybridChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; +import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing"; import { createConditionalWarningCollector, projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; -import { - attachChannelToResult, - createEmptyChannelDirectoryAdapter, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; +import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress"; import { z } from "zod"; -import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; diff --git a/extensions/synology-chat/src/config-schema.ts b/extensions/synology-chat/src/config-schema.ts index cfdc3fb7a81..4a9f868a87f 100644 --- a/extensions/synology-chat/src/config-schema.ts +++ b/extensions/synology-chat/src/config-schema.ts @@ -1,4 +1,4 @@ +import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; import { z } from "zod"; -import { buildChannelConfigSchema } from "../api.js"; export const SynologyChatChannelConfigSchema = buildChannelConfigSchema(z.object({}).passthrough()); diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index e1288f74468..3e0234029ac 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -1,5 +1,5 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "../api.js"; const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = createPluginRuntimeStore( diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 8ac50016a12..c6a10560efb 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -3,7 +3,10 @@ */ import * as crypto from "node:crypto"; -import { createFixedWindowRateLimiter, type FixedWindowRateLimiter } from "../api.js"; +import { + createFixedWindowRateLimiter, + type FixedWindowRateLimiter, +} from "openclaw/plugin-sdk/webhook-ingress"; export type DmAuthorizationResult = | { allowed: true } diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index 4f38136e9a5..9382b78e54f 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -9,7 +9,7 @@ import { isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "../api.js"; +} from "openclaw/plugin-sdk/webhook-ingress"; import { sendMessage, resolveChatUserId } from "./client.js"; import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; diff --git a/extensions/tavily/src/config.ts b/extensions/tavily/src/config.ts index 752a721d17c..7bef2dcdd51 100644 --- a/extensions/tavily/src/config.ts +++ b/extensions/tavily/src/config.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { normalizeSecretInput } from "openclaw/plugin-sdk/provider-auth"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; export const DEFAULT_TAVILY_BASE_URL = "https://api.tavily.com"; export const DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS = 30; diff --git a/extensions/tavily/src/tavily-client.ts b/extensions/tavily/src/tavily-client.ts index 8308f8b8772..c57f5850af3 100644 --- a/extensions/tavily/src/tavily-client.ts +++ b/extensions/tavily/src/tavily-client.ts @@ -1,10 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { withTrustedWebToolsEndpoint } from "openclaw/plugin-sdk/provider-web-search"; import { DEFAULT_CACHE_TTL_MINUTES, normalizeCacheKey, + postTrustedWebToolsJson, readCache, - readResponseText, resolveCacheTtlMs, writeCache, } from "openclaw/plugin-sdk/provider-web-search"; @@ -26,7 +25,6 @@ const EXTRACT_CACHE = new Map< { value: Record; expiresAt: number; insertedAt: number } >(); const DEFAULT_SEARCH_COUNT = 5; -const DEFAULT_ERROR_MAX_BYTES = 64_000; export type TavilySearchParams = { cfg?: OpenClawConfig; @@ -67,41 +65,6 @@ function resolveEndpoint(baseUrl: string, pathname: string): string { } } -async function postTavilyJson(params: { - baseUrl: string; - pathname: string; - apiKey: string; - body: Record; - timeoutSeconds: number; - errorLabel: string; -}): Promise> { - const endpoint = resolveEndpoint(params.baseUrl, params.pathname); - return await withTrustedWebToolsEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(params.body), - }, - }, - async ({ response }) => { - if (!response.ok) { - const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES }); - throw new Error( - `${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`, - ); - } - return (await response.json()) as Record; - }, - ); -} - export async function runTavilySearch( params: TavilySearchParams, ): Promise> { @@ -149,14 +112,16 @@ export async function runTavilySearch( if (params.excludeDomains?.length) body.exclude_domains = params.excludeDomains; const start = Date.now(); - const payload = await postTavilyJson({ - baseUrl, - pathname: "/search", - apiKey, - body, - timeoutSeconds, - errorLabel: "Tavily Search", - }); + const payload = await postTrustedWebToolsJson( + { + url: resolveEndpoint(baseUrl, "/search"), + timeoutSeconds, + apiKey, + body, + errorLabel: "Tavily Search", + }, + async (response) => (await response.json()) as Record, + ); const rawResults = Array.isArray(payload.results) ? payload.results : []; const results = rawResults.map((r: Record) => ({ @@ -228,14 +193,16 @@ export async function runTavilyExtract( if (params.includeImages) body.include_images = true; const start = Date.now(); - const payload = await postTavilyJson({ - baseUrl, - pathname: "/extract", - apiKey, - body, - timeoutSeconds, - errorLabel: "Tavily Extract", - }); + const payload = await postTrustedWebToolsJson( + { + url: resolveEndpoint(baseUrl, "/extract"), + timeoutSeconds, + apiKey, + body, + errorLabel: "Tavily Extract", + }, + async (response) => (await response.json()) as Record, + ); const rawResults = Array.isArray(payload.results) ? payload.results : []; const results = rawResults.map((r: Record) => ({ @@ -282,5 +249,5 @@ export async function runTavilyExtract( } export const __testing = { - postTavilyJson, + resolveEndpoint, }; diff --git a/extensions/tavily/src/tavily-extract-tool.ts b/extensions/tavily/src/tavily-extract-tool.ts index 1a3c381fc64..29a7b04399a 100644 --- a/extensions/tavily/src/tavily-extract-tool.ts +++ b/extensions/tavily/src/tavily-extract-tool.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; -import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime"; import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import { optionalStringEnum } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; import { runTavilyExtract } from "./tavily-client.js"; diff --git a/extensions/tavily/src/tavily-search-tool.ts b/extensions/tavily/src/tavily-search-tool.ts index 1d925973fe0..08cfe3e6606 100644 --- a/extensions/tavily/src/tavily-search-tool.ts +++ b/extensions/tavily/src/tavily-search-tool.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; -import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime"; import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import { optionalStringEnum } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; import { runTavilySearch } from "./tavily-client.js"; diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts index c069a35e40e..28c7788ef9d 100644 --- a/extensions/telegram/runtime-api.ts +++ b/extensions/telegram/runtime-api.ts @@ -7,7 +7,7 @@ export type { TelegramAccountConfig, TelegramActionConfig, TelegramNetworkConfig, -} from "openclaw/plugin-sdk/telegram"; +} from "../../src/plugin-sdk/telegram.js"; export type { OpenClawPluginService, OpenClawPluginServiceContext, @@ -37,7 +37,7 @@ export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveTelegramPollVisibility, -} from "openclaw/plugin-sdk/telegram"; +} from "../../src/plugin-sdk/telegram.js"; export { buildChannelConfigSchema, getChatChannelMeta, @@ -49,7 +49,7 @@ export { readStringParam, resolvePollMaxSelections, TelegramConfigSchema, -} from "openclaw/plugin-sdk/telegram-core"; +} from "../../src/plugin-sdk/telegram-core.js"; export type { TelegramProbe } from "./src/probe.js"; export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js"; export { telegramMessageActions } from "./src/channel-actions.js"; diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts index 5d131a70586..47c6183fb8b 100644 --- a/extensions/telegram/src/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -1,13 +1,13 @@ import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - coerceSecretRef, - hasConfiguredSecretInput, - normalizeSecretInputString, -} from "openclaw/plugin-sdk/config-runtime"; +import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; import type { TelegramAccountConfig } from "../runtime-api.js"; import { mergeTelegramAccountConfig, diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index c07dae07681..436f7d84874 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; -import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; import { resolveTelegramPollVisibility } from "../runtime-api.js"; import { jsonResult, diff --git a/extensions/telegram/src/bot-access.ts b/extensions/telegram/src/bot-access.ts index c89a8fe6f48..82034aeadb2 100644 --- a/extensions/telegram/src/bot-access.ts +++ b/extensions/telegram/src/bot-access.ts @@ -2,8 +2,8 @@ import { firstDefined, isSenderIdAllowed, mergeDmAllowFromSources, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { AllowlistMatch } from "openclaw/plugin-sdk/channel-runtime"; + type AllowlistMatch, +} from "openclaw/plugin-sdk/allow-from"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; export type NormalizedAllowFrom = { diff --git a/extensions/telegram/src/bot-deps.ts b/extensions/telegram/src/bot-deps.ts index a21c4f0c586..93aac0c8b8f 100644 --- a/extensions/telegram/src/bot-deps.ts +++ b/extensions/telegram/src/bot-deps.ts @@ -1,12 +1,12 @@ +import { + buildModelsProviderData, + listSkillCommandsForAgents, +} from "openclaw/plugin-sdk/command-auth"; import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { - buildModelsProviderData, - dispatchReplyWithBufferedBlockDispatcher, - listSkillCommandsForAgents, -} from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { wasSentByBot } from "./sent-message-cache.js"; export type TelegramBotDeps = { diff --git a/extensions/telegram/src/bot-handlers.buffers.ts b/extensions/telegram/src/bot-handlers.buffers.ts index 41dcee18aa4..7d301251176 100644 --- a/extensions/telegram/src/bot-handlers.buffers.ts +++ b/extensions/telegram/src/bot-handlers.buffers.ts @@ -1,10 +1,10 @@ import type { Message } from "@grammyjs/types"; -import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createInboundDebouncer, resolveInboundDebounceMs, -} from "openclaw/plugin-sdk/reply-runtime"; + shouldDebounceTextInbound, +} from "openclaw/plugin-sdk/channel-inbound"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { hasInboundMedia, diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 00dc35041c9..6df428d1273 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -1,8 +1,18 @@ import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; -import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers"; +import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "openclaw/plugin-sdk/channel-inbound"; +import { + buildCommandsMessagePaginated, + buildCommandsPaginationKeyboard, + formatModelsAvailableHeader, + resolveStoredModelOverride, +} from "openclaw/plugin-sdk/command-auth"; import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, @@ -22,14 +32,6 @@ import { resolvePluginConversationBindingApproval, } from "openclaw/plugin-sdk/conversation-runtime"; import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; -import { - createInboundDebouncer, - resolveInboundDebounceMs, -} from "openclaw/plugin-sdk/reply-runtime"; -import { buildCommandsPaginationKeyboard } from "openclaw/plugin-sdk/reply-runtime"; -import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/reply-runtime"; -import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime"; -import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index 63e6aaa12dd..04e5739d663 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -4,22 +4,26 @@ import { modelSupportsVision, } from "openclaw/plugin-sdk/agent-runtime"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; -import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; +import { + buildMentionRegexes, + formatLocationText, + logInboundDrop, + matchesMentionWithExplicit, + resolveMentionGatingWithBypass, + type NormalizedLocation, +} from "openclaw/plugin-sdk/channel-inbound"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; import { recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { NormalizedAllowFrom } from "./bot-access.js"; diff --git a/extensions/telegram/src/bot-message-context.named-account-dm.test.ts b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts index e51c7920ae7..33d1e35e470 100644 --- a/extensions/telegram/src/bot-message-context.named-account-dm.test.ts +++ b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts @@ -6,8 +6,8 @@ import { import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 47bcda8592f..2581e1d398b 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -1,5 +1,10 @@ -import { toLocationContext } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, + toLocationContext, + type NormalizedLocation, +} from "openclaw/plugin-sdk/channel-inbound"; +import { normalizeCommandBody } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { @@ -7,15 +12,11 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeCommandBody } from "openclaw/plugin-sdk/reply-runtime"; -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPendingHistoryContextFromMap, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; @@ -63,7 +64,7 @@ export async function buildTelegramInboundContextPayload(params: { stickerCacheHit: boolean; effectiveWasMentioned: boolean; commandAuthorized: boolean; - locationData?: import("openclaw/plugin-sdk/channel-runtime").NormalizedLocation; + locationData?: NormalizedLocation; options?: TelegramMessageContextOptions; dmAllowFrom?: Array; }): Promise<{ diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 3c90a344708..046717b8175 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -1,10 +1,10 @@ import { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime"; -import { shouldAckReaction as shouldAckReactionGate } from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; import { createStatusReactionController, + shouldAckReaction as shouldAckReactionGate, type StatusReactionController, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-feedback"; +import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime"; import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index ff782c0a1fa..a7e00397b33 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -6,7 +6,7 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; export type TelegramMediaRef = { diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 14992a5f631..f3c07df7c87 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -11,6 +11,7 @@ import { const createTelegramDraftStream = vi.hoisted(() => vi.fn()); const dispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => vi.fn()); const deliverReplies = vi.hoisted(() => vi.fn()); +const emitInternalMessageSentHook = vi.hoisted(() => vi.fn()); const createForumTopicTelegram = vi.hoisted(() => vi.fn()); const deleteMessageTelegram = vi.hoisted(() => vi.fn()); const editForumTopicTelegram = vi.hoisted(() => vi.fn()); @@ -46,6 +47,7 @@ vi.mock("./draft-stream.js", () => ({ vi.mock("./bot/delivery.js", () => ({ deliverReplies, + emitInternalMessageSentHook, })); vi.mock("./send.js", () => ({ @@ -103,6 +105,7 @@ describe("dispatchTelegramMessage draft streaming", () => { createTelegramDraftStream.mockClear(); dispatchReplyWithBufferedBlockDispatcher.mockClear(); deliverReplies.mockClear(); + emitInternalMessageSentHook.mockClear(); createForumTopicTelegram.mockClear(); deleteMessageTelegram.mockClear(); editForumTopicTelegram.mockClear(); @@ -521,6 +524,38 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.stop).toHaveBeenCalled(); }); + it("emits only the internal message:sent hook when a final answer stays in preview", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Primary result" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"], + }), + }); + + expect(deliverReplies).not.toHaveBeenCalled(); + expect(editMessageTelegram).toHaveBeenCalledWith( + 123, + 999, + "Primary result", + expect.any(Object), + ); + expect(emitInternalMessageSentHook).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKeyForInternalHooks: "s1", + chatId: "123", + content: "Primary result", + success: true, + messageId: 999, + }), + ); + }); + it("keeps streamed preview visible when final text regresses after a tool warning", async () => { const draftStream = createDraftStream(999); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 6b9e2a766d2..70e5acf0922 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -6,9 +6,12 @@ import { modelSupportsVision, } from "openclaw/plugin-sdk/agent-runtime"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { + logAckFailure, + logTypingFailure, + removeAckReactionAfterReply, +} from "openclaw/plugin-sdk/channel-feedback"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { removeAckReactionAfterReply } from "openclaw/plugin-sdk/channel-runtime"; -import { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, @@ -21,16 +24,16 @@ import type { TelegramAccountConfig, } from "openclaw/plugin-sdk/config-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history"; 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"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js"; import type { TelegramMessageContext } from "./bot-message-context.js"; import type { TelegramBotOptions } from "./bot.js"; -import { deliverReplies } from "./bot/delivery.js"; +import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js"; import type { TelegramStreamMode } from "./bot/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { createTelegramDraftStream } from "./draft-stream.js"; @@ -41,6 +44,7 @@ import { createLaneDeliveryStateTracker, createLaneTextDeliverer, type DraftLaneState, + type LaneDeliveryResult, type LaneName, type LanePreviewLifecycle, } from "./lane-delivery.js"; @@ -480,6 +484,21 @@ export const dispatchTelegramMessage = async ({ } return result.delivered; }; + const emitPreviewFinalizedHook = (result: LaneDeliveryResult) => { + if (result.kind !== "preview-finalized") { + return; + } + emitInternalMessageSentHook({ + sessionKeyForInternalHooks: deliveryBaseOptions.sessionKeyForInternalHooks, + chatId: deliveryBaseOptions.chatId, + accountId: deliveryBaseOptions.accountId, + content: result.delivery.content, + success: true, + messageId: result.delivery.messageId, + isGroup: deliveryBaseOptions.mirrorIsGroup, + groupId: deliveryBaseOptions.mirrorGroupId, + }); + }; const deliverLaneText = createLaneTextDeliverer({ lanes, archivedAnswerPreviews, @@ -612,8 +631,11 @@ export const dispatchTelegramMessage = async ({ previewButtons, allowPreviewUpdateForNonFinal: segment.lane === "reasoning", }); + if (info.kind === "final") { + emitPreviewFinalizedHook(result); + } if (segment.lane === "reasoning") { - if (result !== "skipped") { + if (result.kind !== "skipped") { reasoningStepState.noteReasoningDelivered(); await flushBufferedFinalAnswer(); } diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts index e74220b248a..9701802bb2a 100644 --- a/extensions/telegram/src/bot-native-commands.menu-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts @@ -34,8 +34,8 @@ const deliveryMocks = vi.hoisted(() => ({ export const listSkillCommandsForAgents = skillCommandMocks.listSkillCommandsForAgents; export const deliverReplies = deliveryMocks.deliverReplies; -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents, diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index bfe314d4140..eef2f76abda 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -73,23 +73,6 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { ...actual, resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute, ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, - readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore, - upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest, - getSessionBindingService: () => ({ - bind: vi.fn(), - getCapabilities: vi.fn(), - listBySession: vi.fn(), - resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref), - touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at), - unbind: vi.fn(), - }), - }; -}); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), recordInboundSessionMetaSafe: vi.fn( async (params: { cfg: OpenClawConfig; @@ -112,6 +95,23 @@ vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { } }, ), + readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore, + upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest, + getSessionBindingService: () => ({ + bind: vi.fn(), + getCapabilities: vi.fn(), + listBySession: vi.fn(), + resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref), + touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at), + unbind: vi.fn(), + }), + }; +}); +vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents: vi.fn(() => []), }; }); vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { @@ -120,7 +120,6 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { ...actual, finalizeInboundContext: vi.fn((ctx: unknown) => ctx), dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, - listSkillCommandsForAgents: vi.fn(() => []), }; }); vi.mock("../../../src/config/sessions.js", () => ({ diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 973d62485ab..65e3baf411d 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -22,7 +22,7 @@ type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< ReturnType >; type RecordInboundSessionMetaSafeFn = - typeof import("openclaw/plugin-sdk/channel-runtime").recordInboundSessionMetaSafe; + typeof import("openclaw/plugin-sdk/conversation-runtime").recordInboundSessionMetaSafe; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; type NativeCommandHarness = { @@ -74,11 +74,12 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, }; }); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe, + readChannelAllowFromStore: vi.fn(async () => []), }; }); vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", async (importOriginal) => { @@ -95,13 +96,6 @@ const deliveryMocks = vi.hoisted(() => ({ })); export const deliverReplies = deliveryMocks.deliverReplies; vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies })); -vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readChannelAllowFromStore: vi.fn(async () => []), - }; -}); export { createNativeCommandTestParams }; export function createNativeCommandsHarness(params?: { diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index e85a444369b..2674762b1e0 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -17,8 +17,8 @@ const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => ({ delivered: true })), })); -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents: skillCommandMocks.listSkillCommandsForAgents, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 103cca984e0..e81713956cd 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1,8 +1,19 @@ import type { Bot, Context } from "grammy"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSessionMetaSafe } from "openclaw/plugin-sdk/channel-runtime"; +import { + resolveCommandAuthorization, + resolveCommandAuthorizedFromAuthorizers, + resolveNativeCommandSessionTargets, +} from "openclaw/plugin-sdk/command-auth"; +import { + buildCommandTextFromArgs, + findCommandByNativeName, + listNativeCommandSpecs, + listNativeCommandSpecsForConfig, + parseCommandArgs, + resolveCommandArgMenu, + type CommandArgs, +} from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; @@ -18,7 +29,10 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; +import { + ensureConfiguredBindingRouteReady, + recordInboundSessionMetaSafe, +} from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { executePluginCommand, @@ -26,16 +40,6 @@ import { matchPluginCommand, } from "openclaw/plugin-sdk/plugin-runtime"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; -import { resolveCommandAuthorization } from "openclaw/plugin-sdk/reply-runtime"; -import type { CommandArgs } from "openclaw/plugin-sdk/reply-runtime"; -import { - buildCommandTextFromArgs, - findCommandByNativeName, - listNativeCommandSpecs, - listNativeCommandSpecsForConfig, - parseCommandArgs, - resolveCommandArgMenu, -} from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index a9793692b21..6009b16947a 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -230,28 +230,40 @@ function createModelsProviderDataFromConfig(cfg: OpenClawConfig): { return { byProvider, providers, resolvedDefault }; } +vi.doMock("openclaw/plugin-sdk/command-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, + buildModelsProviderData, + }; +}); +vi.doMock("openclaw/plugin-sdk/command-auth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, + buildModelsProviderData, + }; +}); vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, getReplyFromConfig: replySpyHoisted.replySpy, __replySpy: replySpyHoisted.replySpy, dispatchReplyWithBufferedBlockDispatcher: dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher, - buildModelsProviderData, }; }); vi.doMock("openclaw/plugin-sdk/reply-runtime.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, getReplyFromConfig: replySpyHoisted.replySpy, __replySpy: replySpyHoisted.replySpy, dispatchReplyWithBufferedBlockDispatcher: dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher, - buildModelsProviderData, }; }); diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index 36dcc0f5db2..479560c8e38 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -1,9 +1,4 @@ import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; -import { - resolveThreadBindingIdleTimeoutMsForChannel, - resolveThreadBindingMaxAgeMsForChannel, - resolveThreadBindingSpawnPolicy, -} from "openclaw/plugin-sdk/channel-runtime"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, @@ -15,9 +10,14 @@ import { resolveChannelGroupRequireMention, } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, + resolveThreadBindingSpawnPolicy, +} from "openclaw/plugin-sdk/conversation-runtime"; import { formatUncaughtError } from "openclaw/plugin-sdk/infra-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index e1f464c52a5..6222e913461 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -491,9 +491,7 @@ async function maybePinFirstDeliveredMessage(params: { } } -function emitMessageSentHooks(params: { - hookRunner: ReturnType; - enabled: boolean; +type EmitMessageSentHookParams = { sessionKeyForInternalHooks?: string; chatId: string; accountId?: string; @@ -503,11 +501,10 @@ function emitMessageSentHooks(params: { messageId?: number; isGroup?: boolean; groupId?: string; -}): void { - if (!params.enabled && !params.sessionKeyForInternalHooks) { - return; - } - const canonical = buildCanonicalSentMessageHookContext({ +}; + +function buildTelegramSentHookContext(params: EmitMessageSentHookParams) { + return buildCanonicalSentMessageHookContext({ to: params.chatId, content: params.content, success: params.success, @@ -519,20 +516,13 @@ function emitMessageSentHooks(params: { isGroup: params.isGroup, groupId: params.groupId, }); - if (params.enabled) { - fireAndForgetHook( - Promise.resolve( - params.hookRunner!.runMessageSent( - toPluginMessageSentEvent(canonical), - toPluginMessageContext(canonical), - ), - ), - "telegram: message_sent plugin hook failed", - ); - } +} + +export function emitInternalMessageSentHook(params: EmitMessageSentHookParams): void { if (!params.sessionKeyForInternalHooks) { return; } + const canonical = buildTelegramSentHookContext(params); fireAndForgetHook( triggerInternalHook( createInternalHookEvent( @@ -546,6 +536,30 @@ function emitMessageSentHooks(params: { ); } +function emitMessageSentHooks( + params: EmitMessageSentHookParams & { + hookRunner: ReturnType; + enabled: boolean; + }, +): void { + if (!params.enabled && !params.sessionKeyForInternalHooks) { + return; + } + const canonical = buildTelegramSentHookContext(params); + if (params.enabled) { + fireAndForgetHook( + Promise.resolve( + params.hookRunner!.runMessageSent( + toPluginMessageSentEvent(canonical), + toPluginMessageContext(canonical), + ), + ), + "telegram: message_sent plugin hook failed", + ); + } + emitInternalMessageSentHook(params); +} + export async function deliverReplies(params: { replies: ReplyPayload[]; chatId: string; diff --git a/extensions/telegram/src/bot/delivery.ts b/extensions/telegram/src/bot/delivery.ts index bbe599f46b0..7a07433e1ed 100644 --- a/extensions/telegram/src/bot/delivery.ts +++ b/extensions/telegram/src/bot/delivery.ts @@ -1,2 +1,2 @@ -export { deliverReplies } from "./delivery.replies.js"; +export { deliverReplies, emitInternalMessageSentHook } from "./delivery.replies.js"; export { resolveMedia } from "./delivery.resolve-media.js"; diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index 98ec1f1aaf6..29561953466 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -1,5 +1,5 @@ import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types"; -import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; +import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; import { resolveTelegramPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index d01c5f91839..5cb17a2ee12 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -1,13 +1,15 @@ import { - createMessageToolButtonsSchema, createUnionActionGate, listTokenSourcedAccounts, resolveReactionMessageId, - type ChannelMessageActionAdapter, - type ChannelMessageActionName, - type ChannelMessageToolDiscovery, - type ChannelMessageToolSchemaContribution, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-actions"; +import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, +} from "openclaw/plugin-sdk/channel-contract"; import type { TelegramActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; import { diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index c9e8df40be0..1a174f7200f 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -1,10 +1,10 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChannelAccountSnapshot, ChannelGatewayContext, - OpenClawConfig, - PluginRuntime, -} from "openclaw/plugin-sdk/telegram"; -import { afterEach, describe, expect, it, vi } from "vitest"; +} from "../../../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { PluginRuntime } from "../../../src/plugins/runtime/types.js"; import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ResolvedTelegramAccount } from "./accounts.js"; import * as auditModule from "./audit.js"; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 25c81509820..a56606af2e0 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -3,22 +3,27 @@ import { createNestedAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { attachChannelToResult, createAttachedChannelResultAdapter, - createChannelDirectoryAdapter, - createPairingPrefixStripper, - createTopLevelChannelReplyToModeResolver, - createTextPairingAdapter, - normalizeMessageChannel, - type OutboundSendDeps, - resolveOutboundSendDep, -} from "openclaw/plugin-sdk/channel-runtime"; -import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/channel-send-result"; +import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; -import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime"; +import { + buildOutboundBaseSessionKey, + normalizeMessageChannel, + normalizeOutboundThreadId, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk/routing"; import { parseTelegramTopicConversation } from "../runtime-api.js"; import { buildTokenChannelStatusSummary, diff --git a/extensions/telegram/src/config-schema.ts b/extensions/telegram/src/config-schema.ts index ec32270c2f2..ea385dcd3a8 100644 --- a/extensions/telegram/src/config-schema.ts +++ b/extensions/telegram/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, TelegramConfigSchema } from "openclaw/plugin-sdk/telegram-core"; +import { buildChannelConfigSchema, TelegramConfigSchema } from "../runtime-api.js"; export const TelegramChannelConfigSchema = buildChannelConfigSchema(TelegramConfigSchema); diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index baebe687c50..ae943f169d3 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -1,5 +1,5 @@ import type { Bot } from "grammy"; -import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-runtime"; +import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-lifecycle"; import { resolveGlobalSingleton } from "openclaw/plugin-sdk/text-runtime"; import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js"; diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index a9a10965243..4d14f179b2f 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -1,6 +1,8 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { chunkMarkdownIR, + FILE_REF_EXTENSIONS_WITH_TLD, + isAutoLinkedFileRef, markdownToIR, type MarkdownLinkSpan, type MarkdownIR, @@ -31,44 +33,6 @@ function escapeHtmlAttr(text: string): string { * * Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io) */ -const FILE_EXTENSIONS_WITH_TLD = new Set([ - "md", // Markdown (Moldova) - very common in repos - "go", // Go language - common in Go projects - "py", // Python (Paraguay) - common in Python projects - "pl", // Perl (Poland) - common in Perl projects - "sh", // Shell (Saint Helena) - common for scripts - "am", // Automake files (Armenia) - "at", // Assembly (Austria) - "be", // Backend files (Belgium) - "cc", // C++ source (Cocos Islands) -]); - -/** Detects when markdown-it linkify auto-generated a link from a bare filename (e.g. README.md → http://README.md) */ -function isAutoLinkedFileRef(href: string, label: string): boolean { - const stripped = href.replace(/^https?:\/\//i, ""); - if (stripped !== label) { - return false; - } - const dotIndex = label.lastIndexOf("."); - if (dotIndex < 1) { - return false; - } - const ext = label.slice(dotIndex + 1).toLowerCase(); - if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) { - return false; - } - // Reject if any path segment before the filename contains a dot (looks like a domain) - const segments = label.split("/"); - if (segments.length > 1) { - for (let i = 0; i < segments.length - 1; i++) { - if (segments[i].includes(".")) { - return false; - } - } - } - return true; -} - function buildTelegramLink(link: MarkdownLinkSpan, text: string) { const href = link.href.trim(); if (!href) { @@ -139,7 +103,7 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -const FILE_EXTENSIONS_PATTERN = Array.from(FILE_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); +const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); const AUTO_LINKED_ANCHOR_PATTERN = /]*>\1<\/a>/gi; const FILE_REFERENCE_PATTERN = new RegExp( `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`, diff --git a/extensions/telegram/src/group-policy.ts b/extensions/telegram/src/group-policy.ts index a90e930a4a5..29614436ccc 100644 --- a/extensions/telegram/src/group-policy.ts +++ b/extensions/telegram/src/group-policy.ts @@ -1,9 +1,9 @@ +import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; -import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; function parseTelegramGroupId(value?: string | null) { const raw = value?.trim() ?? ""; diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index c67a091995e..7ecf392eb24 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -57,11 +57,14 @@ export type ArchivedPreview = { export type LanePreviewLifecycle = "transient" | "complete"; export type LaneDeliveryResult = - | "preview-finalized" - | "preview-retained" - | "preview-updated" - | "sent" - | "skipped"; + | { + kind: "preview-finalized"; + delivery: { + content: string; + messageId?: number; + }; + } + | { kind: "preview-retained" | "preview-updated" | "sent" | "skipped" }; type CreateLaneTextDelivererParams = { lanes: Record; @@ -107,7 +110,7 @@ type TryUpdatePreviewParams = { previewTextSnapshot?: string; }; -type PreviewEditResult = "edited" | "retained" | "fallback"; +type PreviewEditResult = "edited" | "retained" | "regressive-skipped" | "fallback"; type ConsumeArchivedAnswerPreviewParams = { lane: DraftLaneState; @@ -133,6 +136,16 @@ type PreviewTargetResolution = { stopCreatesFirstPreview: boolean; }; +function result( + kind: LaneDeliveryResult["kind"], + delivery?: Extract["delivery"], +): LaneDeliveryResult { + if (kind === "preview-finalized") { + return { kind, delivery: delivery! }; + } + return { kind }; +} + function shouldSkipRegressivePreviewUpdate(args: { currentPreviewText: string | undefined; text: string; @@ -189,10 +202,10 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { lane: DraftLaneState; laneName: LaneName; text: string; - }): Promise => { + }): Promise => { const stream = args.lane.stream; if (!stream || !isDraftPreviewLane(args.lane)) { - return false; + return undefined; } // Draft previews have no message_id to edit; materialize the final text // into a real message and treat that as the finalized delivery. @@ -202,11 +215,11 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { params.log( `telegram: ${args.laneName} draft preview materialize produced no message id; falling back to standard send`, ); - return false; + return undefined; } args.lane.lastPartialText = args.text; params.markDelivered(); - return true; + return materializedMessageId; }; const tryEditPreviewMessage = async (args: { @@ -338,7 +351,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { }); if (shouldSkipRegressive) { params.markDelivered(); - return "edited"; + return "regressive-skipped"; } return editPreview( previewMessageId, @@ -427,11 +440,20 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewTextSnapshot: archivedPreview.textSnapshot, }); if (finalized === "edited") { - return "preview-finalized"; + return result("preview-finalized", { + content: text, + messageId: archivedPreview.messageId, + }); + } + if (finalized === "regressive-skipped") { + return result("preview-finalized", { + content: archivedPreview.textSnapshot, + messageId: archivedPreview.messageId, + }); } if (finalized === "retained") { params.retainPreviewOnCleanupByLane.answer = true; - return "preview-retained"; + return result("preview-retained"); } } // Send the replacement message first, then clean up the old preview. @@ -448,7 +470,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { ); } } - return delivered ? "sent" : "skipped"; + return delivered ? result("sent") : result("skipped"); }; return async ({ @@ -499,16 +521,20 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { } } if (canMaterializeDraftFinal(lane, previewButtons)) { - const materialized = await tryMaterializeDraftPreviewForFinal({ + const materializedMessageId = await tryMaterializeDraftPreviewForFinal({ lane, laneName, text, }); - if (materialized) { + if (typeof materializedMessageId === "number") { markActivePreviewComplete(laneName); - return "preview-finalized"; + return result("preview-finalized", { + content: text, + messageId: materializedMessageId, + }); } } + const previewMessageId = lane.stream?.messageId(); const finalized = await tryUpdatePreviewForLane({ lane, laneName, @@ -520,11 +546,21 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { }); if (finalized === "edited") { markActivePreviewComplete(laneName); - return "preview-finalized"; + return result("preview-finalized", { + content: text, + messageId: previewMessageId ?? lane.stream?.messageId(), + }); + } + if (finalized === "regressive-skipped") { + markActivePreviewComplete(laneName); + return result("preview-finalized", { + content: lane.lastPartialText, + messageId: previewMessageId ?? lane.stream?.messageId(), + }); } if (finalized === "retained") { markActivePreviewComplete(laneName); - return "preview-retained"; + return result("preview-retained"); } } else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) { params.log( @@ -533,7 +569,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { } await params.stopDraftLane(lane); const delivered = await params.sendPayload(params.applyTextToPayload(payload, text)); - return delivered ? "sent" : "skipped"; + return delivered ? result("sent") : result("skipped"); } if (allowPreviewUpdateForNonFinal && canEditViaPreview) { @@ -549,11 +585,11 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { `telegram: ${laneName} draft preview update not emitted; falling back to standard send`, ); const delivered = await params.sendPayload(params.applyTextToPayload(payload, text)); - return delivered ? "sent" : "skipped"; + return delivered ? result("sent") : result("skipped"); } lane.lastPartialText = text; params.markDelivered(); - return "preview-updated"; + return result("preview-updated"); } const updated = await tryUpdatePreviewForLane({ lane, @@ -565,12 +601,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { skipRegressive: "always", context: "update", }); - if (updated === "edited") { - return "preview-updated"; + if (updated === "edited" || updated === "regressive-skipped") { + return result("preview-updated"); } } const delivered = await params.sendPayload(params.applyTextToPayload(payload, text)); - return delivered ? "sent" : "skipped"; + return delivered ? result("sent") : result("skipped"); }; } diff --git a/extensions/telegram/src/lane-delivery.test.ts b/extensions/telegram/src/lane-delivery.test.ts index aba9974eff5..3470a6257c8 100644 --- a/extensions/telegram/src/lane-delivery.test.ts +++ b/extensions/telegram/src/lane-delivery.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import type { ReplyPayload } from "../../../src/auto-reply/types.js"; import { createTestDraftStream } from "./draft-stream.test-helpers.js"; -import { createLaneTextDeliverer, type DraftLaneState, type LaneName } from "./lane-delivery.js"; +import { + createLaneTextDeliverer, + type DraftLaneState, + type LaneDeliveryResult, + type LaneName, +} from "./lane-delivery.js"; const HELLO_FINAL = "Hello final"; @@ -101,7 +106,7 @@ async function expectFinalPreviewRetained(params: { expectedLogSnippet?: string; }) { const result = await deliverFinalAnswer(params.harness, params.text ?? HELLO_FINAL); - expect(result).toBe("preview-retained"); + expect(result.kind).toBe("preview-retained"); expect(params.harness.sendPayload).not.toHaveBeenCalled(); if (params.expectedLogSnippet) { expect(params.harness.log).toHaveBeenCalledWith( @@ -124,7 +129,7 @@ async function expectFinalEditFallbackToSend(params: { expectedLogSnippet: string; }) { const result = await deliverFinalAnswer(params.harness, params.text); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(params.harness.editPreview).toHaveBeenCalledTimes(1); expect(params.harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: params.text }), @@ -134,13 +139,23 @@ async function expectFinalEditFallbackToSend(params: { ); } +function expectPreviewFinalized( + result: LaneDeliveryResult, +): Extract["delivery"] { + expect(result.kind).toBe("preview-finalized"); + if (result.kind !== "preview-finalized") { + throw new Error(`expected preview-finalized, got ${result.kind}`); + } + return result.delivery; +} + describe("createLaneTextDeliverer", () => { it("finalizes text-only replies by editing an existing preview message", async () => { const harness = createHarness({ answerMessageId: 999 }); const result = await deliverFinalAnswer(harness, HELLO_FINAL); - expect(result).toBe("preview-finalized"); + expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 }); expect(harness.editPreview).toHaveBeenCalledWith( expect.objectContaining({ laneName: "answer", @@ -164,7 +179,7 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-finalized"); + expect(expectPreviewFinalized(result)).toEqual({ content: "no problem", messageId: 777 }); expect(harness.answer.stream?.update).toHaveBeenCalledWith("no problem"); expect(harness.editPreview).toHaveBeenCalledWith( expect.objectContaining({ @@ -187,7 +202,7 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-retained"); + expect(result.kind).toBe("preview-retained"); expect(harness.editPreview).toHaveBeenCalledTimes(1); expect(harness.sendPayload).not.toHaveBeenCalled(); expect(harness.log).toHaveBeenCalledWith( @@ -205,7 +220,7 @@ describe("createLaneTextDeliverer", () => { const result = await deliverFinalAnswer(harness, HELLO_FINAL); - expect(result).toBe("preview-finalized"); + expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 }); expect(harness.editPreview).toHaveBeenCalledTimes(1); expect(harness.sendPayload).not.toHaveBeenCalled(); expect(harness.markDelivered).toHaveBeenCalledTimes(1); @@ -244,7 +259,7 @@ describe("createLaneTextDeliverer", () => { const result = await deliverFinalAnswer(harness, HELLO_FINAL); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: HELLO_FINAL }), ); @@ -273,7 +288,7 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(harness.editPreview).not.toHaveBeenCalled(); expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: "Short final" }), @@ -291,7 +306,10 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-finalized"); + expect(expectPreviewFinalized(result)).toEqual({ + content: "Recovered final answer.", + messageId: 999, + }); expect(harness.editPreview).not.toHaveBeenCalled(); expect(harness.sendPayload).not.toHaveBeenCalled(); expect(harness.markDelivered).toHaveBeenCalledTimes(1); @@ -308,7 +326,7 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(harness.editPreview).not.toHaveBeenCalled(); expect(harness.sendPayload).toHaveBeenCalledWith(expect.objectContaining({ text: longText })); expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long")); @@ -331,7 +349,7 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-finalized"); + expect(expectPreviewFinalized(result)).toEqual({ content: "Hello final", messageId: 321 }); expect(harness.flushDraftLane).toHaveBeenCalled(); expect(answerStream.materialize).toHaveBeenCalledTimes(1); expect(harness.sendPayload).not.toHaveBeenCalled(); @@ -360,7 +378,7 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-finalized"); + expect(expectPreviewFinalized(result)).toEqual({ content: "Final answer", messageId: 654 }); expect(answerStream.materialize).toHaveBeenCalledTimes(1); expect(harness.sendPayload).not.toHaveBeenCalled(); expect(harness.markDelivered).toHaveBeenCalledTimes(1); @@ -377,7 +395,7 @@ describe("createLaneTextDeliverer", () => { const result = await deliverFinalAnswer(harness, HELLO_FINAL); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(answerStream.materialize).toHaveBeenCalledTimes(1); expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: HELLO_FINAL }), @@ -402,7 +420,7 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: "Image incoming", mediaUrl: "file:///tmp/example.png" }), ); @@ -425,7 +443,7 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: "Choose one" }), ); @@ -456,7 +474,7 @@ describe("createLaneTextDeliverer", () => { expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: "Complete final answer" }), ); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555); }); @@ -469,12 +487,30 @@ describe("createLaneTextDeliverer", () => { expect(harness.editPreview).toHaveBeenCalledTimes(1); expect(harness.sendPayload).not.toHaveBeenCalled(); - expect(result).toBe("preview-retained"); + expect(result.kind).toBe("preview-retained"); expect(harness.log).toHaveBeenCalledWith( expect.stringContaining("edit target missing; keeping alternate preview without fallback"), ); }); + it("keeps the archived preview when the final text regresses", async () => { + const harness = createHarness(); + harness.archivedAnswerPreviews.push({ + messageId: 5555, + textSnapshot: "Recovered final answer.", + deleteIfUnused: true, + }); + + const result = await deliverFinalAnswer(harness, "Recovered final answer"); + + expect(expectPreviewFinalized(result)).toEqual({ + content: "Recovered final answer.", + messageId: 5555, + }); + expect(harness.editPreview).not.toHaveBeenCalled(); + expect(harness.sendPayload).not.toHaveBeenCalled(); + }); + it("falls back on 4xx client rejection with error_code during final", async () => { const harness = createHarness({ answerMessageId: 999 }); const err = Object.assign(new Error("403: Forbidden"), { error_code: 403 }); @@ -505,7 +541,7 @@ describe("createLaneTextDeliverer", () => { const result = await deliverFinalAnswer(harness, HELLO_FINAL); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: HELLO_FINAL }), ); @@ -546,7 +582,7 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("sent"); + expect(result.kind).toBe("sent"); expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: "Final with media", mediaUrl: "file:///tmp/example.png" }), ); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index b5cb70a2c66..b500fb870cf 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -1,14 +1,14 @@ -import { - resolvePayloadMediaUrls, - 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 type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { attachChannelToResult, createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime"; import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime"; +import { + resolvePayloadMediaUrls, + sendPayloadMediaSequenceOrFallback, +} from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index 60d9b3a3a40..d297635e4a1 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; import type { TelegramNetworkConfig } from "../runtime-api.js"; import { resolveTelegramFetch } from "./fetch.js"; diff --git a/extensions/telegram/src/status-issues.ts b/extensions/telegram/src/status-issues.ts index 0178c0c7346..b819308503a 100644 --- a/extensions/telegram/src/status-issues.ts +++ b/extensions/telegram/src/status-issues.ts @@ -1,13 +1,13 @@ +import type { + ChannelAccountSnapshot, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; import { appendMatchMetadata, asString, isRecord, resolveEnabledConfiguredAccountId, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { - ChannelAccountSnapshot, - ChannelStatusIssue, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/status-helpers"; type TelegramAccountStatus = { accountId?: unknown; diff --git a/extensions/telegram/src/status-reaction-variants.ts b/extensions/telegram/src/status-reaction-variants.ts index 8c04a87554e..7d995a23168 100644 --- a/extensions/telegram/src/status-reaction-variants.ts +++ b/extensions/telegram/src/status-reaction-variants.ts @@ -1,4 +1,4 @@ -import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "openclaw/plugin-sdk/channel-runtime"; +import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "openclaw/plugin-sdk/channel-feedback"; type StatusReactionEmojiKey = keyof Required; diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index 8b7be041197..be734804efb 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -1,10 +1,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; -import { formatThreadBindingDurationLabel } from "openclaw/plugin-sdk/channel-runtime"; import { + formatThreadBindingDurationLabel, registerSessionBindingAdapter, + resolveThreadBindingConversationIdFromBindingId, + resolveThreadBindingEffectiveExpiresAt, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, @@ -115,32 +116,6 @@ function toTelegramTargetKind(raw: BindingTargetKind): TelegramBindingTargetKind return raw === "subagent" ? "subagent" : "acp"; } -function resolveEffectiveBindingExpiresAt(params: { - record: TelegramThreadBindingRecord; - defaultIdleTimeoutMs: number; - defaultMaxAgeMs: number; -}): number | undefined { - const idleTimeoutMs = - typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; - const maxAgeMs = - typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; - - const inactivityExpiresAt = - idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; - const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; - - if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { - return Math.min(inactivityExpiresAt, maxAgeExpiresAt); - } - return inactivityExpiresAt ?? maxAgeExpiresAt; -} - function toSessionBindingRecord( record: TelegramThreadBindingRecord, defaults: { idleTimeoutMs: number; maxAgeMs: number }, @@ -159,7 +134,7 @@ function toSessionBindingRecord( }, status: "active", boundAt: record.boundAt, - expiresAt: resolveEffectiveBindingExpiresAt({ + expiresAt: resolveThreadBindingEffectiveExpiresAt({ record, defaultIdleTimeoutMs: defaults.idleTimeoutMs, defaultMaxAgeMs: defaults.maxAgeMs, diff --git a/extensions/telegram/src/token.test.ts b/extensions/telegram/src/token.test.ts index c81e5d57b2c..74218f83ddd 100644 --- a/extensions/telegram/src/token.test.ts +++ b/extensions/telegram/src/token.test.ts @@ -188,6 +188,24 @@ describe("resolveTelegramToken", () => { expect(res.source).toBe("none"); }); + it("does not fall through to channel-level token when non-default accountId is not in config", () => { + vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); + const cfg = { + channels: { + telegram: { + botToken: "wrong-bot-token", + accounts: { + knownBot: { botToken: "known-bot-token" }, + }, + }, + }, + } as OpenClawConfig; + + const res = resolveTelegramToken(cfg, { accountId: "unknownBot" }); + expect(res.token).toBe(""); + expect(res.source).toBe("none"); + }); + it("throws when botToken is an unresolved SecretRef object", () => { const cfg = { channels: { diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index 6727e9a7ee4..c2482772c61 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -1,8 +1,8 @@ -import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import type { TelegramAccountConfig } from "../runtime-api.js"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; @@ -44,6 +44,17 @@ export function resolveTelegramToken( const accountCfg = resolveAccountCfg( accountId !== DEFAULT_ACCOUNT_ID ? accountId : DEFAULT_ACCOUNT_ID, ); + + // When a non-default accountId is explicitly specified but not found in config, + // return empty immediately — do NOT fall through to channel-level defaults, + // which would silently route the message via the wrong bot's token. + if (accountId !== DEFAULT_ACCOUNT_ID && !accountCfg) { + opts.logMissingFile?.( + `channels.telegram.accounts: unknown accountId "${accountId}" — not found in config, refusing channel-level fallback`, + ); + return { token: "", source: "none" }; + } + const accountTokenFile = accountCfg?.tokenFile?.trim(); if (accountTokenFile) { const token = tryReadSecretFileSync( diff --git a/extensions/tlon/api.ts b/extensions/tlon/api.ts index 5364c68f07d..6606fb316b4 100644 --- a/extensions/tlon/api.ts +++ b/extensions/tlon/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/tlon"; +export * from "./runtime-api.js"; diff --git a/extensions/tlon/runtime-api.ts b/extensions/tlon/runtime-api.ts new file mode 100644 index 00000000000..3ba9718868f --- /dev/null +++ b/extensions/tlon/runtime-api.ts @@ -0,0 +1,4 @@ +// Private runtime barrel for the bundled Tlon extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/tlon.js"; diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index 56d59d6003b..c00199eeb9b 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -1,8 +1,6 @@ import crypto from "node:crypto"; -import type { - ChannelAccountSnapshot, - ChannelOutboundAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { createLoggerBackedRuntime } from "openclaw/plugin-sdk/runtime"; diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 89e4a235b60..71752c4d1a3 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,10 +1,8 @@ import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { - createRuntimeOutboundDelegates, - type ChannelAccountSnapshot, - type ChannelPlugin, -} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { resolveTlonOutboundSessionRoute } from "./session-route.js"; diff --git a/extensions/twitch/api.ts b/extensions/twitch/api.ts index 68033283423..6606fb316b4 100644 --- a/extensions/twitch/api.ts +++ b/extensions/twitch/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/twitch"; +export * from "./runtime-api.js"; diff --git a/extensions/twitch/runtime-api.ts b/extensions/twitch/runtime-api.ts index 68033283423..9d055202a39 100644 --- a/extensions/twitch/runtime-api.ts +++ b/extensions/twitch/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/twitch"; +// Private runtime barrel for the bundled Twitch extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/twitch.js"; diff --git a/extensions/voice-call/api.ts b/extensions/voice-call/api.ts index ef9f7d7a3c0..6606fb316b4 100644 --- a/extensions/voice-call/api.ts +++ b/extensions/voice-call/api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/voice-call"; +export * from "./runtime-api.js"; diff --git a/extensions/voice-call/runtime-api.ts b/extensions/voice-call/runtime-api.ts new file mode 100644 index 00000000000..f0b32548645 --- /dev/null +++ b/extensions/voice-call/runtime-api.ts @@ -0,0 +1,4 @@ +// Private runtime barrel for the bundled Voice Call extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/voice-call.js"; diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts index 8bf50cefccd..c9d2ae0bcee 100644 --- a/extensions/whatsapp/api.ts +++ b/extensions/whatsapp/api.ts @@ -7,4 +7,4 @@ export { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, } from "./src/directory-config.js"; -export { resolveWhatsAppGroupIntroHint } from "openclaw/plugin-sdk/whatsapp-core"; +export { resolveWhatsAppGroupIntroHint } from "./src/runtime-api.js"; diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts index d53f5105ca2..653f4c5ef6b 100644 --- a/extensions/whatsapp/src/agent-tools-login.ts +++ b/extensions/whatsapp/src/agent-tools-login.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; -import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-runtime"; -import { startWebLoginWithQr, waitForWebLogin } from "openclaw/plugin-sdk/whatsapp-login-qr"; +import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract"; +import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js"; export function createWhatsAppLoginTool(): ChannelAgentTool { return { diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts index 8fb27a39fe4..8c8c8639734 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts @@ -1,5 +1,4 @@ import { appendCronStyleCurrentTimeLine } from "openclaw/plugin-sdk/agent-runtime"; -import { resolveWhatsAppHeartbeatRecipients } from "openclaw/plugin-sdk/channel-runtime"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, @@ -25,6 +24,7 @@ import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { redactIdentifier } from "openclaw/plugin-sdk/text-runtime"; import { newConnectionId } from "../reconnect.js"; +import { resolveWhatsAppHeartbeatRecipients } from "../runtime-api.js"; import { sendMessageWhatsApp } from "../send.js"; import { formatError } from "../session.js"; import { whatsappHeartbeatLog } from "./loggers.js"; diff --git a/extensions/whatsapp/src/auto-reply/mentions.ts b/extensions/whatsapp/src/auto-reply/mentions.ts index ad42c814c26..967b4c1c61b 100644 --- a/extensions/whatsapp/src/auto-reply/mentions.ts +++ b/extensions/whatsapp/src/auto-reply/mentions.ts @@ -1,5 +1,5 @@ +import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/channel-inbound"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/reply-runtime"; import { isSelfChatMode, jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import type { WebInboundMsg } from "./types.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 2f83e65079a..1997ddc38a1 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -1,13 +1,13 @@ +import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { waitForever } from "openclaw/plugin-sdk/cli-runtime"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { formatDurationPrecise } from "openclaw/plugin-sdk/infra-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/reply-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history"; import { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; -import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index 126c485ec6f..bb6e1a181ab 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -1,4 +1,4 @@ -import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-runtime"; +import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-feedback"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { sendReactionWhatsApp } from "../../send.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index 847e5e3182f..d639e9e182a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -1,8 +1,8 @@ -import { resolveMentionGating } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMentionGating } from "openclaw/plugin-sdk/channel-inbound"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; +import { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history"; import { parseActivationCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import type { MentionConfig } from "../mentions.js"; import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts index b9494f0325c..4b33649da43 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts @@ -1,9 +1,9 @@ import { resolveMessagePrefix } from "openclaw/plugin-sdk/agent-runtime"; -import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatInboundEnvelope, type EnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/channel-inbound"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import type { WebInboundMsg } from "../types.js"; export function formatReplyContext(msg: WebInboundMsg) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 067087f87d3..255c211f0ee 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -1,20 +1,22 @@ import { resolveIdentityNamePrefix } from "openclaw/plugin-sdk/agent-runtime"; +import { + resolveInboundSessionEnvelopeContext, + toLocationContext, +} from "openclaw/plugin-sdk/channel-inbound"; +import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { toLocationContext } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveInboundSessionEnvelopeContext } from "openclaw/plugin-sdk/channel-runtime"; +import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-auth"; 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"; -import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; import { buildHistoryContextFromEntries, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/whatsapp/src/channel.directory.test.ts b/extensions/whatsapp/src/channel.directory.test.ts index 3fd58b31d4d..d9a072c86f1 100644 --- a/extensions/whatsapp/src/channel.directory.test.ts +++ b/extensions/whatsapp/src/channel.directory.test.ts @@ -1,10 +1,10 @@ -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"; +import type { OpenClawConfig } from "./runtime-api.js"; describe("whatsapp directory", () => { const runtimeEnv = createDirectoryTestRuntime() as never; diff --git a/extensions/whatsapp/src/config-schema.ts b/extensions/whatsapp/src/config-schema.ts index 23f7de4058f..89681ce2d54 100644 --- a/extensions/whatsapp/src/config-schema.ts +++ b/extensions/whatsapp/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, WhatsAppConfigSchema } from "openclaw/plugin-sdk/whatsapp-core"; +import { buildChannelConfigSchema, WhatsAppConfigSchema } from "./runtime-api.js"; export const WhatsAppChannelConfigSchema = buildChannelConfigSchema(WhatsAppConfigSchema); diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts index 9fa663847a6..b1b64e4fe91 100644 --- a/extensions/whatsapp/src/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -4,7 +4,7 @@ import { getContentType, normalizeMessageContent, } from "@whiskeysockets/baileys"; -import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; +import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { jidToE164 } from "openclaw/plugin-sdk/text-runtime"; import { parseVcard } from "../vcard.js"; diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 35669bc1b49..b19e37feb69 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -1,9 +1,8 @@ import type { AnyMessageContent, proto, WAMessage } from "@whiskeysockets/baileys"; import { DisconnectReason, isJidGroup } from "@whiskeysockets/baileys"; -import { formatLocationText } from "openclaw/plugin-sdk/channel-runtime"; +import { createInboundDebouncer, formatLocationText } from "openclaw/plugin-sdk/channel-inbound"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; -import { createInboundDebouncer } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/whatsapp/src/inbound/types.ts b/extensions/whatsapp/src/inbound/types.ts index 42e4b5121d1..731dcd2c8cc 100644 --- a/extensions/whatsapp/src/inbound/types.ts +++ b/extensions/whatsapp/src/inbound/types.ts @@ -1,5 +1,5 @@ import type { AnyMessageContent } from "@whiskeysockets/baileys"; -import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; +import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; export type WebListenerCloseReason = { status?: number; diff --git a/extensions/whatsapp/src/normalize.ts b/extensions/whatsapp/src/normalize.ts index d0506cd5883..63a1c8279bb 100644 --- a/extensions/whatsapp/src/normalize.ts +++ b/extensions/whatsapp/src/normalize.ts @@ -4,4 +4,4 @@ export { normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, normalizeWhatsAppTarget, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "./runtime-api.js"; diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index 4800e2ded43..45fa8d046e7 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -1,11 +1,13 @@ -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 { + type ChannelOutboundAdapter, createAttachedChannelResultAdapter, createEmptyChannelResult, } from "openclaw/plugin-sdk/channel-send-result"; -import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; +import { + resolveSendableOutboundReplyParts, + sendTextMediaPayload, +} 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"; diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index c24b6812cae..ca5cef77b9b 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -1,10 +1,8 @@ import { installCommonResolveTargetErrorCases } from "openclaw/plugin-sdk/testing"; import { describe, expect, it, vi } from "vitest"; -vi.mock("openclaw/plugin-sdk/whatsapp", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/whatsapp", - ); +vi.mock("./runtime-api.js", async () => { + const actual = await vi.importActual("./runtime-api.js"); const normalizeWhatsAppTarget = (value: string) => { if (value === "invalid-target") return null; // Simulate E.164 normalization: strip leading + and whatsapp: prefix. @@ -84,7 +82,7 @@ describe("whatsapp resolveTarget", () => { if (!result.ok) { throw result.error; } - expect(result.to).toBe("+5511999999999"); + expect(result.to).toBe("5511999999999@s.whatsapp.net"); }); it("should resolve target in implicit mode with wildcard", () => { @@ -98,7 +96,7 @@ describe("whatsapp resolveTarget", () => { if (!result.ok) { throw result.error; } - expect(result.to).toBe("+5511999999999"); + expect(result.to).toBe("5511999999999@s.whatsapp.net"); }); it("should resolve target in implicit mode when in allowlist", () => { @@ -112,7 +110,7 @@ describe("whatsapp resolveTarget", () => { if (!result.ok) { throw result.error; } - expect(result.to).toBe("+5511999999999"); + expect(result.to).toBe("5511999999999@s.whatsapp.net"); }); it("should allow group JID regardless of allowlist", () => { diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts index 515040ffb42..a98c264b2b2 100644 --- a/extensions/whatsapp/src/runtime-api.ts +++ b/extensions/whatsapp/src/runtime-api.ts @@ -9,16 +9,21 @@ export { readReactionParams, readStringParam, resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, resolveWhatsAppOutboundTarget, ToolAuthorizationError, WhatsAppConfigSchema, type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/whatsapp-core"; +} from "../../../src/plugin-sdk/whatsapp-core.js"; export { createWhatsAppOutboundBase, isWhatsAppGroupJid, + looksLikeWhatsAppTargetId, + normalizeWhatsAppAllowFromEntries, + normalizeWhatsAppMessagingTarget, normalizeWhatsAppTarget, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, @@ -26,6 +31,6 @@ export { type DmPolicy, type GroupPolicy, type WhatsAppAccountConfig, -} from "openclaw/plugin-sdk/whatsapp-shared"; +} from "../../../src/plugin-sdk/whatsapp-shared.js"; export { monitorWebChannel } from "./channel.runtime.js"; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 3e241c9f94c..fcc5bb92421 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -5,6 +5,12 @@ import { import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; import { buildChannelConfigSchema, formatWhatsAppConfigAllowFromEntries, @@ -15,13 +21,7 @@ import { resolveWhatsAppGroupToolPolicy, WhatsAppConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp-core"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, - type ResolvedWhatsAppAccount, -} from "./accounts.js"; +} from "./runtime-api.js"; export const WHATSAPP_CHANNEL = "whatsapp" as const; diff --git a/extensions/whatsapp/src/status-issues.ts b/extensions/whatsapp/src/status-issues.ts index f369ba29cda..15e6e6b216f 100644 --- a/extensions/whatsapp/src/status-issues.ts +++ b/extensions/whatsapp/src/status-issues.ts @@ -1,13 +1,13 @@ +import type { + ChannelAccountSnapshot, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; +import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { asString, collectIssuesForEnabledAccounts, isRecord, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { - ChannelAccountSnapshot, - ChannelStatusIssue, -} from "openclaw/plugin-sdk/channel-runtime"; -import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; +} from "openclaw/plugin-sdk/status-helpers"; type WhatsAppAccountStatus = { accountId?: unknown; diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts index 87d18484264..a925f7848ca 100644 --- a/extensions/xai/model-definitions.ts +++ b/extensions/xai/model-definitions.ts @@ -59,14 +59,14 @@ const XAI_MODEL_CATALOG = [ contextWindow: XAI_LARGE_CONTEXT_WINDOW, }, { - id: "grok-4.20-experimental-beta-0304-reasoning", - name: "Grok 4.20 Experimental Beta 0304 (Reasoning)", + id: "grok-4.20-reasoning", + name: "Grok 4.20 (Reasoning)", reasoning: true, contextWindow: XAI_LARGE_CONTEXT_WINDOW, }, { - id: "grok-4.20-experimental-beta-0304-non-reasoning", - name: "Grok 4.20 Experimental Beta 0304 (Non-Reasoning)", + id: "grok-4.20-non-reasoning", + name: "Grok 4.20 (Non-Reasoning)", reasoning: false, contextWindow: XAI_LARGE_CONTEXT_WINDOW, }, diff --git a/extensions/xai/provider-models.test.ts b/extensions/xai/provider-models.test.ts index 175209f4975..d0d025a852a 100644 --- a/extensions/xai/provider-models.test.ts +++ b/extensions/xai/provider-models.test.ts @@ -16,8 +16,21 @@ describe("xai provider models", () => { }); }); + it("publishes Grok 4.20 reasoning and non-reasoning models", () => { + expect(resolveXaiCatalogEntry("grok-4.20-reasoning")).toMatchObject({ + id: "grok-4.20-reasoning", + reasoning: true, + contextWindow: 2_000_000, + }); + expect(resolveXaiCatalogEntry("grok-4.20-non-reasoning")).toMatchObject({ + id: "grok-4.20-non-reasoning", + reasoning: false, + contextWindow: 2_000_000, + }); + }); + it("marks current Grok families as modern while excluding multi-agent ids", () => { - expect(isModernXaiModel("grok-4.20-experimental-beta-0304-reasoning")).toBe(true); + expect(isModernXaiModel("grok-4.20-reasoning")).toBe(true); expect(isModernXaiModel("grok-code-fast-1")).toBe(true); expect(isModernXaiModel("grok-3-mini-fast")).toBe(false); expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false); @@ -40,7 +53,7 @@ describe("xai provider models", () => { providerId: "xai", ctx: { provider: "xai", - modelId: "grok-4.20-experimental-beta-0304-reasoning", + modelId: "grok-4.20-reasoning", modelRegistry: { find: () => null } as never, providerConfig: { api: "openai-completions", @@ -59,7 +72,7 @@ describe("xai provider models", () => { }); expect(grok420).toMatchObject({ provider: "xai", - id: "grok-4.20-experimental-beta-0304-reasoning", + id: "grok-4.20-reasoning", api: "openai-completions", baseUrl: "https://api.x.ai/v1", reasoning: true, diff --git a/extensions/xai/src/grok-web-search-provider.ts b/extensions/xai/src/grok-web-search-provider.ts index d9a6f0f8d46..705a8299917 100644 --- a/extensions/xai/src/grok-web-search-provider.ts +++ b/extensions/xai/src/grok-web-search-provider.ts @@ -5,12 +5,12 @@ import { DEFAULT_SEARCH_COUNT, getScopedCredentialValue, MAX_SEARCH_COUNT, - mergeScopedSearchConfig, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, readProviderEnvValue, readStringParam, + mergeScopedSearchConfig, resolveProviderWebSearchPluginConfig, resolveSearchCacheTtlMs, resolveSearchCount, @@ -20,151 +20,24 @@ import { type SearchConfigRecord, type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; +import { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + requestXaiWebSearch, + resolveXaiInlineCitations, + resolveXaiSearchConfig, + resolveXaiWebSearchModel, +} from "./web-search-shared.js"; -const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; -const DEFAULT_GROK_MODEL = "grok-4-1-fast"; - -type GrokConfig = { - apiKey?: string; - model?: string; - inlineCitations?: boolean; -}; - -type GrokSearchResponse = { - output?: Array<{ - type?: string; - role?: string; - text?: string; - content?: Array<{ - type?: string; - text?: string; - annotations?: Array<{ - type?: string; - url?: string; - start_index?: number; - end_index?: number; - }>; - }>; - annotations?: Array<{ - type?: string; - url?: string; - start_index?: number; - end_index?: number; - }>; - }>; - output_text?: string; - citations?: string[]; - inline_citations?: Array<{ - start_index: number; - end_index: number; - url: string; - }>; -}; - -function resolveGrokConfig(searchConfig?: SearchConfigRecord): GrokConfig { - const grok = searchConfig?.grok; - return grok && typeof grok === "object" && !Array.isArray(grok) ? (grok as GrokConfig) : {}; -} - -function resolveGrokApiKey(grok?: GrokConfig): string | undefined { +function resolveGrokApiKey(grok?: Record): string | undefined { return ( readConfiguredSecretString(grok?.apiKey, "tools.web.search.grok.apiKey") ?? readProviderEnvValue(["XAI_API_KEY"]) ); } -function resolveGrokModel(grok?: GrokConfig): string { - const model = typeof grok?.model === "string" ? grok.model.trim() : ""; - return model || DEFAULT_GROK_MODEL; -} - -function resolveGrokInlineCitations(grok?: GrokConfig): boolean { - return grok?.inlineCitations === true; -} - -function extractGrokContent(data: GrokSearchResponse): { - text: string | undefined; - annotationCitations: string[]; -} { - for (const output of data.output ?? []) { - if (output.type === "message") { - for (const block of output.content ?? []) { - if (block.type === "output_text" && typeof block.text === "string" && block.text) { - const urls = (block.annotations ?? []) - .filter( - (annotation) => - annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: block.text, annotationCitations: [...new Set(urls)] }; - } - } - } - if (output.type === "output_text" && typeof output.text === "string" && output.text) { - const urls = (Array.isArray(output.annotations) ? output.annotations : []) - .filter( - (annotation) => annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: output.text, annotationCitations: [...new Set(urls)] }; - } - } - - return { - text: typeof data.output_text === "string" ? data.output_text : undefined, - annotationCitations: [], - }; -} - -async function runGrokSearch(params: { - query: string; - apiKey: string; - model: string; - timeoutSeconds: number; - inlineCitations: boolean; -}): Promise<{ - content: string; - citations: string[]; - inlineCitations?: GrokSearchResponse["inline_citations"]; -}> { - return withTrustedWebSearchEndpoint( - { - url: XAI_API_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify({ - model: params.model, - input: [{ role: "user", content: params.query }], - tools: [{ type: "web_search" }], - }), - }, - }, - async (res) => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`xAI API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as GrokSearchResponse; - const { text, annotationCitations } = extractGrokContent(data); - return { - content: text ?? "No response", - citations: (data.citations ?? []).length > 0 ? data.citations! : annotationCitations, - inlineCitations: data.inline_citations, - }; - }, - ); -} - function createGrokSchema() { return Type.Object({ query: Type.String({ description: "Search query string." }), @@ -197,7 +70,7 @@ function createGrokToolDefinition( return unsupportedResponse; } - const grokConfig = resolveGrokConfig(searchConfig); + const grokConfig = resolveXaiSearchConfig(searchConfig); const apiKey = resolveGrokApiKey(grokConfig); if (!apiKey) { return { @@ -213,8 +86,8 @@ function createGrokToolDefinition( readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; - const model = resolveGrokModel(grokConfig); - const inlineCitations = resolveGrokInlineCitations(grokConfig); + const model = resolveXaiWebSearchModel(searchConfig); + const inlineCitations = resolveXaiInlineCitations(searchConfig); const cacheKey = buildSearchCacheKey([ "grok", query, @@ -228,28 +101,22 @@ function createGrokToolDefinition( } const start = Date.now(); - const result = await runGrokSearch({ + const result = await requestXaiWebSearch({ query, apiKey, model, timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), inlineCitations, }); - const payload = { + const payload = buildXaiWebSearchPayload({ query, provider: "grok", model, tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "grok", - wrapped: true, - }, - content: wrapWebContent(result.content), + content: result.content, citations: result.citations, inlineCitations: result.inlineCitations, - }; + }); writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); return payload; }, @@ -289,7 +156,15 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin { export const __testing = { resolveGrokApiKey, - resolveGrokModel, - resolveGrokInlineCitations, - extractGrokContent, + resolveGrokModel: (grok?: Record) => + resolveXaiWebSearchModel(grok ? { grok } : undefined), + resolveGrokInlineCitations: (grok?: Record) => + resolveXaiInlineCitations(grok ? { grok } : undefined), + extractGrokContent: extractXaiWebSearchContent, + extractXaiWebSearchContent, + resolveXaiInlineCitations, + resolveXaiSearchConfig, + resolveXaiWebSearchModel, + requestXaiWebSearch, + buildXaiWebSearchPayload, } as const; diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts new file mode 100644 index 00000000000..85ea11aa49d --- /dev/null +++ b/extensions/xai/src/web-search-shared.ts @@ -0,0 +1,172 @@ +import { normalizeXaiModelId } from "openclaw/plugin-sdk/provider-models"; +import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search"; + +export const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; +export const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast"; + +export type XaiWebSearchResponse = { + output?: Array<{ + type?: string; + text?: string; + content?: Array<{ + type?: string; + text?: string; + annotations?: Array<{ + type?: string; + url?: string; + }>; + }>; + annotations?: Array<{ + type?: string; + url?: string; + }>; + }>; + output_text?: string; + citations?: string[]; + inline_citations?: Array<{ + start_index: number; + end_index: number; + url: string; + }>; +}; + +type XaiWebSearchConfig = Record & { + model?: unknown; + inlineCitations?: unknown; +}; + +export type XaiWebSearchResult = { + content: string; + citations: string[]; + inlineCitations?: XaiWebSearchResponse["inline_citations"]; +}; + +export function buildXaiWebSearchPayload(params: { + query: string; + provider: string; + model: string; + tookMs: number; + content: string; + citations: string[]; + inlineCitations?: XaiWebSearchResponse["inline_citations"]; +}): Record { + return { + query: params.query, + provider: params.provider, + model: params.model, + tookMs: params.tookMs, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(params.content, "web_search"), + citations: params.citations, + ...(params.inlineCitations ? { inlineCitations: params.inlineCitations } : {}), + }; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function resolveXaiSearchConfig(searchConfig?: Record): XaiWebSearchConfig { + return (asRecord(searchConfig?.grok) as XaiWebSearchConfig | undefined) ?? {}; +} + +export function resolveXaiWebSearchModel(searchConfig?: Record): string { + const config = resolveXaiSearchConfig(searchConfig); + return typeof config.model === "string" && config.model.trim() + ? normalizeXaiModelId(config.model.trim()) + : XAI_DEFAULT_WEB_SEARCH_MODEL; +} + +export function resolveXaiInlineCitations(searchConfig?: Record): boolean { + return resolveXaiSearchConfig(searchConfig).inlineCitations === true; +} + +export function extractXaiWebSearchContent(data: XaiWebSearchResponse): { + text: string | undefined; + annotationCitations: string[]; +} { + for (const output of data.output ?? []) { + if (output.type === "message") { + for (const block of output.content ?? []) { + if (block.type === "output_text" && typeof block.text === "string" && block.text) { + const urls = (block.annotations ?? []) + .filter( + (annotation) => + annotation.type === "url_citation" && typeof annotation.url === "string", + ) + .map((annotation) => annotation.url as string); + return { text: block.text, annotationCitations: [...new Set(urls)] }; + } + } + } + + if (output.type === "output_text" && typeof output.text === "string" && output.text) { + const urls = (output.annotations ?? []) + .filter( + (annotation) => annotation.type === "url_citation" && typeof annotation.url === "string", + ) + .map((annotation) => annotation.url as string); + return { text: output.text, annotationCitations: [...new Set(urls)] }; + } + } + + return { + text: typeof data.output_text === "string" ? data.output_text : undefined, + annotationCitations: [], + }; +} + +export async function requestXaiWebSearch(params: { + query: string; + model: string; + apiKey: string; + timeoutSeconds: number; + inlineCitations: boolean; +}): Promise { + return await postTrustedWebToolsJson( + { + url: XAI_WEB_SEARCH_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + apiKey: params.apiKey, + body: { + model: params.model, + input: [{ role: "user", content: params.query }], + tools: [{ type: "web_search" }], + }, + errorLabel: "xAI", + }, + async (response) => { + const data = (await response.json()) as XaiWebSearchResponse; + const { text, annotationCitations } = extractXaiWebSearchContent(data); + const citations = + Array.isArray(data.citations) && data.citations.length > 0 + ? data.citations + : annotationCitations; + return { + content: text ?? "No response", + citations, + inlineCitations: + params.inlineCitations && Array.isArray(data.inline_citations) + ? data.inline_citations + : undefined, + }; + }, + ); +} + +export const __testing = { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + resolveXaiInlineCitations, + resolveXaiSearchConfig, + resolveXaiWebSearchModel, + requestXaiWebSearch, + XAI_DEFAULT_WEB_SEARCH_MODEL, +} as const; diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 29433ec7efa..a6dfff40633 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -44,6 +44,19 @@ describe("xai web search config resolution", () => { ); }); + it("normalizes deprecated grok 4.20 beta model ids to GA ids", () => { + expect( + resolveXaiWebSearchModel({ + grok: { model: "grok-4.20-experimental-beta-0304-reasoning" }, + }), + ).toBe("grok-4.20-reasoning"); + expect( + resolveXaiWebSearchModel({ + grok: { model: "grok-4.20-experimental-beta-0304-non-reasoning" }, + }), + ).toBe("grok-4.20-non-reasoning"); + }); + it("defaults inlineCitations to false", () => { expect(resolveXaiInlineCitations({})).toBe(false); expect(resolveXaiInlineCitations(undefined)).toBe(false); diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index c1d97652d54..d160892c0c5 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -5,133 +5,29 @@ import { getScopedCredentialValue, normalizeCacheKey, readCache, - readResponseText, + readNumberParam, + readStringParam, resolveCacheTtlMs, resolveTimeoutSeconds, resolveWebSearchProviderCredential, setScopedCredentialValue, type WebSearchProviderPlugin, - withTrustedWebToolsEndpoint, - wrapWebContent, writeCache, } from "openclaw/plugin-sdk/provider-web-search"; +import { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + requestXaiWebSearch, + resolveXaiInlineCitations, + resolveXaiWebSearchModel, +} from "./src/web-search-shared.js"; -const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; -const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast"; const XAI_WEB_SEARCH_CACHE = new Map< string, { value: Record; insertedAt: number; expiresAt: number } >(); -type XaiWebSearchResponse = { - output?: Array<{ - type?: string; - text?: string; - content?: Array<{ - type?: string; - text?: string; - annotations?: Array<{ - type?: string; - url?: string; - }>; - }>; - annotations?: Array<{ - type?: string; - url?: string; - }>; - }>; - output_text?: string; - citations?: string[]; - inline_citations?: Array<{ - start_index: number; - end_index: number; - url: string; - }>; -}; - -function extractXaiWebSearchContent(data: XaiWebSearchResponse): { - text: string | undefined; - annotationCitations: string[]; -} { - for (const output of data.output ?? []) { - if (output.type === "message") { - for (const block of output.content ?? []) { - if (block.type === "output_text" && typeof block.text === "string" && block.text) { - const urls = (block.annotations ?? []) - .filter( - (annotation) => - annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: block.text, annotationCitations: [...new Set(urls)] }; - } - } - } - - if (output.type === "output_text" && typeof output.text === "string" && output.text) { - const urls = (output.annotations ?? []) - .filter( - (annotation) => annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: output.text, annotationCitations: [...new Set(urls)] }; - } - } - - return { - text: typeof data.output_text === "string" ? data.output_text : undefined, - annotationCitations: [], - }; -} - -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function resolveXaiWebSearchConfig( - searchConfig?: Record, -): Record { - return asRecord(searchConfig?.grok) ?? {}; -} - -function resolveXaiWebSearchModel(searchConfig?: Record): string { - const config = resolveXaiWebSearchConfig(searchConfig); - return typeof config.model === "string" && config.model.trim() - ? config.model.trim() - : XAI_DEFAULT_WEB_SEARCH_MODEL; -} - -function resolveXaiInlineCitations(searchConfig?: Record): boolean { - return resolveXaiWebSearchConfig(searchConfig).inlineCitations === true; -} - -function readQuery(args: Record): string { - const value = typeof args.query === "string" ? args.query.trim() : ""; - if (!value) { - throw new Error("query required"); - } - return value; -} - -function readCount(args: Record): number { - const raw = args.count; - const parsed = - typeof raw === "number" && Number.isFinite(raw) - ? raw - : typeof raw === "string" && raw.trim() - ? Number.parseFloat(raw) - : 5; - return Math.max(1, Math.min(10, Math.trunc(parsed))); -} - -async function throwXaiWebSearchApiError(res: Response): Promise { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - throw new Error(`xAI API error (${res.status}): ${detailResult.text || res.statusText}`); -} - -async function runXaiWebSearch(params: { +function runXaiWebSearch(params: { query: string; model: string; apiKey: string; @@ -144,61 +40,31 @@ async function runXaiWebSearch(params: { ); const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey); if (cached) { - return { ...cached.value, cached: true }; + return Promise.resolve({ ...cached.value, cached: true }); } - const startedAt = Date.now(); - const payload = await withTrustedWebToolsEndpoint( - { - url: XAI_WEB_SEARCH_ENDPOINT, + return (async () => { + const startedAt = Date.now(); + const result = await requestXaiWebSearch({ + query: params.query, + model: params.model, + apiKey: params.apiKey, timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify({ - model: params.model, - input: [{ role: "user", content: params.query }], - tools: [{ type: "web_search" }], - }), - }, - }, - async ({ response }) => { - if (!response.ok) { - return await throwXaiWebSearchApiError(response); - } + inlineCitations: params.inlineCitations, + }); + const payload = buildXaiWebSearchPayload({ + query: params.query, + provider: "grok", + model: params.model, + tookMs: Date.now() - startedAt, + content: result.content, + citations: result.citations, + inlineCitations: result.inlineCitations, + }); - const data = (await response.json()) as XaiWebSearchResponse; - const { text, annotationCitations } = extractXaiWebSearchContent(data); - const citations = - Array.isArray(data.citations) && data.citations.length > 0 - ? data.citations - : annotationCitations; - - return { - query: params.query, - provider: "grok", - model: params.model, - tookMs: Date.now() - startedAt, - externalContent: { - untrusted: true, - source: "web_search", - provider: "grok", - wrapped: true, - }, - content: wrapWebContent(text ?? "No response", "web_search"), - citations, - ...(params.inlineCitations && Array.isArray(data.inline_citations) - ? { inlineCitations: data.inline_citations } - : {}), - }; - }, - ); - - writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; + writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + })(); } export function createXaiWebSearchProvider(): WebSearchProviderPlugin { @@ -246,8 +112,9 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin { }; } - const query = readQuery(args); - const count = readCount(args); + const query = readStringParam(args, "query", { required: true }); + void readNumberParam(args, "count", { integer: true }); + return await runXaiWebSearch({ query, model: resolveXaiWebSearchModel(ctx.searchConfig), @@ -268,7 +135,9 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin { } export const __testing = { + buildXaiWebSearchPayload, extractXaiWebSearchContent, - resolveXaiWebSearchModel, resolveXaiInlineCitations, + resolveXaiWebSearchModel, + requestXaiWebSearch, }; diff --git a/extensions/zai/runtime-api.ts b/extensions/zai/runtime-api.ts index 27c34abce5a..f512627cde8 100644 --- a/extensions/zai/runtime-api.ts +++ b/extensions/zai/runtime-api.ts @@ -1 +1,5 @@ -export * from "openclaw/plugin-sdk/zai"; +export { + detectZaiEndpoint, + type ZaiDetectedEndpoint, + type ZaiEndpointId, +} from "openclaw/plugin-sdk/provider-zai-endpoint"; diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index 666b1c2a59d..082f65d43b8 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/zalo"; +// Private runtime barrel for the bundled Zalo extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/zalo.js"; diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index b8d11b50937..165fe5bac52 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -9,11 +9,11 @@ import { createOpenProviderGroupPolicyWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { - createChannelDirectoryAdapter, createEmptyChannelResult, createRawChannelSendResultAdapter, - createStaticReplyToModeResolver, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-send-result"; +import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts index ef062d07887..1b63edaea42 100644 --- a/extensions/zalouser/runtime-api.ts +++ b/extensions/zalouser/runtime-api.ts @@ -1 +1,4 @@ -export * from "openclaw/plugin-sdk/zalouser"; +// Private runtime barrel for the bundled Zalo Personal extension. +// Keep this barrel thin and aligned with the local extension surface. + +export * from "../../src/plugin-sdk/zalouser.js"; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 24e46323a8d..c9b6fc17a67 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,12 +1,14 @@ 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"; +} from "openclaw/plugin-sdk/channel-pairing"; +import { + createEmptyChannelResult, + createRawChannelSendResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; +import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import type { ChannelAccountSnapshot, @@ -39,7 +41,12 @@ import { probeZalouser } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; -import { resolveZalouserOutboundSessionRoute } from "./session-route.js"; +import { + normalizeZalouserTarget, + parseZalouserDirectoryGroupId, + parseZalouserOutboundTarget, + resolveZalouserOutboundSessionRoute, +} from "./session-route.js"; import { zalouserSetupAdapter } from "./setup-core.js"; import { zalouserSetupWizard } from "./setup-surface.js"; import { createZalouserPluginBase } from "./shared.js"; @@ -56,97 +63,6 @@ import { const ZALOUSER_TEXT_CHUNK_LIMIT = 2000; -function stripZalouserTargetPrefix(raw: string): string { - return raw - .trim() - .replace(/^(zalouser|zlu):/i, "") - .trim(); -} - -function normalizePrefixedTarget(raw: string): string | undefined { - const trimmed = stripZalouserTargetPrefix(raw); - if (!trimmed) { - return undefined; - } - - const lower = trimmed.toLowerCase(); - if (lower.startsWith("group:")) { - const id = trimmed.slice("group:".length).trim(); - return id ? `group:${id}` : undefined; - } - if (lower.startsWith("g:")) { - const id = trimmed.slice("g:".length).trim(); - return id ? `group:${id}` : undefined; - } - if (lower.startsWith("user:")) { - const id = trimmed.slice("user:".length).trim(); - return id ? `user:${id}` : undefined; - } - if (lower.startsWith("dm:")) { - const id = trimmed.slice("dm:".length).trim(); - return id ? `user:${id}` : undefined; - } - if (lower.startsWith("u:")) { - const id = trimmed.slice("u:".length).trim(); - return id ? `user:${id}` : undefined; - } - if (/^g-\S+$/i.test(trimmed)) { - return `group:${trimmed}`; - } - if (/^u-\S+$/i.test(trimmed)) { - return `user:${trimmed}`; - } - - return trimmed; -} - -function parseZalouserOutboundTarget(raw: string): { - threadId: string; - isGroup: boolean; -} { - const normalized = normalizePrefixedTarget(raw); - if (!normalized) { - throw new Error("Zalouser target is required"); - } - const lowered = normalized.toLowerCase(); - if (lowered.startsWith("group:")) { - const threadId = normalized.slice("group:".length).trim(); - if (!threadId) { - throw new Error("Zalouser group target is missing group id"); - } - return { threadId, isGroup: true }; - } - if (lowered.startsWith("user:")) { - const threadId = normalized.slice("user:".length).trim(); - if (!threadId) { - throw new Error("Zalouser user target is missing user id"); - } - return { threadId, isGroup: false }; - } - // Backward-compatible fallback for bare IDs. - // Group sends should use explicit `group:` targets. - return { threadId: normalized, isGroup: false }; -} - -function parseZalouserDirectoryGroupId(raw: string): string { - const normalized = normalizePrefixedTarget(raw); - if (!normalized) { - throw new Error("Zalouser group target is required"); - } - const lowered = normalized.toLowerCase(); - if (lowered.startsWith("group:")) { - const groupId = normalized.slice("group:".length).trim(); - if (!groupId) { - throw new Error("Zalouser group target is missing group id"); - } - return groupId; - } - if (lowered.startsWith("user:")) { - throw new Error("Zalouser group members lookup requires a group target (group:)"); - } - return normalized; -} - function resolveZalouserQrProfile(accountId?: string | null): string { const normalized = normalizeAccountId(accountId); if (!normalized || normalized === DEFAULT_ACCOUNT_ID) { @@ -318,11 +234,11 @@ export const zalouserPlugin: ChannelPlugin = { }, actions: zalouserMessageActions, messaging: { - normalizeTarget: (raw) => normalizePrefixedTarget(raw), + normalizeTarget: (raw) => normalizeZalouserTarget(raw), resolveOutboundSessionRoute: (params) => resolveZalouserOutboundSessionRoute(params), targetResolver: { looksLikeId: (raw) => { - const normalized = normalizePrefixedTarget(raw); + const normalized = normalizeZalouserTarget(raw); if (!normalized) { return false; } diff --git a/extensions/zalouser/src/session-route.ts b/extensions/zalouser/src/session-route.ts index c6a1761818d..1356ec434c0 100644 --- a/extensions/zalouser/src/session-route.ts +++ b/extensions/zalouser/src/session-route.ts @@ -3,14 +3,14 @@ import { type ChannelOutboundSessionRouteParams, } from "openclaw/plugin-sdk/core"; -function stripZalouserTargetPrefix(raw: string): string { +export function stripZalouserTargetPrefix(raw: string): string { return raw .trim() .replace(/^(zalouser|zlu):/i, "") .trim(); } -function normalizePrefixedTarget(raw: string): string | undefined { +export function normalizeZalouserTarget(raw: string): string | undefined { const trimmed = stripZalouserTargetPrefix(raw); if (!trimmed) { return undefined; @@ -47,8 +47,55 @@ function normalizePrefixedTarget(raw: string): string | undefined { return trimmed; } +export function parseZalouserOutboundTarget(raw: string): { + threadId: string; + isGroup: boolean; +} { + const normalized = normalizeZalouserTarget(raw); + if (!normalized) { + throw new Error("Zalouser target is required"); + } + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("group:")) { + const threadId = normalized.slice("group:".length).trim(); + if (!threadId) { + throw new Error("Zalouser group target is missing group id"); + } + return { threadId, isGroup: true }; + } + if (lowered.startsWith("user:")) { + const threadId = normalized.slice("user:".length).trim(); + if (!threadId) { + throw new Error("Zalouser user target is missing user id"); + } + return { threadId, isGroup: false }; + } + // Backward-compatible fallback for bare IDs. + // Group sends should use explicit `group:` targets. + return { threadId: normalized, isGroup: false }; +} + +export function parseZalouserDirectoryGroupId(raw: string): string { + const normalized = normalizeZalouserTarget(raw); + if (!normalized) { + throw new Error("Zalouser group target is required"); + } + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("group:")) { + const groupId = normalized.slice("group:".length).trim(); + if (!groupId) { + throw new Error("Zalouser group target is missing group id"); + } + return groupId; + } + if (lowered.startsWith("user:")) { + throw new Error("Zalouser group members lookup requires a group target (group:)"); + } + return normalized; +} + export function resolveZalouserOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { - const normalized = normalizePrefixedTarget(params.target); + const normalized = normalizeZalouserTarget(params.target); if (!normalized) { return null; } diff --git a/package.json b/package.json index ed8cc402625..d0ace1f4e9c 100644 --- a/package.json +++ b/package.json @@ -169,114 +169,14 @@ "types": "./dist/plugin-sdk/process-runtime.d.ts", "default": "./dist/plugin-sdk/process-runtime.js" }, + "./plugin-sdk/windows-spawn": { + "types": "./dist/plugin-sdk/windows-spawn.d.ts", + "default": "./dist/plugin-sdk/windows-spawn.js" + }, "./plugin-sdk/acp-runtime": { "types": "./dist/plugin-sdk/acp-runtime.d.ts", "default": "./dist/plugin-sdk/acp-runtime.js" }, - "./plugin-sdk/acpx": { - "types": "./dist/plugin-sdk/acpx.d.ts", - "default": "./dist/plugin-sdk/acpx.js" - }, - "./plugin-sdk/telegram": { - "types": "./dist/plugin-sdk/telegram.d.ts", - "default": "./dist/plugin-sdk/telegram.js" - }, - "./plugin-sdk/telegram-core": { - "types": "./dist/plugin-sdk/telegram-core.d.ts", - "default": "./dist/plugin-sdk/telegram-core.js" - }, - "./plugin-sdk/discord": { - "types": "./dist/plugin-sdk/discord.d.ts", - "default": "./dist/plugin-sdk/discord.js" - }, - "./plugin-sdk/discord-core": { - "types": "./dist/plugin-sdk/discord-core.d.ts", - "default": "./dist/plugin-sdk/discord-core.js" - }, - "./plugin-sdk/feishu": { - "types": "./dist/plugin-sdk/feishu.d.ts", - "default": "./dist/plugin-sdk/feishu.js" - }, - "./plugin-sdk/google": { - "types": "./dist/plugin-sdk/google.d.ts", - "default": "./dist/plugin-sdk/google.js" - }, - "./plugin-sdk/googlechat": { - "types": "./dist/plugin-sdk/googlechat.d.ts", - "default": "./dist/plugin-sdk/googlechat.js" - }, - "./plugin-sdk/irc": { - "types": "./dist/plugin-sdk/irc.d.ts", - "default": "./dist/plugin-sdk/irc.js" - }, - "./plugin-sdk/line-core": { - "types": "./dist/plugin-sdk/line-core.d.ts", - "default": "./dist/plugin-sdk/line-core.js" - }, - "./plugin-sdk/lobster": { - "types": "./dist/plugin-sdk/lobster.d.ts", - "default": "./dist/plugin-sdk/lobster.js" - }, - "./plugin-sdk/matrix": { - "types": "./dist/plugin-sdk/matrix.d.ts", - "default": "./dist/plugin-sdk/matrix.js" - }, - "./plugin-sdk/mattermost": { - "types": "./dist/plugin-sdk/mattermost.d.ts", - "default": "./dist/plugin-sdk/mattermost.js" - }, - "./plugin-sdk/msteams": { - "types": "./dist/plugin-sdk/msteams.d.ts", - "default": "./dist/plugin-sdk/msteams.js" - }, - "./plugin-sdk/nextcloud-talk": { - "types": "./dist/plugin-sdk/nextcloud-talk.d.ts", - "default": "./dist/plugin-sdk/nextcloud-talk.js" - }, - "./plugin-sdk/slack": { - "types": "./dist/plugin-sdk/slack.d.ts", - "default": "./dist/plugin-sdk/slack.js" - }, - "./plugin-sdk/slack-core": { - "types": "./dist/plugin-sdk/slack-core.d.ts", - "default": "./dist/plugin-sdk/slack-core.js" - }, - "./plugin-sdk/imessage": { - "types": "./dist/plugin-sdk/imessage.d.ts", - "default": "./dist/plugin-sdk/imessage.js" - }, - "./plugin-sdk/imessage-core": { - "types": "./dist/plugin-sdk/imessage-core.d.ts", - "default": "./dist/plugin-sdk/imessage-core.js" - }, - "./plugin-sdk/signal": { - "types": "./dist/plugin-sdk/signal.d.ts", - "default": "./dist/plugin-sdk/signal.js" - }, - "./plugin-sdk/whatsapp": { - "types": "./dist/plugin-sdk/whatsapp.d.ts", - "default": "./dist/plugin-sdk/whatsapp.js" - }, - "./plugin-sdk/whatsapp-shared": { - "types": "./dist/plugin-sdk/whatsapp-shared.d.ts", - "default": "./dist/plugin-sdk/whatsapp-shared.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" - }, - "./plugin-sdk/bluebubbles": { - "types": "./dist/plugin-sdk/bluebubbles.d.ts", - "default": "./dist/plugin-sdk/bluebubbles.js" - }, "./plugin-sdk/lazy-runtime": { "types": "./dist/plugin-sdk/lazy-runtime.d.ts", "default": "./dist/plugin-sdk/lazy-runtime.js" @@ -301,10 +201,6 @@ "types": "./dist/plugin-sdk/allow-from.d.ts", "default": "./dist/plugin-sdk/allow-from.js" }, - "./plugin-sdk/allowlist-resolution": { - "types": "./dist/plugin-sdk/allowlist-resolution.d.ts", - "default": "./dist/plugin-sdk/allowlist-resolution.js" - }, "./plugin-sdk/allowlist-config-edit": { "types": "./dist/plugin-sdk/allowlist-config-edit.d.ts", "default": "./dist/plugin-sdk/allowlist-config-edit.js" @@ -313,9 +209,13 @@ "types": "./dist/plugin-sdk/boolean-param.d.ts", "default": "./dist/plugin-sdk/boolean-param.js" }, - "./plugin-sdk/device-pair": { - "types": "./dist/plugin-sdk/device-pair.d.ts", - "default": "./dist/plugin-sdk/device-pair.js" + "./plugin-sdk/command-auth": { + "types": "./dist/plugin-sdk/command-auth.d.ts", + "default": "./dist/plugin-sdk/command-auth.js" + }, + "./plugin-sdk/device-bootstrap": { + "types": "./dist/plugin-sdk/device-bootstrap.d.ts", + "default": "./dist/plugin-sdk/device-bootstrap.js" }, "./plugin-sdk/diagnostics-otel": { "types": "./dist/plugin-sdk/diagnostics-otel.d.ts", @@ -337,6 +237,22 @@ "types": "./dist/plugin-sdk/channel-config-schema.d.ts", "default": "./dist/plugin-sdk/channel-config-schema.js" }, + "./plugin-sdk/channel-actions": { + "types": "./dist/plugin-sdk/channel-actions.d.ts", + "default": "./dist/plugin-sdk/channel-actions.js" + }, + "./plugin-sdk/channel-contract": { + "types": "./dist/plugin-sdk/channel-contract.d.ts", + "default": "./dist/plugin-sdk/channel-contract.js" + }, + "./plugin-sdk/channel-feedback": { + "types": "./dist/plugin-sdk/channel-feedback.d.ts", + "default": "./dist/plugin-sdk/channel-feedback.js" + }, + "./plugin-sdk/channel-inbound": { + "types": "./dist/plugin-sdk/channel-inbound.d.ts", + "default": "./dist/plugin-sdk/channel-inbound.js" + }, "./plugin-sdk/channel-lifecycle": { "types": "./dist/plugin-sdk/channel-lifecycle.d.ts", "default": "./dist/plugin-sdk/channel-lifecycle.js" @@ -353,6 +269,10 @@ "types": "./dist/plugin-sdk/channel-send-result.d.ts", "default": "./dist/plugin-sdk/channel-send-result.js" }, + "./plugin-sdk/channel-targets": { + "types": "./dist/plugin-sdk/channel-targets.d.ts", + "default": "./dist/plugin-sdk/channel-targets.js" + }, "./plugin-sdk/group-access": { "types": "./dist/plugin-sdk/group-access.d.ts", "default": "./dist/plugin-sdk/group-access.js" @@ -369,10 +289,6 @@ "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" }, - "./plugin-sdk/line": { - "types": "./dist/plugin-sdk/line.d.ts", - "default": "./dist/plugin-sdk/line.js" - }, "./plugin-sdk/llm-task": { "types": "./dist/plugin-sdk/llm-task.d.ts", "default": "./dist/plugin-sdk/llm-task.js" @@ -381,10 +297,6 @@ "types": "./dist/plugin-sdk/memory-lancedb.d.ts", "default": "./dist/plugin-sdk/memory-lancedb.js" }, - "./plugin-sdk/minimax-portal-auth": { - "types": "./dist/plugin-sdk/minimax-portal-auth.d.ts", - "default": "./dist/plugin-sdk/minimax-portal-auth.js" - }, "./plugin-sdk/provider-auth": { "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" @@ -405,6 +317,14 @@ "types": "./dist/plugin-sdk/provider-catalog.d.ts", "default": "./dist/plugin-sdk/provider-catalog.js" }, + "./plugin-sdk/provider-env-vars": { + "types": "./dist/plugin-sdk/provider-env-vars.d.ts", + "default": "./dist/plugin-sdk/provider-env-vars.js" + }, + "./plugin-sdk/provider-google": { + "types": "./dist/plugin-sdk/provider-google.d.ts", + "default": "./dist/plugin-sdk/provider-google.js" + }, "./plugin-sdk/provider-models": { "types": "./dist/plugin-sdk/provider-models.d.ts", "default": "./dist/plugin-sdk/provider-models.js" @@ -425,14 +345,14 @@ "types": "./dist/plugin-sdk/provider-web-search.d.ts", "default": "./dist/plugin-sdk/provider-web-search.js" }, + "./plugin-sdk/provider-zai-endpoint": { + "types": "./dist/plugin-sdk/provider-zai-endpoint.d.ts", + "default": "./dist/plugin-sdk/provider-zai-endpoint.js" + }, "./plugin-sdk/image-generation": { "types": "./dist/plugin-sdk/image-generation.d.ts", "default": "./dist/plugin-sdk/image-generation.js" }, - "./plugin-sdk/nostr": { - "types": "./dist/plugin-sdk/nostr.d.ts", - "default": "./dist/plugin-sdk/nostr.js" - }, "./plugin-sdk/reply-history": { "types": "./dist/plugin-sdk/reply-history.d.ts", "default": "./dist/plugin-sdk/reply-history.js" @@ -441,22 +361,10 @@ "types": "./dist/plugin-sdk/media-understanding.d.ts", "default": "./dist/plugin-sdk/media-understanding.js" }, - "./plugin-sdk/secret-input-runtime": { - "types": "./dist/plugin-sdk/secret-input-runtime.d.ts", - "default": "./dist/plugin-sdk/secret-input-runtime.js" - }, - "./plugin-sdk/secret-input-schema": { - "types": "./dist/plugin-sdk/secret-input-schema.d.ts", - "default": "./dist/plugin-sdk/secret-input-schema.js" - }, "./plugin-sdk/request-url": { "types": "./dist/plugin-sdk/request-url.d.ts", "default": "./dist/plugin-sdk/request-url.js" }, - "./plugin-sdk/qwen-portal-auth": { - "types": "./dist/plugin-sdk/qwen-portal-auth.d.ts", - "default": "./dist/plugin-sdk/qwen-portal-auth.js" - }, "./plugin-sdk/webhook-ingress": { "types": "./dist/plugin-sdk/webhook-ingress.d.ts", "default": "./dist/plugin-sdk/webhook-ingress.js" @@ -469,42 +377,22 @@ "types": "./dist/plugin-sdk/runtime-store.d.ts", "default": "./dist/plugin-sdk/runtime-store.js" }, + "./plugin-sdk/status-helpers": { + "types": "./dist/plugin-sdk/status-helpers.d.ts", + "default": "./dist/plugin-sdk/status-helpers.js" + }, "./plugin-sdk/secret-input": { "types": "./dist/plugin-sdk/secret-input.d.ts", "default": "./dist/plugin-sdk/secret-input.js" }, - "./plugin-sdk/signal-core": { - "types": "./dist/plugin-sdk/signal-core.d.ts", - "default": "./dist/plugin-sdk/signal-core.js" - }, - "./plugin-sdk/synology-chat": { - "types": "./dist/plugin-sdk/synology-chat.d.ts", - "default": "./dist/plugin-sdk/synology-chat.js" - }, "./plugin-sdk/thread-ownership": { "types": "./dist/plugin-sdk/thread-ownership.d.ts", "default": "./dist/plugin-sdk/thread-ownership.js" }, - "./plugin-sdk/tlon": { - "types": "./dist/plugin-sdk/tlon.d.ts", - "default": "./dist/plugin-sdk/tlon.js" - }, - "./plugin-sdk/twitch": { - "types": "./dist/plugin-sdk/twitch.d.ts", - "default": "./dist/plugin-sdk/twitch.js" - }, - "./plugin-sdk/voice-call": { - "types": "./dist/plugin-sdk/voice-call.d.ts", - "default": "./dist/plugin-sdk/voice-call.js" - }, "./plugin-sdk/web-media": { "types": "./dist/plugin-sdk/web-media.d.ts", "default": "./dist/plugin-sdk/web-media.js" }, - "./plugin-sdk/zai": { - "types": "./dist/plugin-sdk/zai.d.ts", - "default": "./dist/plugin-sdk/zai.js" - }, "./plugin-sdk/zalo": { "types": "./dist/plugin-sdk/zalo.d.ts", "default": "./dist/plugin-sdk/zalo.js" @@ -525,6 +413,7 @@ "types": "./dist/plugin-sdk/tool-send.d.ts", "default": "./dist/plugin-sdk/tool-send.js" }, + "./extension-api": "./dist/extensionAPI.js", "./cli-entry": "./openclaw.mjs" }, "scripts": { diff --git a/scripts/check-plugin-extension-import-boundary.mjs b/scripts/check-plugin-extension-import-boundary.mjs index 13c4fa596a3..ac9c5e178a4 100644 --- a/scripts/check-plugin-extension-import-boundary.mjs +++ b/scripts/check-plugin-extension-import-boundary.mjs @@ -194,7 +194,11 @@ function scanWebSearchRegistrySmells(sourceFile, filePath) { function shouldSkipFile(filePath) { const relativeFile = normalizePath(filePath); - return relativeFile.startsWith("src/plugins/contracts/"); + return ( + relativeFile === "src/plugins/bundled-web-search-registry.ts" || + relativeFile.startsWith("src/plugins/contracts/") || + /^src\/plugins\/runtime\/runtime-[^/]+-contract\.[cm]?[jt]s$/u.test(relativeFile) + ); } export async function collectPluginExtensionImportBoundaryInventory() { diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 60c89056ca0..90d784235f5 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -42,7 +42,7 @@ const exportedNames = exportMatch[1] const exportSet = new Set(exportedNames); -const requiredRuntimeShimEntries = ["root-alias.cjs"]; +const requiredRuntimeShimEntries = ["compat.js", "root-alias.cjs"]; // Critical functions that channel extension plugins import from openclaw/plugin-sdk. // If any of these are missing, plugins will fail at runtime with: @@ -65,6 +65,7 @@ const requiredExports = [ "resolveChannelMediaMaxBytes", "warnMissingProviderGroupPolicyFallbackOnce", "emptyPluginConfigSchema", + "onDiagnosticEvent", "normalizePluginHttpPath", "registerPluginHttpRoute", "DEFAULT_ACCOUNT_ID", diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index f9c20590e4b..914abc25627 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -32,90 +32,62 @@ "cli-runtime", "hook-runtime", "process-runtime", + "windows-spawn", "acp-runtime", - "acpx", - "telegram", - "telegram-core", - "discord", - "discord-core", - "feishu", - "google", - "googlechat", - "irc", - "line-core", - "lobster", - "matrix", - "mattermost", - "msteams", - "nextcloud-talk", - "slack", - "slack-core", - "imessage", - "imessage-core", - "signal", - "whatsapp", - "whatsapp-shared", - "whatsapp-action-runtime", - "whatsapp-login-qr", - "whatsapp-core", - "bluebubbles", "lazy-runtime", "testing", "account-helpers", "account-id", "account-resolution", "allow-from", - "allowlist-resolution", "allowlist-config-edit", "boolean-param", - "device-pair", + "command-auth", + "device-bootstrap", "diagnostics-otel", "diffs", "extension-shared", "channel-config-helpers", "channel-config-schema", + "channel-actions", + "channel-contract", + "channel-feedback", + "channel-inbound", "channel-lifecycle", "channel-pairing", "channel-policy", "channel-send-result", + "channel-targets", "group-access", "directory-runtime", "json-store", "keyed-async-queue", - "line", "llm-task", "memory-lancedb", - "minimax-portal-auth", "provider-auth", "provider-auth-api-key", "provider-auth-login", "plugin-entry", "provider-catalog", + "provider-env-vars", + "provider-google", "provider-models", "provider-onboard", "provider-stream", "provider-usage", "provider-web-search", + "provider-zai-endpoint", "image-generation", - "nostr", "reply-history", "media-understanding", - "secret-input-runtime", - "secret-input-schema", "request-url", - "qwen-portal-auth", "webhook-ingress", "webhook-path", "runtime-store", + "status-helpers", "secret-input", - "signal-core", - "synology-chat", "thread-ownership", - "tlon", - "twitch", - "voice-call", "web-media", - "zai", "zalo", "zalouser", "speech", diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 72d729cc1cd..f7f36373a49 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -21,6 +21,7 @@ const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], ["dist/entry.js", "dist/entry.mjs"], ...listPluginSdkDistArtifacts(), + "dist/plugin-sdk/compat.js", "dist/plugin-sdk/root-alias.cjs", "dist/build-info.json", ]; @@ -228,6 +229,7 @@ const requiredPluginSdkExports = [ "resolveChannelMediaMaxBytes", "warnMissingProviderGroupPolicyFallbackOnce", "emptyPluginConfigSchema", + "onDiagnosticEvent", "normalizePluginHttpPath", "registerPluginHttpRoute", "DEFAULT_ACCOUNT_ID", diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 011211a307b..f3c03970080 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -15,6 +15,7 @@ import { resolveTestRunExitCode, } from "./test-parallel-utils.mjs"; import { + dedupeFilesPreserveOrder, loadUnitMemoryHotspotManifest, loadTestRunnerBehavior, loadUnitTimingManifest, @@ -81,18 +82,18 @@ const testProfile = ? rawTestProfile : "normal"; const isMacMiniProfile = testProfile === "macmini"; -// vmForks is a big win for transform/import heavy suites. Node 24 is stable again -// for the default unit-fast lane after moving the known flaky files to fork-only -// isolation, but Node 25+ still falls back to process forks until re-validated. -// Keep it opt-out via OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1. +// Vitest executes Node tests through Vite's SSR/module-runner pipeline, so the +// shared unit lane still retains transformed ESM/module state even when the +// tests themselves are not "server rendering" a website. vmForks can win in +// ideal transform-heavy cases, but for this repo we measured higher aggregate +// CPU load and fatal heap OOMs on memory-constrained dev machines and CI when +// unit-fast stayed on vmForks. Keep forks as the default unless that evidence +// is re-run and replaced: +// PR: https://github.com/openclaw/openclaw/pull/51145 +// OOM evidence: https://github.com/openclaw/openclaw/pull/51145#issuecomment-4099663958 +// Preserve OPENCLAW_TEST_VM_FORKS=1 as the explicit override/debug escape hatch. const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor <= 24 : true; -const useVmForks = - process.env.OPENCLAW_TEST_VM_FORKS === "1" || - (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && - !isWindows && - supportsVmForks && - !lowMemLocalHost && - (isCI || testProfile !== "low")); +const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" && supportsVmForks; const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1"; const includeGatewaySuite = process.env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1"; const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1"; @@ -296,7 +297,7 @@ const defaultHeavyUnitFileLimit = : isMacMiniProfile ? 90 : testProfile === "low" - ? 20 + ? 32 : highMemLocalHost ? 80 : 60; @@ -306,7 +307,7 @@ const defaultHeavyUnitLaneCount = : isMacMiniProfile ? 6 : testProfile === "low" - ? 2 + ? 3 : highMemLocalHost ? 5 : 4; @@ -345,15 +346,46 @@ const { memoryHeavyFiles: memoryHeavyUnitFiles, timedHeavyFiles: timedHeavyUnitF memoryHeavyFiles: [], timedHeavyFiles: [], }; +const unitSingletonBatchFiles = dedupeFilesPreserveOrder( + unitSingletonIsolatedFiles, + new Set(unitBehaviorIsolatedFiles), +); +const unitMemorySingletonFiles = dedupeFilesPreserveOrder( + memoryHeavyUnitFiles, + new Set([...unitBehaviorOverrideSet, ...unitSingletonBatchFiles]), +); const unitSchedulingOverrideSet = new Set([...unitBehaviorOverrideSet, ...memoryHeavyUnitFiles]); const unitFastExcludedFiles = [ ...new Set([...unitSchedulingOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]), ]; -const unitAutoSingletonFiles = [ - ...new Set([...unitSingletonIsolatedFiles, ...memoryHeavyUnitFiles]), -]; +const defaultSingletonBatchLaneCount = + testProfile === "serial" + ? 0 + : unitSingletonBatchFiles.length === 0 + ? 0 + : isCI + ? Math.ceil(unitSingletonBatchFiles.length / 6) + : highMemLocalHost + ? Math.ceil(unitSingletonBatchFiles.length / 8) + : lowMemLocalHost + ? Math.ceil(unitSingletonBatchFiles.length / 12) + : Math.ceil(unitSingletonBatchFiles.length / 10); +const singletonBatchLaneCount = + unitSingletonBatchFiles.length === 0 + ? 0 + : Math.min( + unitSingletonBatchFiles.length, + Math.max( + 1, + parseEnvNumber("OPENCLAW_TEST_SINGLETON_ISOLATED_LANES", defaultSingletonBatchLaneCount), + ), + ); const estimateUnitDurationMs = (file) => unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs; +const unitSingletonBuckets = + singletonBatchLaneCount > 0 + ? packFilesByDuration(unitSingletonBatchFiles, singletonBatchLaneCount, estimateUnitDurationMs) + : []; const unitFastExcludedFileSet = new Set(unitFastExcludedFiles); const unitFastCandidateFiles = allKnownUnitFiles.filter( (file) => !unitFastExcludedFileSet.has(file), @@ -400,6 +432,11 @@ const unitHeavyEntries = heavyUnitBuckets.map((files, index) => ({ name: `unit-heavy-${String(index + 1)}`, args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files], })); +const unitSingletonEntries = unitSingletonBuckets.map((files, index) => ({ + name: + unitSingletonBuckets.length === 1 ? "unit-singleton" : `unit-singleton-${String(index + 1)}`, + args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files], +})); const baseRuns = [ ...(shouldSplitUnitRuns ? [ @@ -420,7 +457,8 @@ const baseRuns = [ ] : []), ...unitHeavyEntries, - ...unitAutoSingletonFiles.map((file) => ({ + ...unitSingletonEntries, + ...unitMemorySingletonFiles.map((file) => ({ name: `${path.basename(file, ".test.ts")}-isolated`, args: [ "vitest", @@ -756,6 +794,9 @@ const maxWorkersForRun = (name) => { if (resolvedOverride) { return resolvedOverride; } + if (name === "unit-singleton" || name.startsWith("unit-singleton-")) { + return 1; + } if (isCI && !isMacOS) { return null; } diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs index 4e0ff9d0a5a..ee5644f3328 100644 --- a/scripts/test-runner-manifest.mjs +++ b/scripts/test-runner-manifest.mjs @@ -231,3 +231,18 @@ export function packFilesByDuration(files, bucketCount, estimateDurationMs) { return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0); } + +export function dedupeFilesPreserveOrder(files, exclude = new Set()) { + const result = []; + const seen = new Set(); + + for (const file of files) { + if (exclude.has(file) || seen.has(file)) { + continue; + } + seen.add(file); + result.push(file); + } + + return result; +} diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index b9fc0c9e9b3..2be5eabe372 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -1,11 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; -import { feishuPlugin } from "../../extensions/feishu/src/channel.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js"; +import { parseTelegramTopicConversation } from "../../extensions/telegram/runtime-api.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import * as persistentBindingsResolveModule from "./persistent-bindings.resolve.js"; import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js"; const managerMocks = vi.hoisted(() => ({ @@ -39,6 +39,10 @@ type PersistentBindingsModule = Pick< "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" >; let persistentBindings: PersistentBindingsModule; +let lifecycleBindingsModule: Pick< + typeof import("./persistent-bindings.lifecycle.js"), + "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" +>; type ConfiguredBinding = NonNullable[number]; type BindingRecordInput = Parameters< @@ -58,6 +62,131 @@ const baseCfg = { const defaultDiscordConversationId = "1478836151241412759"; const defaultDiscordAccountId = "default"; +const discordBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const normalized = conversationId.trim(); + return normalized ? { conversationId: normalized } : null; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + if (compiledBinding.conversationId === conversationId) { + return { conversationId, matchPriority: 2 }; + } + if ( + parentConversationId && + parentConversationId !== conversationId && + compiledBinding.conversationId === parentConversationId + ) { + return { conversationId: parentConversationId, matchPriority: 1 }; + } + return null; + }, +}; + +const telegramBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const parsed = parseTelegramTopicConversation({ conversationId }); + if (!parsed || !parsed.chatId.startsWith("-")) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: parsed.chatId, + }; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + const incoming = parseTelegramTopicConversation({ + conversationId, + parentConversationId, + }); + if (!incoming || !incoming.chatId.startsWith("-")) { + return null; + } + if (compiledBinding.conversationId !== incoming.canonicalConversationId) { + return null; + } + return { + conversationId: incoming.canonicalConversationId, + parentConversationId: incoming.chatId, + matchPriority: 2, + }; + }, +}; + +function isSupportedFeishuDirectConversationId(conversationId: string): boolean { + const trimmed = conversationId.trim(); + if (!trimmed || trimmed.includes(":")) { + return false; + } + if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) { + return false; + } + return true; +} + +const feishuBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const parsed = parseFeishuConversationId({ conversationId }); + if ( + !parsed || + (parsed.scope !== "group_topic" && + parsed.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId)) + ) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: + parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" + ? parsed.chatId + : undefined, + }; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + const incoming = parseFeishuConversationId({ + conversationId, + parentConversationId, + }); + if ( + !incoming || + (incoming.scope !== "group_topic" && + incoming.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(incoming.canonicalConversationId)) + ) { + return null; + } + const matchesCanonicalConversation = + compiledBinding.conversationId === incoming.canonicalConversationId; + const matchesParentTopicForSenderScopedConversation = + incoming.scope === "group_topic_sender" && + compiledBinding.parentConversationId === incoming.chatId && + compiledBinding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`; + if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { + return null; + } + return { + conversationId: matchesParentTopicForSenderScopedConversation + ? compiledBinding.conversationId + : incoming.canonicalConversationId, + parentConversationId: + incoming.scope === "group_topic" || incoming.scope === "group_topic_sender" + ? incoming.chatId + : undefined, + matchPriority: matchesCanonicalConversation ? 2 : 1, + }; + }, +}; + +function createConfiguredBindingTestPlugin( + id: ChannelPlugin["id"], + bindings: ChannelConfiguredBindingProvider, +): Pick { + return { + ...createChannelTestPluginBase({ id }), + bindings, + }; +} + function createCfgWithBindings( bindings: ConfiguredBinding[], overrides?: Partial, @@ -185,20 +314,26 @@ beforeEach(() => { persistentBindingsResolveModule.resolveConfiguredAcpBindingRecord, resolveConfiguredAcpBindingSpecBySessionKey: persistentBindingsResolveModule.resolveConfiguredAcpBindingSpecBySessionKey, - ensureConfiguredAcpBindingSession: async (...args) => { - const lifecycleModule = await import("./persistent-bindings.lifecycle.js"); - return await lifecycleModule.ensureConfiguredAcpBindingSession(...args); - }, - resetAcpSessionInPlace: async (...args) => { - const lifecycleModule = await import("./persistent-bindings.lifecycle.js"); - return await lifecycleModule.resetAcpSessionInPlace(...args); - }, + ensureConfiguredAcpBindingSession: lifecycleBindingsModule.ensureConfiguredAcpBindingSession, + resetAcpSessionInPlace: lifecycleBindingsModule.resetAcpSessionInPlace, }; setActivePluginRegistry( createTestRegistry([ - { pluginId: "discord", plugin: discordPlugin, source: "test" }, - { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, - { pluginId: "feishu", plugin: feishuPlugin, source: "test" }, + { + pluginId: "discord", + plugin: createConfiguredBindingTestPlugin("discord", discordBindings), + source: "test", + }, + { + pluginId: "telegram", + plugin: createConfiguredBindingTestPlugin("telegram", telegramBindings), + source: "test", + }, + { + pluginId: "feishu", + plugin: createConfiguredBindingTestPlugin("feishu", feishuBindings), + source: "test", + }, ]), ); managerMocks.resolveSession.mockReset(); @@ -211,6 +346,10 @@ beforeEach(() => { sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined); }); +beforeAll(async () => { + lifecycleBindingsModule = await import("./persistent-bindings.lifecycle.js"); +}); + describe("resolveConfiguredAcpBindingRecord", () => { it("resolves discord channel ACP binding from top-level typed bindings", () => { const cfg = createCfgWithBindings([ diff --git a/src/agents/model-id-normalization.test.ts b/src/agents/model-id-normalization.test.ts new file mode 100644 index 00000000000..7ae0d1b736b --- /dev/null +++ b/src/agents/model-id-normalization.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { normalizeXaiModelId } from "./model-id-normalization.js"; + +describe("normalizeXaiModelId", () => { + it("maps deprecated grok 4.20 beta ids to GA ids", () => { + expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-reasoning")).toBe( + "grok-4.20-reasoning", + ); + expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-non-reasoning")).toBe( + "grok-4.20-non-reasoning", + ); + }); + + it("leaves current xai model ids unchanged", () => { + expect(normalizeXaiModelId("grok-4.20-reasoning")).toBe("grok-4.20-reasoning"); + expect(normalizeXaiModelId("grok-4")).toBe("grok-4"); + }); +}); diff --git a/src/agents/model-id-normalization.ts b/src/agents/model-id-normalization.ts index 9b0b27a7f01..8131c5a1d29 100644 --- a/src/agents/model-id-normalization.ts +++ b/src/agents/model-id-normalization.ts @@ -21,3 +21,13 @@ export function normalizeGoogleModelId(id: string): string { } return id; } + +export function normalizeXaiModelId(id: string): string { + if (id === "grok-4.20-experimental-beta-0304-reasoning") { + return "grok-4.20-reasoning"; + } + if (id === "grok-4.20-experimental-beta-0304-non-reasoning") { + return "grok-4.20-non-reasoning"; + } + return id; +} diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index e7d583d106f..5d81afc4970 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -194,6 +194,15 @@ describe("model-selection", () => { defaultProvider: "google", expected: { provider: "google", model: "gemini-3.1-flash-lite-preview" }, }, + { + name: "normalizes deprecated xai grok 4.20 beta ids", + variants: [ + "xai/grok-4.20-experimental-beta-0304-reasoning", + "grok-4.20-experimental-beta-0304-reasoning", + ], + defaultProvider: "xai", + expected: { provider: "xai", model: "grok-4.20-reasoning" }, + }, { name: "keeps OpenAI codex refs on the openai provider", variants: ["openai/gpt-5.3-codex", "gpt-5.3-codex"], diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index acc29a32bf9..7e654dd24f3 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -14,7 +14,7 @@ import { } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; -import { normalizeGoogleModelId } from "./model-id-normalization.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; import { findNormalizedProviderKey, @@ -121,6 +121,9 @@ function normalizeProviderModelId(provider: string, model: string): string { if (provider === "google" || provider === "google-vertex") { return normalizeGoogleModelId(model); } + if (provider === "xai") { + return normalizeXaiModelId(model); + } // OpenRouter-native models (e.g. "openrouter/aurora-alpha") need the full // "openrouter/" as the model ID sent to the API. Models from external // providers already contain a slash (e.g. "anthropic/claude-sonnet-4-5") and diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index af9c3d6e34a..57f10206984 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -9,7 +9,7 @@ import { isRecord } from "../utils.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; -import { normalizeGoogleModelId } from "./model-id-normalization.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; import { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js"; export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js"; @@ -42,7 +42,7 @@ import { } from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; export { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; -export { normalizeGoogleModelId }; +export { normalizeGoogleModelId, normalizeXaiModelId }; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 8fc8ac1fddc..35fc741db58 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -4,7 +4,9 @@ import { BILLING_ERROR_USER_MESSAGE, formatBillingErrorMessage, formatAssistantErrorText, + getApiErrorPayloadFingerprint, formatRawAssistantErrorForUi, + isRawApiErrorPayload, } from "./pi-embedded-helpers.js"; import { makeAssistantMessageFixture } from "./test-helpers/assistant-message-fixtures.js"; @@ -159,3 +161,14 @@ describe("formatRawAssistantErrorForUi", () => { ); }); }); + +describe("raw API error payload helpers", () => { + it("recognizes provider-prefixed JSON payloads for observation fingerprints", () => { + const raw = + 'Ollama API error: {"type":"error","error":{"type":"server_error","message":"Boom"},"request_id":"req_123"}'; + + expect(isRawApiErrorPayload(raw)).toBe(true); + expect(getApiErrorPayloadFingerprint(raw)).toContain("server_error"); + expect(getApiErrorPayloadFingerprint(raw)).toContain("req_123"); + }); +}); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 2fec27a45e2..7719ecb41a0 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -5,6 +5,7 @@ import { extractLeadingHttpStatus, formatRawAssistantErrorForUi, isCloudflareOrHtmlErrorPage, + parseApiErrorPayload, } from "../../shared/assistant-error-format.js"; export { extractLeadingHttpStatus, @@ -223,9 +224,6 @@ export function extractObservedOverflowTokenCount(errorMessage?: string): number return undefined; } -// Allow provider-wrapped API payloads such as "Ollama API error 400: {...}". -const ERROR_PAYLOAD_PREFIX_RE = - /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i; const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi; const ERROR_PREFIX_RE = /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i; @@ -482,63 +480,6 @@ function shouldRewriteContextOverflowText(raw: string): boolean { ); } -type ErrorPayload = Record; - -function isErrorPayloadObject(payload: unknown): payload is ErrorPayload { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return false; - } - const record = payload as ErrorPayload; - if (record.type === "error") { - return true; - } - if (typeof record.request_id === "string" || typeof record.requestId === "string") { - return true; - } - if ("error" in record) { - const err = record.error; - if (err && typeof err === "object" && !Array.isArray(err)) { - const errRecord = err as ErrorPayload; - if ( - typeof errRecord.message === "string" || - typeof errRecord.type === "string" || - typeof errRecord.code === "string" - ) { - return true; - } - } - } - return false; -} - -function parseApiErrorPayload(raw: string): ErrorPayload | null { - if (!raw) { - return null; - } - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - const candidates = [trimmed]; - if (ERROR_PAYLOAD_PREFIX_RE.test(trimmed)) { - candidates.push(trimmed.replace(ERROR_PAYLOAD_PREFIX_RE, "").trim()); - } - for (const candidate of candidates) { - if (!candidate.startsWith("{") || !candidate.endsWith("}")) { - continue; - } - try { - const parsed = JSON.parse(candidate) as unknown; - if (isErrorPayloadObject(parsed)) { - return parsed; - } - } catch { - // ignore parse errors - } - } - return null; -} - export function getApiErrorPayloadFingerprint(raw?: string): string | null { if (!raw) { return null; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 6c753e9d723..d76a01ed5af 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -10,7 +10,7 @@ import { import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../extensions/telegram/api.js"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts index fa2bb58fbbc..082442045d3 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts @@ -39,6 +39,7 @@ const hoisted = vi.hoisted(() => { contextFiles: [], })); const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined); + const initializeGlobalHookRunnerMock = vi.fn(); const sessionManager = { getLeafEntry: vi.fn(() => null), branch: vi.fn(), @@ -55,6 +56,7 @@ const hoisted = vi.hoisted(() => { acquireSessionWriteLockMock, resolveBootstrapContextForRunMock, getGlobalHookRunnerMock, + initializeGlobalHookRunnerMock, sessionManager, }; }); @@ -94,6 +96,7 @@ vi.mock("../../pi-embedded-subscribe.js", () => ({ vi.mock("../../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: hoisted.getGlobalHookRunnerMock, + initializeGlobalHookRunner: hoisted.initializeGlobalHookRunnerMock, })); vi.mock("../../../infra/machine-name.js", () => ({ @@ -216,6 +219,16 @@ vi.mock("../../cache-trace.js", () => ({ createCacheTrace: () => undefined, })); +vi.mock("../../pi-tools.js", () => ({ + createOpenClawCodingTools: () => [], + resolveToolLoopDetectionConfig: () => undefined, +})); + +vi.mock("../../../image-generation/runtime.js", () => ({ + generateImage: vi.fn(), + listRuntimeImageGenerationProviders: () => [], +})); + vi.mock("../../model-selection.js", async (importOriginal) => { const actual = await importOriginal(); @@ -346,10 +359,12 @@ function createDefaultEmbeddedSession(params?: { function createContextEngineBootstrapAndAssemble() { return { bootstrap: vi.fn(async (_params: { sessionKey?: string }) => ({ bootstrapped: true })), - assemble: vi.fn(async ({ messages }: { messages: AgentMessage[]; sessionKey?: string }) => ({ - messages, - estimatedTokens: 1, - })), + assemble: vi.fn( + async ({ messages }: { messages: AgentMessage[]; sessionKey?: string; model?: string }) => ({ + messages, + estimatedTokens: 1, + }), + ), }; } @@ -677,6 +692,7 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { sessionKey?: string; messages: AgentMessage[]; tokenBudget?: number; + model?: string; }) => Promise; afterTurn?: (params: { sessionId: string; @@ -783,6 +799,22 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expectCalledWithSessionKey(afterTurn, sessionKey); }); + it("forwards modelId to assemble", async () => { + const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); + + const result = await runAttemptWithContextEngine({ + bootstrap, + assemble, + }); + + expect(result.promptError).toBeNull(); + expect(assemble).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-test", + }), + ); + }); + it("forwards sessionKey to ingestBatch when afterTurn is absent", async () => { const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); const ingestBatch = vi.fn( diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f89759606de..c7c7a728ae7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -10,7 +10,7 @@ import { import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../../extensions/telegram/api.js"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../../config/config.js"; @@ -2167,6 +2167,7 @@ export async function runEmbeddedAttempt( sessionKey: params.sessionKey, messages: activeSession.messages, tokenBudget: params.contextTokenBudget, + model: params.modelId, }); if (assembled.messages !== activeSession.messages) { activeSession.agent.replaceMessages(assembled.messages); diff --git a/src/agents/pi-embedded-subscribe.tools.extract.test.ts b/src/agents/pi-embedded-subscribe.tools.extract.test.ts index cd99ee6b674..044edc93a6d 100644 --- a/src/agents/pi-embedded-subscribe.tools.extract.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.extract.test.ts @@ -1,13 +1,22 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { normalizeTelegramMessagingTarget } from "../../extensions/telegram/api.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { extractMessagingToolSend } from "./pi-embedded-subscribe.tools.js"; describe("extractMessagingToolSend", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), + createTestRegistry([ + { + pluginId: "telegram", + plugin: { + ...createChannelTestPluginBase({ id: "telegram" }), + messaging: { normalizeTarget: normalizeTelegramMessagingTarget }, + }, + source: "test", + }, + ]), ); }); diff --git a/src/agents/subagent-depth.test.ts b/src/agents/subagent-depth.test.ts index 5d9427b7818..2c62432a692 100644 --- a/src/agents/subagent-depth.test.ts +++ b/src/agents/subagent-depth.test.ts @@ -76,6 +76,33 @@ describe("getSubagentDepthFromSessionStore", () => { expect(depth).toBe(2); }); + it("accepts JSON5 syntax in the on-disk depth store for backward compatibility", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-json5-")); + const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); + const storePath = storeTemplate.replaceAll("{agentId}", "main"); + fs.writeFileSync( + storePath, + `{ + // hand-edited legacy store + "agent:main:subagent:flat": { + sessionId: "subagent-flat", + spawnDepth: 2, + }, + }`, + "utf-8", + ); + + const depth = getSubagentDepthFromSessionStore("subagent:flat", { + cfg: { + session: { + store: storeTemplate, + }, + }, + }); + + expect(depth).toBe(2); + }); + it("falls back to session-key segment counting when metadata is missing", () => { const key = "agent:main:subagent:flat"; const depth = getSubagentDepthFromSessionStore(key, { diff --git a/src/agents/subagent-depth.ts b/src/agents/subagent-depth.ts index 8b62539ac45..53fa6bfe7c5 100644 --- a/src/agents/subagent-depth.ts +++ b/src/agents/subagent-depth.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; -import JSON5 from "json5"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { getSubagentDepth, parseAgentSessionKey } from "../sessions/session-key-utils.js"; +import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; import { resolveDefaultAgentId } from "./agent-scope.js"; type SessionDepthEntry = { @@ -37,7 +37,7 @@ function normalizeSessionKey(value: unknown): string | undefined { function readSessionStore(storePath: string): Record { try { const raw = fs.readFileSync(storePath, "utf-8"); - const parsed = JSON5.parse(raw); + const parsed = parseJsonWithJson5Fallback(raw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed as Record; } diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index e87df84b909..0b418806612 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; -import { createMessageToolButtonsSchema } from "../../plugin-sdk/message-tool-schema.js"; +import { createMessageToolButtonsSchema } from "../../plugin-sdk/channel-actions.js"; type CreateMessageTool = typeof import("./message-tool.js").createMessageTool; type SetActivePluginRegistry = typeof import("../../plugins/runtime.js").setActivePluginRegistry; type CreateTestRegistry = typeof import("../../test-utils/channel-plugins.js").createTestRegistry; diff --git a/src/agents/tools/web-search-provider-common.ts b/src/agents/tools/web-search-provider-common.ts index f69876ed04a..79827ef7cb8 100644 --- a/src/agents/tools/web-search-provider-common.ts +++ b/src/agents/tools/web-search-provider-common.ts @@ -92,6 +92,45 @@ export async function withTrustedWebSearchEndpoint( ); } +export async function postTrustedWebToolsJson( + params: { + url: string; + timeoutSeconds: number; + apiKey: string; + body: Record; + errorLabel: string; + maxErrorBytes?: number; + }, + parseResponse: (response: Response) => Promise, +): Promise { + return withTrustedWebToolsEndpoint( + { + url: params.url, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params.body), + }, + }, + async ({ response }) => { + if (!response.ok) { + const detail = await readResponseText(response, { + maxBytes: params.maxErrorBytes ?? 64_000, + }); + throw new Error( + `${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`, + ); + } + return await parseResponse(response); + }, + ); +} + export async function throwWebSearchApiError(res: Response, providerLabel: string): Promise { const detailResult = await readResponseText(res, { maxBytes: 64_000 }); const detail = detailResult.text; diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 9f3a6fe017c..5bb2585f3ed 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -341,6 +341,15 @@ describe("web_search grok config resolution", () => { expect(resolveGrokModel({ model: "grok-4-fast" })).toBe("grok-4-fast"); }); + it("normalizes deprecated grok 4.20 beta ids to GA ids", () => { + expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-reasoning" })).toBe( + "grok-4.20-reasoning", + ); + expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-non-reasoning" })).toBe( + "grok-4.20-non-reasoning", + ); + }); + it("falls back to default model", () => { expect(resolveGrokModel({})).toBe("grok-4-1-fast"); }); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 11955d4a9b0..ec7291d7730 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -29,7 +29,6 @@ export function createWebSearchTool(options?: { export const __testing = { SEARCH_CACHE, - resolveSearchProvider: ( - search?: NonNullable["web"]>["search"], - ) => resolveWebSearchProviderId({ search }), + resolveSearchProvider: (search?: Parameters[0]["search"]) => + resolveWebSearchProviderId({ search }), }; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 05d7fe0139a..94487294500 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,7 +1,7 @@ import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../extensions/telegram/api.js"; import { callGateway } from "../../gateway/call.js"; import { logVerbose } from "../../globals.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index b1a1fcba8da..08574530ed9 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -4,7 +4,7 @@ import { calculateTotalPages, getModelsPageSize, type ProviderInfo, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../extensions/telegram/api.js"; import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 8d31fbf8c0d..c0988a72443 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -1,9 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; const hoisted = vi.hoisted(() => { const getThreadBindingManagerMock = vi.fn(); @@ -233,9 +230,6 @@ function createFakeThreadBindingManager(binding: FakeBinding | null) { describe("/session idle and /session max-age", () => { beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]), - ); hoisted.getThreadBindingManagerMock.mockReset(); hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset(); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 5d8d871f9ec..5e79ed7ae9f 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -1,4 +1,4 @@ -import { buildBrowseProvidersButton } from "openclaw/plugin-sdk/telegram"; +import { buildBrowseProvidersButton } from "../../../extensions/telegram/api.js"; import { ensureAuthProfileStore, resolveAuthStorePathForDisplay, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index e20084ed923..f31df4c0707 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -9,6 +9,8 @@ vi.mock("../../agents/model-catalog.js", () => ({ { provider: "kimi", id: "kimi-code", name: "Kimi Code" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, + { provider: "xai", id: "grok-4", name: "Grok 4" }, + { provider: "xai", id: "grok-4.20-reasoning", name: "Grok 4.20 (Reasoning)" }, ]), })); @@ -263,6 +265,45 @@ describe("createModelSelectionState respects session model override", () => { expect(state.provider).toBe(defaultProvider); expect(state.model).toBe("deepseek-v3-4bit-mlx"); }); + + it("normalizes deprecated xai beta session overrides before allowlist checks", async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "xai/grok-4", + }, + models: { + "xai/grok-4": {}, + "xai/grok-4.20-experimental-beta-0304-reasoning": {}, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:telegram:group:123:topic:99"; + const sessionEntry = makeEntry({ + providerOverride: "xai", + modelOverride: "grok-4.20-experimental-beta-0304-reasoning", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "xai", + defaultModel: "grok-4", + provider: "xai", + model: "grok-4", + hasModelDirective: false, + }); + + expect(state.provider).toBe("xai"); + expect(state.model).toBe("grok-4.20-reasoning"); + expect(state.resetModelOverride).toBe(false); + }); }); describe("createModelSelectionState resolveDefaultReasoningLevel", () => { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 33132e1f477..26ae8a9b46d 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -6,6 +6,7 @@ import { buildAllowedModelSet, type ModelAliasIndex, modelKey, + normalizeModelRef, normalizeProviderId, resolveModelRefFromString, resolveReasoningDefault, @@ -326,7 +327,8 @@ export async function createModelSelectionState(params: { const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider; const overrideModel = sessionEntry.modelOverride?.trim(); if (overrideModel) { - const key = modelKey(overrideProvider, overrideModel); + const normalizedOverride = normalizeModelRef(overrideProvider, overrideModel); + const key = modelKey(normalizedOverride.provider, normalizedOverride.model); if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { const { updated } = applyModelOverrideToSessionEntry({ entry: sessionEntry, @@ -356,11 +358,14 @@ export async function createModelSelectionState(params: { // the regular session/parent model override behavior. const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true; if (storedOverride?.model && !skipStoredOverride) { - const candidateProvider = storedOverride.provider || defaultProvider; - const key = modelKey(candidateProvider, storedOverride.model); + const normalizedStoredOverride = normalizeModelRef( + storedOverride.provider || defaultProvider, + storedOverride.model, + ); + const key = modelKey(normalizedStoredOverride.provider, normalizedStoredOverride.model); if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { - provider = candidateProvider; - model = storedOverride.model; + provider = normalizedStoredOverride.provider; + model = normalizedStoredOverride.model; } } diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 515d71726fb..c0eca8d6996 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { discordOutbound, imessageOutbound, @@ -9,7 +7,12 @@ import { telegramOutbound, whatsappOutbound, } from "../../../test/channel-outbounds.js"; -import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js"; +import type { + ChannelMessagingAdapter, + ChannelOutboundAdapter, + ChannelPlugin, + ChannelThreadingAdapter, +} from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -28,13 +31,22 @@ const mocks = vi.hoisted(() => ({ sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })), sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })), - sendMessageMattermost: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), + sendMessageMattermost: vi.fn(async (..._args: unknown[]) => ({ + messageId: "m1", + channelId: "c1", + })), deliverOutboundPayloads: vi.fn(), })); -vi.mock("../../../extensions/discord/src/send.js", () => ({ - sendMessageDiscord: mocks.sendMessageDiscord, -})); +vi.mock("../../../extensions/discord/src/send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageDiscord: mocks.sendMessageDiscord, + sendPollDiscord: mocks.sendMessageDiscord, + sendWebhookMessageDiscord: vi.fn(), + }; +}); vi.mock("../../../extensions/imessage/src/send.js", () => ({ sendMessageIMessage: mocks.sendMessageIMessage, })); @@ -44,21 +56,17 @@ vi.mock("../../../extensions/signal/src/send.js", () => ({ vi.mock("../../../extensions/slack/src/send.js", () => ({ sendMessageSlack: mocks.sendMessageSlack, })); -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - sendMessageTelegram: mocks.sendMessageTelegram, -})); -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - sendMessageTelegram: mocks.sendMessageTelegram, -})); +vi.mock("../../../extensions/telegram/src/send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageTelegram: mocks.sendMessageTelegram, + }; +}); vi.mock("../../../extensions/whatsapp/src/send.js", () => ({ sendMessageWhatsApp: mocks.sendMessageWhatsApp, sendPollWhatsApp: mocks.sendMessageWhatsApp, })); -vi.mock("../../../extensions/discord/src/send.js", () => ({ - sendMessageDiscord: mocks.sendMessageDiscord, - sendPollDiscord: mocks.sendMessageDiscord, - sendWebhookMessageDiscord: vi.fn(), -})); vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({ sendMessageMattermost: mocks.sendMessageMattermost, })); @@ -132,6 +140,47 @@ const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): Chan outbound: params.outbound, }); +const slackMessaging: ChannelMessagingAdapter = { + enableInteractiveReplies: ({ cfg }) => + (cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined) + ?.capabilities?.interactiveReplies === true, + hasStructuredReplyPayload: ({ payload }) => { + const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks; + if (typeof blocks === "string") { + return blocks.trim().length > 0; + } + return Array.isArray(blocks) && blocks.length > 0; + }, +}; + +const slackThreading: ChannelThreadingAdapter = { + resolveReplyTransport: ({ threadId, replyToId }) => ({ + replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined), + threadId: null, + }), +}; + +const mattermostOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async ({ to, text, cfg, accountId, replyToId, threadId }) => { + const result = await mocks.sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }); + return { channel: "mattermost", ...result }; + }, + sendMedia: async ({ to, text, cfg, accountId, replyToId, threadId, mediaUrl }) => { + const result = await mocks.sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + mediaUrl, + }); + return { channel: "mattermost", ...result }; + }, +}; + async function expectSlackNoSend( payload: Parameters[0]["payload"], overrides: Partial[0]> = {}, @@ -553,8 +602,8 @@ const defaultRegistry = createTestRegistry([ pluginId: "slack", plugin: { ...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }), - messaging: slackPlugin.messaging, - threading: slackPlugin.threading, + messaging: slackMessaging, + threading: slackThreading, }, source: "test", }, @@ -595,7 +644,11 @@ const defaultRegistry = createTestRegistry([ }, { pluginId: "mattermost", - plugin: mattermostPlugin, + plugin: createOutboundTestPlugin({ + id: "mattermost", + outbound: mattermostOutbound, + label: "Mattermost", + }), source: "test", }, ]); diff --git a/src/auto-reply/reply/telegram-context.test.ts b/src/auto-reply/reply/telegram-context.test.ts index b38397a1c01..7b58b780180 100644 --- a/src/auto-reply/reply/telegram-context.test.ts +++ b/src/auto-reply/reply/telegram-context.test.ts @@ -1,15 +1,6 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { describe, expect, it } from "vitest"; import { resolveTelegramConversationId } from "./telegram-context.js"; -beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]), - ); -}); - describe("resolveTelegramConversationId", () => { it("builds canonical topic ids from chat target and message thread id", () => { const conversationId = resolveTelegramConversationId({ diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 4485e2c22ee..601fa6891bf 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,4 +1,4 @@ -import type { StickerMetadata } from "openclaw/plugin-sdk/telegram"; +import type { StickerMetadata } from "../../extensions/telegram/api.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { MediaUnderstandingDecision, diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts index 761d1274091..5488b918510 100644 --- a/src/channels/plugins/contracts/outbound-payload.contract.test.ts +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -1,11 +1,14 @@ import { describe, vi } from "vitest"; import { discordOutbound } from "../../../../extensions/discord/src/outbound-adapter.js"; import { whatsappOutbound } from "../../../../extensions/whatsapp/src/outbound-adapter.js"; -import { zaloPlugin } from "../../../../extensions/zalo/src/channel.js"; import { sendMessageZalo } from "../../../../extensions/zalo/src/send.js"; -import { zalouserPlugin } from "../../../../extensions/zalouser/src/channel.js"; -import { setZalouserRuntime } from "../../../../extensions/zalouser/src/runtime.js"; import { sendMessageZalouser } from "../../../../extensions/zalouser/src/send.js"; +import { parseZalouserOutboundTarget } from "../../../../extensions/zalouser/src/session-route.js"; +import { + chunkTextForOutbound as chunkZaloTextForOutbound, + sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia, +} from "../../../../src/plugin-sdk/zalo.js"; +import { sendPayloadWithChunkedTextAndMedia as sendZalouserPayloadWithChunkedTextAndMedia } from "../../../../src/plugin-sdk/zalouser.js"; import { slackOutbound } from "../../../../test/channel-outbounds.js"; import type { ReplyPayload } from "../../../auto-reply/types.js"; import { createDirectTextMediaOutbound } from "../outbound/direct-text-media.js"; @@ -69,6 +72,13 @@ type PayloadHarnessParams = { sendResults?: Array<{ messageId: string }>; }; +function buildChannelSendResult(channel: string, result: Record) { + return { + channel, + messageId: typeof result.messageId === "string" ? result.messageId : "", + }; +} + const mockedSendZalo = vi.mocked(sendMessageZalo); const mockedSendZalouser = vi.mocked(sendMessageZalouser); @@ -160,6 +170,94 @@ function createDirectTextMediaHarness(params: PayloadHarnessParams) { }; } +function createZaloHarness(params: PayloadHarnessParams) { + primeChannelOutboundSendMock(mockedSendZalo, { ok: true, messageId: "zl-1" }, params.sendResults); + const ctx = { + cfg: {}, + to: "123456789", + text: "", + payload: params.payload, + }; + return { + run: async () => + await sendZaloPayloadWithChunkedTextAndMedia({ + ctx, + textChunkLimit: 2000, + chunker: chunkZaloTextForOutbound, + sendText: async (nextCtx) => + buildChannelSendResult( + "zalo", + await mockedSendZalo(nextCtx.to, nextCtx.text, { + accountId: undefined, + cfg: nextCtx.cfg, + }), + ), + sendMedia: async (nextCtx) => + buildChannelSendResult( + "zalo", + await mockedSendZalo(nextCtx.to, nextCtx.text, { + accountId: undefined, + cfg: nextCtx.cfg, + mediaUrl: nextCtx.mediaUrl, + }), + ), + emptyResult: { channel: "zalo", messageId: "" }, + }), + sendMock: mockedSendZalo, + to: ctx.to, + }; +} + +function createZalouserHarness(params: PayloadHarnessParams) { + primeChannelOutboundSendMock( + mockedSendZalouser, + { ok: true, messageId: "zlu-1" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "user:987654321", + text: "", + payload: params.payload, + }; + return { + run: async () => + await sendZalouserPayloadWithChunkedTextAndMedia({ + ctx, + sendText: async (nextCtx) => { + const target = parseZalouserOutboundTarget(nextCtx.to); + return buildChannelSendResult( + "zalouser", + await mockedSendZalouser(target.threadId, nextCtx.text, { + profile: "default", + isGroup: target.isGroup, + textMode: "markdown", + textChunkMode: "length", + textChunkLimit: 1200, + }), + ); + }, + sendMedia: async (nextCtx) => { + const target = parseZalouserOutboundTarget(nextCtx.to); + return buildChannelSendResult( + "zalouser", + await mockedSendZalouser(target.threadId, nextCtx.text, { + profile: "default", + isGroup: target.isGroup, + mediaUrl: nextCtx.mediaUrl, + textMode: "markdown", + textChunkMode: "length", + textChunkLimit: 1200, + }), + ); + }, + emptyResult: { channel: "zalouser", messageId: "" }, + }), + sendMock: mockedSendZalouser, + to: "987654321", + }; +} + describe("channel outbound payload contract", () => { describe("slack", () => { installChannelOutboundPayloadContractSuite({ @@ -189,20 +287,7 @@ describe("channel outbound payload contract", () => { installChannelOutboundPayloadContractSuite({ channel: "zalo", chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, - createHarness: ({ payload, sendResults }) => { - primeChannelOutboundSendMock(mockedSendZalo, { ok: true, messageId: "zl-1" }, sendResults); - return { - run: async () => - await zaloPlugin.outbound!.sendPayload!({ - cfg: {}, - to: "123456789", - text: "", - payload, - }), - sendMock: mockedSendZalo, - to: "123456789", - }; - }, + createHarness: createZaloHarness, }); }); @@ -210,32 +295,7 @@ describe("channel outbound payload contract", () => { installChannelOutboundPayloadContractSuite({ channel: "zalouser", chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness: ({ payload, sendResults }) => { - setZalouserRuntime({ - channel: { - text: { - resolveChunkMode: vi.fn(() => "length"), - resolveTextChunkLimit: vi.fn(() => 1200), - }, - }, - } as never); - primeChannelOutboundSendMock( - mockedSendZalouser, - { ok: true, messageId: "zlu-1" }, - sendResults, - ); - return { - run: async () => - await zalouserPlugin.outbound!.sendPayload!({ - cfg: {}, - to: "user:987654321", - text: "", - payload, - }), - sendMock: mockedSendZalouser, - to: "987654321", - }; - }, + createHarness: createZalouserHarness, }); }); diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts index 459193d0792..bbe4c0bb744 100644 --- a/src/channels/plugins/message-capability-matrix.test.ts +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -5,37 +5,115 @@ import type { ChannelMessageActionAdapter, ChannelPlugin } from "./types.js"; const telegramDescribeMessageToolMock = vi.fn(); const discordDescribeMessageToolMock = vi.fn(); -vi.mock("../../../extensions/telegram/src/runtime.js", () => ({ - getTelegramRuntime: () => ({ - channel: { - telegram: { - messageActions: { - describeMessageTool: telegramDescribeMessageToolMock, - }, - }, - }, - }), -})); +const telegramPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => telegramDescribeMessageToolMock({ cfg }), + supportsAction: () => true, + }, +}; -vi.mock("../../../extensions/discord/src/runtime.js", () => ({ - getDiscordRuntime: () => ({ - channel: { - discord: { - messageActions: { - describeMessageTool: discordDescribeMessageToolMock, - }, - }, - }, - }), -})); +const discordPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => discordDescribeMessageToolMock({ cfg }), + supportsAction: () => true, + }, +}; -const { slackPlugin } = await import("../../../extensions/slack/src/channel.js"); -const { telegramPlugin } = await import("../../../extensions/telegram/src/channel.js"); -const { discordPlugin } = await import("../../../extensions/discord/src/channel.js"); -const { mattermostPlugin } = await import("../../../extensions/mattermost/src/channel.js"); -const { feishuPlugin } = await import("../../../extensions/feishu/src/channel.js"); -const { msteamsPlugin } = await import("../../../extensions/msteams/src/channel.js"); -const { zaloPlugin } = await import("../../../extensions/zalo/src/channel.js"); +// Keep this matrix focused on capability wiring. The extension packages already +// cover their own full channel/plugin boot paths, so local stubs are enough here. +const slackPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.slack; + const enabled = + typeof account?.botToken === "string" && + account.botToken.trim() !== "" && + typeof account?.appToken === "string" && + account.appToken.trim() !== ""; + const capabilities = new Set(); + if (enabled) { + capabilities.add("blocks"); + } + if ( + account?.capabilities && + (account.capabilities as { interactiveReplies?: unknown }).interactiveReplies === true + ) { + capabilities.add("interactive"); + } + return { + actions: enabled ? ["send"] : [], + capabilities: Array.from(capabilities) as Array<"blocks" | "interactive">, + }; + }, + supportsAction: () => true, + }, +}; + +const mattermostPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.mattermost; + const enabled = + account?.enabled !== false && + typeof account?.botToken === "string" && + account.botToken.trim() !== "" && + typeof account?.baseUrl === "string" && + account.baseUrl.trim() !== ""; + return { + actions: enabled ? ["send"] : [], + capabilities: enabled ? (["buttons"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const feishuPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.feishu; + const enabled = + account?.enabled !== false && + typeof account?.appId === "string" && + account.appId.trim() !== "" && + typeof account?.appSecret === "string" && + account.appSecret.trim() !== ""; + return { + actions: enabled ? ["send"] : [], + capabilities: enabled ? (["cards"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const msteamsPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.msteams; + const enabled = + account?.enabled !== false && + typeof account?.tenantId === "string" && + account.tenantId.trim() !== "" && + typeof account?.appId === "string" && + account.appId.trim() !== "" && + typeof account?.appPassword === "string" && + account.appPassword.trim() !== ""; + return { + actions: enabled ? ["poll"] : [], + capabilities: enabled ? (["cards"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const zaloPlugin: Pick = { + actions: { + describeMessageTool: () => ({ actions: [], capabilities: [] }), + supportsAction: () => true, + }, +}; describe("channel action capability matrix", () => { afterEach(() => { diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index c0b4caafeba..80a7178a10e 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -1,4 +1,10 @@ -import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; +import { + resolvePayloadMediaUrls, + sendPayloadMediaSequence, + sendPayloadMediaSequenceAndFinalize, + sendPayloadMediaSequenceOrFallback, + sendTextMediaPayload, +} 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"; @@ -21,110 +27,13 @@ type DirectSendFn, TResult extends DirectS text: string, opts: TOpts, ) => Promise; - -type SendPayloadContext = Parameters>[0]; -type SendPayloadResult = Awaited>>; -type SendPayloadAdapter = Pick< - ChannelOutboundAdapter, - "sendMedia" | "sendText" | "chunker" | "textChunkLimit" ->; - -export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { - return resolveOutboundMediaUrls(payload); -} - -export async function sendPayloadMediaSequence(params: { - text: string; - mediaUrls: readonly string[]; - send: (input: { - text: string; - mediaUrl: string; - index: number; - isFirst: boolean; - }) => Promise; -}): Promise { - let lastResult: TResult | undefined; - for (let i = 0; i < params.mediaUrls.length; i += 1) { - const mediaUrl = params.mediaUrls[i]; - if (!mediaUrl) { - continue; - } - lastResult = await params.send({ - text: i === 0 ? params.text : "", - mediaUrl, - index: i, - isFirst: i === 0, - }); - } - 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; - adapter: SendPayloadAdapter; -}): Promise { - const text = params.ctx.payload.text ?? ""; - const urls = resolvePayloadMediaUrls(params.ctx.payload); - if (!text && urls.length === 0) { - return { channel: params.channel, messageId: "" }; - } - if (urls.length > 0) { - const lastResult = await sendPayloadMediaSequence({ - text, - mediaUrls: urls, - send: async ({ text, mediaUrl }) => - await params.adapter.sendMedia!({ - ...params.ctx, - text, - mediaUrl, - }), - }); - return lastResult ?? { channel: params.channel, messageId: "" }; - } - const limit = params.adapter.textChunkLimit; - const chunks = limit && params.adapter.chunker ? params.adapter.chunker(text, limit) : [text]; - let lastResult: Awaited>>; - for (const chunk of chunks) { - lastResult = await params.adapter.sendText!({ ...params.ctx, text: chunk }); - } - return lastResult!; -} +export { + resolvePayloadMediaUrls, + sendPayloadMediaSequence, + sendPayloadMediaSequenceAndFinalize, + sendPayloadMediaSequenceOrFallback, + sendTextMediaPayload, +} from "openclaw/plugin-sdk/reply-payload"; export function resolveScopedChannelMediaMaxBytes(params: { cfg: OpenClawConfig; diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts index d52f56ad316..5e3fe8fdafd 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 "openclaw/plugin-sdk/discord"; +import { inspectDiscordAccount as inspectDiscordAccountImpl } from "../../extensions/discord/api.js"; -export type { InspectedDiscordAccount } from "openclaw/plugin-sdk/discord"; +export type { InspectedDiscordAccount } from "../../extensions/discord/api.js"; -type InspectDiscordAccount = typeof import("openclaw/plugin-sdk/discord").inspectDiscordAccount; +type InspectDiscordAccount = typeof import("../../extensions/discord/api.js").inspectDiscordAccount; export function inspectDiscordAccount( ...args: Parameters diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts index 0d3e2c878c1..8e8db46073c 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 "openclaw/plugin-sdk/slack"; +import { inspectSlackAccount as inspectSlackAccountImpl } from "../../extensions/slack/api.js"; -export type { InspectedSlackAccount } from "openclaw/plugin-sdk/slack"; +export type { InspectedSlackAccount } from "../../extensions/slack/api.js"; -type InspectSlackAccount = typeof import("openclaw/plugin-sdk/slack").inspectSlackAccount; +type InspectSlackAccount = typeof import("../../extensions/slack/api.js").inspectSlackAccount; export function inspectSlackAccount( ...args: Parameters diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts index 12158022b2b..661cdd3b9c4 100644 --- a/src/channels/read-only-account-inspect.telegram.runtime.ts +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -1,8 +1,9 @@ -import { inspectTelegramAccount as inspectTelegramAccountImpl } from "openclaw/plugin-sdk/telegram"; +import { inspectTelegramAccount as inspectTelegramAccountImpl } from "../../extensions/telegram/api.js"; -export type { InspectedTelegramAccount } from "openclaw/plugin-sdk/telegram"; +export type { InspectedTelegramAccount } from "../../extensions/telegram/api.js"; -type InspectTelegramAccount = typeof import("openclaw/plugin-sdk/telegram").inspectTelegramAccount; +type InspectTelegramAccount = + typeof import("../../extensions/telegram/api.js").inspectTelegramAccount; export function inspectTelegramAccount( ...args: Parameters diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 5fe30994da0..730984d61df 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -73,6 +73,58 @@ export function resolveThreadBindingMaxAgeMs(params: { return Math.floor(maxAgeHours * 60 * 60 * 1000); } +type ThreadBindingLifecycleRecord = { + boundAt: number; + lastActivityAt: number; + idleTimeoutMs?: number; + maxAgeMs?: number; +}; + +export function resolveThreadBindingLifecycle(params: { + record: ThreadBindingLifecycleRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): { + expiresAt?: number; + reason?: "idle-expired" | "max-age-expired"; +} { + const idleTimeoutMs = + typeof params.record.idleTimeoutMs === "number" + ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) + : params.defaultIdleTimeoutMs; + const maxAgeMs = + typeof params.record.maxAgeMs === "number" + ? Math.max(0, Math.floor(params.record.maxAgeMs)) + : params.defaultMaxAgeMs; + + const inactivityExpiresAt = + idleTimeoutMs > 0 + ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs + : undefined; + const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; + + if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { + return inactivityExpiresAt <= maxAgeExpiresAt + ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } + : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + if (inactivityExpiresAt != null) { + return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; + } + if (maxAgeExpiresAt != null) { + return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + return {}; +} + +export function resolveThreadBindingEffectiveExpiresAt(params: { + record: ThreadBindingLifecycleRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): number | undefined { + return resolveThreadBindingLifecycle(params).expiresAt; +} + export function resolveThreadBindingsEnabled(params: { channelEnabledRaw: unknown; sessionEnabledRaw: unknown; diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index d30a476004d..6e9cc07bf7e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -442,6 +442,15 @@ describe("config cli", () => { expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); }); + it("rejects JSON5-only object syntax when strict parsing is enabled", async () => { + await expect( + runConfigCommand(["config", "set", "gateway.auth", "{mode:'token'}", "--strict-json"]), + ).rejects.toThrow("__exit__:1"); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); + }); + it("accepts --strict-json with batch mode and applies batch payload", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 } }; setSnapshot(resolved, resolved); @@ -470,6 +479,8 @@ describe("config cli", () => { expect(helpText).toContain("--strict-json"); expect(helpText).toContain("--json"); expect(helpText).toContain("Legacy alias for --strict-json"); + expect(helpText).toContain("Value (JSON/JSON5 or raw string)"); + expect(helpText).toContain("Strict JSON parsing (error instead of"); expect(helpText).toContain("--ref-provider"); expect(helpText).toContain("--provider-source"); expect(helpText).toContain("--batch-json"); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 604e27666c9..e7a94ae99ab 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -159,9 +159,9 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown { const trimmed = raw.trim(); if (opts.strictJson) { try { - return JSON5.parse(trimmed); + return JSON.parse(trimmed); } catch (err) { - throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err }); + throw new Error(`Failed to parse JSON value: ${String(err)}`, { cause: err }); } } @@ -1280,8 +1280,8 @@ export function registerConfigCli(program: Command) { .command("set") .description(CONFIG_SET_DESCRIPTION) .argument("[path]", "Config path (dot or bracket notation)") - .argument("[value]", "Value (JSON5 or raw string)") - .option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false) + .argument("[value]", "Value (JSON/JSON5 or raw string)") + .option("--strict-json", "Strict JSON parsing (error instead of raw string fallback)", false) .option("--json", "Legacy alias for --strict-json", false) .option( "--dry-run", diff --git a/src/cli/directory-cli.test.ts b/src/cli/directory-cli.test.ts new file mode 100644 index 00000000000..d5a92b44c35 --- /dev/null +++ b/src/cli/directory-cli.test.ts @@ -0,0 +1,105 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerDirectoryCli } from "./directory-cli.js"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), + writeConfigFile: vi.fn(), + resolveInstallableChannelPlugin: vi.fn(), + resolveMessageChannelSelection: vi.fn(), + getChannelPlugin: vi.fn(), + resolveChannelDefaultAccountId: vi.fn(), + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, + writeConfigFile: mocks.writeConfigFile, +})); + +vi.mock("../commands/channel-setup/channel-plugin-resolution.js", () => ({ + resolveInstallableChannelPlugin: mocks.resolveInstallableChannelPlugin, +})); + +vi.mock("../infra/outbound/channel-selection.js", () => ({ + resolveMessageChannelSelection: mocks.resolveMessageChannelSelection, +})); + +vi.mock("../channels/plugins/index.js", () => ({ + getChannelPlugin: mocks.getChannelPlugin, +})); + +vi.mock("../channels/plugins/helpers.js", () => ({ + resolveChannelDefaultAccountId: mocks.resolveChannelDefaultAccountId, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: { + log: (...args: unknown[]) => mocks.log(...args), + error: (...args: unknown[]) => mocks.error(...args), + exit: (...args: unknown[]) => mocks.exit(...args), + }, +})); + +describe("registerDirectoryCli", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({ channels: {} }); + mocks.writeConfigFile.mockResolvedValue(undefined); + mocks.resolveChannelDefaultAccountId.mockReturnValue("default"); + mocks.resolveMessageChannelSelection.mockResolvedValue({ + channel: "slack", + configured: ["slack"], + source: "explicit", + }); + mocks.exit.mockImplementation((code?: number) => { + throw new Error(`exit:${code ?? 0}`); + }); + }); + + it("installs an explicit optional directory channel on demand", async () => { + const self = vi.fn().mockResolvedValue({ id: "self-1", name: "Family Phone" }); + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: { + channels: {}, + plugins: { entries: { whatsapp: { enabled: true } } }, + }, + channelId: "whatsapp", + plugin: { + id: "whatsapp", + directory: { self }, + }, + configChanged: true, + }); + + const program = new Command().name("openclaw"); + registerDirectoryCli(program); + + await program.parseAsync(["directory", "self", "--channel", "whatsapp", "--json"], { + from: "user", + }); + + expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + rawChannel: "whatsapp", + allowInstall: true, + }), + ); + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + plugins: { entries: { whatsapp: { enabled: true } } }, + }), + ); + expect(self).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "default", + }), + ); + expect(mocks.log).toHaveBeenCalledWith( + JSON.stringify({ id: "self-1", name: "Family Phone" }, null, 2), + ); + expect(mocks.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index 1a9949f224a..3566d96fa47 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -1,7 +1,8 @@ import type { Command } from "commander"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; -import { loadConfig } from "../config/config.js"; +import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js"; +import { loadConfig, writeConfigFile } from "../config/config.js"; import { danger } from "../globals.js"; import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js"; import { defaultRuntime } from "../runtime.js"; @@ -96,13 +97,32 @@ export function registerDirectoryCli(program: Command) { .option("--json", "Output JSON", false); const resolve = async (opts: { channel?: string; account?: string }) => { - const cfg = loadConfig(); - const selection = await resolveMessageChannelSelection({ - cfg, - channel: opts.channel ?? null, - }); + let cfg = loadConfig(); + const explicitChannel = opts.channel?.trim(); + const resolvedExplicit = explicitChannel + ? await resolveInstallableChannelPlugin({ + cfg, + runtime: defaultRuntime, + rawChannel: explicitChannel, + allowInstall: true, + supports: (plugin) => Boolean(plugin.directory), + }) + : null; + if (resolvedExplicit?.configChanged) { + cfg = resolvedExplicit.cfg; + await writeConfigFile(cfg); + } + const selection = explicitChannel + ? { + channel: resolvedExplicit?.channelId, + } + : await resolveMessageChannelSelection({ + cfg, + channel: opts.channel ?? null, + }); const channelId = selection.channel; - const plugin = getChannelPlugin(channelId); + const plugin = + resolvedExplicit?.plugin ?? (channelId ? getChannelPlugin(channelId) : undefined); if (!plugin) { throw new Error(`Unsupported channel: ${String(channelId)}`); } diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 3a0490d996f..1bc8a645719 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -135,24 +135,16 @@ describe("registerQrCli", () => { }; } - function expectLoggedSetupCode( - url: string, - auth?: { - token?: string; - password?: string; - }, - ) { + function expectLoggedSetupCode(url: string) { const expected = encodePairingSetupCode({ url, bootstrapToken: "bootstrap-123", - ...(auth?.token ? { token: auth.token } : {}), - ...(auth?.password ? { password: auth.password } : {}), }); expect(runtime.log).toHaveBeenCalledWith(expected); } - function expectLoggedLocalSetupCode(auth?: { token?: string; password?: string }) { - expectLoggedSetupCode("ws://gateway.local:18789", auth); + function expectLoggedLocalSetupCode() { + expectLoggedSetupCode("ws://gateway.local:18789"); } function mockTailscaleStatusLookup() { @@ -189,7 +181,6 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", bootstrapToken: "bootstrap-123", - token: "tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(qrGenerate).not.toHaveBeenCalled(); @@ -225,7 +216,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only", "--token", "override-token"]); - expectLoggedLocalSetupCode({ token: "override-token" }); + expectLoggedLocalSetupCode(); }); it("skips local password SecretRef resolution when --token override is provided", async () => { @@ -237,7 +228,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only", "--token", "override-token"]); - expectLoggedLocalSetupCode({ token: "override-token" }); + expectLoggedLocalSetupCode(); }); it("resolves local gateway auth password SecretRefs before setup code generation", async () => { @@ -250,7 +241,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ password: "local-password-secret" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -264,7 +255,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ password: "password-from-env" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -279,7 +270,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ token: "token-123" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -293,7 +284,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ password: "inferred-password" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -342,7 +333,6 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", - token: "remote-tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( @@ -386,7 +376,6 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", - token: "remote-tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); }); diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts index 559b9a8fc15..81550c5922a 100644 --- a/src/cli/qr-dashboard.integration.test.ts +++ b/src/cli/qr-dashboard.integration.test.ts @@ -69,8 +69,6 @@ function createGatewayTokenRefFixture() { function decodeSetupCode(setupCode: string): { url?: string; bootstrapToken?: string; - token?: string; - password?: string; } { const padded = setupCode.replace(/-/g, "+").replace(/_/g, "/"); const padLength = (4 - (padded.length % 4)) % 4; @@ -79,8 +77,6 @@ function decodeSetupCode(setupCode: string): { return JSON.parse(json) as { url?: string; bootstrapToken?: string; - token?: string; - password?: string; }; } @@ -119,7 +115,7 @@ describe("cli integration: qr + dashboard token SecretRef", () => { delete process.env.SHARED_GATEWAY_TOKEN; }); - it("uses the same resolved token SecretRef for both qr and dashboard commands", async () => { + it("uses the same resolved token SecretRef for qr auth validation and dashboard commands", async () => { const fixture = createGatewayTokenRefFixture(); process.env.SHARED_GATEWAY_TOKEN = "shared-token-123"; loadConfigMock.mockReturnValue(fixture); @@ -137,7 +133,6 @@ describe("cli integration: qr + dashboard token SecretRef", () => { const payload = decodeSetupCode(setupCode ?? ""); expect(payload.url).toBe("ws://gateway.local:18789"); expect(payload.bootstrapToken).toBeTruthy(); - expect(payload.token).toBe("shared-token-123"); expect(runtimeErrors).toEqual([]); runtimeLogs.length = 0; diff --git a/src/cli/send-runtime/discord.ts b/src/cli/send-runtime/discord.ts index 3c6527a8175..5c15549cfae 100644 --- a/src/cli/send-runtime/discord.ts +++ b/src/cli/send-runtime/discord.ts @@ -1,7 +1,7 @@ -import { sendMessageDiscord as sendMessageDiscordImpl } from "openclaw/plugin-sdk/discord"; +import { sendMessageDiscord as sendMessageDiscordImpl } from "../../../extensions/discord/runtime-api.js"; type RuntimeSend = { - sendMessage: typeof import("openclaw/plugin-sdk/discord").sendMessageDiscord; + sendMessage: typeof import("../../../extensions/discord/runtime-api.js").sendMessageDiscord; }; export const runtimeSend = { diff --git a/src/cli/send-runtime/slack.ts b/src/cli/send-runtime/slack.ts index beec4f55906..e7d50aefe1f 100644 --- a/src/cli/send-runtime/slack.ts +++ b/src/cli/send-runtime/slack.ts @@ -1,7 +1,7 @@ -import { sendMessageSlack as sendMessageSlackImpl } from "openclaw/plugin-sdk/slack"; +import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/runtime-api.js"; type RuntimeSend = { - sendMessage: typeof import("openclaw/plugin-sdk/slack").sendMessageSlack; + sendMessage: typeof import("../../../extensions/slack/runtime-api.js").sendMessageSlack; }; export const runtimeSend = { diff --git a/src/cli/send-runtime/telegram.ts b/src/cli/send-runtime/telegram.ts index bfa22643976..e5e04680532 100644 --- a/src/cli/send-runtime/telegram.ts +++ b/src/cli/send-runtime/telegram.ts @@ -1,7 +1,7 @@ -import { sendMessageTelegram as sendMessageTelegramImpl } from "openclaw/plugin-sdk/telegram"; +import { sendMessageTelegram as sendMessageTelegramImpl } from "../../../extensions/telegram/runtime-api.js"; type RuntimeSend = { - sendMessage: typeof import("openclaw/plugin-sdk/telegram").sendMessageTelegram; + sendMessage: typeof import("../../../extensions/telegram/runtime-api.js").sendMessageTelegram; }; export const runtimeSend = { diff --git a/src/commands/agents.bind.matrix.integration.test.ts b/src/commands/agents.bind.matrix.integration.test.ts index 416d9f88250..e9f82a8dc69 100644 --- a/src/commands/agents.bind.matrix.integration.test.ts +++ b/src/commands/agents.bind.matrix.integration.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { matrixPlugin } from "../../extensions/matrix/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { agentsBindCommand } from "./agents.js"; import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; @@ -9,6 +8,20 @@ import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-hel const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const matrixBindingPlugin = { + ...createChannelTestPluginBase({ id: "matrix" }), + setup: { + resolveBindingAccountId: ({ accountId, agentId }: { accountId?: string; agentId?: string }) => { + const explicit = accountId?.trim(); + if (explicit) { + return explicit; + } + const agent = agentId?.trim(); + return agent || "default"; + }, + }, +}; + vi.mock("../config/config.js", async (importOriginal) => ({ ...(await importOriginal()), readConfigFileSnapshot: readConfigFileSnapshotMock, @@ -26,7 +39,7 @@ describe("agents bind matrix integration", () => { runtime.exit.mockClear(); setActivePluginRegistry( - createTestRegistry([{ pluginId: "matrix", plugin: matrixPlugin, source: "test" }]), + createTestRegistry([{ pluginId: "matrix", plugin: matrixBindingPlugin, source: "test" }]), ); }); diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts index 83ef8718b0a..e2437c8b667 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts @@ -1,10 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { signalPlugin } from "../../extensions/signal/src/channel.js"; +import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"; import { formatGatewayChannelsStatusLines } from "./channels/status.js"; +const signalPlugin = { + ...createChannelTestPluginBase({ id: "signal" }), + status: { + collectStatusIssues: (accounts: Parameters[1]) => + collectStatusIssuesFromLastError("signal", accounts), + }, +}; + describe("channels command", () => { beforeEach(() => { setActivePluginRegistry( diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 034a3fdf505..27fc1047103 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; const mocks = vi.hoisted(() => ({ @@ -7,6 +7,12 @@ const mocks = vi.hoisted(() => ({ clackSelect: vi.fn(), clackText: vi.fn(), clackConfirm: vi.fn(), + applySearchKey: vi.fn(), + applySearchProviderSelection: vi.fn(), + hasExistingKey: vi.fn(), + hasKeyInEnv: vi.fn(), + resolveExistingKey: vi.fn(), + resolveSearchProviderOptions: vi.fn(), readConfigFileSnapshot: vi.fn(), writeConfigFile: vi.fn(), resolveGatewayPort: vi.fn(), @@ -95,10 +101,51 @@ vi.mock("./onboard-channels.js", () => ({ setupChannels: vi.fn(), })); +vi.mock("./onboard-search.js", () => ({ + resolveSearchProviderOptions: mocks.resolveSearchProviderOptions, + SEARCH_PROVIDER_OPTIONS: [ + { + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results with optional result scraping", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + }, + ], + resolveExistingKey: mocks.resolveExistingKey, + hasExistingKey: mocks.hasExistingKey, + applySearchKey: mocks.applySearchKey, + applySearchProviderSelection: mocks.applySearchProviderSelection, + hasKeyInEnv: mocks.hasKeyInEnv, +})); + import { WizardCancelledError } from "../wizard/prompts.js"; import { runConfigureWizard } from "./configure.wizard.js"; describe("runConfigureWizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true }); + mocks.resolveExistingKey.mockReturnValue(undefined); + mocks.hasExistingKey.mockReturnValue(false); + mocks.hasKeyInEnv.mockReturnValue(false); + mocks.resolveSearchProviderOptions.mockReturnValue([ + { + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results with optional result scraping", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + }, + ]); + mocks.applySearchKey.mockReset(); + mocks.applySearchProviderSelection.mockReset(); + }); + it("persists gateway.mode=local when only the run mode is selected", async () => { mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, @@ -158,4 +205,214 @@ describe("runConfigureWizard", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); + + it("persists provider-owned web search config changes returned by applySearchKey", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.resolveExistingKey.mockReturnValue(undefined); + mocks.hasExistingKey.mockReturnValue(false); + mocks.hasKeyInEnv.mockReturnValue(false); + mocks.applySearchKey.mockImplementation( + (cfg: OpenClawConfig, provider: string, key: string) => ({ + ...cfg, + tools: { + ...cfg.tools, + web: { + ...cfg.tools?.web, + search: { + provider, + enabled: true, + }, + }, + }, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + firecrawl: { + enabled: true, + config: { webSearch: { apiKey: key } }, + }, + }, + }, + }), + ); + + const selectQueue = ["local", "firecrawl"]; + const confirmQueue = [true, false]; + mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); + mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); + mocks.clackText.mockResolvedValue("fc-entered-key"); + mocks.clackIntro.mockResolvedValue(undefined); + mocks.clackOutro.mockResolvedValue(undefined); + + await runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ); + + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + web: expect.objectContaining({ + search: expect.objectContaining({ + provider: "firecrawl", + enabled: true, + }), + }), + }), + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + firecrawl: expect.objectContaining({ + enabled: true, + config: expect.objectContaining({ + webSearch: expect.objectContaining({ apiKey: "fc-entered-key" }), + }), + }), + }), + }), + }), + ); + }); + + it("applies provider selection side effects when a key already exists via secret ref or env", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.resolveExistingKey.mockReturnValue(undefined); + mocks.hasExistingKey.mockReturnValue(true); + mocks.hasKeyInEnv.mockReturnValue(false); + mocks.applySearchProviderSelection.mockImplementation( + (cfg: OpenClawConfig, provider: string) => ({ + ...cfg, + tools: { + ...cfg.tools, + web: { + ...cfg.tools?.web, + search: { + provider, + enabled: true, + }, + }, + }, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + firecrawl: { + enabled: true, + }, + }, + }, + }), + ); + + const selectQueue = ["local", "firecrawl"]; + const confirmQueue = [true, false]; + mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); + mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); + mocks.clackText.mockResolvedValue(""); + mocks.clackIntro.mockResolvedValue(undefined); + mocks.clackOutro.mockResolvedValue(undefined); + + await runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ); + + expect(mocks.applySearchProviderSelection).toHaveBeenCalledWith( + expect.objectContaining({ + gateway: expect.objectContaining({ mode: "local" }), + }), + "firecrawl", + ); + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + firecrawl: expect.objectContaining({ + enabled: true, + }), + }), + }), + }), + ); + }); + + it("does not crash when web search providers are unavailable under plugin policy", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.resolveSearchProviderOptions.mockReturnValue([]); + + const selectQueue = ["local"]; + const confirmQueue = [true, false]; + mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); + mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); + mocks.clackText.mockResolvedValue(""); + mocks.clackIntro.mockResolvedValue(undefined); + mocks.clackOutro.mockResolvedValue(undefined); + + await expect( + runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ), + ).resolves.toBeUndefined(); + + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining( + "No web search providers are currently available under this plugin policy.", + ), + "Web search", + ); + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + web: expect.objectContaining({ + search: expect.objectContaining({ + enabled: false, + }), + }), + }), + }), + ); + }); }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index c74909ae14b..b1a5816cfdc 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -167,34 +167,30 @@ async function promptWebToolsConfig( const existingSearch = nextConfig.tools?.web?.search; const existingFetch = nextConfig.tools?.web?.fetch; const { - SEARCH_PROVIDER_OPTIONS, + resolveSearchProviderOptions, resolveExistingKey, hasExistingKey, applySearchKey, + applySearchProviderSelection, hasKeyInEnv, } = await import("./onboard-search.js"); - type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"]; - const defaultProvider = SEARCH_PROVIDER_OPTIONS[0]?.value; - if (!defaultProvider) { - throw new Error("No web search providers are registered."); - } + const searchProviderOptions = resolveSearchProviderOptions(nextConfig); + const defaultProvider = searchProviderOptions[0]?.id; const hasKeyForProvider = (provider: string): boolean => { - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); + const entry = searchProviderOptions.find((e) => e.id === provider); if (!entry) { return false; } return hasExistingKey(nextConfig, provider) || hasKeyInEnv(entry); }; - const existingProvider: SP = (() => { + const existingProvider = (() => { const stored = existingSearch?.provider; - if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { + if (stored && searchProviderOptions.some((e) => e.id === stored)) { return stored; } - return ( - SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? defaultProvider - ); + return searchProviderOptions.find((e) => hasKeyForProvider(e.id))?.id ?? defaultProvider; })(); note( @@ -210,7 +206,7 @@ async function promptWebToolsConfig( await confirm({ message: "Enable web_search?", initialValue: - existingSearch?.enabled ?? SEARCH_PROVIDER_OPTIONS.some((e) => hasKeyForProvider(e.value)), + existingSearch?.enabled ?? searchProviderOptions.some((e) => hasKeyForProvider(e.id)), }), runtime, ); @@ -219,64 +215,82 @@ async function promptWebToolsConfig( ...existingSearch, enabled: enableSearch, }; + let workingConfig = nextConfig; if (enableSearch) { - const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => { - const configured = hasKeyForProvider(entry.value); - return { - value: entry.value, - label: entry.label, - hint: configured ? `${entry.hint} · configured` : entry.hint, - }; - }); - - const providerChoice = guardCancel( - await select({ - message: "Choose web search provider", - options: providerOptions, - initialValue: existingProvider, - }), - runtime, - ); - - nextSearch = { ...nextSearch, provider: providerChoice }; - - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; - const existingKey = resolveExistingKey(nextConfig, providerChoice); - const keyConfigured = hasExistingKey(nextConfig, providerChoice); - const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); - const envVarNames = entry.envKeys.join(" / "); - - const keyInput = guardCancel( - await text({ - message: keyConfigured - ? envAvailable - ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` - : `${entry.label} API key (leave blank to keep current)` - : envAvailable - ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` - : `${entry.label} API key`, - placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, - }), - runtime, - ); - const key = String(keyInput ?? "").trim(); - - if (key || existingKey) { - const applied = applySearchKey(nextConfig, providerChoice, (key || existingKey)!); - nextSearch = { ...applied.tools?.web?.search }; - } else if (keyConfigured || envAvailable) { - nextSearch = { ...nextSearch }; - } else { + if (searchProviderOptions.length === 0) { note( [ - "No key stored yet — web_search won't work until a key is available.", - `Store a key here or set ${envVarNames} in the Gateway environment.`, - `Get your API key at: ${entry.signupUrl}`, + "No web search providers are currently available under this plugin policy.", + "Enable plugins or remove deny rules, then rerun configure.", "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), "Web search", ); + nextSearch = { + ...existingSearch, + enabled: false, + }; + } else { + const providerOptions = searchProviderOptions.map((entry) => { + const configured = hasKeyForProvider(entry.id); + return { + value: entry.id, + label: entry.label, + hint: configured ? `${entry.hint} · configured` : entry.hint, + }; + }); + + const providerChoice = guardCancel( + await select({ + message: "Choose web search provider", + options: providerOptions, + initialValue: existingProvider, + }), + runtime, + ); + + nextSearch = { ...nextSearch, provider: providerChoice }; + + const entry = searchProviderOptions.find((e) => e.id === providerChoice)!; + const existingKey = resolveExistingKey(nextConfig, providerChoice); + const keyConfigured = hasExistingKey(nextConfig, providerChoice); + const envAvailable = entry.envVars.some((k) => Boolean(process.env[k]?.trim())); + const envVarNames = entry.envVars.join(" / "); + + const keyInput = guardCancel( + await text({ + message: keyConfigured + ? envAvailable + ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` + : `${entry.label} API key (leave blank to keep current)` + : envAvailable + ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` + : `${entry.label} API key`, + placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, + }), + runtime, + ); + const key = String(keyInput ?? "").trim(); + + if (key || existingKey) { + workingConfig = applySearchKey(workingConfig, providerChoice, (key || existingKey)!); + nextSearch = { ...workingConfig.tools?.web?.search }; + } else if (keyConfigured || envAvailable) { + workingConfig = applySearchProviderSelection(workingConfig, providerChoice); + nextSearch = { ...workingConfig.tools?.web?.search }; + } else { + nextSearch = { ...nextSearch, provider: providerChoice }; + note( + [ + "No key stored yet — web_search won't work until a key is available.", + `Store a key here or set ${envVarNames} in the Gateway environment.`, + `Get your API key at: ${entry.signupUrl}`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } } } @@ -294,11 +308,11 @@ async function promptWebToolsConfig( }; return { - ...nextConfig, + ...workingConfig, tools: { - ...nextConfig.tools, + ...workingConfig.tools, web: { - ...nextConfig.tools?.web, + ...workingConfig.tools?.web, search: nextSearch, fetch: nextFetch, }, diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index e0599eca1bb..3bd8c871e6e 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -4,7 +4,7 @@ import { isNumericTelegramUserId, listTelegramAccountIds, normalizeTelegramAllowFromEntry, -} from "openclaw/plugin-sdk/telegram"; +} from "../../extensions/telegram/api.js"; import { normalizeChatChannelId } from "../channels/registry.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 03055c8eb17..24653eb187c 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -1,10 +1,18 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildTokenChannelStatusSummary, + probeTelegram, + type ChannelPlugin as TelegramChannelPlugin, +} from "../../extensions/telegram/runtime-api.js"; +import { + listTelegramAccountIds, + resolveTelegramAccount, +} from "../../extensions/telegram/src/accounts.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import type { HealthSummary } from "./health.js"; import { getHealthSnapshot } from "./health.js"; @@ -109,20 +117,32 @@ async function runSuccessfulTelegramProbe( return { calls, telegram }; } -let createPluginRuntime: typeof import("../plugins/runtime/index.js").createPluginRuntime; -let setTelegramRuntime: typeof import("../../extensions/telegram/src/runtime.js").setTelegramRuntime; +const telegramHealthPlugin: Pick< + TelegramChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "status" +> = { + ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), + config: { + listAccountIds: (cfg) => listTelegramAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + isConfigured: (account) => Boolean(account.token?.trim()), + }, + status: { + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), + probeAccount: async ({ account, timeoutMs }) => + await probeTelegram(account.token, timeoutMs, { + proxyUrl: account.config.proxy, + network: account.config.network, + accountId: account.accountId, + }), + }, +}; describe("getHealthSnapshot", () => { - beforeAll(async () => { - ({ createPluginRuntime } = await import("../plugins/runtime/index.js")); - ({ setTelegramRuntime } = await import("../../extensions/telegram/src/runtime.js")); - }); - beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), + createTestRegistry([{ pluginId: "telegram", plugin: telegramHealthPlugin, source: "test" }]), ); - setTelegramRuntime(createPluginRuntime()); }); afterEach(() => { diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 29df194cf2d..daeb4e95893 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -5,6 +5,7 @@ import type { ChannelPlugin, } from "../channels/plugins/types.js"; import type { CliDeps } from "../cli/deps.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { captureEnv } from "../test-utils/env.js"; @@ -69,21 +70,17 @@ vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ handleWhatsAppAction, })); +import { messageCommand } from "./message.js"; + let envSnapshot: ReturnType; +const EMPTY_TEST_REGISTRY = createTestRegistry([]); -const setRegistry = async (registry: ReturnType) => { - const { setActivePluginRegistry } = await import("../plugins/runtime.js"); - setActivePluginRegistry(registry); -}; - -beforeEach(async () => { - vi.resetModules(); +beforeEach(() => { envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN"]); process.env.TELEGRAM_BOT_TOKEN = ""; process.env.DISCORD_BOT_TOKEN = ""; testConfig = {}; - ({ messageCommand } = await import("./message.js")); - await setRegistry(createTestRegistry([])); + setActivePluginRegistry(EMPTY_TEST_REGISTRY); callGatewayMock.mockClear(); webAuthExists.mockClear().mockResolvedValue(false); handleDiscordAction.mockClear(); @@ -197,8 +194,6 @@ const createTelegramPollPluginRegistration = () => ({ }), }); -let messageCommand: typeof import("./message.js").messageCommand; - function createTelegramSecretRawConfig() { return { channels: { @@ -247,7 +242,7 @@ async function runTelegramDirectOutboundSend(params: { messageId: "msg-2", chatId: "123456", })); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { pluginId: "telegram", @@ -288,7 +283,7 @@ describe("messageCommand", () => { rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, }); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -379,7 +374,7 @@ describe("messageCommand", () => { it("defaults channel when only one configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -401,7 +396,7 @@ describe("messageCommand", () => { it("requires channel when multiple configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; process.env.DISCORD_BOT_TOKEN = "token-discord"; - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -426,7 +421,7 @@ describe("messageCommand", () => { it("sends via gateway for WhatsApp", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "g1" }); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { pluginId: "whatsapp", @@ -456,7 +451,7 @@ describe("messageCommand", () => { }); it("routes discord polls through message action", async () => { - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createDiscordPollPluginRegistration(), @@ -485,7 +480,7 @@ describe("messageCommand", () => { }); it("routes telegram polls through message action", async () => { - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramPollPluginRegistration(), diff --git a/src/commands/onboard-search.providers.test.ts b/src/commands/onboard-search.providers.test.ts new file mode 100644 index 00000000000..db57239951b --- /dev/null +++ b/src/commands/onboard-search.providers.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; + +const mocks = vi.hoisted(() => ({ + resolvePluginWebSearchProviders: vi.fn< + (params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[] + >(() => []), + listBundledWebSearchProviders: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []), + resolveBundledWebSearchPluginId: vi.fn<(providerId?: string) => string | undefined>( + () => undefined, + ), +})); + +vi.mock("../plugins/web-search-providers.runtime.js", () => ({ + resolvePluginWebSearchProviders: mocks.resolvePluginWebSearchProviders, +})); + +vi.mock("../plugins/bundled-web-search.js", () => ({ + listBundledWebSearchProviders: mocks.listBundledWebSearchProviders, + resolveBundledWebSearchPluginId: mocks.resolveBundledWebSearchPluginId, +})); + +function createCustomProviderEntry(): PluginWebSearchProviderEntry { + return { + id: "custom-search" as never, + pluginId: "custom-plugin", + label: "Custom Search", + hint: "Custom provider", + envVars: ["CUSTOM_SEARCH_API_KEY"], + placeholder: "custom-...", + signupUrl: "https://example.com/custom", + credentialPath: "plugins.entries.custom-plugin.config.webSearch.apiKey", + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + getConfiguredCredentialValue: (config) => + ( + config?.plugins?.entries?.["custom-plugin"]?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined + )?.webSearch?.apiKey, + setConfiguredCredentialValue: (configTarget, value) => { + const entries = ((configTarget.plugins ??= {}).entries ??= {}); + const pluginEntry = (entries["custom-plugin"] ??= {}); + const pluginConfig = ((pluginEntry as Record).config ??= {}) as Record< + string, + unknown + >; + const webSearch = (pluginConfig.webSearch ??= {}) as Record; + webSearch.apiKey = value; + }, + createTool: () => null, + }; +} + +function createBundledFirecrawlEntry(): PluginWebSearchProviderEntry { + return { + id: "firecrawl", + pluginId: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://example.com/firecrawl", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + getConfiguredCredentialValue: (config) => + ( + config?.plugins?.entries?.firecrawl?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined + )?.webSearch?.apiKey, + setConfiguredCredentialValue: () => {}, + createTool: () => null, + }; +} + +describe("onboard-search provider resolution", () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("uses config-aware non-bundled provider hooks when resolving existing keys", async () => { + const customEntry = createCustomProviderEntry(); + mocks.resolvePluginWebSearchProviders.mockImplementation((params) => + params?.config ? [customEntry] : [], + ); + + const mod = await import("./onboard-search.js"); + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "custom-search" as never, + }, + }, + }, + plugins: { + entries: { + "custom-plugin": { + config: { + webSearch: { + apiKey: "custom-key", + }, + }, + }, + }, + }, + }; + + expect(mod.hasExistingKey(cfg, "custom-search" as never)).toBe(true); + expect(mod.resolveExistingKey(cfg, "custom-search" as never)).toBe("custom-key"); + + const updated = mod.applySearchKey(cfg, "custom-search" as never, "next-key"); + expect( + ( + updated.plugins?.entries?.["custom-plugin"]?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined + )?.webSearch?.apiKey, + ).toBe("next-key"); + }); + + it("uses config-aware non-bundled providers when building secret refs", async () => { + const customEntry = createCustomProviderEntry(); + mocks.resolvePluginWebSearchProviders.mockImplementation((params) => + params?.config ? [customEntry] : [], + ); + + const mod = await import("./onboard-search.js"); + const cfg: OpenClawConfig = { + plugins: { + installs: { + "custom-plugin": { + installPath: "/tmp/custom-plugin", + source: "path", + }, + }, + }, + }; + const notes: Array<{ title?: string; message: string }> = []; + const prompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async (message: string, title?: string) => { + notes.push({ title, message }); + }), + select: vi.fn(async () => "custom-search"), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm: vi.fn(async () => true), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + + const result = await mod.setupSearch(cfg, {} as never, prompter as never, { + secretInputMode: "ref", + }); + + expect(result.tools?.web?.search?.provider).toBe("custom-search"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect( + ( + result.plugins?.entries?.["custom-plugin"]?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined + )?.webSearch?.apiKey, + ).toEqual({ + source: "env", + provider: "default", + id: "CUSTOM_SEARCH_API_KEY", + }); + expect(notes.some((note) => note.message.includes("CUSTOM_SEARCH_API_KEY"))).toBe(true); + }); + + it("does not treat hard-disabled bundled providers as selectable credentials", async () => { + const firecrawlEntry = createBundledFirecrawlEntry(); + mocks.resolvePluginWebSearchProviders.mockReturnValue([]); + mocks.listBundledWebSearchProviders.mockReturnValue([firecrawlEntry]); + mocks.resolveBundledWebSearchPluginId.mockReturnValue("firecrawl"); + + const mod = await import("./onboard-search.js"); + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "firecrawl", + }, + }, + }, + plugins: { + enabled: false, + entries: { + firecrawl: { + config: { + webSearch: { + apiKey: "fc-disabled-key", + }, + }, + }, + }, + }, + }; + + expect(mod.hasExistingKey(cfg, "firecrawl")).toBe(false); + expect(mod.resolveExistingKey(cfg, "firecrawl")).toBeUndefined(); + expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg); + }); +}); diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index c15fdefcf72..ce4ac6be96c 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -57,6 +57,45 @@ function pluginWebSearchApiKey(config: OpenClawConfig, pluginId: string): unknow return entry?.config?.webSearch?.apiKey; } +function createDisabledFirecrawlConfig(apiKey?: string): OpenClawConfig { + return { + tools: { + web: { + search: { + provider: "firecrawl", + }, + }, + }, + plugins: { + entries: { + firecrawl: { + enabled: false, + ...(apiKey + ? { + config: { + webSearch: { + apiKey, + }, + }, + } + : {}), + }, + }, + }, + }; +} + +function readFirecrawlPluginApiKey(config: OpenClawConfig): string | undefined { + const pluginConfig = config.plugins?.entries?.firecrawl?.config as + | { + webSearch?: { + apiKey?: string; + }; + } + | undefined; + return pluginConfig?.webSearch?.apiKey; +} + async function runBlankPerplexityKeyEntry( apiKey: string, enabled?: boolean, @@ -141,6 +180,20 @@ describe("setupSearch", () => { expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); }); + it("re-enables firecrawl and persists its plugin config when selected from disabled state", async () => { + const cfg = createDisabledFirecrawlConfig(); + const { prompter } = createPrompter({ + selectValue: "firecrawl", + textValue: "fc-disabled-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("firecrawl"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.firecrawl?.apiKey).toBeUndefined(); + expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); + expect(readFirecrawlPluginApiKey(result)).toBe("fc-disabled-key"); + }); + it("sets provider and key for grok", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ @@ -314,6 +367,60 @@ describe("setupSearch", () => { } }); + it("quickstart detects an existing firecrawl key even when the plugin is disabled", async () => { + const cfg = createDisabledFirecrawlConfig("fc-configured-key"); + const { prompter } = createPrompter({ selectValue: "firecrawl" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(prompter.text).not.toHaveBeenCalled(); + expect(result.tools?.web?.search?.provider).toBe("firecrawl"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.firecrawl?.apiKey).toBeUndefined(); + expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); + expect(readFirecrawlPluginApiKey(result)).toBe("fc-configured-key"); + }); + + it("preserves disabled firecrawl plugin state and allowlist when web search stays disabled", async () => { + const original = process.env.FIRECRAWL_API_KEY; + process.env.FIRECRAWL_API_KEY = "env-firecrawl-key"; // pragma: allowlist secret + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "firecrawl", + enabled: false, + }, + }, + }, + plugins: { + allow: ["google"], + entries: { + firecrawl: { + enabled: false, + }, + }, + }, + }; + try { + const { prompter } = createPrompter({ selectValue: "firecrawl" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(prompter.text).not.toHaveBeenCalled(); + expect(result.tools?.web?.search?.provider).toBe("firecrawl"); + expect(result.tools?.web?.search?.enabled).toBe(false); + expect(result.plugins?.entries?.firecrawl?.enabled).toBe(false); + expect(result.plugins?.allow).toEqual(["google"]); + } finally { + if (original === undefined) { + delete process.env.FIRECRAWL_API_KEY; + } else { + process.env.FIRECRAWL_API_KEY = original; + } + } + }); + it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => { const originalPerplexity = process.env.PERPLEXITY_API_KEY; const originalOpenRouter = process.env.OPENROUTER_API_KEY; @@ -430,8 +537,8 @@ describe("setupSearch", () => { }); it("exports all 7 providers in SEARCH_PROVIDER_OPTIONS", () => { + const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.id); expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(7); - const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); expect(values).toEqual([ "brave", "gemini", diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 2047328433f..7052260f748 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -6,6 +6,10 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../config/types.secrets.js"; +import { + listBundledWebSearchProviders, + resolveBundledWebSearchPluginId, +} from "../plugins/bundled-web-search.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -18,41 +22,77 @@ export type SearchProvider = NonNullable< type SearchConfig = NonNullable["web"]>["search"]>; type MutableSearchConfig = SearchConfig & Record; -type SearchProviderEntry = { - value: SearchProvider; - label: string; - hint: string; - envKeys: string[]; - placeholder: string; - signupUrl: string; - credentialPath: string; - applySelectionConfig?: PluginWebSearchProviderEntry["applySelectionConfig"]; -}; - -export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = +export const SEARCH_PROVIDER_OPTIONS: readonly PluginWebSearchProviderEntry[] = resolvePluginWebSearchProviders({ bundledAllowlistCompat: true, - }).map((provider) => ({ - value: provider.id, - label: provider.label, - hint: provider.hint, - envKeys: provider.envVars, - placeholder: provider.placeholder, - signupUrl: provider.signupUrl, - credentialPath: provider.credentialPath, - applySelectionConfig: provider.applySelectionConfig, - })); + }); -export function hasKeyInEnv(entry: SearchProviderEntry): boolean { - return entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); +function sortSearchProviderOptions( + providers: PluginWebSearchProviderEntry[], +): PluginWebSearchProviderEntry[] { + return providers.toSorted((left, right) => { + const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.id.localeCompare(right.id); + }); +} + +function canRepairBundledProviderSelection( + config: OpenClawConfig, + provider: Pick, +): boolean { + const pluginId = provider.pluginId ?? resolveBundledWebSearchPluginId(provider.id); + if (!pluginId) { + return false; + } + if (config.plugins?.enabled === false) { + return false; + } + return !config.plugins?.deny?.includes(pluginId); +} + +export function resolveSearchProviderOptions( + config?: OpenClawConfig, +): readonly PluginWebSearchProviderEntry[] { + if (!config) { + return SEARCH_PROVIDER_OPTIONS; + } + + const merged = new Map( + resolvePluginWebSearchProviders({ + config, + bundledAllowlistCompat: true, + env: process.env, + }).map((entry) => [entry.id, entry]), + ); + + for (const entry of listBundledWebSearchProviders()) { + if (merged.has(entry.id) || !canRepairBundledProviderSelection(config, entry)) { + continue; + } + merged.set(entry.id, entry); + } + + return sortSearchProviderOptions([...merged.values()]); +} + +function resolveSearchProviderEntry( + config: OpenClawConfig, + provider: SearchProvider, +): PluginWebSearchProviderEntry | undefined { + return resolveSearchProviderOptions(config).find((entry) => entry.id === provider); +} + +export function hasKeyInEnv(entry: Pick): boolean { + return entry.envVars.some((k) => Boolean(process.env[k]?.trim())); } function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown { const search = config.tools?.web?.search; - const entry = resolvePluginWebSearchProviders({ - config, - bundledAllowlistCompat: true, - }).find((candidate) => candidate.id === provider); + const entry = resolveSearchProviderEntry(config, provider); return ( entry?.getConfiguredCredentialValue?.(config) ?? entry?.getCredentialValue(search as Record | undefined) @@ -73,9 +113,12 @@ export function hasExistingKey(config: OpenClawConfig, provider: SearchProvider) } /** Build an env-backed SecretRef for a search provider. */ -function buildSearchEnvRef(provider: SearchProvider): SecretRef { - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); - const envVar = entry?.envKeys.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envKeys[0]; +function buildSearchEnvRef(config: OpenClawConfig, provider: SearchProvider): SecretRef { + const entry = + resolveSearchProviderEntry(config, provider) ?? + SEARCH_PROVIDER_OPTIONS.find((candidate) => candidate.id === provider) ?? + listBundledWebSearchProviders().find((candidate) => candidate.id === provider); + const envVar = entry?.envVars.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envVars[0]; if (!envVar) { throw new Error( `No env var mapping for search provider "${provider}" at ${entry?.credentialPath ?? "unknown path"} in secret-input-mode=ref.`, @@ -86,13 +129,14 @@ function buildSearchEnvRef(provider: SearchProvider): SecretRef { /** Resolve a plaintext key into the appropriate SecretInput based on mode. */ function resolveSearchSecretInput( + config: OpenClawConfig, provider: SearchProvider, key: string, secretInputMode?: SecretInputMode, ): SecretInput { const useSecretRefMode = secretInputMode === "ref"; // pragma: allowlist secret if (useSecretRefMode) { - return buildSearchEnvRef(provider); + return buildSearchEnvRef(config, provider); } return key; } @@ -102,12 +146,12 @@ export function applySearchKey( provider: SearchProvider, key: SecretInput, ): OpenClawConfig { - const providerEntry = resolvePluginWebSearchProviders({ - config, - bundledAllowlistCompat: true, - }).find((candidate) => candidate.id === provider); + const providerEntry = resolveSearchProviderEntry(config, provider); + if (!providerEntry) { + return config; + } const search: MutableSearchConfig = { ...config.tools?.web?.search, provider, enabled: true }; - if (providerEntry && !providerEntry.setConfiguredCredentialValue) { + if (!providerEntry.setConfiguredCredentialValue) { providerEntry.setCredentialValue(search, key); } const nextBase: OpenClawConfig = { @@ -117,16 +161,19 @@ export function applySearchKey( web: { ...config.tools?.web, search }, }, }; - const next = providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase; - providerEntry?.setConfiguredCredentialValue?.(next, key); + const next = providerEntry.applySelectionConfig?.(nextBase) ?? nextBase; + providerEntry.setConfiguredCredentialValue?.(next, key); return next; } -function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig { - const providerEntry = resolvePluginWebSearchProviders({ - config, - bundledAllowlistCompat: true, - }).find((candidate) => candidate.id === provider); +export function applySearchProviderSelection( + config: OpenClawConfig, + provider: SearchProvider, +): OpenClawConfig { + const providerEntry = resolveSearchProviderEntry(config, provider); + if (!providerEntry) { + return config; + } const search: MutableSearchConfig = { ...config.tools?.web?.search, provider, @@ -142,20 +189,65 @@ function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): Op }, }, }; - return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase; + return providerEntry.applySelectionConfig?.(nextBase) ?? nextBase; } function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig { if (original.tools?.web?.search?.enabled !== false) { return result; } - return { + + const next: OpenClawConfig = { ...result, tools: { ...result.tools, web: { ...result.tools?.web, search: { ...result.tools?.web?.search, enabled: false } }, }, }; + + const provider = next.tools?.web?.search?.provider; + if (typeof provider !== "string") { + return next; + } + const providerEntry = resolveSearchProviderEntry(original, provider); + if (!providerEntry?.pluginId) { + return next; + } + + const pluginId = providerEntry.pluginId; + const originalPluginEntry = ( + original.plugins?.entries as Record> | undefined + )?.[pluginId]; + const resultPluginEntry = ( + next.plugins?.entries as Record> | undefined + )?.[pluginId]; + + const nextPlugins = { ...next.plugins } as Record; + + if (Array.isArray(original.plugins?.allow)) { + nextPlugins.allow = [...original.plugins.allow]; + } else { + delete nextPlugins.allow; + } + + if (resultPluginEntry || originalPluginEntry) { + const nextEntries = { + ...(nextPlugins.entries as Record> | undefined), + }; + const patchedEntry = { ...resultPluginEntry }; + if (typeof originalPluginEntry?.enabled === "boolean") { + patchedEntry.enabled = originalPluginEntry.enabled; + } else { + delete patchedEntry.enabled; + } + nextEntries[pluginId] = patchedEntry; + nextPlugins.entries = nextEntries; + } + + return { + ...next, + plugins: nextPlugins as OpenClawConfig["plugins"], + }; } export type SetupSearchOptions = { @@ -169,6 +261,19 @@ export async function setupSearch( prompter: WizardPrompter, opts?: SetupSearchOptions, ): Promise { + const providerOptions = resolveSearchProviderOptions(config); + if (providerOptions.length === 0) { + await prompter.note( + [ + "No web search providers are currently available under this plugin policy.", + "Enable plugins or remove deny rules, then run setup again.", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + return config; + } + await prompter.note( [ "Web search lets your agent look things up online.", @@ -180,23 +285,21 @@ export async function setupSearch( const existingProvider = config.tools?.web?.search?.provider; - const options = SEARCH_PROVIDER_OPTIONS.map((entry) => { - const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry); + const options = providerOptions.map((entry) => { + const configured = hasExistingKey(config, entry.id) || hasKeyInEnv(entry); const hint = configured ? `${entry.hint} · configured` : entry.hint; - return { value: entry.value, label: entry.label, hint }; + return { value: entry.id, label: entry.label, hint }; }); const defaultProvider: SearchProvider = (() => { - if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) { + if (existingProvider && providerOptions.some((entry) => entry.id === existingProvider)) { return existingProvider; } - const detected = SEARCH_PROVIDER_OPTIONS.find( - (e) => hasExistingKey(config, e.value) || hasKeyInEnv(e), - ); + const detected = providerOptions.find((e) => hasExistingKey(config, e.id) || hasKeyInEnv(e)); if (detected) { - return detected.value; + return detected.id; } - return SEARCH_PROVIDER_OPTIONS[0].value; + return providerOptions[0].id; })(); const choice = await prompter.select({ @@ -216,7 +319,11 @@ export async function setupSearch( return config; } - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!; + const entry = + resolveSearchProviderEntry(config, choice) ?? providerOptions.find((e) => e.id === choice); + if (!entry) { + return config; + } const existingKey = resolveExistingKey(config, choice); const keyConfigured = hasExistingKey(config, choice); const envAvailable = hasKeyInEnv(entry); @@ -224,16 +331,16 @@ export async function setupSearch( if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) { const result = existingKey ? applySearchKey(config, choice, existingKey) - : applyProviderOnly(config, choice); + : applySearchProviderSelection(config, choice); return preserveDisabledState(config, result); } const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret if (useSecretRefMode) { if (keyConfigured) { - return preserveDisabledState(config, applyProviderOnly(config, choice)); + return preserveDisabledState(config, applySearchProviderSelection(config, choice)); } - const ref = buildSearchEnvRef(choice); + const ref = buildSearchEnvRef(config, choice); await prompter.note( [ "Secret references enabled — OpenClaw will store a reference instead of the API key.", @@ -257,7 +364,7 @@ export async function setupSearch( const key = keyInput?.trim() ?? ""; if (key) { - const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode); + const secretInput = resolveSearchSecretInput(config, choice, key, opts?.secretInputMode); return applySearchKey(config, choice, secretInput); } @@ -266,7 +373,7 @@ export async function setupSearch( } if (keyConfigured || envAvailable) { - return preserveDisabledState(config, applyProviderOnly(config, choice)); + return preserveDisabledState(config, applySearchProviderSelection(config, choice)); } await prompter.note( diff --git a/src/config/doc-baseline.integration.test.ts b/src/config/doc-baseline.integration.test.ts index 1cb81623889..71c95d03f07 100644 --- a/src/config/doc-baseline.integration.test.ts +++ b/src/config/doc-baseline.integration.test.ts @@ -70,6 +70,26 @@ describe("config doc baseline integration", () => { expect(tokenEntry?.tags).toContain("security"); }); + it("uses human-readable channel metadata for top-level channel sections", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("channels.discord")).toMatchObject({ + label: "Discord", + help: "very well supported right now.", + }); + expect(byPath.get("channels.msteams")).toMatchObject({ + label: "Microsoft Teams", + help: "Bot Framework; enterprise support.", + }); + expect(byPath.get("channels.matrix")).toMatchObject({ + label: "Matrix", + help: "open protocol; install the plugin to enable.", + }); + expect(byPath.get("channels.msteams")?.label).not.toContain("@openclaw/"); + expect(byPath.get("channels.matrix")?.help).not.toContain("homeserver"); + }); + it("matches array help hints that still use [] notation", async () => { const baseline = await getSharedBaseline(); const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 1603fa3dd1b..1aa996fd5b6 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -269,7 +269,12 @@ function resolveFirstExistingPath(candidates: string[]): string | null { } async function loadBundledConfigSchemaResponse(): Promise { - const [{ loadPluginManifestRegistry }, { buildConfigSchema }] = await Promise.all([ + const [ + { listChannelPluginCatalogEntries }, + { loadPluginManifestRegistry }, + { buildConfigSchema }, + ] = await Promise.all([ + import("../channels/plugins/catalog.js"), import("../plugins/manifest-registry.js"), import("./schema.js"), ]); @@ -286,6 +291,12 @@ async function loadBundledConfigSchemaResponse(): Promise env, config: {}, }); + const channelCatalogById = new Map( + listChannelPluginCatalogEntries({ + workspaceDir: repoRoot, + env, + }).map((entry) => [entry.id, entry.meta] as const), + ); logConfigDocBaselineDebug(`loaded ${manifestRegistry.plugins.length} bundled plugin manifests`); const bundledChannelPlugins = manifestRegistry.plugins.filter( (plugin) => plugin.origin === "bundled" && plugin.channels.length > 0, @@ -295,16 +306,20 @@ async function loadBundledConfigSchemaResponse(): Promise ? await bundledChannelPlugins.reduce>( async (promise, plugin) => { const loaded = await promise; + const catalogMeta = channelCatalogById.get(plugin.id); + const label = catalogMeta?.label ?? plugin.name ?? plugin.id; + const description = catalogMeta?.blurb ?? plugin.description; loaded.push( (await loadChannelSurfaceMetadata( plugin.rootDir, plugin.id, - plugin.name ?? plugin.id, + label, + description, repoRoot, )) ?? { id: plugin.id, - label: plugin.name ?? plugin.id, - description: plugin.description, + label, + description, configSchema: plugin.configSchema, configUiHints: plugin.configUiHints, }, @@ -314,21 +329,26 @@ async function loadBundledConfigSchemaResponse(): Promise Promise.resolve([]), ) : await Promise.all( - bundledChannelPlugins.map( - async (plugin) => + bundledChannelPlugins.map(async (plugin) => { + const catalogMeta = channelCatalogById.get(plugin.id); + const label = catalogMeta?.label ?? plugin.name ?? plugin.id; + const description = catalogMeta?.blurb ?? plugin.description; + return ( (await loadChannelSurfaceMetadata( plugin.rootDir, plugin.id, - plugin.name ?? plugin.id, + label, + description, repoRoot, )) ?? { id: plugin.id, - label: plugin.name ?? plugin.id, - description: plugin.description, + label, + description, configSchema: plugin.configSchema, configUiHints: plugin.configUiHints, - }, - ), + } + ); + }), ); logConfigDocBaselineDebug( `loaded ${channelPlugins.length} bundled channel entries from channel surfaces`, @@ -359,6 +379,7 @@ async function loadChannelSurfaceMetadata( rootDir: string, id: string, label: string, + description: string | undefined, repoRoot: string, ): Promise { logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`); @@ -386,6 +407,7 @@ async function loadChannelSurfaceMetadata( return { id, label, + description, configSchema: configSurface.schema, configUiHints: configSurface.uiHints as ConfigSchemaResponse["uiHints"] | undefined, }; diff --git a/src/config/paths.ts b/src/config/paths.ts index 84c27749bcf..a35a1a3d03d 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -99,7 +99,7 @@ function resolveUserPath( export const STATE_DIR = resolveStateDir(); /** - * Config file path (JSON5). + * Config file path (JSON or JSON5). * Can be overridden via OPENCLAW_CONFIG_PATH. * Default: ~/.openclaw/openclaw.json (or $OPENCLAW_STATE_DIR/openclaw.json) */ diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 54fd24b5880..6cf09647cf6 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,4 +1,4 @@ -import { hasAnyWhatsAppAuth } from "openclaw/plugin-sdk/whatsapp"; +import { hasAnyWhatsAppAuth } from "../../extensions/whatsapp/api.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { hasMeaningfulChannelConfig } from "../channels/config-presence.js"; import { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index c22d5e15b32..947726bd7e8 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 "openclaw/plugin-sdk/discord"; +} from "../../extensions/discord/runtime-api.js"; 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/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 3fde5236294..cba88dda8b9 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -10,11 +10,9 @@ vi.mock("../config.js", () => ({ loadConfig: vi.fn().mockReturnValue({}), })); -type StoreModule = typeof import("./store.js"); +import { loadConfig } from "../config.js"; +import { clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } from "./store.js"; -let clearSessionStoreCacheForTest: StoreModule["clearSessionStoreCacheForTest"]; -let loadSessionStore: StoreModule["loadSessionStore"]; -let saveSessionStore: StoreModule["saveSessionStore"]; let mockLoadConfig: ReturnType; const DAY_MS = 24 * 60 * 60 * 1000; @@ -81,10 +79,6 @@ describe("Integration: saveSessionStore with pruning", () => { }); beforeEach(async () => { - vi.resetModules(); - ({ clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } = - await import("./store.js")); - const { loadConfig } = await import("../config.js"); mockLoadConfig = vi.mocked(loadConfig) as ReturnType; testDir = await createCaseDir("pruning-integ"); storePath = path.join(testDir, "sessions.json"); diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts index 09659c968fb..c823979c964 100644 --- a/src/context-engine/legacy.ts +++ b/src/context-engine/legacy.ts @@ -40,6 +40,7 @@ export class LegacyContextEngine implements ContextEngine { sessionKey?: string; messages: AgentMessage[]; tokenBudget?: number; + model?: string; }): Promise { // Pass-through: the existing sanitize -> validate -> limit -> repair pipeline // in attempt.ts handles context assembly for the legacy engine. diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index 7ddd695b5b6..438ae625d2d 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -131,6 +131,9 @@ export interface ContextEngine { sessionKey?: string; messages: AgentMessage[]; tokenBudget?: number; + /** Current model identifier (e.g. "claude-opus-4", "gpt-4o", "qwen2.5-7b"). + * Allows context engine plugins to adapt formatting per model. */ + model?: string; }): Promise; /** diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 85966c3e07c..538ebdca273 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -1,4 +1,4 @@ -import { resolveWhatsAppAccount } from "openclaw/plugin-sdk/whatsapp"; +import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/api.js"; import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index f511636fb85..405d04cbe60 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -56,6 +56,38 @@ describe("cron store", () => { await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i); }); + it("accepts JSON5 syntax when loading an existing cron store", async () => { + const store = await makeStorePath(); + await fs.mkdir(path.dirname(store.storePath), { recursive: true }); + await fs.writeFile( + store.storePath, + `{ + // hand-edited legacy store + version: 1, + jobs: [ + { + id: 'job-1', + name: 'Job 1', + enabled: true, + createdAtMs: 1, + updatedAtMs: 1, + schedule: { kind: 'every', everyMs: 60000 }, + sessionTarget: 'main', + wakeMode: 'next-heartbeat', + payload: { kind: 'systemEvent', text: 'tick-job-1' }, + state: {}, + }, + ], + }`, + "utf-8", + ); + + await expect(loadCronStore(store.storePath)).resolves.toMatchObject({ + version: 1, + jobs: [{ id: "job-1", enabled: true }], + }); + }); + it("does not create a backup file when saving unchanged content", async () => { const store = await makeStorePath(); const payload = makeStore("job-1", true); diff --git a/src/cron/store.ts b/src/cron/store.ts index 8e8f0440f35..be286806f2c 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -1,9 +1,9 @@ import { randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import JSON5 from "json5"; import { expandHomePrefix } from "../infra/home-dir.js"; import { CONFIG_DIR } from "../utils.js"; +import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; import type { CronStoreFile } from "./types.js"; export const DEFAULT_CRON_DIR = path.join(CONFIG_DIR, "cron"); @@ -26,7 +26,7 @@ export async function loadCronStore(storePath: string): Promise { const raw = await fs.promises.readFile(storePath, "utf-8"); let parsed: unknown; try { - parsed = JSON5.parse(raw); + parsed = parseJsonWithJson5Fallback(raw); } catch (err) { throw new Error(`Failed to parse cron store at ${storePath}: ${String(err)}`, { cause: err, diff --git a/src/extensionAPI.test.ts b/src/extensionAPI.test.ts new file mode 100644 index 00000000000..d2d9bf657a0 --- /dev/null +++ b/src/extensionAPI.test.ts @@ -0,0 +1,21 @@ +import * as extensionApi from "openclaw/extension-api"; +import { describe, expect, it } from "vitest"; + +describe("extension-api compat surface", () => { + it("keeps legacy agent helpers importable", () => { + expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function"); + expect(typeof extensionApi.resolveAgentDir).toBe("function"); + expect(typeof extensionApi.resolveAgentWorkspaceDir).toBe("function"); + expect(typeof extensionApi.resolveAgentTimeoutMs).toBe("function"); + expect(typeof extensionApi.ensureAgentWorkspace).toBe("function"); + }); + + it("keeps legacy defaults and session helpers importable", () => { + expect(typeof extensionApi.DEFAULT_MODEL).toBe("string"); + expect(typeof extensionApi.DEFAULT_PROVIDER).toBe("string"); + expect(typeof extensionApi.resolveStorePath).toBe("function"); + expect(typeof extensionApi.loadSessionStore).toBe("function"); + expect(typeof extensionApi.saveSessionStore).toBe("function"); + expect(typeof extensionApi.resolveSessionFilePath).toBe("function"); + }); +}); diff --git a/src/extensionAPI.ts b/src/extensionAPI.ts new file mode 100644 index 00000000000..267ba27ab3c --- /dev/null +++ b/src/extensionAPI.ts @@ -0,0 +1,32 @@ +// Legacy compat surface for plugins that still import openclaw/extension-api. +// Keep this file intentionally narrow and forward-only. + +const shouldWarnExtensionApiImport = + process.env.VITEST !== "true" && + process.env.NODE_ENV !== "test" && + process.env.OPENCLAW_SUPPRESS_EXTENSION_API_WARNING !== "1"; + +if (shouldWarnExtensionApiImport) { + process.emitWarning( + "openclaw/extension-api is deprecated. Migrate to api.runtime.agent.* or focused openclaw/plugin-sdk/ imports. See https://docs.openclaw.ai/plugins/sdk-migration", + { + code: "OPENCLAW_EXTENSION_API_DEPRECATED", + detail: + "This compatibility bridge is temporary. Bundled plugins should use the injected plugin runtime instead of importing host-side agent helpers directly. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration", + }, + ); +} + +export { resolveAgentDir, resolveAgentWorkspaceDir } from "./agents/agent-scope.js"; +export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./agents/defaults.js"; +export { resolveAgentIdentity } from "./agents/identity.js"; +export { resolveThinkingDefault } from "./agents/model-selection.js"; +export { runEmbeddedPiAgent } from "./agents/pi-embedded.js"; +export { resolveAgentTimeoutMs } from "./agents/timeout.js"; +export { ensureAgentWorkspace } from "./agents/workspace.js"; +export { + resolveStorePath, + loadSessionStore, + saveSessionStore, + resolveSessionFilePath, +} from "./config/sessions.js"; diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index 8ce128d4938..159211f7e8e 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -101,7 +101,7 @@ describe("model-pricing-cache", () => { ], }, hooks: { - mappings: [{ model: "xai/grok-4" }], + mappings: [{ model: "xai/grok-4.20-experimental-beta-0304-reasoning" }], }, tools: { subagents: { model: { primary: "zai/glm-5" } }, @@ -130,7 +130,7 @@ describe("model-pricing-cache", () => { }, }, { - id: "x-ai/grok-4", + id: "x-ai/grok-4.20-experimental-beta-0304-reasoning", pricing: { prompt: "0.000002", completion: "0.00001", @@ -172,12 +172,25 @@ describe("model-pricing-cache", () => { cacheRead: 0.3, cacheWrite: 0, }); - expect(getCachedGatewayModelPricing({ provider: "xai", model: "grok-4" })).toEqual({ + expect( + getCachedGatewayModelPricing({ + provider: "xai", + model: "grok-4.20-experimental-beta-0304-reasoning", + }), + ).toEqual({ input: 2, output: 10, cacheRead: 0, cacheWrite: 0, }); + expect(getCachedGatewayModelPricing({ provider: "xai", model: "grok-4.20-reasoning" })).toEqual( + { + input: 2, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + ); expect(getCachedGatewayModelPricing({ provider: "zai", model: "glm-5" })).toEqual({ input: 1, output: 4, diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index 8a2e250f53f..ef05628d234 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -7,7 +7,7 @@ import { resolveModelRefFromString, type ModelRef, } from "../agents/model-selection.js"; -import { normalizeGoogleModelId } from "../agents/models-config.providers.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "../agents/models-config.providers.js"; import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -155,6 +155,9 @@ function canonicalizeOpenRouterLookupId(id: string): string { if (provider === "google") { model = normalizeGoogleModelId(model); } + if (provider === "x-ai") { + model = normalizeXaiModelId(model); + } return `${provider}/${model}`; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index ebf81bea62c..dd5a659dbc9 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -7,8 +7,8 @@ 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 { handleSlackHttpRequest } from "../../extensions/slack/api.js"; 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"; diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 61fff855a8f..a5ffeae9a21 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -3,9 +3,9 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; -import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; +import { createChannelTestPluginBase } from "../test-utils/channel-plugins.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { @@ -58,12 +58,31 @@ const createMSTeamsPlugin = (params?: { aliases?: string[] }): ChannelPlugin => }, }); +const createStubChannelPlugin = (params: { + id: ChannelPlugin["id"]; + label: string; +}): ChannelPlugin => ({ + ...createChannelTestPluginBase({ + id: params.id, + label: params.label, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }), + outbound: { + deliveryMode: "direct", + sendText: async () => ({ channel: params.id, messageId: "msg-test" }), + sendMedia: async () => ({ channel: params.id, messageId: "msg-test" }), + }, +}); + const emptyRegistry = createRegistry([]); const defaultRegistry = createRegistry([ { pluginId: "whatsapp", source: "test", - plugin: whatsappPlugin, + plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }), }, ]); @@ -181,7 +200,7 @@ describe("gateway server agent", () => { expect(vi.mocked(agentCommand)).not.toHaveBeenCalled(); }); - test("agent accepts channel aliases (imsg/teams)", async () => { + test("agent accepts built-in channel alias (imsg)", async () => { const registry = createRegistry([ { pluginId: "msteams", @@ -204,6 +223,19 @@ describe("gateway server agent", () => { }); expect(resIMessage.ok).toBe(true); + expectAgentRoutingCall({ channel: "imessage", deliver: true, fromEnd: 1 }); + }); + + test("agent accepts plugin channel alias (teams)", async () => { + const registry = createRegistry([ + { + pluginId: "msteams", + source: "test", + plugin: createMSTeamsPlugin({ aliases: ["teams"] }), + }, + ]); + setRegistry(registry); + const resTeams = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", @@ -213,8 +245,6 @@ describe("gateway server agent", () => { idempotencyKey: "idem-agent-teams", }); expect(resTeams.ok).toBe(true); - - expectAgentRoutingCall({ channel: "imessage", deliver: true, fromEnd: 2 }); expectAgentRoutingCall({ channel: "msteams", deliver: false, diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index f6b29fe041a..a450fcddde2 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -1,6 +1,7 @@ import { vi } from "vitest"; import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { setTestPluginRegistry } from "./test-helpers.mocks.js"; export const registryState: { registry: PluginRegistry } = { registry: createEmptyPluginRegistry(), @@ -8,6 +9,7 @@ export const registryState: { registry: PluginRegistry } = { export function setRegistry(registry: PluginRegistry) { registryState.registry = registry; + setTestPluginRegistry(registry); setActivePluginRegistry(registry); } diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index d77b1e0bdb4..5f62200314e 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -11,6 +11,7 @@ import { extractArchive, resolvePackedRootDir } from "./archive.js"; let fixtureRoot = ""; let fixtureCount = 0; const directorySymlinkType = process.platform === "win32" ? "junction" : undefined; +const ARCHIVE_EXTRACT_TIMEOUT_MS = 15_000; async function makeTempDir(prefix = "case") { const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); @@ -67,7 +68,7 @@ async function expectExtractedSizeBudgetExceeded(params: { extractArchive({ archivePath: params.archivePath, destDir: params.destDir, - timeoutMs: params.timeoutMs ?? 5_000, + timeoutMs: params.timeoutMs ?? ARCHIVE_EXTRACT_TIMEOUT_MS, limits: { maxExtractedBytes: params.maxExtractedBytes }, }), ).rejects.toThrow("archive extracted size exceeds limit"); @@ -93,7 +94,11 @@ describe("archive utils", () => { fileName: "hello.txt", content: "hi", }); - await extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }); + await extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }); const rootDir = await resolvePackedRootDir(extractDir); const content = await fs.readFile(path.join(rootDir, "hello.txt"), "utf-8"); expect(content).toBe("hi"); @@ -118,7 +123,11 @@ describe("archive utils", () => { await createDirectorySymlink(realExtractDir, extractDir); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink", } satisfies Partial); @@ -135,7 +144,11 @@ describe("archive utils", () => { await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" })); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toThrow(/(escapes destination|absolute)/i); }); }); @@ -151,7 +164,11 @@ describe("archive utils", () => { await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" })); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink-traversal", } satisfies Partial); @@ -186,7 +203,11 @@ describe("archive utils", () => { timing: "after-realpath", run: async () => { await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink-traversal", } satisfies Partial); @@ -222,7 +243,11 @@ describe("archive utils", () => { try { await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink-traversal", } satisfies Partial); @@ -245,7 +270,11 @@ describe("archive utils", () => { await tar.c({ cwd: insideDir, file: archivePath }, ["../outside.txt"]); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toThrow(/escapes destination/i); }); }); @@ -261,7 +290,11 @@ describe("archive utils", () => { await tar.c({ cwd: archiveRoot, file: archivePath }, ["escape"]); await expect( - extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, + }), ).rejects.toMatchObject({ code: "destination-symlink-traversal", } satisfies Partial); @@ -308,7 +341,7 @@ describe("archive utils", () => { extractArchive({ archivePath, destDir: extractDir, - timeoutMs: 5_000, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, limits: { maxArchiveBytes: Math.max(1, stat.size - 1) }, }), ).rejects.toThrow("archive size exceeds limit"); @@ -328,7 +361,7 @@ describe("archive utils", () => { extractArchive({ archivePath, destDir: extractDir, - timeoutMs: 5_000, + timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS, }), ).rejects.toThrow(/absolute|drive path|escapes destination/i); }); diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 2dfc1c97dbd..4e1f0b003e2 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { isDiscordExecApprovalClientEnabled } from "../../extensions/discord/src/exec-approvals.js"; +import { buildTelegramExecApprovalButtons } from "../../extensions/telegram/src/approval-buttons.js"; +import { isTelegramExecApprovalClientEnabled } from "../../extensions/telegram/src/exec-approvals.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { buildExecApprovalPendingReplyPayload } from "../infra/exec-approval-reply.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; const baseRequest = { @@ -23,15 +26,65 @@ afterEach(() => { }); const emptyRegistry = createTestRegistry([]); +const telegramApprovalPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "execApprovals" +> = { + ...createChannelTestPluginBase({ id: "telegram" }), + execApprovals: { + shouldSuppressForwardingFallback: ({ cfg, target, request }) => { + if (target.channel !== "telegram" || request.request.turnSourceChannel !== "telegram") { + return false; + } + const accountId = target.accountId?.trim() || request.request.turnSourceAccountId?.trim(); + return isTelegramExecApprovalClientEnabled({ cfg, accountId }); + }, + buildPendingPayload: ({ request, nowMs }) => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: request.request.command, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs, + }); + const buttons = buildTelegramExecApprovalButtons(request.id); + if (!buttons) { + return payload; + } + return { + ...payload, + channelData: { + ...payload.channelData, + telegram: { buttons }, + }, + }; + }, + }, +}; +const discordApprovalPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "execApprovals" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + execApprovals: { + shouldSuppressForwardingFallback: ({ cfg, target }) => + target.channel === "discord" && + isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }), + }, +}; const defaultRegistry = createTestRegistry([ { pluginId: "telegram", - plugin: telegramPlugin, + plugin: telegramApprovalPlugin, source: "test", }, { pluginId: "discord", - plugin: discordPlugin, + plugin: discordApprovalPlugin, source: "test", }, ]); diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index 92c89e0b026..0026297c56e 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -1,19 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; -import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; -import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js"; import * as replyModule from "../auto-reply/reply.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentMainSessionKey, resolveMainSessionKey } from "../config/sessions.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createPluginRuntime } from "../plugins/runtime/index.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; import { seedSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); +vi.mock("./outbound/deliver.js", () => ({ + deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined), +})); type SeedSessionInput = { lastChannel: string; @@ -44,17 +40,7 @@ async function withHeartbeatFixture( ); } -beforeEach(() => { - const runtime = createPluginRuntime(); - setTelegramRuntime(runtime); - setWhatsAppRuntime(runtime); - setActivePluginRegistry( - createTestRegistry([ - { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, - { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, - ]), - ); -}); +beforeEach(() => {}); afterEach(() => { vi.restoreAllMocks(); diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index f90df5271f1..dc57971af4b 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -278,6 +278,40 @@ describe("fetchWithSsrFGuard hardening", () => { }); }); + it("blocks URLs that use credentials to obscure a private host", async () => { + const fetchImpl = vi.fn(); + // http://attacker.com@127.0.0.1:8080/ — URL parser extracts hostname as 127.0.0.1 + await expect( + fetchWithSsrFGuard({ + url: "http://attacker.com@127.0.0.1:8080/internal", + fetchImpl, + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("blocks private IPv6 addresses embedded in URLs with credentials", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "http://user:pass@[::1]:8080/internal", + fetchImpl, + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("blocks redirect to a URL using credentials to obscure a private host", async () => { + const lookupFn = createPublicLookup(); + const fetchImpl = await expectRedirectFailure({ + url: "https://public.example/start", + responses: [redirectResponse("http://public@127.0.0.1:6379/")], + expectedError: /private|internal|blocked/i, + lookupFn, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => { await runProxyModeDispatcherTest({ mode: GUARDED_FETCH_MODE.STRICT, diff --git a/src/infra/outbound/channel-adapters.test.ts b/src/infra/outbound/channel-adapters.test.ts index ca39b403226..7656c879b3b 100644 --- a/src/infra/outbound/channel-adapters.test.ts +++ b/src/infra/outbound/channel-adapters.test.ts @@ -1,15 +1,42 @@ -import { Separator, TextDisplay } from "@buape/carbon"; +import { Container, Separator, TextDisplay } from "@buape/carbon"; import { beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { getChannelMessageAdapter } from "./channel-adapters.js"; +class TestDiscordUiContainer extends Container {} + +const discordCrossContextPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "messaging" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + messaging: { + buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => { + const trimmed = message.trim(); + const components: Array = []; + if (trimmed) { + components.push(new TextDisplay(message)); + components.push(new Separator({ divider: true, spacing: "small" })); + } + components.push(new TextDisplay(`*From ${originLabel}*`)); + void cfg; + void accountId; + return [new TestDiscordUiContainer(components)]; + }, + }, +}; + describe("getChannelMessageAdapter", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" }, + ]), ); }); @@ -31,10 +58,10 @@ describe("getChannelMessageAdapter", () => { cfg: {} as never, accountId: "primary", }); - const container = components?.[0] as DiscordUiContainer | undefined; + const container = components?.[0] as TestDiscordUiContainer | undefined; expect(components).toHaveLength(1); - expect(container).toBeInstanceOf(DiscordUiContainer); + expect(container).toBeInstanceOf(TestDiscordUiContainer); expect(container?.components).toEqual([ expect.any(TextDisplay), expect.any(Separator), @@ -49,7 +76,7 @@ describe("getChannelMessageAdapter", () => { message: " ", cfg: {} as never, }); - const container = components?.[0] as DiscordUiContainer | undefined; + const container = components?.[0] as TestDiscordUiContainer | undefined; expect(components).toHaveLength(1); expect(container?.components).toEqual([expect.any(TextDisplay)]); diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index 3442711eab4..309a237af52 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; +import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; +import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -27,28 +27,67 @@ function createToolContext( }; } +function resolveSlackAutoThreadId(params: { + to: string; + toolContext?: { + currentChannelId?: string; + currentThreadTs?: string; + replyToMode?: "off" | "first" | "all"; + hasRepliedRef?: { value: boolean }; + }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + if (context.replyToMode !== "all" && context.replyToMode !== "first") { + return undefined; + } + const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); + if (!parsedTarget || parsedTarget.kind !== "channel") { + return undefined; + } + if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) { + return undefined; + } + if (context.replyToMode === "first" && context.hasRepliedRef?.value) { + return undefined; + } + return context.currentThreadTs; +} + +function resolveTelegramAutoThreadId(params: { + to: string; + toolContext?: { currentThreadTs?: string; currentChannelId?: string }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + const parsedTo = parseTelegramTarget(params.to); + const parsedChannel = parseTelegramTarget(context.currentChannelId); + if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) { + return undefined; + } + return context.currentThreadTs; +} + describe("message action threading helpers", () => { it("resolves Slack auto-thread ids only for matching active channels", () => { expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "#c123", toolContext: createToolContext(), }), ).toBe("thread-1"); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "channel:C999", toolContext: createToolContext(), }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "user:U123", toolContext: createToolContext(), }), @@ -57,9 +96,7 @@ describe("message action threading helpers", () => { it("skips Slack auto-thread ids when reply mode or context blocks them", () => { expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ replyToMode: "first", @@ -68,17 +105,13 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ replyToMode: "off" }), }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ currentThreadTs: undefined }), }), @@ -87,9 +120,7 @@ describe("message action threading helpers", () => { it("resolves Telegram auto-thread ids for matching chats across target formats", () => { expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "telegram:group:-100123:topic:77", toolContext: createToolContext({ currentChannelId: "tg:group:-100123", @@ -97,9 +128,7 @@ describe("message action threading helpers", () => { }), ).toBe("thread-1"); expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "-100999:77", toolContext: createToolContext({ currentChannelId: "-100123", @@ -107,9 +136,7 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "-100123", toolContext: createToolContext({ currentChannelId: undefined }), }), diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index 89ab0cd6c2c..9665e44f558 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -6,7 +6,10 @@ import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; vi.mock("../../media/web-media.js", async () => { @@ -78,28 +81,45 @@ async function expectSandboxMediaRewrite(params: { type MessageActionRunnerModule = typeof import("./message-action-runner.js"); type WebMediaModule = typeof import("../../media/web-media.js"); -type SlackChannelModule = typeof import("../../../extensions/slack/src/channel.js"); -type RuntimeIndexModule = typeof import("../../plugins/runtime/index.js"); -type SlackRuntimeModule = typeof import("../../../extensions/slack/src/runtime.js"); let runMessageAction: MessageActionRunnerModule["runMessageAction"]; let loadWebMedia: WebMediaModule["loadWebMedia"]; -let slackPlugin: SlackChannelModule["slackPlugin"]; -let createPluginRuntime: RuntimeIndexModule["createPluginRuntime"]; -let setSlackRuntime: SlackRuntimeModule["setSlackRuntime"]; -function installSlackRuntime() { - const runtime = createPluginRuntime(); - setSlackRuntime(runtime); -} +const slackPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "slack", + label: "Slack", + config: { + listAccountIds: () => ["default"], + resolveAccount: (cfg) => cfg.channels?.slack ?? {}, + isConfigured: async (account) => + typeof (account as { botToken?: unknown }).botToken === "string" && + (account as { botToken?: string }).botToken!.trim() !== "" && + typeof (account as { appToken?: unknown }).appToken === "string" && + (account as { appToken?: string }).appToken!.trim() !== "", + }, + }), + outbound: { + deliveryMode: "direct", + resolveTarget: ({ to }) => { + const trimmed = to?.trim() ?? ""; + if (!trimmed) { + return { + ok: false, + error: new Error("missing target for slack"), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async () => ({ channel: "slack", messageId: "msg-test" }), + sendMedia: async () => ({ channel: "slack", messageId: "msg-test" }), + }, +}; describe("runMessageAction media behavior", () => { beforeAll(async () => { ({ runMessageAction } = await import("./message-action-runner.js")); ({ loadWebMedia } = await import("../../media/web-media.js")); - ({ slackPlugin } = await import("../../../extensions/slack/src/channel.js")); - ({ createPluginRuntime } = await import("../../plugins/runtime/index.js")); - ({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js")); }); beforeEach(() => { @@ -304,7 +324,6 @@ describe("runMessageAction media behavior", () => { describe("sandboxed media validation", () => { beforeEach(() => { - installSlackRuntime(); setActivePluginRegistry( createTestRegistry([ { diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index 43e71afb923..72abac24d58 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -1,8 +1,12 @@ +import { Container, Separator, TextDisplay } from "@buape/carbon"; import { beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { applyCrossContextDecoration, buildCrossContextDecoration, @@ -10,6 +14,29 @@ import { shouldApplyCrossContextMarker, } from "./outbound-policy.js"; +class TestDiscordUiContainer extends Container {} + +const discordCrossContextPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "messaging" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + messaging: { + buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => { + const trimmed = message.trim(); + const components: Array = []; + if (trimmed) { + components.push(new TextDisplay(message)); + components.push(new Separator({ divider: true, spacing: "small" })); + } + components.push(new TextDisplay(`*From ${originLabel}*`)); + void cfg; + void accountId; + return [new TestDiscordUiContainer(components)]; + }, + }, +}; + const slackConfig = { channels: { slack: { @@ -28,7 +55,9 @@ const discordConfig = { describe("outbound policy helpers", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" }, + ]), ); }); diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index f90fc7f221e..006a160e6ab 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -44,9 +43,7 @@ import { import { runResolveOutboundTargetCoreTests } from "./targets.shared-test.js"; beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), - ); + setActivePluginRegistry(createTestRegistry([])); }); describe("delivery-queue", () => { diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 8c8dd821df6..a5b5bc9111f 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -1,7 +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 { listTelegramAccountIds } from "../../extensions/telegram/api.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 94332c5b307..c47bbcb2192 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -36,6 +36,7 @@ describe("tsdown config", () => { expect.arrayContaining([ "index", "plugins/runtime/index", + "plugin-sdk/compat", "plugin-sdk/index", "extensions/openai/index", "bundled/boot-md/handler", diff --git a/src/line/webhook.test.ts b/src/line/webhook.test.ts index 9b3b9c0539a..5c38c58f3ce 100644 --- a/src/line/webhook.test.ts +++ b/src/line/webhook.test.ts @@ -138,6 +138,92 @@ describe("createLineWebhookMiddleware", () => { expect(onEvents).not.toHaveBeenCalled(); }); + it("uses the signed raw body instead of a pre-parsed req.body object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBody = JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-user" } }], + }); + const reqBody = { + events: [{ type: "message", source: { userId: "tampered-user" } }], + }; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBody, SECRET) }, + rawBody, + body: reqBody, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(200); + expect(onEvents).toHaveBeenCalledTimes(1); + const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; + expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-user"); + expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); + }); + + it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBodyText = JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-buffer-user" } }], + }); + const reqBody = { + events: [{ type: "message", source: { userId: "tampered-user" } }], + }; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBodyText, SECRET) }, + rawBody: Buffer.from(rawBodyText, "utf-8"), + body: reqBody, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(200); + expect(onEvents).toHaveBeenCalledTimes(1); + const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; + expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-buffer-user"); + expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); + }); + + it("rejects invalid signed raw JSON even when req.body is a valid object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBody = "not-json"; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBody, SECRET) }, + rawBody, + body: { events: [{ type: "message" }] }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" }); + expect(onEvents).not.toHaveBeenCalled(); + }); + it("returns 500 when event processing fails and does not acknowledge with 200", async () => { const onEvents = vi.fn(async () => { throw new Error("boom"); diff --git a/src/line/webhook.ts b/src/line/webhook.ts index 99c338db2f9..879972d0490 100644 --- a/src/line/webhook.ts +++ b/src/line/webhook.ts @@ -23,10 +23,7 @@ function readRawBody(req: Request): string | null { return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody; } -function parseWebhookBody(req: Request, rawBody?: string | null): WebhookRequestBody | null { - if (req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body)) { - return req.body as WebhookRequestBody; - } +function parseWebhookBody(rawBody?: string | null): WebhookRequestBody | null { if (!rawBody) { return null; } @@ -64,7 +61,8 @@ export function createLineWebhookMiddleware( return; } - const body = parseWebhookBody(req, rawBody); + // Keep processing tied to the exact bytes that passed signature verification. + const body = parseWebhookBody(rawBody); if (!body) { res.status(400).json({ error: "Invalid webhook payload" }); diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index b1d80a5e50d..6622f6c010f 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -45,8 +45,6 @@ describe("pairing setup code", () => { authLabel: string; url?: string; urlSource?: string; - token?: string; - password?: string; }, ) { expect(resolved.ok).toBe(true); @@ -55,8 +53,6 @@ describe("pairing setup code", () => { } expect(resolved.authLabel).toBe(params.authLabel); expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); - expect(resolved.payload.token).toBe(params.token); - expect(resolved.payload.password).toBe(params.password); if (params.url) { expect(resolved.payload.url).toBe(params.url); } @@ -117,7 +113,6 @@ describe("pairing setup code", () => { payload: { url: "ws://gateway.local:19001", bootstrapToken: "bootstrap-123", - token: "tok_123", }, authLabel: "token", urlSource: "gateway.bind=custom", @@ -144,7 +139,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "resolved-password" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("uses OPENCLAW_GATEWAY_PASSWORD without resolving configured password SecretRef", async () => { @@ -167,7 +162,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "password-from-env" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("does not resolve gateway.auth.password SecretRef in token mode", async () => { @@ -189,7 +184,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "token", token: "tok_123" }); + expectResolvedSetupOk(resolved, { authLabel: "token" }); }); it("resolves gateway.auth.token SecretRef for pairing payload", async () => { @@ -212,7 +207,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "token", token: "resolved-token" }); + expectResolvedSetupOk(resolved, { authLabel: "token" }); }); it("errors when gateway.auth.token SecretRef is unresolved in token mode", async () => { @@ -261,13 +256,13 @@ describe("pairing setup code", () => { id: "MISSING_GW_TOKEN", }); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "password-from-env" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("does not treat env-template token as plaintext in inferred mode", async () => { const resolved = await resolveInferredModeWithPasswordEnv("${MISSING_GW_TOKEN}"); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "password-from-env" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("requires explicit auth mode when token and password are both configured", async () => { @@ -333,7 +328,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "token", token: "new-token" }); + expectResolvedSetupOk(resolved, { authLabel: "token" }); }); it("errors when gateway is loopback only", async () => { @@ -367,7 +362,6 @@ describe("pairing setup code", () => { payload: { url: "wss://mb-server.tailnet.ts.net", bootstrapToken: "bootstrap-123", - password: "secret", }, authLabel: "password", urlSource: "gateway.tailscale.mode=serve", @@ -396,7 +390,6 @@ describe("pairing setup code", () => { payload: { url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", - token: "tok_123", }, authLabel: "token", urlSource: "gateway.remote.url", diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index c64ae36077e..6a2c5dd0b39 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -16,8 +16,6 @@ import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type PairingSetupPayload = { url: string; bootstrapToken: string; - token?: string; - password?: string; }; export type PairingSetupCommandResult = { @@ -64,11 +62,6 @@ type ResolveAuthLabelResult = { error?: string; }; -type ResolveSharedAuthResult = { - token?: string; - password?: string; -}; - function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const trimmed = raw.trim(); if (!trimmed) { @@ -213,41 +206,6 @@ function resolvePairingSetupAuthLabel( return { error: "Gateway auth is not configured (no token or password)." }; } -function resolvePairingSetupSharedAuth( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv, -): ResolveSharedAuthResult { - const defaults = cfg.secrets?.defaults; - const tokenRef = resolveSecretInputRef({ - value: cfg.gateway?.auth?.token, - defaults, - }).ref; - const passwordRef = resolveSecretInputRef({ - value: cfg.gateway?.auth?.password, - defaults, - }).ref; - const token = - resolveGatewayTokenFromEnv(env) || - (tokenRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.token)); - const password = - resolveGatewayPasswordFromEnv(env) || - (passwordRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.password)); - const mode = cfg.gateway?.auth?.mode; - if (mode === "token") { - return { token }; - } - if (mode === "password") { - return { password }; - } - if (token) { - return { token }; - } - if (password) { - return { password }; - } - return {}; -} - async function resolveGatewayTokenSecretRef( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, @@ -417,8 +375,6 @@ export async function resolvePairingSetupFromConfig( if (authLabel.error) { return { ok: false, error: authLabel.error }; } - const sharedAuth = resolvePairingSetupSharedAuth(cfgForAuth, env); - const urlResult = await resolveGatewayUrl(cfgForAuth, { env, publicUrl: options.publicUrl, @@ -445,8 +401,6 @@ export async function resolvePairingSetupFromConfig( baseDir: options.pairingBaseDir, }) ).token, - ...(sharedAuth.token ? { token: sharedAuth.token } : {}), - ...(sharedAuth.password ? { password: sharedAuth.password } : {}), }, authLabel: authLabel.label, urlSource: urlResult.source ?? "unknown", diff --git a/src/plugin-sdk/acp-runtime.ts b/src/plugin-sdk/acp-runtime.ts index 84435bb896a..1657cb7cace 100644 --- a/src/plugin-sdk/acp-runtime.ts +++ b/src/plugin-sdk/acp-runtime.ts @@ -3,6 +3,12 @@ export { getAcpSessionManager } from "../acp/control-plane/manager.js"; export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js"; export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; +export { + getAcpRuntimeBackend, + registerAcpRuntimeBackend, + requireAcpRuntimeBackend, + unregisterAcpRuntimeBackend, +} from "../acp/runtime/registry.js"; export type { AcpRuntime, AcpRuntimeCapabilities, diff --git a/src/plugin-sdk/acpx.ts b/src/plugin-sdk/acpx.ts index 9d634ec8fb5..1e131f0dfd3 100644 --- a/src/plugin-sdk/acpx.ts +++ b/src/plugin-sdk/acpx.ts @@ -1,4 +1,4 @@ -// Public ACPX runtime backend helpers. +// Private ACPX runtime backend helpers for bundled extensions. // Keep this surface narrow and limited to the ACP runtime/backend contract. export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; diff --git a/src/plugin-sdk/allow-from.ts b/src/plugin-sdk/allow-from.ts index f03f2427558..5b15896c917 100644 --- a/src/plugin-sdk/allow-from.ts +++ b/src/plugin-sdk/allow-from.ts @@ -1,3 +1,32 @@ +export type { + AllowlistMatch, + AllowlistMatchSource, + CompiledAllowlist, +} from "../channels/allowlist-match.js"; +export type { AllowlistUserResolutionLike } from "../channels/allowlists/resolve-utils.js"; +export { + compileAllowlist, + formatAllowlistMatchMeta, + resolveAllowlistCandidates, + resolveAllowlistMatchByCandidates, + resolveAllowlistMatchSimple, + resolveCompiledAllowlistMatch, +} from "../channels/allowlist-match.js"; +export { + firstDefined, + isSenderIdAllowed, + mergeDmAllowFromSources, + resolveGroupAllowFromSources, +} from "../channels/allow-from.js"; +export { + addAllowlistUserEntriesFromConfigEntry, + buildAllowlistResolutionSummary, + canonicalizeAllowlistWithResolvedIds, + mergeAllowlist, + patchAllowlistUsersInConfigEntries, + summarizeMapping, +} from "../channels/allowlists/resolve-utils.js"; + /** Lowercase and optionally strip prefixes from allowlist entries before sender comparisons. */ export function formatAllowFromLowercase(params: { allowFrom: Array; @@ -96,3 +125,36 @@ export function isAllowedParsedChatSender } return false; } + +export type BasicAllowlistResolutionEntry = { + input: string; + resolved: boolean; + id?: string; + name?: string; + note?: string; +}; + +/** Clone allowlist resolution entries into a plain serializable shape for UI and docs output. */ +export function mapBasicAllowlistResolutionEntries( + entries: BasicAllowlistResolutionEntry[], +): BasicAllowlistResolutionEntry[] { + return entries.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note: entry.note, + })); +} + +/** Map allowlist inputs sequentially so resolver side effects stay ordered and predictable. */ +export async function mapAllowlistResolutionInputs(params: { + inputs: string[]; + mapInput: (input: string) => Promise | T; +}): Promise { + const results: T[] = []; + for (const input of params.inputs) { + results.push(await params.mapInput(input)); + } + return results; +} diff --git a/src/plugin-sdk/allowlist-resolution.test.ts b/src/plugin-sdk/allowlist-resolution.test.ts index 5b606cfbe9f..12619308269 100644 --- a/src/plugin-sdk/allowlist-resolution.test.ts +++ b/src/plugin-sdk/allowlist-resolution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mapAllowlistResolutionInputs } from "./allowlist-resolution.js"; +import { mapAllowlistResolutionInputs } from "./allow-from.js"; describe("mapAllowlistResolutionInputs", () => { it("maps inputs sequentially and preserves order", async () => { diff --git a/src/plugin-sdk/allowlist-resolution.ts b/src/plugin-sdk/allowlist-resolution.ts deleted file mode 100644 index 1acf87f4d1c..00000000000 --- a/src/plugin-sdk/allowlist-resolution.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type BasicAllowlistResolutionEntry = { - input: string; - resolved: boolean; - id?: string; - name?: string; - note?: string; -}; - -/** Clone allowlist resolution entries into a plain serializable shape for UI and docs output. */ -export function mapBasicAllowlistResolutionEntries( - entries: BasicAllowlistResolutionEntry[], -): BasicAllowlistResolutionEntry[] { - return entries.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.note, - })); -} - -/** Map allowlist inputs sequentially so resolver side effects stay ordered and predictable. */ -export async function mapAllowlistResolutionInputs(params: { - inputs: string[]; - mapInput: (input: string) => Promise | T; -}): Promise { - const results: T[] = []; - for (const input of params.inputs) { - results.push(await params.mapInput(input)); - } - return results; -} diff --git a/src/plugin-sdk/message-tool-schema.ts b/src/plugin-sdk/channel-actions.ts similarity index 78% rename from src/plugin-sdk/message-tool-schema.ts rename to src/plugin-sdk/channel-actions.ts index 889812fdbe4..2f6f5748461 100644 --- a/src/plugin-sdk/message-tool-schema.ts +++ b/src/plugin-sdk/channel-actions.ts @@ -1,3 +1,8 @@ +export { + createUnionActionGate, + listTokenSourcedAccounts, +} from "../channels/plugins/actions/shared.js"; +export { resolveReactionMessageId } from "../channels/plugins/actions/reaction-message-id.js"; import { Type } from "@sinclair/typebox"; import type { TSchema } from "@sinclair/typebox"; import { stringEnum } from "../agents/schema/typebox.js"; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index d9a229657dd..18fb609de31 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -2,6 +2,15 @@ import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; +import { + authorizeConfigWrite, + canBypassConfigWritePolicy, + formatConfigWriteDeniedMessage, + resolveChannelConfigWrites, + type ConfigWriteAuthorizationResult, + type ConfigWriteScope, + type ConfigWriteTarget, +} from "../channels/plugins/config-writes.js"; import { collectAllowlistProviderGroupPolicyWarnings, collectAllowlistProviderRestrictSendersWarnings, @@ -17,6 +26,14 @@ import type { OpenClawConfig } from "../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; +export { + authorizeConfigWrite, + canBypassConfigWritePolicy, + formatConfigWriteDeniedMessage, + resolveChannelConfigWrites, +}; +export type { ConfigWriteAuthorizationResult, ConfigWriteScope, ConfigWriteTarget }; + /** Coerce mixed allowlist config values into plain strings without trimming or deduping. */ export function mapAllowFromEntries( allowFrom: Array | null | undefined, diff --git a/src/plugin-sdk/channel-contract.ts b/src/plugin-sdk/channel-contract.ts new file mode 100644 index 00000000000..507166d87f0 --- /dev/null +++ b/src/plugin-sdk/channel-contract.ts @@ -0,0 +1,16 @@ +export type { + BaseProbeResult, + BaseTokenResolution, + ChannelAgentTool, + ChannelAccountSnapshot, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMessageActionDiscoveryContext, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, + ChannelStatusIssue, + ChannelThreadingContext, + ChannelThreadingToolContext, +} from "../channels/plugins/types.js"; diff --git a/src/plugin-sdk/channel-feedback.ts b/src/plugin-sdk/channel-feedback.ts new file mode 100644 index 00000000000..f9f03011ee0 --- /dev/null +++ b/src/plugin-sdk/channel-feedback.ts @@ -0,0 +1,21 @@ +export { + removeAckReactionAfterReply, + shouldAckReaction, + shouldAckReactionForWhatsApp, + type AckReactionGateParams, + type AckReactionScope, + type WhatsAppAckReactionMode, +} from "../channels/ack-reactions.js"; +export { logAckFailure, logTypingFailure, type LogFn } from "../channels/logging.js"; +export { + CODING_TOOL_TOKENS, + createStatusReactionController, + DEFAULT_EMOJIS, + DEFAULT_TIMING, + resolveToolEmoji, + WEB_TOOL_TOKENS, + type StatusReactionAdapter, + type StatusReactionController, + type StatusReactionEmojis, + type StatusReactionTiming, +} from "../channels/status-reactions.js"; diff --git a/src/plugin-sdk/channel-inbound.ts b/src/plugin-sdk/channel-inbound.ts new file mode 100644 index 00000000000..3f2f2708564 --- /dev/null +++ b/src/plugin-sdk/channel-inbound.ts @@ -0,0 +1,34 @@ +export { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "../auto-reply/inbound-debounce.js"; +export { + formatInboundEnvelope, + formatInboundFromLabel, + resolveEnvelopeFormatOptions, +} from "../auto-reply/envelope.js"; +export type { EnvelopeFormatOptions } from "../auto-reply/envelope.js"; +export { + buildMentionRegexes, + matchesMentionPatterns, + matchesMentionWithExplicit, + normalizeMentionText, +} from "../auto-reply/reply/mentions.js"; +export { + createChannelInboundDebouncer, + shouldDebounceTextInbound, +} from "../channels/inbound-debounce-policy.js"; +export type { + MentionGateParams, + MentionGateResult, + MentionGateWithBypassParams, + MentionGateWithBypassResult, +} from "../channels/mention-gating.js"; +export { + resolveMentionGating, + resolveMentionGatingWithBypass, +} from "../channels/mention-gating.js"; +export type { NormalizedLocation } from "../channels/location.js"; +export { formatLocationText, toLocationContext } from "../channels/location.js"; +export { logInboundDrop } from "../channels/logging.js"; +export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; diff --git a/src/plugin-sdk/channel-lifecycle.ts b/src/plugin-sdk/channel-lifecycle.ts index 28045aeb058..96a031ce5b7 100644 --- a/src/plugin-sdk/channel-lifecycle.ts +++ b/src/plugin-sdk/channel-lifecycle.ts @@ -1,4 +1,12 @@ import type { ChannelAccountSnapshot } from "../channels/plugins/types.core.js"; +export * from "../channels/draft-stream-controls.js"; +export * from "../channels/draft-stream-loop.js"; +export { createRunStateMachine } from "../channels/run-state-machine.js"; +export { + createArmableStallWatchdog, + type ArmableStallWatchdog, + type StallWatchdogTimeoutMeta, +} from "../channels/transport/stall-watchdog.js"; type CloseAwareServer = { once: (event: "close", listener: () => void) => unknown; diff --git a/src/plugin-sdk/channel-pairing.ts b/src/plugin-sdk/channel-pairing.ts index 749c18bf86c..e085dc4e381 100644 --- a/src/plugin-sdk/channel-pairing.ts +++ b/src/plugin-sdk/channel-pairing.ts @@ -1,4 +1,9 @@ import type { ChannelId } from "../channels/plugins/types.js"; +export { + createLoggedPairingApprovalNotifier, + createPairingPrefixStripper, + createTextPairingAdapter, +} from "../channels/plugins/pairing-adapters.js"; import { issuePairingChallenge } from "../pairing/pairing-challenge.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { createScopedPairingAccess } from "./pairing-access.js"; diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index b45315a6757..377c7269613 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -1,73 +1,18 @@ -// Shared channel/runtime helpers for plugins. Channel plugins should use this -// surface instead of reaching into src/channels or adjacent infra modules. +// Legacy compatibility shim for older channel helpers. Prefer the dedicated +// plugin-sdk subpaths instead of adding new imports here. -export * from "../channels/ack-reactions.js"; -export * from "../channels/allow-from.js"; -export * from "../channels/allowlists/resolve-utils.js"; -export * from "../channels/allowlist-match.js"; -export * from "../channels/channel-config.js"; export * from "../channels/chat-type.js"; -export * from "../channels/command-gating.js"; -export * from "../channels/conversation-label.js"; -export * from "../channels/draft-stream-controls.js"; -export * from "../channels/draft-stream-loop.js"; -export * from "../channels/inbound-debounce-policy.js"; -export * from "../channels/location.js"; -export * from "../channels/logging.js"; -export * from "../channels/mention-gating.js"; -export * from "../channels/native-command-session-targets.js"; export * from "../channels/reply-prefix.js"; -export * from "../channels/run-state-machine.js"; -export * from "../channels/session.js"; -export * from "../channels/session-envelope.js"; -export * from "../channels/session-meta.js"; -export * from "../channels/status-reactions.js"; -export * from "../channels/targets.js"; -export * from "../channels/thread-binding-id.js"; -export * from "../channels/thread-bindings-messages.js"; -export * from "../channels/thread-bindings-policy.js"; -export * from "../channels/transport/stall-watchdog.js"; export * from "../channels/typing.js"; -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 { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export * from "./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/threading-helpers.js"; -export * from "../channels/plugins/status-issues/shared.js"; export * from "../channels/plugins/whatsapp-heartbeat.js"; -export { - buildComputedAccountStatusSnapshot, - buildTokenChannelStatusSummary, -} from "./status-helpers.js"; -export { - projectCredentialSnapshotFields, - resolveConfiguredFromCredentialStatuses, -} from "../channels/account-snapshot-fields.js"; -export * from "../infra/outbound/send-deps.js"; 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 { - InteractiveButtonStyle, - InteractiveReplyButton, - InteractiveReply, -} from "../interactive/payload.js"; export { - normalizeInteractiveReply, - resolveInteractiveTextFallback, -} from "../interactive/payload.js"; + createAccountStatusSink, + keepHttpServerTaskAlive, + waitUntilAbort, +} from "./channel-lifecycle.js"; diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts index 12e74741264..07c0099500f 100644 --- a/src/plugin-sdk/channel-send-result.ts +++ b/src/plugin-sdk/channel-send-result.ts @@ -1,6 +1,8 @@ import type { ChannelOutboundAdapter, ChannelPollResult } from "../channels/plugins/types.js"; import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; +export type { ChannelOutboundAdapter } from "../channels/plugins/types.js"; + export type ChannelSendRawResult = { ok: boolean; messageId?: string | null; diff --git a/src/plugin-sdk/channel-setup.ts b/src/plugin-sdk/channel-setup.ts index 6488bd1a770..c12027f2944 100644 --- a/src/plugin-sdk/channel-setup.ts +++ b/src/plugin-sdk/channel-setup.ts @@ -1,11 +1,13 @@ import type { ChannelSetupWizard } from "../channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../channels/plugins/types.core.js"; import { createOptionalChannelSetupAdapter, createOptionalChannelSetupWizard, } from "./optional-channel-setup.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +export type { ChannelSetupInput } from "../channels/plugins/types.core.js"; export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "./setup.js"; export { DEFAULT_ACCOUNT_ID, diff --git a/src/plugin-sdk/channel-targets.ts b/src/plugin-sdk/channel-targets.ts new file mode 100644 index 00000000000..c24bc9b3046 --- /dev/null +++ b/src/plugin-sdk/channel-targets.ts @@ -0,0 +1,29 @@ +export { + applyChannelMatchMeta, + buildChannelKeyCandidates, + normalizeChannelSlug, + resolveChannelEntryMatch, + resolveChannelEntryMatchWithFallback, + resolveChannelMatchConfig, + resolveNestedAllowlistDecision, + type ChannelEntryMatch, + type ChannelMatchSource, +} from "../channels/channel-config.js"; +export { + buildMessagingTarget, + ensureTargetId, + normalizeTargetId, + parseAtUserTarget, + parseMentionPrefixOrAtUserTarget, + parseTargetMention, + parseTargetPrefix, + parseTargetPrefixes, + requireTargetKind, + type MessagingTarget, + type MessagingTargetKind, + type MessagingTargetParseOptions, +} from "../channels/targets.js"; +export { + buildUnresolvedTargetResults, + resolveTargetsWithOptionalToken, +} from "../channels/plugins/target-resolvers.js"; diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 0a09e0c1dcd..4d4324ce891 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -1,6 +1,83 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; +export { + hasControlCommand, + hasInlineCommandTokens, + isControlCommandMessage, + shouldComputeCommandAuthorized, +} from "../auto-reply/command-detection.js"; +export { + buildCommandText, + buildCommandTextFromArgs, + findCommandByNativeName, + getCommandDetection, + isCommandEnabled, + isCommandMessage, + isNativeCommandSurface, + listChatCommands, + listChatCommandsForConfig, + listNativeCommandSpecs, + listNativeCommandSpecsForConfig, + maybeResolveTextAlias, + normalizeCommandBody, + parseCommandArgs, + resolveCommandArgChoices, + resolveCommandArgMenu, + resolveTextCommand, + serializeCommandArgs, + shouldHandleTextCommands, +} from "../auto-reply/commands-registry.js"; +export type { + ChatCommandDefinition, + CommandArgChoiceContext, + CommandArgDefinition, + CommandArgMenuSpec, + CommandArgValues, + CommandArgs, + CommandDetection, + CommandNormalizeOptions, + CommandScope, + NativeCommandSpec, + ResolvedCommandArgChoice, + ShouldHandleTextCommandsParams, +} from "../auto-reply/commands-registry.js"; +export { + resolveCommandAuthorizedFromAuthorizers, + resolveControlCommandGate, + resolveDualTextControlCommandGate, + type CommandAuthorizer, + type CommandGatingModeWhenAccessGroupsOff, +} from "../channels/command-gating.js"; +export { + resolveNativeCommandSessionTargets, + type ResolveNativeCommandSessionTargetsParams, +} from "../channels/native-command-session-targets.js"; +export { + resolveCommandAuthorization, + type CommandAuthorization, +} from "../auto-reply/command-auth.js"; +export { + listReservedChatSlashCommandNames, + listSkillCommandsForAgents, + listSkillCommandsForWorkspace, + resolveSkillCommandInvocation, +} from "../auto-reply/skill-commands.js"; +export { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js"; +export { + buildModelsProviderData, + formatModelsAvailableHeader, + resolveModelsCommandReply, +} from "../auto-reply/reply/commands-models.js"; +export type { ModelsProviderData } from "../auto-reply/reply/commands-models.js"; +export { resolveStoredModelOverride } from "../auto-reply/reply/model-selection.js"; +export type { StoredModelOverride } from "../auto-reply/reply/model-selection.js"; +export { + buildCommandsMessage, + buildCommandsMessagePaginated, + buildHelpMessage, +} from "../auto-reply/status.js"; + export type ResolveSenderCommandAuthorizationParams = { cfg: OpenClawConfig; rawBody: string; diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 5e2bcd11f58..eb85c062c71 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -8,11 +8,11 @@ const shouldWarnCompatImport = if (shouldWarnCompatImport) { process.emitWarning( - "openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/ imports.", + "openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/ imports. See https://docs.openclaw.ai/plugins/sdk-migration", { code: "OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED", detail: - "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating.", + "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration", }, ); } @@ -20,6 +20,8 @@ if (shouldWarnCompatImport) { export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; +export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js"; +export { onDiagnosticEvent } from "../infra/diagnostic-events.js"; export { createAccountStatusSink } from "./channel-lifecycle.js"; export { createPluginRuntimeStore } from "./runtime-store.js"; @@ -41,7 +43,7 @@ export * from "./channel-config-schema.js"; export * from "./channel-policy.js"; export * from "./reply-history.js"; export * from "./directory-runtime.js"; -export { mapAllowlistResolutionInputs } from "./allowlist-resolution.js"; +export { mapAllowlistResolutionInputs } from "./allow-from.js"; export { resolveBlueBubblesGroupRequireMention, diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index 67b2ec82fee..3836f15508d 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -1,19 +1,78 @@ // Shared config/runtime boundary for plugins that need config loading, // config writes, or session-store helpers without importing src internals. -export * from "../config/config.js"; -export * from "../config/markdown-tables.js"; -export * from "../config/group-policy.js"; -export * from "../config/runtime-group-policy.js"; -export * from "../config/commands.js"; -export * from "../config/discord-preview-streaming.js"; -export * from "../config/io.js"; -export * from "../config/telegram-custom-commands.js"; -export * from "../config/talk.js"; -export * from "../config/agent-limits.js"; -export * from "../cron/store.js"; -export * from "../sessions/model-overrides.js"; -export type * from "../config/types.slack.js"; +export { + getRuntimeConfigSnapshot, + loadConfig, + readConfigFileSnapshotForWrite, + writeConfigFile, +} from "../config/io.js"; +export { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +export { + resolveChannelGroupPolicy, + resolveChannelGroupRequireMention, + type ChannelGroupPolicy, +} from "../config/group-policy.js"; +export { + GROUP_POLICY_BLOCKED_LABEL, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../config/runtime-group-policy.js"; +export { + isNativeCommandsExplicitlyDisabled, + resolveNativeCommandsEnabled, + resolveNativeSkillsEnabled, +} from "../config/commands.js"; +export { + TELEGRAM_COMMAND_NAME_PATTERN, + normalizeTelegramCommandName, + resolveTelegramCustomCommands, +} from "../config/telegram-custom-commands.js"; +export { + mapStreamingModeToSlackLegacyDraftStreamMode, + resolveDiscordPreviewStreamMode, + resolveSlackNativeStreaming, + resolveSlackStreamingMode, + resolveTelegramPreviewStreamMode, + type SlackLegacyDraftStreamMode, + type StreamingMode, +} from "../config/discord-preview-streaming.js"; +export { resolveActiveTalkProviderConfig } from "../config/talk.js"; +export { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; +export { loadCronStore, resolveCronStorePath, saveCronStore } from "../cron/store.js"; +export { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; +export { coerceSecretRef } from "../config/types.secrets.js"; +export type { + DiscordAccountConfig, + DiscordActionConfig, + DiscordAutoPresenceConfig, + DiscordExecApprovalConfig, + DiscordGuildChannelConfig, + DiscordGuildEntry, + DiscordIntentsConfig, + DiscordSlashCommandConfig, + DmPolicy, + GroupPolicy, + MarkdownTableMode, + OpenClawConfig, + ReplyToMode, + SignalReactionNotificationMode, + SlackAccountConfig, + SlackChannelConfig, + SlackReactionNotificationMode, + SlackSlashCommandConfig, + TelegramAccountConfig, + TelegramActionConfig, + TelegramDirectConfig, + TelegramExecApprovalConfig, + TelegramGroupConfig, + TelegramInlineButtonsScope, + TelegramNetworkConfig, + TelegramTopicConfig, + TtsConfig, +} from "../config/types.js"; export { loadSessionStore, readSessionUpdatedAt, @@ -35,8 +94,3 @@ export { } from "../config/sessions/reset.js"; export { resolveSessionStoreEntry } from "../config/sessions/store.js"; export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; -export { - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "../config/types.secrets.js"; diff --git a/src/plugin-sdk/conversation-runtime.ts b/src/plugin-sdk/conversation-runtime.ts index 66b7e3b938f..6a9546bf0f2 100644 --- a/src/plugin-sdk/conversation-runtime.ts +++ b/src/plugin-sdk/conversation-runtime.ts @@ -26,6 +26,36 @@ export { ensureConfiguredBindingTargetSession, resetConfiguredBindingTargetInPlace, } from "../channels/plugins/binding-targets.js"; +export { resolveConversationLabel } from "../channels/conversation-label.js"; +export { recordInboundSession } from "../channels/session.js"; +export { recordInboundSessionMetaSafe } from "../channels/session-meta.js"; +export { resolveThreadBindingConversationIdFromBindingId } from "../channels/thread-binding-id.js"; +export { + createScopedAccountReplyToModeResolver, + createStaticReplyToModeResolver, + createTopLevelChannelReplyToModeResolver, +} from "../channels/plugins/threading-helpers.js"; +export { + formatThreadBindingDurationLabel, + resolveThreadBindingFarewellText, + resolveThreadBindingIntroText, + resolveThreadBindingThreadName, +} from "../channels/thread-bindings-messages.js"; +export { + DISCORD_THREAD_BINDING_CHANNEL, + MATRIX_THREAD_BINDING_CHANNEL, + formatThreadBindingDisabledError, + resolveThreadBindingEffectiveExpiresAt, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingLifecycle, + resolveThreadBindingMaxAgeMs, + resolveThreadBindingMaxAgeMsForChannel, + resolveThreadBindingsEnabled, + resolveThreadBindingSpawnPolicy, + type ThreadBindingSpawnKind, + type ThreadBindingSpawnPolicy, +} from "../channels/thread-bindings-policy.js"; export type { ConfiguredBindingConversation, ConfiguredBindingResolution, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 3c588f5a06e..24f99bb3dad 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -51,6 +51,8 @@ export type { ProviderAuthMethodNonInteractiveContext, ProviderAuthMethod, ProviderAuthResult, + OpenClawPluginToolContext, + OpenClawPluginToolFactory, OpenClawPluginCommandDefinition, OpenClawPluginDefinition, PluginCommandContext, @@ -83,6 +85,7 @@ export { migrateBaseNameToDefaultAccount, } from "../channels/plugins/setup-helpers.js"; export { + clearAccountEntryFields, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; @@ -91,7 +94,6 @@ export { parseOptionalDelimitedEntries, } from "../channels/plugins/helpers.js"; export { getChatChannelMeta } from "../channels/registry.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { channelTargetSchema, channelTargetsSchema, @@ -120,8 +122,6 @@ export { type RoutePeer, type RoutePeerKind, } from "../routing/resolve-route.js"; -export { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; -export { normalizeOutboundThreadId } from "../infra/outbound/thread-id.js"; export { resolveThreadSessionKeys } from "../routing/session-key.js"; export type ChannelOutboundSessionRouteParams = Parameters< diff --git a/src/plugin-sdk/device-bootstrap.ts b/src/plugin-sdk/device-bootstrap.ts new file mode 100644 index 00000000000..c3ecf15ab51 --- /dev/null +++ b/src/plugin-sdk/device-bootstrap.ts @@ -0,0 +1,4 @@ +// Shared bootstrap/pairing helpers for plugins that provision remote devices. + +export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; +export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; diff --git a/src/plugin-sdk/device-pair.ts b/src/plugin-sdk/device-pair.ts deleted file mode 100644 index a87e1eea8f1..00000000000 --- a/src/plugin-sdk/device-pair.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Narrow plugin-sdk surface for the bundled device-pair plugin. -// Keep this list additive and scoped to symbols used under extensions/device-pair. - -export { definePluginEntry } from "./core.js"; -export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; -export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; -export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; -export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; -export { runPluginCommandWithTimeout } from "./run-command.js"; diff --git a/src/plugin-sdk/directory-runtime.ts b/src/plugin-sdk/directory-runtime.ts index caa21657810..31209a89561 100644 --- a/src/plugin-sdk/directory-runtime.ts +++ b/src/plugin-sdk/directory-runtime.ts @@ -1,6 +1,16 @@ /** Shared directory listing helpers for plugins that derive users/groups from config maps. */ export type { DirectoryConfigParams } from "../channels/plugins/directory-types.js"; +export type { + ChannelDirectoryEntry, + ChannelDirectoryEntryKind, +} from "../channels/plugins/types.js"; export type { ReadOnlyInspectedAccount } from "../channels/read-only-account-inspect.js"; +export { + createChannelDirectoryAdapter, + createEmptyChannelDirectoryAdapter, + emptyChannelDirectoryList, + nullChannelDirectorySelf, +} from "../channels/plugins/directory-adapters.js"; export { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, @@ -15,4 +25,5 @@ export { listDirectoryUserEntriesFromAllowFromAndMapKeys, toDirectoryEntries, } from "../channels/plugins/directory-config-helpers.js"; +export { createRuntimeDirectoryLiveAdapter } from "../channels/plugins/runtime-forwarders.js"; export { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; diff --git a/src/plugin-sdk/extension-shared.ts b/src/plugin-sdk/extension-shared.ts index 43c11f7c09d..a0c5a12faa1 100644 --- a/src/plugin-sdk/extension-shared.ts +++ b/src/plugin-sdk/extension-shared.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import { runPassiveAccountLifecycle } from "./channel-runtime.js"; +import { runPassiveAccountLifecycle } from "./channel-lifecycle.js"; import { createLoggerBackedRuntime } from "./runtime.js"; type PassiveChannelStatusSnapshot = { diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 70a55d58474..b616d16fdd0 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled feishu plugin. +// Private helper surface for the bundled feishu plugin. // Keep this list additive and scoped to symbols used under extensions/feishu. export type { HistoryEntry } from "../auto-reply/reply/history.js"; diff --git a/src/plugin-sdk/google.ts b/src/plugin-sdk/google.ts index b39d4aa4ced..79ca16d674d 100644 --- a/src/plugin-sdk/google.ts +++ b/src/plugin-sdk/google.ts @@ -1,4 +1,4 @@ -// Public Google-specific helpers used by bundled Google plugins. +// Private Google-specific helpers used by bundled Google plugins. export { normalizeGoogleModelId } from "../agents/model-id-normalization.js"; export { parseGeminiAuth } from "../infra/gemini-auth.js"; diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 35f07014e86..026a5d157f8 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled googlechat plugin. +// Private helper surface for the bundled googlechat plugin. // Keep this list additive and scoped to symbols used under extensions/googlechat. import { resolveChannelGroupRequireMention } from "./channel-policy.js"; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 30040416729..db54ebbd1ff 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -50,9 +50,11 @@ describe("plugin-sdk exports", () => { it("keeps the root runtime surface intentionally small", () => { expect(typeof sdk.emptyPluginConfigSchema).toBe("function"); expect(typeof sdk.delegateCompactionToRuntime).toBe("function"); + expect(typeof sdk.onDiagnosticEvent).toBe("function"); expect(Object.prototype.hasOwnProperty.call(sdk, "resolveControlCommandGate")).toBe(false); expect(Object.prototype.hasOwnProperty.call(sdk, "buildAgentSessionKey")).toBe(false); expect(Object.prototype.hasOwnProperty.call(sdk, "isDangerousNameMatchingEnabled")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(sdk, "emitDiagnosticEvent")).toBe(false); }); it("keeps package.json plugin-sdk exports synced with the manifest", async () => { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 5bb67920734..20f8a34672a 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -64,7 +64,9 @@ export type { HookEntry } from "../hooks/types.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export type { ContextEngineFactory } from "../context-engine/registry.js"; +export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerContextEngine } from "../context-engine/registry.js"; export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; +export { onDiagnosticEvent } from "../infra/diagnostic-events.js"; diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts index 0339ca1f307..dfc21eb753b 100644 --- a/src/plugin-sdk/infra-runtime.ts +++ b/src/plugin-sdk/infra-runtime.ts @@ -27,6 +27,7 @@ export * from "../infra/net/proxy-env.js"; export * from "../infra/net/proxy-fetch.js"; export * from "../infra/net/ssrf.js"; export * from "../infra/outbound/identity.js"; +export * from "../infra/outbound/send-deps.js"; export * from "../infra/retry.js"; export * from "../infra/retry-policy.js"; export * from "../infra/scp-host.ts"; @@ -37,4 +38,5 @@ export * from "../infra/system-message.ts"; export * from "../infra/tmp-openclaw-dir.js"; export * from "../infra/transport-ready.js"; export * from "../infra/wsl.ts"; +export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forwarders.js"; export * from "./ssrf-policy.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 29df9fb5748..01e9b8557b9 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled irc plugin. +// Private helper surface for the bundled irc plugin. // Keep this list additive and scoped to symbols used under extensions/irc. export { resolveControlCommandGate } from "../channels/command-gating.js"; diff --git a/src/plugin-sdk/line-core.ts b/src/plugin-sdk/line-core.ts index 04b2950a50d..083e9ddcbe0 100644 --- a/src/plugin-sdk/line-core.ts +++ b/src/plugin-sdk/line-core.ts @@ -1,13 +1,13 @@ export type { OpenClawConfig } from "../config/config.js"; -export type { LineConfig } from "../line/types.js"; +export type { LineChannelData, LineConfig } from "../line/types.js"; export { createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, - formatDocsLink, setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, splitSetupEntries, } from "./setup.js"; +export { formatDocsLink } from "../terminal/links.js"; export type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard } from "./setup.js"; export { listLineAccountIds, @@ -18,3 +18,13 @@ export { export { resolveExactLineGroupConfigKey } from "../line/group-keys.js"; export type { ResolvedLineAccount } from "../line/types.js"; export { LineConfigSchema } from "../line/config-schema.js"; +export { + createActionCard, + createImageCard, + createInfoCard, + createListCard, + createReceiptCard, + type CardAction, + type ListItem, +} from "../line/flex-templates.js"; +export { processLineMessage } from "../line/markdown-to-line.js"; diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index 16a6c235ac3..e2196996397 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -32,7 +32,6 @@ export { resolveDefaultLineAccountId, resolveLineAccount, } from "../line/accounts.js"; -export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/setup-api.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { diff --git a/src/plugin-sdk/lobster.ts b/src/plugin-sdk/lobster.ts index c6a2a413acc..2434e1be70e 100644 --- a/src/plugin-sdk/lobster.ts +++ b/src/plugin-sdk/lobster.ts @@ -1,4 +1,4 @@ -// Public Lobster plugin helpers. +// Private Lobster plugin helpers for bundled extensions. // Keep this surface narrow and limited to the Lobster workflow/tool contract. export { definePluginEntry } from "./core.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 660fe7183fb..22bba927e64 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled matrix plugin. +// Private helper surface for the bundled matrix plugin. // Keep this list additive and scoped to symbols used under extensions/matrix. import { createOptionalChannelSetupSurface } from "./channel-setup.js"; diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 8ab28d2a4ea..25856195bd2 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled mattermost plugin. +// Private helper surface for the bundled mattermost plugin. // Keep this list additive and scoped to symbols used under extensions/mattermost. export { formatInboundFromLabel } from "../auto-reply/envelope.js"; diff --git a/src/plugin-sdk/media-runtime.ts b/src/plugin-sdk/media-runtime.ts index f824246ed51..8563c4513a6 100644 --- a/src/plugin-sdk/media-runtime.ts +++ b/src/plugin-sdk/media-runtime.ts @@ -14,9 +14,15 @@ export * from "../media/outbound-attachment.js"; export * from "../media/png-encode.ts"; export * from "../media/store.js"; export * from "../media/temp-files.js"; +export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export * from "./agent-media-payload.js"; export * from "../media-understanding/audio-preflight.ts"; export * from "../media-understanding/defaults.js"; export * from "../media-understanding/providers/image-runtime.ts"; export * from "../media-understanding/runner.js"; export * from "../polls.js"; +export { + createDirectTextMediaOutbound, + createScopedChannelMediaMaxBytesResolver, + resolveScopedChannelMediaMaxBytes, +} from "../channels/plugins/outbound/direct-text-media.js"; diff --git a/src/plugin-sdk/minimax-portal-auth.ts b/src/plugin-sdk/minimax-portal-auth.ts deleted file mode 100644 index a8dad415488..00000000000 --- a/src/plugin-sdk/minimax-portal-auth.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Narrow plugin-sdk surface for MiniMax OAuth helpers used by the bundled minimax plugin. -// Keep this list additive and scoped to MiniMax OAuth support code. - -export { definePluginEntry } from "./core.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; -export type { - OpenClawPluginApi, - ProviderAuthContext, - ProviderCatalogContext, - ProviderAuthResult, -} from "../plugins/types.js"; -export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 1c72c82ea53..9937d1d9c3d 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled msteams plugin. +// Private helper surface for the bundled msteams plugin. // Keep this list additive and scoped to symbols used under extensions/msteams. import { createOptionalChannelSetupSurface } from "./channel-setup.js"; diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 229ff806db0..c231cf49564 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled nextcloud-talk plugin. +// Private helper surface for the bundled nextcloud-talk plugin. // Keep this list additive and scoped to symbols used under extensions/nextcloud-talk. export { logInboundDrop } from "../channels/logging.js"; diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index 640642dcd46..95647cc1dcc 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled nostr plugin. +// Private helper surface for the bundled nostr plugin. // Keep this list additive and scoped to symbols used under extensions/nostr. import { createOptionalChannelSetupSurface } from "./channel-setup.js"; diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 9d0cb1eceba..e411cb51e89 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -11,6 +11,7 @@ export type { AnyAgentTool, MediaUnderstandingProviderPlugin, OpenClawPluginApi, + PluginCommandContext, OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 645073a4d02..b5de7026f0e 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -5,7 +5,6 @@ export type { SecretInput } from "../config/types.secrets.js"; export type { ProviderAuthResult } from "../plugins/types.js"; export type { ProviderAuthContext } from "../plugins/types.js"; export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { CLAUDE_CLI_PROFILE_ID, @@ -44,3 +43,9 @@ export { normalizeOptionalSecretInput, normalizeSecretInput, } from "../utils/normalize-secret-input.js"; +export { + listKnownProviderAuthEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "../secrets/provider-env-vars.js"; +export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; +export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/provider-env-vars.ts b/src/plugin-sdk/provider-env-vars.ts new file mode 100644 index 00000000000..fb4d0271bf1 --- /dev/null +++ b/src/plugin-sdk/provider-env-vars.ts @@ -0,0 +1,6 @@ +// Public provider auth environment variable helpers for plugin runtimes. + +export { + listKnownProviderAuthEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "../secrets/provider-env-vars.js"; diff --git a/src/plugin-sdk/provider-google.ts b/src/plugin-sdk/provider-google.ts new file mode 100644 index 00000000000..43130b853ca --- /dev/null +++ b/src/plugin-sdk/provider-google.ts @@ -0,0 +1,4 @@ +// Public Google provider helpers shared by bundled Google extensions. + +export { normalizeGoogleModelId } from "../agents/model-id-normalization.js"; +export { parseGeminiAuth } from "../infra/gemini-auth.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index 7103147e91d..da71fc796aa 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -24,6 +24,7 @@ export { XAI_TOOL_SCHEMA_PROFILE, } from "../agents/model-compat.js"; export { normalizeProviderId } from "../agents/provider-id.js"; +export { normalizeXaiModelId } from "../agents/model-id-normalization.js"; export { cloneFirstTemplateModel } from "../plugins/provider-model-helpers.js"; export { diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts index 258d26e7ee4..9ed067cbf23 100644 --- a/src/plugin-sdk/provider-web-search.ts +++ b/src/plugin-sdk/provider-web-search.ts @@ -23,6 +23,7 @@ export { resolveSearchCount, resolveSearchTimeoutSeconds, resolveSiteName, + postTrustedWebToolsJson, throwWebSearchApiError, withTrustedWebSearchEndpoint, writeCachedSearchPayload, diff --git a/src/plugin-sdk/provider-zai-endpoint.ts b/src/plugin-sdk/provider-zai-endpoint.ts new file mode 100644 index 00000000000..d2c288b7ed6 --- /dev/null +++ b/src/plugin-sdk/provider-zai-endpoint.ts @@ -0,0 +1,7 @@ +// Public Z.AI endpoint detection helpers for provider plugins. + +export { + detectZaiEndpoint, + type ZaiDetectedEndpoint, + type ZaiEndpointId, +} from "../plugins/provider-zai-endpoint.js"; diff --git a/src/plugin-sdk/qwen-portal-auth.ts b/src/plugin-sdk/qwen-portal-auth.ts deleted file mode 100644 index adc61259a09..00000000000 --- a/src/plugin-sdk/qwen-portal-auth.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Narrow plugin-sdk surface for the bundled qwen-portal-auth plugin. -// Keep this list additive and scoped to symbols used under extensions/qwen-portal-auth. - -export { definePluginEntry } from "./core.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; -export type { - OpenClawPluginApi, - ProviderAuthContext, - ProviderCatalogContext, -} from "../plugins/types.js"; -export { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; -export { QWEN_OAUTH_MARKER } from "../agents/model-auth-markers.js"; -export { refreshQwenPortalCredentials } from "../providers/qwen-portal-oauth.js"; -export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index 52cc878c83d..98df862d748 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -1,3 +1,8 @@ +import type { ChannelOutboundAdapter } from "../channels/plugins/types.js"; + +export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js"; +export { buildMediaPayload } from "../channels/plugins/media-payload.js"; + export type OutboundReplyPayload = { text?: string; mediaUrls?: string[]; @@ -15,6 +20,13 @@ export type SendableOutboundReplyParts = { hasContent: boolean; }; +type SendPayloadContext = Parameters>[0]; +type SendPayloadResult = Awaited>>; +type SendPayloadAdapter = Pick< + ChannelOutboundAdapter, + "sendMedia" | "sendText" | "chunker" | "textChunkLimit" +>; + /** Extract the supported outbound reply fields from loose tool or agent payload objects. */ export function normalizeOutboundReplyPayload( payload: Record, @@ -62,6 +74,11 @@ export function resolveOutboundMediaUrls(payload: { return []; } +/** Resolve media URLs from a channel sendPayload context after legacy fallback normalization. */ +export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { + return resolveOutboundMediaUrls(payload); +} + /** Count outbound media items after legacy single-media fallback normalization. */ export function countOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): number { return resolveOutboundMediaUrls(payload).length; @@ -163,6 +180,99 @@ export async function sendPayloadWithChunkedTextAndMedia< return lastResult!; } +export async function sendPayloadMediaSequence(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; +}): Promise { + let lastResult: TResult | undefined; + for (let i = 0; i < params.mediaUrls.length; i += 1) { + const mediaUrl = params.mediaUrls[i]; + if (!mediaUrl) { + continue; + } + lastResult = await params.send({ + text: i === 0 ? params.text : "", + mediaUrl, + index: i, + isFirst: i === 0, + }); + } + 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; + adapter: SendPayloadAdapter; +}): Promise { + const text = params.ctx.payload.text ?? ""; + const urls = resolvePayloadMediaUrls(params.ctx.payload); + if (!text && urls.length === 0) { + return { channel: params.channel, messageId: "" }; + } + if (urls.length > 0) { + const lastResult = await sendPayloadMediaSequence({ + text, + mediaUrls: urls, + send: async ({ text, mediaUrl }) => + await params.adapter.sendMedia!({ + ...params.ctx, + text, + mediaUrl, + }), + }); + return lastResult ?? { channel: params.channel, messageId: "" }; + } + const limit = params.adapter.textChunkLimit; + const chunks = limit && params.adapter.chunker ? params.adapter.chunker(text, limit) : [text]; + let lastResult: Awaited>>; + for (const chunk of chunks) { + lastResult = await params.adapter.sendText!({ ...params.ctx, text: chunk }); + } + return lastResult!; +} + /** Detect numeric-looking target ids for channels that distinguish ids from handles. */ export function isNumericTargetId(raw: string): boolean { const trimmed = raw.trim(); diff --git a/src/plugin-sdk/reply-runtime.ts b/src/plugin-sdk/reply-runtime.ts index 689cf4cdba7..386ecae10ad 100644 --- a/src/plugin-sdk/reply-runtime.ts +++ b/src/plugin-sdk/reply-runtime.ts @@ -1,31 +1,49 @@ // Shared agent/reply runtime helpers for channel plugins. Keep channel plugins // off direct src/auto-reply imports by routing common reply primitives here. -export * from "../auto-reply/chunk.js"; -export * from "../auto-reply/command-auth.js"; -export * from "../auto-reply/command-detection.js"; -export * from "../auto-reply/commands-registry.js"; -export * from "../auto-reply/dispatch.js"; -export * from "../auto-reply/group-activation.js"; -export * from "../auto-reply/heartbeat.js"; -export * from "../auto-reply/heartbeat-reply-payload.js"; -export * from "../auto-reply/inbound-debounce.js"; -export * from "../auto-reply/reply.js"; -export * from "../auto-reply/tokens.js"; -export * from "../auto-reply/envelope.js"; -export * from "../auto-reply/reply/history.js"; -export * from "../auto-reply/reply/abort.js"; -export * from "../auto-reply/reply/btw-command.js"; -export * from "../auto-reply/reply/commands-models.js"; -export * from "../auto-reply/reply/inbound-dedupe.js"; -export * from "../auto-reply/reply/inbound-context.js"; -export * from "../auto-reply/reply/mentions.js"; -export * from "../auto-reply/reply/reply-dispatcher.js"; -export * from "../auto-reply/reply/reply-reference.js"; -export * from "../auto-reply/reply/provider-dispatcher.js"; -export * from "../auto-reply/reply/model-selection.js"; -export * from "../auto-reply/reply/commands-info.js"; -export * from "../auto-reply/skill-commands.js"; -export * from "../auto-reply/status.js"; -export type { ReplyPayload } from "../auto-reply/types.js"; +export { + chunkMarkdownTextWithMode, + chunkText, + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, +} from "../auto-reply/chunk.js"; +export type { ChunkMode } from "../auto-reply/chunk.js"; +export { + dispatchInboundMessage, + dispatchInboundMessageWithBufferedDispatcher, + dispatchInboundMessageWithDispatcher, +} from "../auto-reply/dispatch.js"; +export { + normalizeGroupActivation, + parseActivationCommand, +} from "../auto-reply/group-activation.js"; +export { + HEARTBEAT_PROMPT, + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + resolveHeartbeatPrompt, + stripHeartbeatToken, +} from "../auto-reply/heartbeat.js"; +export { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js"; +export { getReplyFromConfig } from "../auto-reply/reply.js"; +export { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +export { isAbortRequestText } from "../auto-reply/reply/abort.js"; +export { isBtwRequestText } from "../auto-reply/reply/btw-command.js"; +export { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +export { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; +export { + dispatchReplyWithBufferedBlockDispatcher, + dispatchReplyWithDispatcher, +} from "../auto-reply/reply/provider-dispatcher.js"; +export { + createReplyDispatcher, + createReplyDispatcherWithTyping, +} from "../auto-reply/reply/reply-dispatcher.js"; +export type { + ReplyDispatcher, + ReplyDispatcherOptions, + ReplyDispatcherWithTypingOptions, +} from "../auto-reply/reply/reply-dispatcher.js"; +export { createReplyReferencePlanner } from "../auto-reply/reply/reply-reference.js"; +export type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js"; export type { FinalizedMsgContext, MsgContext } from "../auto-reply/templating.js"; diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 23e583f8c4d..11ffc459ef2 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -5,6 +5,7 @@ const fs = require("node:fs"); let monolithicSdk = null; const jitiLoaders = new Map(); +const pluginSdkSubpathsCache = new Map(); function emptyPluginConfigSchema() { function error(message) { @@ -61,6 +62,57 @@ function resolveControlCommandGate(params) { return { commandAuthorized, shouldBlock }; } +function onDiagnosticEvent(listener) { + const monolithic = loadMonolithicSdk(); + if (!monolithic || typeof monolithic.onDiagnosticEvent !== "function") { + throw new Error("openclaw/plugin-sdk root alias could not resolve onDiagnosticEvent"); + } + return monolithic.onDiagnosticEvent(listener); +} + +function getPackageRoot() { + return path.resolve(__dirname, "..", ".."); +} + +function listPluginSdkExportedSubpaths() { + const packageRoot = getPackageRoot(); + if (pluginSdkSubpathsCache.has(packageRoot)) { + return pluginSdkSubpathsCache.get(packageRoot); + } + + let subpaths = []; + try { + const packageJsonPath = path.join(packageRoot, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + subpaths = Object.keys(packageJson.exports ?? {}) + .filter((key) => key.startsWith("./plugin-sdk/")) + .map((key) => key.slice("./plugin-sdk/".length)); + } catch { + subpaths = []; + } + + pluginSdkSubpathsCache.set(packageRoot, subpaths); + return subpaths; +} + +function buildPluginSdkAliasMap(useDist) { + const packageRoot = getPackageRoot(); + const pluginSdkDir = path.join(packageRoot, useDist ? "dist" : "src", "plugin-sdk"); + const ext = useDist ? ".js" : ".ts"; + const aliasMap = { + "openclaw/plugin-sdk": __filename, + }; + + for (const subpath of listPluginSdkExportedSubpaths()) { + const candidate = path.join(pluginSdkDir, `${subpath}${ext}`); + if (fs.existsSync(candidate)) { + aliasMap[`openclaw/plugin-sdk/${subpath}`] = candidate; + } + } + + return aliasMap; +} + function getJiti(tryNative) { if (jitiLoaders.has(tryNative)) { return jitiLoaders.get(tryNative); @@ -68,6 +120,7 @@ function getJiti(tryNative) { const { createJiti } = require("jiti"); const jitiLoader = createJiti(__filename, { + alias: buildPluginSdkAliasMap(tryNative), 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. @@ -107,6 +160,7 @@ function tryLoadMonolithicSdk() { const fastExports = { emptyPluginConfigSchema, + onDiagnosticEvent, resolveControlCommandGate, }; diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 83937c34b44..37072f9ded7 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -48,6 +48,12 @@ function loadRootAliasWithStubs(options?: { } if (id === "node:fs") { return { + readFileSync: () => + JSON.stringify({ + exports: { + "./plugin-sdk/group-access": { default: "./dist/plugin-sdk/group-access.js" }, + }, + }), existsSync: () => options?.distExists ?? false, }; } @@ -164,8 +170,27 @@ describe("plugin-sdk root alias", () => { expect("delegateCompactionToRuntime" in lazyRootSdk).toBe(true); }); + it("forwards onDiagnosticEvent through the compat-backed root alias", () => { + const onDiagnosticEvent = () => () => undefined; + const lazyModule = loadRootAliasWithStubs({ + monolithicExports: { + onDiagnosticEvent, + }, + }); + const lazyRootSdk = lazyModule.moduleExports; + + expect(typeof lazyRootSdk.onDiagnosticEvent).toBe("function"); + expect( + typeof (lazyRootSdk.onDiagnosticEvent as (listener: () => void) => () => void)( + () => undefined, + ), + ).toBe("function"); + expect("onDiagnosticEvent" in lazyRootSdk).toBe(true); + }); + it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => { expect(typeof rootSdk.resolveControlCommandGate).toBe("function"); + expect(typeof rootSdk.onDiagnosticEvent).toBe("function"); expect(typeof rootSdk.default).toBe("object"); expect(rootSdk.default).toBe(rootSdk); expect(rootSdk.__esModule).toBe(true); @@ -173,9 +198,12 @@ describe("plugin-sdk root alias", () => { it("preserves reflection semantics for lazily resolved exports", { timeout: 240_000 }, () => { expect("resolveControlCommandGate" in rootSdk).toBe(true); + expect("onDiagnosticEvent" in rootSdk).toBe(true); const keys = Object.keys(rootSdk); expect(keys).toContain("resolveControlCommandGate"); + expect(keys).toContain("onDiagnosticEvent"); const descriptor = Object.getOwnPropertyDescriptor(rootSdk, "resolveControlCommandGate"); expect(descriptor).toBeDefined(); + expect(Object.getOwnPropertyDescriptor(rootSdk, "onDiagnosticEvent")).toBeDefined(); }); }); diff --git a/src/plugin-sdk/routing.ts b/src/plugin-sdk/routing.ts index 144304a607c..6bf7917170b 100644 --- a/src/plugin-sdk/routing.ts +++ b/src/plugin-sdk/routing.ts @@ -29,3 +29,6 @@ export { formatSetExplicitDefaultInstruction, formatSetExplicitDefaultToConfiguredInstruction, } from "../routing/default-account-warnings.js"; +export { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; +export { normalizeOutboundThreadId } from "../infra/outbound/thread-id.js"; +export { normalizeMessageChannel, resolveGatewayMessageChannel } from "../utils/message-channel.js"; diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index a8a7f4cd769..47d3543dd33 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -27,20 +27,25 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/send.js";', ], "extensions/imessage/runtime-api.ts": [ - 'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, collectStatusIssuesFromLastError, formatTrimmedAllowFromEntries, getChatChannelMeta, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveChannelMediaMaxBytes, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, IMessageConfigSchema, type ChannelPlugin, type IMessageAccountConfig } from "openclaw/plugin-sdk/imessage";', + 'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, collectStatusIssuesFromLastError, formatTrimmedAllowFromEntries, getChatChannelMeta, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveChannelMediaMaxBytes, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, IMessageConfigSchema, type ChannelPlugin, type IMessageAccountConfig } from "../../src/plugin-sdk/imessage.js";', 'export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy } from "./src/group-policy.js";', 'export { monitorIMessageProvider } from "./src/monitor.js";', 'export type { MonitorIMessageOpts } from "./src/monitor.js";', 'export { probeIMessage } from "./src/probe.js";', 'export { sendMessageIMessage } from "./src/send.js";', ], - "extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'], + "extensions/googlechat/runtime-api.ts": ['export * from "../../src/plugin-sdk/googlechat.js";'], "extensions/matrix/runtime-api.ts": [ 'export * from "./src/auth-precedence.js";', 'export * from "./helper-api.js";', + 'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime";', + 'export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey } from "./thread-bindings-runtime.js";', + 'export { writeJsonFileAtomically } from "../../src/plugin-sdk/json-store.js";', + 'export type { ChannelDirectoryEntry, ChannelMessageActionContext, OpenClawConfig, PluginRuntime, RuntimeLogger, RuntimeEnv, WizardPrompter } from "../../src/plugin-sdk/matrix.js";', + 'export { formatZonedTimestamp } from "../../src/plugin-sdk/matrix.js";', ], "extensions/nextcloud-talk/runtime-api.ts": [ - 'export * from "openclaw/plugin-sdk/nextcloud-talk";', + 'export * from "../../src/plugin-sdk/nextcloud-talk.js";', ], "extensions/signal/runtime-api.ts": ['export * from "./src/runtime-api.js";'], "extensions/slack/runtime-api.ts": [ @@ -51,12 +56,12 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/resolve-users.js";', ], "extensions/telegram/runtime-api.ts": [ - 'export type { ChannelMessageActionAdapter, ChannelPlugin, OpenClawConfig, OpenClawPluginApi, PluginRuntime, TelegramAccountConfig, TelegramActionConfig, TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram";', + 'export type { ChannelMessageActionAdapter, ChannelPlugin, OpenClawConfig, OpenClawPluginApi, PluginRuntime, TelegramAccountConfig, TelegramActionConfig, TelegramNetworkConfig } from "../../src/plugin-sdk/telegram.js";', 'export type { OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "openclaw/plugin-sdk/core";', 'export type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeDoctorReport, AcpRuntimeEnsureInput, AcpRuntimeEvent, AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, AcpRuntimeErrorCode, AcpSessionUpdateTag } from "openclaw/plugin-sdk/acp-runtime";', 'export { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime";', - 'export { buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, normalizeAccountId, PAIRING_APPROVED_MESSAGE, parseTelegramTopicConversation, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveTelegramPollVisibility } from "openclaw/plugin-sdk/telegram";', - 'export { buildChannelConfigSchema, getChatChannelMeta, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, readStringParam, resolvePollMaxSelections, TelegramConfigSchema } from "openclaw/plugin-sdk/telegram-core";', + 'export { buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, normalizeAccountId, PAIRING_APPROVED_MESSAGE, parseTelegramTopicConversation, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveTelegramPollVisibility } from "../../src/plugin-sdk/telegram.js";', + 'export { buildChannelConfigSchema, getChatChannelMeta, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, readStringParam, resolvePollMaxSelections, TelegramConfigSchema } from "../../src/plugin-sdk/telegram-core.js";', 'export type { TelegramProbe } from "./src/probe.js";', 'export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js";', 'export { telegramMessageActions } from "./src/channel-actions.js";', diff --git a/src/plugin-sdk/signal-core.ts b/src/plugin-sdk/signal-core.ts index 89b0dde05af..d7e5277d1ab 100644 --- a/src/plugin-sdk/signal-core.ts +++ b/src/plugin-sdk/signal-core.ts @@ -1,3 +1,6 @@ +// Private helper surface for the bundled signal plugin. +// Keep this list additive and scoped to symbols used under extensions/signal. + export type { SignalAccountConfig } from "../config/types.js"; export type { ChannelPlugin } from "./channel-plugin-common.js"; export { diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index b3a7d0147b5..def847ccd33 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,3 +1,6 @@ +// Private helper surface for the bundled signal plugin. +// Keep this list additive and scoped to symbols used under extensions/signal. + export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { SignalAccountConfig } from "../config/types.js"; diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index 231c438b8ef..7ae74b14ed6 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -1,4 +1,12 @@ import type { ChannelStatusIssue } from "../channels/plugins/types.js"; +export { isRecord } from "../channels/plugins/status-issues/shared.js"; +export { + appendMatchMetadata, + asString, + collectIssuesForEnabledAccounts, + formatMatchMetadata, + resolveEnabledConfiguredAccountId, +} from "../channels/plugins/status-issues/shared.js"; type RuntimeLifecycleSnapshot = { running?: boolean | null; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 069a0be8067..a5fd1d9dc23 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,9 +1,32 @@ -import * as bluebubblesSdk from "openclaw/plugin-sdk/bluebubbles"; +import * as allowFromSdk from "openclaw/plugin-sdk/allow-from"; +import * as channelActionsSdk from "openclaw/plugin-sdk/channel-actions"; +import * as channelConfigHelpersSdk from "openclaw/plugin-sdk/channel-config-helpers"; +import type { + BaseProbeResult as ContractBaseProbeResult, + BaseTokenResolution as ContractBaseTokenResolution, + ChannelAgentTool as ContractChannelAgentTool, + ChannelAccountSnapshot as ContractChannelAccountSnapshot, + ChannelGroupContext as ContractChannelGroupContext, + ChannelMessageActionAdapter as ContractChannelMessageActionAdapter, + ChannelMessageActionContext as ContractChannelMessageActionContext, + ChannelMessageActionName as ContractChannelMessageActionName, + ChannelMessageToolDiscovery as ContractChannelMessageToolDiscovery, + ChannelStatusIssue as ContractChannelStatusIssue, + ChannelThreadingContext as ContractChannelThreadingContext, + ChannelThreadingToolContext as ContractChannelThreadingToolContext, +} from "openclaw/plugin-sdk/channel-contract"; +import * as channelFeedbackSdk from "openclaw/plugin-sdk/channel-feedback"; +import * as channelInboundSdk from "openclaw/plugin-sdk/channel-inbound"; +import * as channelLifecycleSdk from "openclaw/plugin-sdk/channel-lifecycle"; import * as channelPairingSdk from "openclaw/plugin-sdk/channel-pairing"; import * as channelReplyPipelineSdk from "openclaw/plugin-sdk/channel-reply-pipeline"; import * as channelRuntimeSdk from "openclaw/plugin-sdk/channel-runtime"; import * as channelSendResultSdk from "openclaw/plugin-sdk/channel-send-result"; import * as channelSetupSdk from "openclaw/plugin-sdk/channel-setup"; +import * as channelTargetsSdk from "openclaw/plugin-sdk/channel-targets"; +import * as commandAuthSdk from "openclaw/plugin-sdk/command-auth"; +import * as configRuntimeSdk from "openclaw/plugin-sdk/config-runtime"; +import * as conversationRuntimeSdk from "openclaw/plugin-sdk/conversation-runtime"; import * as coreSdk from "openclaw/plugin-sdk/core"; import type { ChannelMessageActionContext as CoreChannelMessageActionContext, @@ -11,29 +34,39 @@ import type { 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 imessageCoreSdk from "openclaw/plugin-sdk/imessage-core"; +import * as infraRuntimeSdk from "openclaw/plugin-sdk/infra-runtime"; import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; +import * as mediaRuntimeSdk from "openclaw/plugin-sdk/media-runtime"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; +import * as providerAuthSdk from "openclaw/plugin-sdk/provider-auth"; import * as providerModelsSdk from "openclaw/plugin-sdk/provider-models"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; +import * as replyHistorySdk from "openclaw/plugin-sdk/reply-history"; import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload"; +import * as replyRuntimeSdk from "openclaw/plugin-sdk/reply-runtime"; import * as routingSdk from "openclaw/plugin-sdk/routing"; import * as runtimeSdk from "openclaw/plugin-sdk/runtime"; import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; import * as secretInputSdk from "openclaw/plugin-sdk/secret-input"; import * as selfHostedProviderSetupSdk from "openclaw/plugin-sdk/self-hosted-provider-setup"; import * as setupSdk from "openclaw/plugin-sdk/setup"; -import * as slackSdk from "openclaw/plugin-sdk/slack"; -import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as testingSdk from "openclaw/plugin-sdk/testing"; import * as webhookIngressSdk from "openclaw/plugin-sdk/webhook-ingress"; -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 { + BaseProbeResult, + BaseTokenResolution, + ChannelAgentTool, + ChannelAccountSnapshot, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelStatusIssue, + ChannelThreadingContext, + ChannelThreadingToolContext, +} from "../channels/plugins/types.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { OpenClawPluginApi } from "../plugins/types.js"; import type { @@ -53,13 +86,51 @@ const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id: string) => ({ const asExports = (mod: object) => mod as Record; const accountHelpersSdk = await import("openclaw/plugin-sdk/account-helpers"); const allowlistEditSdk = await import("openclaw/plugin-sdk/allowlist-config-edit"); +const statusHelpersSdk = await import("openclaw/plugin-sdk/status-helpers"); describe("plugin-sdk subpath exports", () => { it("keeps the curated public list free of internal implementation subpaths", () => { + expect(pluginSdkSubpaths).not.toContain("acpx"); + expect(pluginSdkSubpaths).not.toContain("bluebubbles"); expect(pluginSdkSubpaths).not.toContain("compat"); + expect(pluginSdkSubpaths).not.toContain("device-pair"); + expect(pluginSdkSubpaths).not.toContain("discord"); + expect(pluginSdkSubpaths).not.toContain("feishu"); + expect(pluginSdkSubpaths).not.toContain("google"); + expect(pluginSdkSubpaths).not.toContain("googlechat"); + expect(pluginSdkSubpaths).not.toContain("imessage"); + expect(pluginSdkSubpaths).not.toContain("irc"); + expect(pluginSdkSubpaths).not.toContain("imessage-core"); + expect(pluginSdkSubpaths).not.toContain("line"); + expect(pluginSdkSubpaths).not.toContain("line-core"); + expect(pluginSdkSubpaths).not.toContain("lobster"); + expect(pluginSdkSubpaths).not.toContain("mattermost"); + expect(pluginSdkSubpaths).not.toContain("matrix"); + expect(pluginSdkSubpaths).not.toContain("msteams"); + expect(pluginSdkSubpaths).not.toContain("nextcloud-talk"); + expect(pluginSdkSubpaths).not.toContain("nostr"); expect(pluginSdkSubpaths).not.toContain("pairing-access"); + expect(pluginSdkSubpaths).not.toContain("qwen-portal-auth"); expect(pluginSdkSubpaths).not.toContain("reply-prefix"); + expect(pluginSdkSubpaths).not.toContain("signal-core"); + expect(pluginSdkSubpaths).not.toContain("slack"); + expect(pluginSdkSubpaths).not.toContain("synology-chat"); + expect(pluginSdkSubpaths).not.toContain("telegram"); + expect(pluginSdkSubpaths).not.toContain("telegram-core"); + expect(pluginSdkSubpaths).not.toContain("tlon"); + expect(pluginSdkSubpaths).not.toContain("twitch"); expect(pluginSdkSubpaths).not.toContain("typing"); + expect(pluginSdkSubpaths).not.toContain("voice-call"); + expect(pluginSdkSubpaths).not.toContain("whatsapp"); + expect(pluginSdkSubpaths).not.toContain("whatsapp-action-runtime"); + expect(pluginSdkSubpaths).not.toContain("whatsapp-core"); + expect(pluginSdkSubpaths).not.toContain("whatsapp-login-qr"); + expect(pluginSdkSubpaths).not.toContain("whatsapp-shared"); + expect(pluginSdkSubpaths).not.toContain("secret-input-runtime"); + expect(pluginSdkSubpaths).not.toContain("secret-input-schema"); + expect(pluginSdkSubpaths).not.toContain("zai"); + expect(pluginSdkSubpaths).not.toContain("discord-core"); + expect(pluginSdkSubpaths).not.toContain("slack-core"); expect(pluginSdkSubpaths).not.toContain("provider-model-definitions"); }); @@ -82,33 +153,222 @@ describe("plugin-sdk subpath exports", () => { }); it("exports reply payload helpers from the dedicated subpath", () => { + expect(typeof replyPayloadSdk.buildMediaPayload).toBe("function"); expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function"); expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function"); + expect(typeof replyPayloadSdk.resolvePayloadMediaUrls).toBe("function"); + expect(typeof replyPayloadSdk.sendPayloadMediaSequenceAndFinalize).toBe("function"); + expect(typeof replyPayloadSdk.sendPayloadMediaSequenceOrFallback).toBe("function"); + expect(typeof replyPayloadSdk.sendTextMediaPayload).toBe("function"); expect(typeof replyPayloadSdk.sendPayloadWithChunkedTextAndMedia).toBe("function"); }); + it("exports media runtime helpers from the dedicated subpath", () => { + expect(typeof mediaRuntimeSdk.createDirectTextMediaOutbound).toBe("function"); + expect(typeof mediaRuntimeSdk.createScopedChannelMediaMaxBytesResolver).toBe("function"); + }); + + it("exports reply history helpers from the dedicated subpath", () => { + expect(typeof replyHistorySdk.buildPendingHistoryContextFromMap).toBe("function"); + expect(typeof replyHistorySdk.clearHistoryEntriesIfEnabled).toBe("function"); + expect(typeof replyHistorySdk.recordPendingHistoryEntryIfEnabled).toBe("function"); + expect("buildPendingHistoryContextFromMap" in asExports(replyRuntimeSdk)).toBe(false); + expect("clearHistoryEntriesIfEnabled" in asExports(replyRuntimeSdk)).toBe(false); + expect("recordPendingHistoryEntryIfEnabled" in asExports(replyRuntimeSdk)).toBe(false); + expect("DEFAULT_GROUP_HISTORY_LIMIT" in asExports(replyRuntimeSdk)).toBe(false); + }); + it("exports account helper builders from the dedicated subpath", () => { expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function"); }); + it("exports device bootstrap helpers from the dedicated subpath", async () => { + const deviceBootstrapSdk = await import("openclaw/plugin-sdk/device-bootstrap"); + expect(typeof deviceBootstrapSdk.approveDevicePairing).toBe("function"); + expect(typeof deviceBootstrapSdk.issueDeviceBootstrapToken).toBe("function"); + expect(typeof deviceBootstrapSdk.listDevicePairing).toBe("function"); + }); + it("exports allowlist edit helpers from the dedicated subpath", () => { expect(typeof allowlistEditSdk.buildDmGroupAccountAllowlistAdapter).toBe("function"); expect(typeof allowlistEditSdk.createNestedAllowlistOverrideResolver).toBe("function"); }); + it("exports allowlist resolution helpers from the dedicated subpath", () => { + expect(typeof allowFromSdk.addAllowlistUserEntriesFromConfigEntry).toBe("function"); + expect(typeof allowFromSdk.buildAllowlistResolutionSummary).toBe("function"); + expect(typeof allowFromSdk.canonicalizeAllowlistWithResolvedIds).toBe("function"); + expect(typeof allowFromSdk.mapAllowlistResolutionInputs).toBe("function"); + expect(typeof allowFromSdk.mergeAllowlist).toBe("function"); + expect(typeof allowFromSdk.patchAllowlistUsersInConfigEntries).toBe("function"); + expect(typeof allowFromSdk.summarizeMapping).toBe("function"); + }); + + it("exports allow-from matching helpers from the dedicated subpath", () => { + expect(typeof allowFromSdk.compileAllowlist).toBe("function"); + expect(typeof allowFromSdk.firstDefined).toBe("function"); + expect(typeof allowFromSdk.formatAllowlistMatchMeta).toBe("function"); + expect(typeof allowFromSdk.isSenderIdAllowed).toBe("function"); + expect(typeof allowFromSdk.mergeDmAllowFromSources).toBe("function"); + expect(typeof allowFromSdk.resolveAllowlistMatchSimple).toBe("function"); + }); + it("exports runtime helpers from the dedicated subpath", () => { expect(typeof runtimeSdk.createLoggerBackedRuntime).toBe("function"); }); + it("exports channel identity and session helpers from stronger existing homes", () => { + expect(typeof routingSdk.normalizeMessageChannel).toBe("function"); + expect(typeof routingSdk.resolveGatewayMessageChannel).toBe("function"); + expect(typeof conversationRuntimeSdk.recordInboundSession).toBe("function"); + expect(typeof conversationRuntimeSdk.recordInboundSessionMetaSafe).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveConversationLabel).toBe("function"); + }); + it("exports directory runtime helpers from the dedicated subpath", () => { + expect(typeof directoryRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); + expect(typeof directoryRuntimeSdk.createRuntimeDirectoryLiveAdapter).toBe("function"); expect(typeof directoryRuntimeSdk.listDirectoryEntriesFromSources).toBe("function"); expect(typeof directoryRuntimeSdk.listResolvedDirectoryEntriesFromSources).toBe("function"); }); + it("exports infra runtime helpers from the dedicated subpath", () => { + expect(typeof infraRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); + expect(typeof infraRuntimeSdk.resolveOutboundSendDep).toBe("function"); + }); + it("exports channel runtime helpers from the dedicated subpath", () => { - expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); - expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); - expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceOrFallback).toBe("function"); + expect("applyChannelMatchMeta" in asExports(channelRuntimeSdk)).toBe(false); + expect("createChannelDirectoryAdapter" in asExports(channelRuntimeSdk)).toBe(false); + expect("createEmptyChannelDirectoryAdapter" in asExports(channelRuntimeSdk)).toBe(false); + expect("createArmableStallWatchdog" in asExports(channelRuntimeSdk)).toBe(false); + expect("createDraftStreamLoop" in asExports(channelRuntimeSdk)).toBe(false); + expect("createLoggedPairingApprovalNotifier" in asExports(channelRuntimeSdk)).toBe(false); + expect("createPairingPrefixStripper" in asExports(channelRuntimeSdk)).toBe(false); + expect("createRunStateMachine" in asExports(channelRuntimeSdk)).toBe(false); + expect("createRuntimeDirectoryLiveAdapter" in asExports(channelRuntimeSdk)).toBe(false); + expect("createRuntimeOutboundDelegates" in asExports(channelRuntimeSdk)).toBe(false); + expect("createStatusReactionController" in asExports(channelRuntimeSdk)).toBe(false); + expect("createTextPairingAdapter" in asExports(channelRuntimeSdk)).toBe(false); + expect("createFinalizableDraftLifecycle" in asExports(channelRuntimeSdk)).toBe(false); + expect("DEFAULT_EMOJIS" in asExports(channelRuntimeSdk)).toBe(false); + expect("logAckFailure" in asExports(channelRuntimeSdk)).toBe(false); + expect("logTypingFailure" in asExports(channelRuntimeSdk)).toBe(false); + expect("logInboundDrop" in asExports(channelRuntimeSdk)).toBe(false); + expect("normalizeMessageChannel" in asExports(channelRuntimeSdk)).toBe(false); + expect("removeAckReactionAfterReply" in asExports(channelRuntimeSdk)).toBe(false); + expect("recordInboundSession" in asExports(channelRuntimeSdk)).toBe(false); + expect("recordInboundSessionMetaSafe" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveInboundSessionEnvelopeContext" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveMentionGating" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveMentionGatingWithBypass" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveOutboundSendDep" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveConversationLabel" in asExports(channelRuntimeSdk)).toBe(false); + expect("shouldDebounceTextInbound" in asExports(channelRuntimeSdk)).toBe(false); + expect("shouldAckReaction" in asExports(channelRuntimeSdk)).toBe(false); + expect("shouldAckReactionForWhatsApp" in asExports(channelRuntimeSdk)).toBe(false); + expect("toLocationContext" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingConversationIdFromBindingId" in asExports(channelRuntimeSdk)).toBe( + false, + ); + expect("resolveThreadBindingEffectiveExpiresAt" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingFarewellText" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingIdleTimeoutMs" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingIdleTimeoutMsForChannel" in asExports(channelRuntimeSdk)).toBe( + false, + ); + expect("resolveThreadBindingIntroText" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingLifecycle" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingMaxAgeMs" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingMaxAgeMsForChannel" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingSpawnPolicy" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingThreadName" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingsEnabled" in asExports(channelRuntimeSdk)).toBe(false); + expect("formatThreadBindingDisabledError" in asExports(channelRuntimeSdk)).toBe(false); + expect("DISCORD_THREAD_BINDING_CHANNEL" in asExports(channelRuntimeSdk)).toBe(false); + expect("MATRIX_THREAD_BINDING_CHANNEL" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveControlCommandGate" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveCommandAuthorizedFromAuthorizers" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveDualTextControlCommandGate" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveNativeCommandSessionTargets" in asExports(channelRuntimeSdk)).toBe(false); + expect("attachChannelToResult" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildComputedAccountStatusSnapshot" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildMediaPayload" in asExports(channelRuntimeSdk)).toBe(false); + expect("createActionGate" in asExports(channelRuntimeSdk)).toBe(false); + expect("jsonResult" in asExports(channelRuntimeSdk)).toBe(false); + expect("normalizeInteractiveReply" in asExports(channelRuntimeSdk)).toBe(false); + expect("PAIRING_APPROVED_MESSAGE" in asExports(channelRuntimeSdk)).toBe(false); + expect("projectCredentialSnapshotFields" in asExports(channelRuntimeSdk)).toBe(false); + expect("readStringParam" in asExports(channelRuntimeSdk)).toBe(false); + expect("compileAllowlist" in asExports(channelRuntimeSdk)).toBe(false); + expect("formatAllowlistMatchMeta" in asExports(channelRuntimeSdk)).toBe(false); + expect("firstDefined" in asExports(channelRuntimeSdk)).toBe(false); + expect("isSenderIdAllowed" in asExports(channelRuntimeSdk)).toBe(false); + expect("mergeDmAllowFromSources" in asExports(channelRuntimeSdk)).toBe(false); + expect("addAllowlistUserEntriesFromConfigEntry" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildAllowlistResolutionSummary" in asExports(channelRuntimeSdk)).toBe(false); + expect("canonicalizeAllowlistWithResolvedIds" in asExports(channelRuntimeSdk)).toBe(false); + expect("mergeAllowlist" in asExports(channelRuntimeSdk)).toBe(false); + expect("patchAllowlistUsersInConfigEntries" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveChannelConfigWrites" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolvePayloadMediaUrls" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveScopedChannelMediaMaxBytes" in asExports(channelRuntimeSdk)).toBe(false); + expect("sendPayloadMediaSequenceAndFinalize" in asExports(channelRuntimeSdk)).toBe(false); + expect("sendPayloadMediaSequenceOrFallback" in asExports(channelRuntimeSdk)).toBe(false); + expect("sendTextMediaPayload" in asExports(channelRuntimeSdk)).toBe(false); + expect("createScopedChannelMediaMaxBytesResolver" in asExports(channelRuntimeSdk)).toBe(false); + expect("runPassiveAccountLifecycle" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildChannelKeyCandidates" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildMessagingTarget" in asExports(channelRuntimeSdk)).toBe(false); + expect("createDirectTextMediaOutbound" in asExports(channelRuntimeSdk)).toBe(false); + expect("createMessageToolButtonsSchema" in asExports(channelRuntimeSdk)).toBe(false); + expect("createMessageToolCardSchema" in asExports(channelRuntimeSdk)).toBe(false); + expect("createScopedAccountReplyToModeResolver" in asExports(channelRuntimeSdk)).toBe(false); + expect("createStaticReplyToModeResolver" in asExports(channelRuntimeSdk)).toBe(false); + expect("createTopLevelChannelReplyToModeResolver" in asExports(channelRuntimeSdk)).toBe(false); + expect("createUnionActionGate" in asExports(channelRuntimeSdk)).toBe(false); + expect("ensureTargetId" in asExports(channelRuntimeSdk)).toBe(false); + expect("listTokenSourcedAccounts" in asExports(channelRuntimeSdk)).toBe(false); + expect("parseMentionPrefixOrAtUserTarget" in asExports(channelRuntimeSdk)).toBe(false); + expect("requireTargetKind" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveChannelEntryMatchWithFallback" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveChannelMatchConfig" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveReactionMessageId" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveTargetsWithOptionalToken" in asExports(channelRuntimeSdk)).toBe(false); + expect("appendMatchMetadata" in asExports(channelRuntimeSdk)).toBe(false); + expect("asString" in asExports(channelRuntimeSdk)).toBe(false); + expect("collectIssuesForEnabledAccounts" in asExports(channelRuntimeSdk)).toBe(false); + expect("isRecord" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveEnabledConfiguredAccountId" in asExports(channelRuntimeSdk)).toBe(false); + }); + + it("exports inbound channel helpers from the dedicated subpath", () => { + expect(typeof channelInboundSdk.buildMentionRegexes).toBe("function"); + expect(typeof channelInboundSdk.createChannelInboundDebouncer).toBe("function"); + expect(typeof channelInboundSdk.createInboundDebouncer).toBe("function"); + expect(typeof channelInboundSdk.formatInboundEnvelope).toBe("function"); + expect(typeof channelInboundSdk.formatInboundFromLabel).toBe("function"); + expect(typeof channelInboundSdk.formatLocationText).toBe("function"); + expect(typeof channelInboundSdk.logInboundDrop).toBe("function"); + expect(typeof channelInboundSdk.matchesMentionPatterns).toBe("function"); + expect(typeof channelInboundSdk.matchesMentionWithExplicit).toBe("function"); + expect(typeof channelInboundSdk.normalizeMentionText).toBe("function"); + expect(typeof channelInboundSdk.resolveInboundDebounceMs).toBe("function"); + expect(typeof channelInboundSdk.resolveEnvelopeFormatOptions).toBe("function"); + expect(typeof channelInboundSdk.resolveInboundSessionEnvelopeContext).toBe("function"); + expect(typeof channelInboundSdk.resolveMentionGating).toBe("function"); + expect(typeof channelInboundSdk.resolveMentionGatingWithBypass).toBe("function"); + expect(typeof channelInboundSdk.shouldDebounceTextInbound).toBe("function"); + expect(typeof channelInboundSdk.toLocationContext).toBe("function"); + expect("buildMentionRegexes" in asExports(replyRuntimeSdk)).toBe(false); + expect("createInboundDebouncer" in asExports(replyRuntimeSdk)).toBe(false); + expect("formatInboundEnvelope" in asExports(replyRuntimeSdk)).toBe(false); + expect("formatInboundFromLabel" in asExports(replyRuntimeSdk)).toBe(false); + expect("matchesMentionPatterns" in asExports(replyRuntimeSdk)).toBe(false); + expect("matchesMentionWithExplicit" in asExports(replyRuntimeSdk)).toBe(false); + expect("normalizeMentionText" in asExports(replyRuntimeSdk)).toBe(false); + expect("resolveEnvelopeFormatOptions" in asExports(replyRuntimeSdk)).toBe(false); + expect("resolveInboundDebounceMs" in asExports(replyRuntimeSdk)).toBe(false); }); it("exports channel setup helpers from the dedicated subpath", () => { @@ -116,9 +376,83 @@ describe("plugin-sdk subpath exports", () => { expect(typeof channelSetupSdk.createTopLevelChannelDmPolicy).toBe("function"); }); + it("exports channel action helpers from the dedicated subpath", () => { + expect(typeof channelActionsSdk.createUnionActionGate).toBe("function"); + expect(typeof channelActionsSdk.listTokenSourcedAccounts).toBe("function"); + expect(typeof channelActionsSdk.resolveReactionMessageId).toBe("function"); + }); + + it("exports channel target helpers from the dedicated subpath", () => { + expect(typeof channelTargetsSdk.applyChannelMatchMeta).toBe("function"); + expect(typeof channelTargetsSdk.buildChannelKeyCandidates).toBe("function"); + expect(typeof channelTargetsSdk.buildMessagingTarget).toBe("function"); + expect(typeof channelTargetsSdk.ensureTargetId).toBe("function"); + expect(typeof channelTargetsSdk.parseMentionPrefixOrAtUserTarget).toBe("function"); + expect(typeof channelTargetsSdk.requireTargetKind).toBe("function"); + expect(typeof channelTargetsSdk.resolveChannelEntryMatchWithFallback).toBe("function"); + expect(typeof channelTargetsSdk.resolveChannelMatchConfig).toBe("function"); + expect(typeof channelTargetsSdk.resolveTargetsWithOptionalToken).toBe("function"); + }); + + it("exports channel config write helpers from the dedicated subpath", () => { + expect(typeof channelConfigHelpersSdk.authorizeConfigWrite).toBe("function"); + expect(typeof channelConfigHelpersSdk.canBypassConfigWritePolicy).toBe("function"); + expect(typeof channelConfigHelpersSdk.formatConfigWriteDeniedMessage).toBe("function"); + expect(typeof channelConfigHelpersSdk.resolveChannelConfigWrites).toBe("function"); + }); + + it("keeps channel contract types on the dedicated subpath", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + + it("exports channel lifecycle helpers from the dedicated subpath", () => { + expect(typeof channelLifecycleSdk.createDraftStreamLoop).toBe("function"); + expect(typeof channelLifecycleSdk.createFinalizableDraftLifecycle).toBe("function"); + expect(typeof channelLifecycleSdk.runPassiveAccountLifecycle).toBe("function"); + expect(typeof channelLifecycleSdk.createRunStateMachine).toBe("function"); + expect(typeof channelLifecycleSdk.createArmableStallWatchdog).toBe("function"); + }); + + it("exports channel feedback helpers from the dedicated subpath", () => { + expect(typeof channelFeedbackSdk.createStatusReactionController).toBe("function"); + expect(typeof channelFeedbackSdk.logAckFailure).toBe("function"); + expect(typeof channelFeedbackSdk.logTypingFailure).toBe("function"); + expect(typeof channelFeedbackSdk.removeAckReactionAfterReply).toBe("function"); + expect(typeof channelFeedbackSdk.shouldAckReaction).toBe("function"); + expect(typeof channelFeedbackSdk.shouldAckReactionForWhatsApp).toBe("function"); + expect(typeof channelFeedbackSdk.DEFAULT_EMOJIS).toBe("object"); + }); + + it("exports status helper utilities from the dedicated subpath", () => { + expect(typeof statusHelpersSdk.appendMatchMetadata).toBe("function"); + expect(typeof statusHelpersSdk.asString).toBe("function"); + expect(typeof statusHelpersSdk.collectIssuesForEnabledAccounts).toBe("function"); + expect(typeof statusHelpersSdk.isRecord).toBe("function"); + expect(typeof statusHelpersSdk.resolveEnabledConfiguredAccountId).toBe("function"); + }); + + it("exports message tool schema helpers from the dedicated subpath", () => { + expect(typeof channelActionsSdk.createMessageToolButtonsSchema).toBe("function"); + expect(typeof channelActionsSdk.createMessageToolCardSchema).toBe("function"); + }); + it("exports channel pairing helpers from the dedicated subpath", () => { expect(typeof channelPairingSdk.createChannelPairingController).toBe("function"); expect(typeof channelPairingSdk.createChannelPairingChallengeIssuer).toBe("function"); + expect(typeof channelPairingSdk.createLoggedPairingApprovalNotifier).toBe("function"); + expect(typeof channelPairingSdk.createPairingPrefixStripper).toBe("function"); + expect(typeof channelPairingSdk.createTextPairingAdapter).toBe("function"); expect("createScopedPairingAccess" in asExports(channelPairingSdk)).toBe(false); }); @@ -129,16 +463,78 @@ describe("plugin-sdk subpath exports", () => { expect("createReplyPrefixOptions" in asExports(channelReplyPipelineSdk)).toBe(false); }); + it("exports command auth helpers from the dedicated subpath", () => { + expect(typeof commandAuthSdk.buildCommandTextFromArgs).toBe("function"); + expect(typeof commandAuthSdk.buildCommandsPaginationKeyboard).toBe("function"); + expect(typeof commandAuthSdk.buildModelsProviderData).toBe("function"); + expect(typeof commandAuthSdk.hasControlCommand).toBe("function"); + expect(typeof commandAuthSdk.listNativeCommandSpecsForConfig).toBe("function"); + expect(typeof commandAuthSdk.listSkillCommandsForAgents).toBe("function"); + expect(typeof commandAuthSdk.normalizeCommandBody).toBe("function"); + expect(typeof commandAuthSdk.resolveCommandAuthorization).toBe("function"); + expect(typeof commandAuthSdk.resolveCommandAuthorizedFromAuthorizers).toBe("function"); + expect(typeof commandAuthSdk.resolveControlCommandGate).toBe("function"); + expect(typeof commandAuthSdk.resolveDualTextControlCommandGate).toBe("function"); + expect(typeof commandAuthSdk.resolveNativeCommandSessionTargets).toBe("function"); + expect(typeof commandAuthSdk.resolveStoredModelOverride).toBe("function"); + expect(typeof commandAuthSdk.shouldComputeCommandAuthorized).toBe("function"); + expect(typeof commandAuthSdk.shouldHandleTextCommands).toBe("function"); + expect("hasControlCommand" in asExports(replyRuntimeSdk)).toBe(false); + expect("buildCommandTextFromArgs" in asExports(replyRuntimeSdk)).toBe(false); + expect("buildCommandsPaginationKeyboard" in asExports(replyRuntimeSdk)).toBe(false); + expect("buildModelsProviderData" in asExports(replyRuntimeSdk)).toBe(false); + expect("listNativeCommandSpecsForConfig" in asExports(replyRuntimeSdk)).toBe(false); + expect("listSkillCommandsForAgents" in asExports(replyRuntimeSdk)).toBe(false); + expect("normalizeCommandBody" in asExports(replyRuntimeSdk)).toBe(false); + expect("resolveCommandAuthorization" in asExports(replyRuntimeSdk)).toBe(false); + expect("resolveStoredModelOverride" in asExports(replyRuntimeSdk)).toBe(false); + expect("shouldComputeCommandAuthorized" in asExports(replyRuntimeSdk)).toBe(false); + expect("shouldHandleTextCommands" in asExports(replyRuntimeSdk)).toBe(false); + }); + it("exports channel send-result helpers from the dedicated subpath", () => { expect(typeof channelSendResultSdk.attachChannelToResult).toBe("function"); expect(typeof channelSendResultSdk.buildChannelSendResult).toBe("function"); }); + it("exports binding lifecycle helpers from the conversation-runtime subpath", () => { + expect(typeof conversationRuntimeSdk.DISCORD_THREAD_BINDING_CHANNEL).toBe("string"); + expect(typeof conversationRuntimeSdk.MATRIX_THREAD_BINDING_CHANNEL).toBe("string"); + expect(typeof conversationRuntimeSdk.formatThreadBindingDisabledError).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingFarewellText).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingConversationIdFromBindingId).toBe( + "function", + ); + expect(typeof conversationRuntimeSdk.resolveThreadBindingEffectiveExpiresAt).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingIdleTimeoutMs).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingIdleTimeoutMsForChannel).toBe( + "function", + ); + expect(typeof conversationRuntimeSdk.resolveThreadBindingIntroText).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingLifecycle).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingMaxAgeMs).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingMaxAgeMsForChannel).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingSpawnPolicy).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingThreadName).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingsEnabled).toBe("function"); + expect(typeof conversationRuntimeSdk.formatThreadBindingDurationLabel).toBe("function"); + expect(typeof conversationRuntimeSdk.createScopedAccountReplyToModeResolver).toBe("function"); + expect(typeof conversationRuntimeSdk.createStaticReplyToModeResolver).toBe("function"); + expect(typeof conversationRuntimeSdk.createTopLevelChannelReplyToModeResolver).toBe("function"); + }); + it("exports provider setup helpers from the dedicated subpath", () => { expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); }); + it("exports oauth helpers from provider-auth", () => { + expect(typeof providerAuthSdk.buildOauthProviderAuthResult).toBe("function"); + expect(typeof providerAuthSdk.generatePkceVerifierChallenge).toBe("function"); + expect(typeof providerAuthSdk.toFormUrlEncoded).toBe("function"); + expect("buildOauthProviderAuthResult" in asExports(coreSdk)).toBe(false); + }); + it("keeps provider models focused on shared provider primitives", () => { expect(typeof providerModelsSdk.applyOpenAIConfig).toBe("function"); expect(typeof providerModelsSdk.buildKilocodeModelDefinition).toBe("function"); @@ -184,15 +580,21 @@ describe("plugin-sdk subpath exports", () => { expect(typeof secretInputSdk.buildSecretInputSchema).toBe("function"); expect(typeof secretInputSdk.buildOptionalSecretInputSchema).toBe("function"); expect(typeof secretInputSdk.normalizeSecretInputString).toBe("function"); + expect("hasConfiguredSecretInput" in asExports(configRuntimeSdk)).toBe(false); + expect("normalizeResolvedSecretInputString" in asExports(configRuntimeSdk)).toBe(false); + expect("normalizeSecretInputString" in asExports(configRuntimeSdk)).toBe(false); }); it("exports webhook ingress helpers from the dedicated subpath", () => { + expect(typeof webhookIngressSdk.registerPluginHttpRoute).toBe("function"); expect(typeof webhookIngressSdk.resolveWebhookPath).toBe("function"); + expect(typeof webhookIngressSdk.readRequestBodyWithLimit).toBe("function"); expect(typeof webhookIngressSdk.readJsonWebhookBodyOrReject).toBe("function"); + expect(typeof webhookIngressSdk.requestBodyErrorToText).toBe("function"); expect(typeof webhookIngressSdk.withResolvedWebhookRequestPipeline).toBe("function"); }); - it("exports shared core types used by bundled channels", () => { + it("exports shared core types used by bundled extensions", () => { expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); @@ -209,62 +611,6 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); }); - it("exports Discord helpers", () => { - expect(typeof discordSdk.buildChannelConfigSchema).toBe("function"); - expect(typeof discordSdk.DiscordConfigSchema).toBe("object"); - expect(typeof discordSdk.projectCredentialSnapshotFields).toBe("function"); - expect("resolveDiscordAccount" in asExports(discordSdk)).toBe(false); - }); - - it("exports Slack helpers", () => { - expect(typeof slackSdk.buildChannelConfigSchema).toBe("function"); - expect(typeof slackSdk.SlackConfigSchema).toBe("object"); - expect(typeof slackSdk.looksLikeSlackTargetId).toBe("function"); - expect("resolveSlackAccount" in asExports(slackSdk)).toBe(false); - }); - - it("exports Telegram helpers", () => { - expect(typeof telegramSdk.buildChannelConfigSchema).toBe("function"); - expect(typeof telegramSdk.TelegramConfigSchema).toBe("object"); - expect(typeof telegramSdk.projectCredentialSnapshotFields).toBe("function"); - expect("resolveTelegramAccount" in asExports(telegramSdk)).toBe(false); - }); - - it("exports iMessage helpers", () => { - expect(typeof imessageSdk.IMessageConfigSchema).toBe("object"); - expect(typeof imessageSdk.resolveIMessageConfigAllowFrom).toBe("function"); - expect(typeof imessageSdk.looksLikeIMessageTargetId).toBe("function"); - expect("resolveIMessageAccount" in asExports(imessageSdk)).toBe(false); - }); - - it("exports iMessage core helpers", () => { - expect(typeof imessageCoreSdk.buildChannelConfigSchema).toBe("function"); - expect(typeof imessageCoreSdk.parseChatTargetPrefixesOrThrow).toBe("function"); - expect(typeof imessageCoreSdk.resolveServicePrefixedTarget).toBe("function"); - expect(typeof imessageCoreSdk.IMessageConfigSchema).toBe("object"); - }); - - it("exports WhatsApp helpers", () => { - expect(typeof whatsappSdk.WhatsAppConfigSchema).toBe("object"); - expect(typeof whatsappSdk.resolveWhatsAppOutboundTarget).toBe("function"); - expect(typeof whatsappSdk.resolveWhatsAppMentionStripRegexes).toBe("function"); - expect(typeof whatsappSdk.sendMessageWhatsApp).toBe("function"); - expect(typeof whatsappSdk.loadWebMedia).toBe("function"); - }); - - 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("keeps the remaining bundled helper surface narrow", () => { - expect(typeof bluebubblesSdk.parseFiniteNumber).toBe("function"); - }); - it("resolves every curated public subpath", async () => { for (const { id, load } of bundledExtensionSubpathLoaders) { const mod = await load(); diff --git a/src/plugin-sdk/synology-chat.ts b/src/plugin-sdk/synology-chat.ts deleted file mode 100644 index 1b10e475f67..00000000000 --- a/src/plugin-sdk/synology-chat.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Narrow plugin-sdk surface for the bundled synology-chat plugin. -// Keep this list additive and scoped to symbols used under extensions/synology-chat. - -export { setAccountEnabledInConfigSection } from "../channels/plugins/config-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; -export { - isRequestBodyLimitError, - readRequestBodyWithLimit, - requestBodyErrorToText, -} from "../infra/http-body.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export { registerPluginHttpRoute } from "../plugins/http-registry.js"; -export type { OpenClawConfig } from "../config/config.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; -export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; -export type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; -export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; -export { - synologyChatSetupAdapter, - synologyChatSetupWizard, -} from "../../extensions/synology-chat/setup-api.js"; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 4b1d41df386..6a579af19f4 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -1,127 +1 @@ -export type { - ChannelAccountSnapshot, - ChannelGatewayContext, - ChannelMessageActionAdapter, - ChannelPlugin, -} from "../channels/plugins/types.js"; -export type { OpenClawConfig } from "../config/config.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; -export type { - TelegramAccountConfig, - TelegramActionConfig, - TelegramNetworkConfig, -} from "../config/types.js"; -export type { - ChannelConfiguredBindingProvider, - ChannelConfiguredBindingConversationRef, - ChannelConfiguredBindingMatch, -} from "../channels/plugins/types.adapters.js"; -export type { InspectedTelegramAccount } from "../../extensions/telegram/api.js"; -export type { ResolvedTelegramAccount } from "../../extensions/telegram/api.js"; -export type { TelegramProbe } from "../../extensions/telegram/runtime-api.js"; -export type { TelegramButtonStyle, TelegramInlineButtons } from "../../extensions/telegram/api.js"; -export type { StickerMetadata } from "../../extensions/telegram/api.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -export { parseTelegramTopicConversation } from "../acp/conversation-id.js"; -export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; -export { resolveTelegramPollVisibility } from "../poll-params.js"; - -export { - PAIRING_APPROVED_MESSAGE, - applyAccountNameToChannelSection, - buildChannelConfigSchema, - deleteAccountFromConfigSection, - formatPairingApproveHint, - getChatChannelMeta, - migrateBaseNameToDefaultAccount, - setAccountEnabledInConfigSection, -} from "./channel-plugin-common.js"; - -export { - projectCredentialSnapshotFields, - resolveConfiguredFromCredentialStatuses, -} from "../channels/account-snapshot-fields.js"; -export { - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, -} from "../config/runtime-group-policy.js"; -export { - listTelegramDirectoryGroupsFromConfig, - listTelegramDirectoryPeersFromConfig, -} from "../../extensions/telegram/api.js"; -export { - resolveTelegramGroupRequireMention, - resolveTelegramGroupToolPolicy, -} from "../../extensions/telegram/api.js"; -export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; - -export { buildTokenChannelStatusSummary } from "./status-helpers.js"; - -export { - createTelegramActionGate, - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramPollActionGateState, -} from "../../extensions/telegram/api.js"; -export { inspectTelegramAccount } from "../../extensions/telegram/api.js"; -export { - looksLikeTelegramTargetId, - normalizeTelegramMessagingTarget, -} from "../../extensions/telegram/api.js"; -export { - parseTelegramReplyToMessageId, - parseTelegramThreadId, -} from "../../extensions/telegram/api.js"; -export { - isNumericTelegramUserId, - normalizeTelegramAllowFromEntry, -} from "../../extensions/telegram/api.js"; -export { fetchTelegramChatId } from "../../extensions/telegram/api.js"; -export { - resolveTelegramInlineButtonsScope, - resolveTelegramTargetChatType, -} 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 { - buildBrowseProvidersButton, - buildModelsKeyboard, - buildProviderKeyboard, - calculateTotalPages, - getModelsPageSize, - type ProviderInfo, -} from "../../extensions/telegram/api.js"; -export { - isTelegramExecApprovalApprover, - isTelegramExecApprovalClientEnabled, -} from "../../extensions/telegram/api.js"; +export * from "../plugins/runtime/runtime-telegram-contract.js"; diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts index bfdb2db690f..5dd70cdcc3c 100644 --- a/src/plugin-sdk/text-runtime.ts +++ b/src/plugin-sdk/text-runtime.ts @@ -13,6 +13,7 @@ export * from "../shared/global-singleton.js"; export * from "../shared/string-normalization.js"; export * from "../shared/string-sample.js"; export * from "../shared/text/assistant-visible-text.js"; +export * from "../shared/text/auto-linked-file-ref.js"; export * from "../shared/text/code-regions.js"; export * from "../shared/text/reasoning-tags.js"; export * from "../terminal/safe-text.js"; diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index da3803e612f..953a87ced2f 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled tlon plugin. +// Private helper surface for the bundled tlon plugin. // Keep this list additive and scoped to symbols used under extensions/tlon. import { createOptionalChannelSetupSurface } from "./channel-setup.js"; diff --git a/src/plugin-sdk/tool-send.ts b/src/plugin-sdk/tool-send.ts index 61ee56fa9ac..7bd3c91acc1 100644 --- a/src/plugin-sdk/tool-send.ts +++ b/src/plugin-sdk/tool-send.ts @@ -1,3 +1,5 @@ +export type { ChannelToolSend } from "../channels/plugins/types.js"; + /** Extract the canonical send target fields from tool arguments when the action matches. */ export function extractToolSend( args: Record, diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index 1194e9c55f5..440f33d15dc 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled twitch plugin. +// Private helper surface for the bundled twitch plugin. // Keep this list additive and scoped to symbols used under extensions/twitch. import { createOptionalChannelSetupSurface } from "./channel-setup.js"; diff --git a/src/plugin-sdk/voice-call.ts b/src/plugin-sdk/voice-call.ts index 8e61959187f..a278d645127 100644 --- a/src/plugin-sdk/voice-call.ts +++ b/src/plugin-sdk/voice-call.ts @@ -1,4 +1,4 @@ -// Public Voice Call plugin helpers. +// Private helper surface for the bundled voice-call plugin. // Keep this surface narrow and limited to the voice-call feature contract. export { definePluginEntry } from "./core.js"; diff --git a/src/plugin-sdk/webhook-ingress.ts b/src/plugin-sdk/webhook-ingress.ts index c76e986c050..88d71b18248 100644 --- a/src/plugin-sdk/webhook-ingress.ts +++ b/src/plugin-sdk/webhook-ingress.ts @@ -14,14 +14,18 @@ export { beginWebhookRequestPipelineOrReject, createWebhookInFlightLimiter, isJsonContentType, + isRequestBodyLimitError, + readRequestBodyWithLimit, readJsonWebhookBodyOrReject, readWebhookBodyOrReject, + requestBodyErrorToText, WEBHOOK_BODY_READ_DEFAULTS, WEBHOOK_IN_FLIGHT_DEFAULTS, type WebhookBodyReadProfile, type WebhookInFlightLimiter, } from "./webhook-request-guards.js"; export { + registerPluginHttpRoute, registerWebhookTarget, registerWebhookTargetWithPluginRoute, resolveSingleWebhookTarget, diff --git a/src/plugin-sdk/webhook-request-guards.ts b/src/plugin-sdk/webhook-request-guards.ts index f181859bc84..670e5b34565 100644 --- a/src/plugin-sdk/webhook-request-guards.ts +++ b/src/plugin-sdk/webhook-request-guards.ts @@ -10,6 +10,12 @@ import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; export type WebhookBodyReadProfile = "pre-auth" | "post-auth"; +export { + isRequestBodyLimitError, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "../infra/http-body.js"; + export const WEBHOOK_BODY_READ_DEFAULTS = Object.freeze({ preAuth: { maxBytes: 64 * 1024, diff --git a/src/plugin-sdk/webhook-targets.ts b/src/plugin-sdk/webhook-targets.ts index e3dd9eda01d..43d67a93e27 100644 --- a/src/plugin-sdk/webhook-targets.ts +++ b/src/plugin-sdk/webhook-targets.ts @@ -19,6 +19,8 @@ export type RegisterWebhookTargetOptions = { type RegisterPluginHttpRouteParams = Parameters[0]; +export { registerPluginHttpRoute }; + export type RegisterWebhookPluginRouteOptions = Omit< RegisterPluginHttpRouteParams, "path" | "fallbackPath" diff --git a/src/plugin-sdk/whatsapp-shared.ts b/src/plugin-sdk/whatsapp-shared.ts index d1794898bc3..b55cf4304d1 100644 --- a/src/plugin-sdk/whatsapp-shared.ts +++ b/src/plugin-sdk/whatsapp-shared.ts @@ -5,5 +5,10 @@ export { resolveWhatsAppGroupIntroHint, resolveWhatsAppMentionStripRegexes, } from "../channels/plugins/whatsapp-shared.js"; +export { + looksLikeWhatsAppTargetId, + normalizeWhatsAppAllowFromEntries, + normalizeWhatsAppMessagingTarget, +} from "../channels/plugins/normalize/whatsapp.js"; export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; diff --git a/src/plugin-sdk/zai.ts b/src/plugin-sdk/zai.ts index 87a745ee7d0..e52dcbb5b9b 100644 --- a/src/plugin-sdk/zai.ts +++ b/src/plugin-sdk/zai.ts @@ -1,4 +1,4 @@ -// Public Z.ai helpers for provider plugins that need endpoint detection. +// Private Z.ai helpers for bundled provider plugins that need endpoint detection. export { detectZaiEndpoint, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 0e1ff28cff0..6441ba0da81 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled zalo plugin. +// Private helper surface for the bundled zalo plugin. // Keep this list additive and scoped to symbols used under extensions/zalo. export { jsonResult, readStringParam } from "../agents/tools/common.js"; diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index e037c0b69ab..bb435627355 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -1,4 +1,4 @@ -// Narrow plugin-sdk surface for the bundled zalouser plugin. +// Private helper surface for the bundled zalouser plugin. // Keep this list additive and scoped to symbols used under extensions/zalouser. import { createOptionalChannelSetupSurface } from "./channel-setup.js"; diff --git a/src/plugins/bundled-web-search-registry.ts b/src/plugins/bundled-web-search-registry.ts new file mode 100644 index 00000000000..15c04dd2935 --- /dev/null +++ b/src/plugins/bundled-web-search-registry.ts @@ -0,0 +1,26 @@ +import bravePlugin from "../../extensions/brave/index.js"; +import firecrawlPlugin from "../../extensions/firecrawl/index.js"; +import googlePlugin from "../../extensions/google/index.js"; +import moonshotPlugin from "../../extensions/moonshot/index.js"; +import perplexityPlugin from "../../extensions/perplexity/index.js"; +import tavilyPlugin from "../../extensions/tavily/index.js"; +import xaiPlugin from "../../extensions/xai/index.js"; +import type { OpenClawPluginApi } from "./types.js"; + +type RegistrablePlugin = { + id: string; + register: (api: OpenClawPluginApi) => void; +}; + +export const bundledWebSearchPluginRegistrations: ReadonlyArray<{ + plugin: RegistrablePlugin; + credentialValue: unknown; +}> = [ + { plugin: bravePlugin, credentialValue: "BSA-test" }, + { plugin: firecrawlPlugin, credentialValue: "fc-test" }, + { plugin: googlePlugin, credentialValue: "AIza-test" }, + { plugin: moonshotPlugin, credentialValue: "sk-test" }, + { plugin: perplexityPlugin, credentialValue: "pplx-test" }, + { plugin: tavilyPlugin, credentialValue: "tvly-test" }, + { plugin: xaiPlugin, credentialValue: "xai-test" }, +]; diff --git a/src/plugins/bundled-web-search.ts b/src/plugins/bundled-web-search.ts index 4b9594caaf8..5b709aa00ee 100644 --- a/src/plugins/bundled-web-search.ts +++ b/src/plugins/bundled-web-search.ts @@ -1,264 +1,29 @@ -import { - getScopedCredentialValue, - getTopLevelCredentialValue, - resolveProviderWebSearchPluginConfig, - setProviderWebSearchPluginConfigValue, - setScopedCredentialValue, - setTopLevelCredentialValue, -} from "../agents/tools/web-search-provider-config.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; -import { enablePluginInConfig } from "./enable.js"; +import { bundledWebSearchPluginRegistrations } from "./bundled-web-search-registry.js"; +import { capturePluginRegistration } from "./captured-registration.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; -import type { PluginWebSearchProviderEntry, WebSearchRuntimeMetadataContext } from "./types.js"; +import type { PluginWebSearchProviderEntry } from "./types.js"; -const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; -const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; -const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; -const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; - -type BundledWebSearchProviderDescriptor = { - pluginId: string; - id: string; - label: string; - hint: string; - envVars: string[]; - placeholder: string; - signupUrl: string; - docsUrl?: string; - autoDetectOrder: number; - credentialPath: string; - inactiveSecretPaths: string[]; - credentialScope: - | { kind: "top-level" } - | { - kind: "scoped"; - key: string; - }; - supportsConfiguredCredentialValue?: boolean; - applySelectionConfig?: (config: OpenClawConfig) => OpenClawConfig; - resolveRuntimeMetadata?: ( - ctx: WebSearchRuntimeMetadataContext, - ) => Partial; -}; - -function inferPerplexityBaseUrlFromApiKey(apiKey?: string): "direct" | "openrouter" | undefined { - if (!apiKey) { - return undefined; - } - const normalized = apiKey.toLowerCase(); - if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "direct"; - } - if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "openrouter"; - } - return undefined; -} - -function isDirectPerplexityBaseUrl(baseUrl: string): boolean { - try { - return new URL(baseUrl.trim()).hostname.toLowerCase() === "api.perplexity.ai"; - } catch { - return false; - } -} - -function resolvePerplexityRuntimeMetadata( - ctx: WebSearchRuntimeMetadataContext, -): Partial { - const perplexity = ctx.searchConfig?.perplexity; - const scoped = - perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) - ? (perplexity as { baseUrl?: string; model?: string }) - : undefined; - const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : ""; - const configuredModel = typeof scoped?.model === "string" ? scoped.model.trim() : ""; - const keySource = ctx.resolvedCredential?.source ?? "missing"; - const baseUrl = (() => { - if (configuredBaseUrl) { - return configuredBaseUrl; - } - if (keySource === "env") { - if (ctx.resolvedCredential?.fallbackEnvVar === "PERPLEXITY_API_KEY") { - return PERPLEXITY_DIRECT_BASE_URL; - } - if (ctx.resolvedCredential?.fallbackEnvVar === "OPENROUTER_API_KEY") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - } - if ((keySource === "config" || keySource === "secretRef") && ctx.resolvedCredential?.value) { - return inferPerplexityBaseUrlFromApiKey(ctx.resolvedCredential.value) === "openrouter" - ? DEFAULT_PERPLEXITY_BASE_URL - : PERPLEXITY_DIRECT_BASE_URL; - } - return DEFAULT_PERPLEXITY_BASE_URL; - })(); - return { - perplexityTransport: - configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl) - ? "chat_completions" - : "search_api", - }; -} - -const BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS = [ - { - pluginId: "brave", - id: "brave", - label: "Brave Search", - hint: "Structured results · country/language/time filters", - envVars: ["BRAVE_API_KEY"], - placeholder: "BSA...", - signupUrl: "https://brave.com/search/api/", - docsUrl: "https://docs.openclaw.ai/brave-search", - autoDetectOrder: 10, - credentialPath: "plugins.entries.brave.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"], - credentialScope: { kind: "top-level" }, - }, - { - pluginId: "google", - id: "gemini", - label: "Gemini (Google Search)", - hint: "Google Search grounding · AI-synthesized", - envVars: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 20, - credentialPath: "plugins.entries.google.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "gemini" }, - }, - { - pluginId: "xai", - id: "grok", - label: "Grok (xAI)", - hint: "xAI web-grounded responses", - envVars: ["XAI_API_KEY"], - placeholder: "xai-...", - signupUrl: "https://console.x.ai/", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 30, - credentialPath: "plugins.entries.xai.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "grok" }, - supportsConfiguredCredentialValue: false, - }, - { - pluginId: "moonshot", - id: "kimi", - label: "Kimi (Moonshot)", - hint: "Moonshot web search", - envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], - placeholder: "sk-...", - signupUrl: "https://platform.moonshot.cn/", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 40, - credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "kimi" }, - }, - { - pluginId: "perplexity", - id: "perplexity", - label: "Perplexity Search", - hint: "Structured results · domain/country/language/time filters", - envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], - placeholder: "pplx-...", - signupUrl: "https://www.perplexity.ai/settings/api", - docsUrl: "https://docs.openclaw.ai/perplexity", - autoDetectOrder: 50, - credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "perplexity" }, - resolveRuntimeMetadata: resolvePerplexityRuntimeMetadata, - }, - { - pluginId: "firecrawl", - id: "firecrawl", - label: "Firecrawl Search", - hint: "Structured results with optional result scraping", - envVars: ["FIRECRAWL_API_KEY"], - placeholder: "fc-...", - signupUrl: "https://www.firecrawl.dev/", - docsUrl: "https://docs.openclaw.ai/tools/firecrawl", - autoDetectOrder: 60, - credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "firecrawl" }, - applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config, - }, - { - pluginId: "tavily", - id: "tavily", - label: "Tavily Search", - hint: "Structured results with domain filters and AI answer summaries", - envVars: ["TAVILY_API_KEY"], - placeholder: "tvly-...", - signupUrl: "https://tavily.com/", - docsUrl: "https://docs.openclaw.ai/tools/tavily", - autoDetectOrder: 70, - credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "tavily" }, - applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config, - }, -] as const satisfies ReadonlyArray; - -export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [ - ...new Set(BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS.map((descriptor) => descriptor.pluginId)), -] as ReadonlyArray; +export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = bundledWebSearchPluginRegistrations + .map((entry) => entry.plugin.id) + .toSorted((left, right) => left.localeCompare(right)); const bundledWebSearchPluginIdSet = new Set(BUNDLED_WEB_SEARCH_PLUGIN_IDS); -function buildBundledWebSearchProviderEntry( - descriptor: BundledWebSearchProviderDescriptor, -): PluginWebSearchProviderEntry { - const scopedKey = - descriptor.credentialScope.kind === "scoped" ? descriptor.credentialScope.key : undefined; - return { - pluginId: descriptor.pluginId, - id: descriptor.id, - label: descriptor.label, - hint: descriptor.hint, - envVars: [...descriptor.envVars], - placeholder: descriptor.placeholder, - signupUrl: descriptor.signupUrl, - docsUrl: descriptor.docsUrl, - autoDetectOrder: descriptor.autoDetectOrder, - credentialPath: descriptor.credentialPath, - inactiveSecretPaths: [...descriptor.inactiveSecretPaths], - getCredentialValue: - descriptor.credentialScope.kind === "top-level" - ? getTopLevelCredentialValue - : (searchConfig) => getScopedCredentialValue(searchConfig, scopedKey!), - setCredentialValue: - descriptor.credentialScope.kind === "top-level" - ? setTopLevelCredentialValue - : (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, scopedKey!, value), - getConfiguredCredentialValue: - descriptor.supportsConfiguredCredentialValue === false - ? undefined - : (config) => resolveProviderWebSearchPluginConfig(config, descriptor.pluginId)?.apiKey, - setConfiguredCredentialValue: - descriptor.supportsConfiguredCredentialValue === false - ? undefined - : (configTarget, value) => { - setProviderWebSearchPluginConfigValue( - configTarget, - descriptor.pluginId, - "apiKey", - value, - ); - }, - applySelectionConfig: descriptor.applySelectionConfig, - resolveRuntimeMetadata: descriptor.resolveRuntimeMetadata, - createTool: () => null, - }; +type BundledWebSearchProviderEntry = PluginWebSearchProviderEntry & { pluginId: string }; + +let bundledWebSearchProvidersCache: BundledWebSearchProviderEntry[] | null = null; + +function loadBundledWebSearchProviders(): BundledWebSearchProviderEntry[] { + if (!bundledWebSearchProvidersCache) { + bundledWebSearchProvidersCache = bundledWebSearchPluginRegistrations.flatMap(({ plugin }) => + capturePluginRegistration(plugin).webSearchProviders.map((provider) => ({ + ...provider, + pluginId: plugin.id, + })), + ); + } + return bundledWebSearchProvidersCache; } export function resolveBundledWebSearchPluginIds(params: { @@ -278,9 +43,7 @@ export function resolveBundledWebSearchPluginIds(params: { } export function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] { - return BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS.map((descriptor) => - buildBundledWebSearchProviderEntry(descriptor), - ); + return loadBundledWebSearchProviders(); } export function resolveBundledWebSearchPluginId( @@ -289,6 +52,5 @@ export function resolveBundledWebSearchPluginId( if (!providerId) { return undefined; } - return BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS.find((descriptor) => descriptor.id === providerId) - ?.pluginId; + return loadBundledWebSearchProviders().find((provider) => provider.id === providerId)?.pluginId; } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 9f10ae7fe81..51997a53fff 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { __testing, @@ -21,9 +20,7 @@ async function importCommandsModule(cacheBust: string): Promise } beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), - ); + setActivePluginRegistry(createTestRegistry([])); }); afterEach(() => { diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 26827e50aa3..986f038e4cd 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -193,7 +193,7 @@ const hasExplicitMemorySlot = (plugins?: OpenClawConfig["plugins"]) => const hasExplicitMemoryEntry = (plugins?: OpenClawConfig["plugins"]) => Boolean(plugins?.entries && Object.prototype.hasOwnProperty.call(plugins.entries, "memory-core")); -const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) => { +export const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) => { if (!plugins) { return false; } diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index cde5b8e8e2d..98cefe7820c 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,13 +1,11 @@ import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js"; import anthropicPlugin from "../../../extensions/anthropic/index.js"; -import bravePlugin from "../../../extensions/brave/index.js"; import byteplusPlugin from "../../../extensions/byteplus/index.js"; import chutesPlugin from "../../../extensions/chutes/index.js"; import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js"; import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js"; import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js"; import falPlugin from "../../../extensions/fal/index.js"; -import firecrawlPlugin from "../../../extensions/firecrawl/index.js"; import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; import googlePlugin from "../../../extensions/google/index.js"; import huggingFacePlugin from "../../../extensions/huggingface/index.js"; @@ -24,12 +22,10 @@ import openAIPlugin from "../../../extensions/openai/index.js"; import opencodeGoPlugin from "../../../extensions/opencode-go/index.js"; import opencodePlugin from "../../../extensions/opencode/index.js"; import openrouterPlugin from "../../../extensions/openrouter/index.js"; -import perplexityPlugin from "../../../extensions/perplexity/index.js"; import qianfanPlugin from "../../../extensions/qianfan/index.js"; import qwenPortalAuthPlugin from "../../../extensions/qwen-portal-auth/index.js"; import sglangPlugin from "../../../extensions/sglang/index.js"; import syntheticPlugin from "../../../extensions/synthetic/index.js"; -import tavilyPlugin from "../../../extensions/tavily/index.js"; import togetherPlugin from "../../../extensions/together/index.js"; import venicePlugin from "../../../extensions/venice/index.js"; import vercelAiGatewayPlugin from "../../../extensions/vercel-ai-gateway/index.js"; @@ -38,6 +34,7 @@ import volcenginePlugin from "../../../extensions/volcengine/index.js"; import xaiPlugin from "../../../extensions/xai/index.js"; import xiaomiPlugin from "../../../extensions/xiaomi/index.js"; import zaiPlugin from "../../../extensions/zai/index.js"; +import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js"; import { createCapturedPluginRegistration } from "../captured-registration.js"; import { resolvePluginProviders } from "../providers.js"; import type { @@ -79,15 +76,11 @@ type PluginRegistrationContractEntry = { toolNames: string[]; }; -const bundledWebSearchPlugins: Array = [ - { ...bravePlugin, credentialValue: "BSA-test" }, - { ...firecrawlPlugin, credentialValue: "fc-test" }, - { ...googlePlugin, credentialValue: "AIza-test" }, - { ...moonshotPlugin, credentialValue: "sk-test" }, - { ...perplexityPlugin, credentialValue: "pplx-test" }, - { ...tavilyPlugin, credentialValue: "tvly-test" }, - { ...xaiPlugin, credentialValue: "xai-test" }, -]; +const bundledWebSearchPlugins: Array = + bundledWebSearchPluginRegistrations.map(({ plugin, credentialValue }) => ({ + ...plugin, + credentialValue, + })); const bundledSpeechPlugins: RegistrablePlugin[] = [elevenLabsPlugin, microsoftPlugin, openAIPlugin]; const bundledMediaUnderstandingPlugins: RegistrablePlugin[] = [ diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 1e614150cb3..551361d1bdd 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -23,8 +23,8 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => { }; }); -vi.mock("../../plugin-sdk/qwen-portal-auth.js", async () => { - const actual = await vi.importActual("../../plugin-sdk/qwen-portal-auth.js"); +vi.mock("../../../extensions/qwen-portal-auth/refresh.js", async () => { + const actual = await vi.importActual("../../../extensions/qwen-portal-auth/refresh.js"); return { ...actual, refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts index fac5e22657c..6f74b508c3d 100644 --- a/src/plugins/loader.git-path-regression.test.ts +++ b/src/plugins/loader.git-path-regression.test.ts @@ -23,7 +23,7 @@ afterEach(() => { }); describe("plugin loader git path regression", () => { - it("loads git-style package extension entries when they import plugin-sdk channel-runtime (#49806)", async () => { + it("loads git-style package extension entries when they import plugin-sdk infra-runtime (#49806)", async () => { const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage"); const copiedSourceDir = path.join(copiedExtensionRoot, "src"); const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk"); @@ -33,7 +33,7 @@ describe("plugin loader git path regression", () => { fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8"); fs.writeFileSync( path.join(copiedSourceDir, "channel.runtime.ts"), - `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; + `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js"; export const copiedRuntimeMarker = { @@ -49,7 +49,7 @@ export const copiedRuntimeMarker = { `, "utf-8", ); - const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts"); + const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "infra-runtime.ts"); fs.writeFileSync( copiedChannelRuntimeShim, `export function resolveOutboundSendDep() { @@ -77,7 +77,7 @@ export const copiedRuntimeMarker = { tryNative: false, extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], alias: { - "openclaw/plugin-sdk/channel-runtime": ${JSON.stringify(copiedChannelRuntimeShim)}, + "openclaw/plugin-sdk/infra-runtime": ${JSON.stringify(copiedChannelRuntimeShim)}, }, }); const mod = withAlias(${JSON.stringify(copiedChannelRuntime)}); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index a4bf12fad15..8af6cf927d4 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; import { withEnv } from "../test-utils/env.js"; type CreateJiti = typeof import("jiti").createJiti; @@ -358,6 +359,23 @@ function createPluginSdkAliasFixture(params?: { return { root, srcFile, distFile }; } +function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) { + const root = makeTempDir(); + const srcFile = path.join(root, "src", "extensionAPI.ts"); + const distFile = path.join(root, "dist", "extensionAPI.js"); + mkdirSafe(path.dirname(srcFile)); + mkdirSafe(path.dirname(distFile)); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", type: "module" }, null, 2), + "utf-8", + ); + fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8"); + fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); + fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); + return { root, srcFile, distFile }; +} + function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?: string }) { const root = makeTempDir(); const srcFile = path.join(root, "src", "plugins", "runtime", "index.ts"); @@ -685,6 +703,7 @@ function resolvePluginRuntimeModule(params: { afterEach(() => { clearPluginLoaderCache(); + resetDiagnosticEventsForTest(); if (prevBundledDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; } else { @@ -3271,6 +3290,70 @@ module.exports = { expect(record?.status).toBe("loaded"); }); + it("supports legacy plugins subscribing to diagnostic events from the root sdk", async () => { + useNoBundledPlugins(); + const seenKey = "__openclawLegacyRootDiagnosticSeen"; + delete (globalThis as Record)[seenKey]; + + const plugin = writePlugin({ + id: "legacy-root-diagnostic-listener", + filename: "legacy-root-diagnostic-listener.cjs", + body: `module.exports = { + id: "legacy-root-diagnostic-listener", + configSchema: (require("openclaw/plugin-sdk").emptyPluginConfigSchema)(), + register() { + const { onDiagnosticEvent } = require("openclaw/plugin-sdk"); + if (typeof onDiagnosticEvent !== "function") { + throw new Error("missing onDiagnosticEvent root export"); + } + globalThis.${seenKey} = []; + onDiagnosticEvent((event) => { + globalThis.${seenKey}.push({ + type: event.type, + sessionKey: event.sessionKey, + }); + }); + }, +};`, + }); + + try { + const registry = withEnv( + { OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins" }, + () => + loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["legacy-root-diagnostic-listener"], + }, + }, + }), + ); + const record = registry.plugins.find( + (entry) => entry.id === "legacy-root-diagnostic-listener", + ); + expect(record?.status).toBe("loaded"); + + emitDiagnosticEvent({ + type: "model.usage", + sessionKey: "agent:main:test:dm:peer", + usage: { total: 1 }, + }); + + expect((globalThis as Record)[seenKey]).toEqual([ + { + type: "model.usage", + sessionKey: "agent:main:test:dm:peer", + }, + ]); + } finally { + delete (globalThis as Record)[seenKey]; + } + }); + it.each([ { name: "prefers dist plugin-sdk alias when loader runs from dist", @@ -3354,6 +3437,36 @@ module.exports = { expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); + it.each([ + { + name: "prefers dist extension-api alias when loader runs from dist", + modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"), + expected: "dist" as const, + }, + { + name: "prefers src extension-api alias when loader runs from src in non-production", + modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"), + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + { + name: "resolves extension-api alias from package root when loader runs from transpiler cache path", + modulePath: () => "/tmp/tsx-cache/openclaw-loader.js", + argv1: (root: string) => path.join(root, "openclaw.mjs"), + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + ])("$name", ({ modulePath, argv1, env, expected }) => { + const fixture = createExtensionApiAliasFixture(); + const resolved = withEnv(env ?? {}, () => + __testing.resolveExtensionApiAlias({ + modulePath: modulePath(fixture.root), + argv1: argv1?.(fixture.root), + }), + ); + expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); + }); + it.each([ { name: "prefers dist candidates first for production src runtime", @@ -3570,7 +3683,7 @@ module.exports = { fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8"); fs.writeFileSync( path.join(copiedSourceDir, "channel.runtime.ts"), - `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; + `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; export const syntheticRuntimeMarker = { resolveOutboundSendDep, @@ -3578,7 +3691,7 @@ export const syntheticRuntimeMarker = { `, "utf-8", ); - const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts"); + const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "infra-runtime.ts"); fs.writeFileSync( copiedChannelRuntimeShim, `export function resolveOutboundSendDep() { @@ -3601,7 +3714,7 @@ export const syntheticRuntimeMarker = { const withAlias = createJiti(jitiBaseUrl, { ...__testing.buildPluginLoaderJitiOptions({ - "openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim, + "openclaw/plugin-sdk/infra-runtime": copiedChannelRuntimeShim, }), tryNative: false, }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 03a1b0810ff..6f5900f8334 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -130,12 +130,42 @@ const resolvePluginSdkAlias = (params: LoaderModuleResolveParams = {}): string | function buildPluginLoaderAliasMap(modulePath: string): Record { const pluginSdkAlias = resolvePluginSdkAlias({ modulePath }); + const extensionApiAlias = resolveExtensionApiAlias({ modulePath }); return { + ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), ...resolvePluginSdkScopedAliasMap({ modulePath }), }; } +const resolveExtensionApiAlias = (params: LoaderModuleResolveParams = {}): string | null => { + try { + const modulePath = resolveLoaderModulePath(params); + const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath }); + if (!packageRoot) { + return null; + } + + const orderedKinds = resolvePluginSdkAliasCandidateOrder({ + modulePath, + isProduction: process.env.NODE_ENV === "production", + }); + const candidateMap = { + src: path.join(packageRoot, "src", "extensionAPI.ts"), + dist: path.join(packageRoot, "dist", "extensionAPI.js"), + } as const; + for (const kind of orderedKinds) { + const candidate = candidateMap[kind]; + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +}; + function resolvePluginRuntimeModulePath(params: LoaderModuleResolveParams = {}): string | null { try { const modulePath = resolveLoaderModulePath(params); @@ -170,6 +200,7 @@ export const __testing = { buildPluginLoaderAliasMap, listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, + resolveExtensionApiAlias, resolvePluginSdkScopedAliasMap, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index e966e9d4128..7c69aa7ca41 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -4,6 +4,7 @@ import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, } from "./bundled-compat.js"; +import { hasExplicitPluginConfig } from "./config-state.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; @@ -12,39 +13,17 @@ import type { ProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); -function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { - const plugins = config?.plugins; - if (!plugins) { - return false; - } - if (typeof plugins.enabled === "boolean") { - return true; - } - if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { - return true; - } - if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { - return true; - } - if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) { - return true; - } - if (plugins.entries && Object.keys(plugins.entries).length > 0) { - return true; - } - if (plugins.slots && Object.keys(plugins.slots).length > 0) { - return true; - } - return false; -} - function withBundledProviderVitestCompat(params: { config: PluginLoadOptions["config"]; pluginIds: readonly string[]; env?: PluginLoadOptions["env"]; }): PluginLoadOptions["config"] { const env = params.env ?? process.env; - if (!env.VITEST || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) { + if ( + !env.VITEST || + hasExplicitPluginConfig(params.config?.plugins) || + params.pluginIds.length === 0 + ) { return params.config; } diff --git a/src/plugins/runtime/runtime-discord-ops.runtime.ts b/src/plugins/runtime/runtime-discord-ops.runtime.ts index 02a4cc22eb0..3e96771094a 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 "openclaw/plugin-sdk/discord"; import { + auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl, listDiscordDirectoryGroupsLive as listDiscordDirectoryGroupsLiveImpl, listDiscordDirectoryPeersLive as listDiscordDirectoryPeersLiveImpl, -} 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"; + monitorDiscordProvider as monitorDiscordProviderImpl, + probeDiscord as probeDiscordImpl, + resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl, + resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl, +} from "../../../extensions/discord/runtime-api.js"; import { createThreadDiscord as createThreadDiscordImpl, deleteMessageDiscord as deleteMessageDiscordImpl, @@ -18,7 +18,7 @@ import { sendPollDiscord as sendPollDiscordImpl, sendTypingDiscord as sendTypingDiscordImpl, unpinMessageDiscord as unpinMessageDiscordImpl, -} from "openclaw/plugin-sdk/discord"; +} from "../../../extensions/discord/runtime-api.js"; 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 354d205a66d..27535bf602c 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -1,5 +1,5 @@ -import { discordMessageActions } from "openclaw/plugin-sdk/discord"; import { + discordMessageActions, getThreadBindingManager, resolveThreadBindingIdleTimeoutMs, resolveThreadBindingInactivityExpiresAt, @@ -8,7 +8,7 @@ import { setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, -} from "openclaw/plugin-sdk/discord"; +} from "../../../extensions/discord/runtime-api.js"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/src/plugins/runtime/runtime-imessage.ts b/src/plugins/runtime/runtime-imessage.ts index 7740b6bdfa3..56136197626 100644 --- a/src/plugins/runtime/runtime-imessage.ts +++ b/src/plugins/runtime/runtime-imessage.ts @@ -2,7 +2,7 @@ import { monitorIMessageProvider, probeIMessage, sendMessageIMessage, -} from "openclaw/plugin-sdk/imessage"; +} from "../../../extensions/imessage/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeIMessage(): PluginRuntimeChannel["imessage"] { diff --git a/src/plugins/runtime/runtime-matrix-contract.ts b/src/plugins/runtime/runtime-matrix-contract.ts new file mode 100644 index 00000000000..ec33e96ef2f --- /dev/null +++ b/src/plugins/runtime/runtime-matrix-contract.ts @@ -0,0 +1,178 @@ +// Narrow plugin-sdk surface for the bundled matrix plugin. +// Keep this list additive and scoped to symbols used under extensions/matrix. + +import { createOptionalChannelSetupSurface } from "../../plugin-sdk/channel-setup.js"; + +export { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "../../agents/tools/common.js"; +export type { ReplyPayload } from "../../auto-reply/types.js"; +export { resolveAckReaction } from "../../agents/identity.js"; +export { + compileAllowlist, + resolveCompiledAllowlistMatch, + resolveAllowlistCandidates, + resolveAllowlistMatchByCandidates, +} from "../../channels/allowlist-match.js"; +export { + addAllowlistUserEntriesFromConfigEntry, + buildAllowlistResolutionSummary, + canonicalizeAllowlistWithResolvedIds, + mergeAllowlist, + patchAllowlistUsersInConfigEntries, + summarizeMapping, +} from "../../channels/allowlists/resolve-utils.js"; +export { ensureConfiguredAcpBindingReady } from "../../acp/persistent-bindings.lifecycle.js"; +export { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.resolve.js"; +export { resolveControlCommandGate } from "../../channels/command-gating.js"; +export type { NormalizedLocation } from "../../channels/location.js"; +export { formatLocationText, toLocationContext } from "../../channels/location.js"; +export { logInboundDrop, logTypingFailure } from "../../channels/logging.js"; +export type { AllowlistMatch } from "../../channels/plugins/allowlist-match.js"; +export { formatAllowlistMatchMeta } from "../../channels/plugins/allowlist-match.js"; +export { + buildChannelKeyCandidates, + resolveChannelEntryMatch, +} from "../../channels/plugins/channel-config.js"; +export { createAccountListHelpers } from "../../channels/plugins/account-helpers.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../../channels/plugins/config-helpers.js"; +export { buildChannelConfigSchema } from "../../channels/plugins/config-schema.js"; +export { formatPairingApproveHint } from "../../channels/plugins/helpers.js"; +export { + buildSingleChannelSecretPromptState, + addWildcardAllowFrom, + mergeAllowFromEntries, + promptAccountId, + promptSingleChannelSecretInput, + setTopLevelChannelGroupPolicy, +} from "../../channels/plugins/setup-wizard-helpers.js"; +export { promptChannelAccessConfig } from "../../channels/plugins/setup-group-access.js"; +export { PAIRING_APPROVED_MESSAGE } from "../../channels/plugins/pairing-message.js"; +export { + applyAccountNameToChannelSection, + moveSingleAccountChannelSectionToDefaultAccount, +} from "../../channels/plugins/setup-helpers.js"; +export type { + BaseProbeResult, + ChannelDirectoryEntry, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, + ChannelOutboundAdapter, + ChannelResolveKind, + ChannelResolveResult, + ChannelSetupInput, + ChannelToolSend, +} from "../../channels/plugins/types.js"; +export type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; +export { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; +export { resolveThreadBindingFarewellText } from "../../channels/thread-bindings-messages.js"; +export { + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, +} from "../../channels/thread-bindings-policy.js"; +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "../../../extensions/matrix/runtime-api.js"; +export { createTypingCallbacks } from "../../channels/typing.js"; +export { createChannelReplyPipeline } from "../../plugin-sdk/channel-reply-pipeline.js"; +export type { OpenClawConfig } from "../../config/config.js"; +export { + GROUP_POLICY_BLOCKED_LABEL, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; +export type { + DmPolicy, + GroupPolicy, + GroupToolPolicyConfig, + MarkdownTableMode, +} from "../../config/types.js"; +export type { SecretInput } from "../../plugin-sdk/secret-input.js"; +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "../../plugin-sdk/secret-input.js"; +export { ToolPolicySchema } from "../../config/zod-schema.agent-runtime.js"; +export { MarkdownConfigSchema } from "../../config/zod-schema.core.js"; +export { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; +export { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; +export { maybeCreateMatrixMigrationSnapshot } from "../../infra/matrix-migration-snapshot.js"; +export { + getSessionBindingService, + registerSessionBindingAdapter, + unregisterSessionBindingAdapter, +} from "../../infra/outbound/session-binding-service.js"; +export { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js"; +export type { + BindingTargetKind, + SessionBindingRecord, +} from "../../infra/outbound/session-binding-service.js"; +export { isPrivateOrLoopbackHost } from "../../gateway/net.js"; +export { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +export { emptyPluginConfigSchema } from "../config-schema.js"; +export type { PluginRuntime, RuntimeLogger } from "./types.js"; +export type { OpenClawPluginApi } from "../types.js"; +export type { PollInput } from "../../polls.js"; +export { normalizePollInput } from "../../polls.js"; +export { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, + resolveAgentIdFromSessionKey, +} from "../../routing/session-key.js"; +export type { RuntimeEnv } from "../../runtime.js"; +export { normalizeStringEntries } from "../../shared/string-normalization.js"; +export { formatDocsLink } from "../../terminal/links.js"; +export { redactSensitiveText } from "../../logging/redact.js"; +export type { WizardPrompter } from "../../wizard/prompts.js"; +export { + evaluateGroupRouteAccessForPolicy, + resolveSenderScopedGroupPolicy, +} from "../../plugin-sdk/group-access.js"; +export { createChannelPairingController } from "../../plugin-sdk/channel-pairing.js"; +export { readJsonFileWithFallback, writeJsonFileAtomically } from "../../plugin-sdk/json-store.js"; +export { formatResolvedUnresolvedNote } from "../../plugin-sdk/resolution-notes.js"; +export { runPluginCommandWithTimeout } from "../../plugin-sdk/run-command.js"; +export { createLoggerBackedRuntime, resolveRuntimeEnv } from "../../plugin-sdk/runtime.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "../../plugin-sdk/inbound-reply-dispatch.js"; +export { + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, +} from "../../plugin-sdk/status-helpers.js"; +export { + resolveMatrixAccountStorageRoot, + resolveMatrixCredentialsDir, + resolveMatrixCredentialsPath, + resolveMatrixLegacyFlatStoragePaths, +} from "../../../extensions/matrix/runtime-api.js"; +export { getMatrixScopedEnvVarNames } from "../../../extensions/matrix/runtime-api.js"; +export { + requiresExplicitMatrixDefaultAccount, + resolveMatrixDefaultOrOnlyAccountId, +} from "../../../extensions/matrix/runtime-api.js"; + +const matrixSetup = createOptionalChannelSetupSurface({ + channel: "matrix", + label: "Matrix", + npmSpec: "@openclaw/matrix", + docsPath: "/channels/matrix", +}); + +export const matrixSetupWizard = matrixSetup.setupWizard; +export const matrixSetupAdapter = matrixSetup.setupAdapter; diff --git a/src/plugins/runtime/runtime-matrix.ts b/src/plugins/runtime/runtime-matrix.ts index d97734397c0..abcb0cdf375 100644 --- a/src/plugins/runtime/runtime-matrix.ts +++ b/src/plugins/runtime/runtime-matrix.ts @@ -1,7 +1,7 @@ import { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey, -} from "openclaw/plugin-sdk/matrix"; +} from "../../../extensions/matrix/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeMatrix(): PluginRuntimeChannel["matrix"] { diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts index 89411fafc00..ec534c0b224 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 "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"; + monitorSlackProvider as monitorSlackProviderImpl, + probeSlack as probeSlackImpl, + resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl, + resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl, + sendMessageSlack as sendMessageSlackImpl, + handleSlackAction as handleSlackActionImpl, +} from "../../../extensions/slack/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeSlackOps = Pick< diff --git a/src/plugins/runtime/runtime-telegram-contract.ts b/src/plugins/runtime/runtime-telegram-contract.ts new file mode 100644 index 00000000000..6700ae25429 --- /dev/null +++ b/src/plugins/runtime/runtime-telegram-contract.ts @@ -0,0 +1,130 @@ +export type { + ChannelAccountSnapshot, + ChannelGatewayContext, + ChannelMessageActionAdapter, +} from "../../channels/plugins/types.js"; +export type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; +export type { OpenClawConfig } from "../../config/config.js"; +export type { PluginRuntime } from "./types.js"; +export type { OpenClawPluginApi } from "../types.js"; +export type { + TelegramAccountConfig, + TelegramActionConfig, + TelegramNetworkConfig, +} from "../../config/types.js"; +export type { + ChannelConfiguredBindingProvider, + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "../../channels/plugins/types.adapters.js"; +export type { InspectedTelegramAccount } from "../../../extensions/telegram/api.js"; +export type { ResolvedTelegramAccount } from "../../../extensions/telegram/api.js"; +export type { TelegramProbe } from "../../../extensions/telegram/runtime-api.js"; +export type { + TelegramButtonStyle, + TelegramInlineButtons, +} from "../../../extensions/telegram/api.js"; +export type { StickerMetadata } from "../../../extensions/telegram/api.js"; + +export { emptyPluginConfigSchema } from "../config-schema.js"; +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +export { parseTelegramTopicConversation } from "../../acp/conversation-id.js"; +export { clearAccountEntryFields } from "../../channels/plugins/config-helpers.js"; +export { resolveTelegramPollVisibility } from "../../poll-params.js"; + +export { + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + setAccountEnabledInConfigSection, +} from "../../plugin-sdk/channel-plugin-common.js"; + +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, +} from "../../channels/account-snapshot-fields.js"; +export { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +} from "../../config/runtime-group-policy.js"; +export { + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, +} from "../../../extensions/telegram/api.js"; +export { + resolveTelegramGroupRequireMention, + resolveTelegramGroupToolPolicy, +} from "../../../extensions/telegram/api.js"; +export { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js"; + +export { buildTokenChannelStatusSummary } from "../../plugin-sdk/status-helpers.js"; + +export { + createTelegramActionGate, + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramPollActionGateState, +} from "../../../extensions/telegram/api.js"; +export { inspectTelegramAccount } from "../../../extensions/telegram/api.js"; +export { + looksLikeTelegramTargetId, + normalizeTelegramMessagingTarget, +} from "../../../extensions/telegram/api.js"; +export { + parseTelegramReplyToMessageId, + parseTelegramThreadId, +} from "../../../extensions/telegram/api.js"; +export { + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +} from "../../../extensions/telegram/api.js"; +export { fetchTelegramChatId } from "../../../extensions/telegram/api.js"; +export { + resolveTelegramInlineButtonsScope, + resolveTelegramTargetChatType, +} 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 { + buildBrowseProvidersButton, + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + type ProviderInfo, +} from "../../../extensions/telegram/api.js"; +export { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, +} from "../../../extensions/telegram/api.js"; diff --git a/src/plugins/runtime/runtime-telegram-ops.runtime.ts b/src/plugins/runtime/runtime-telegram-ops.runtime.ts index 5b49e854651..8f236d5e2b6 100644 --- a/src/plugins/runtime/runtime-telegram-ops.runtime.ts +++ b/src/plugins/runtime/runtime-telegram-ops.runtime.ts @@ -1,6 +1,8 @@ -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 { + auditTelegramGroupMembership as auditTelegramGroupMembershipImpl, + monitorTelegramProvider as monitorTelegramProviderImpl, + probeTelegram as probeTelegramImpl, +} from "../../../extensions/telegram/runtime-api.js"; import { deleteMessageTelegram as deleteMessageTelegramImpl, editMessageReplyMarkupTelegram as editMessageReplyMarkupTelegramImpl, @@ -11,7 +13,7 @@ import { sendPollTelegram as sendPollTelegramImpl, sendTypingTelegram as sendTypingTelegramImpl, unpinMessageTelegram as unpinMessageTelegramImpl, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../extensions/telegram/runtime-api.js"; 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 fd01f964f2a..5754066cd8a 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,10 +1,10 @@ -import { collectTelegramUnmentionedGroupIds } from "openclaw/plugin-sdk/telegram"; -import { telegramMessageActions } from "openclaw/plugin-sdk/telegram"; import { + collectTelegramUnmentionedGroupIds, + resolveTelegramToken, setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, -} from "openclaw/plugin-sdk/telegram"; -import { resolveTelegramToken } from "openclaw/plugin-sdk/telegram"; + telegramMessageActions, +} from "../../../extensions/telegram/runtime-api.js"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 1a44e0e45f1..5712f50eb31 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -94,29 +94,29 @@ export type PluginRuntimeChannel = { shouldHandleTextCommands: typeof import("../../auto-reply/commands-registry.js").shouldHandleTextCommands; }; discord: { - messageActions: typeof import("../../../extensions/discord/runtime-api.js").discordMessageActions; - auditChannelPermissions: typeof import("../../../extensions/discord/runtime-api.js").auditDiscordChannelPermissions; - listDirectoryGroupsLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryPeersLive; - probeDiscord: typeof import("../../../extensions/discord/runtime-api.js").probeDiscord; - resolveChannelAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordChannelAllowlist; - resolveUserAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordUserAllowlist; - sendComponentMessage: typeof import("../../../extensions/discord/runtime-api.js").sendDiscordComponentMessage; - sendMessageDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendMessageDiscord; - sendPollDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendPollDiscord; - monitorDiscordProvider: typeof import("../../../extensions/discord/runtime-api.js").monitorDiscordProvider; + messageActions: typeof import("../../plugin-sdk/discord.js").discordMessageActions; + auditChannelPermissions: typeof import("../../plugin-sdk/discord.js").auditDiscordChannelPermissions; + listDirectoryGroupsLive: typeof import("../../plugin-sdk/discord.js").listDiscordDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../plugin-sdk/discord.js").listDiscordDirectoryPeersLive; + probeDiscord: typeof import("../../plugin-sdk/discord.js").probeDiscord; + resolveChannelAllowlist: typeof import("../../plugin-sdk/discord.js").resolveDiscordChannelAllowlist; + resolveUserAllowlist: typeof import("../../plugin-sdk/discord.js").resolveDiscordUserAllowlist; + sendComponentMessage: typeof import("../../plugin-sdk/discord.js").sendDiscordComponentMessage; + sendMessageDiscord: typeof import("../../plugin-sdk/discord.js").sendMessageDiscord; + sendPollDiscord: typeof import("../../plugin-sdk/discord.js").sendPollDiscord; + monitorDiscordProvider: typeof import("../../plugin-sdk/discord.js").monitorDiscordProvider; threadBindings: { - getManager: typeof import("../../../extensions/discord/runtime-api.js").getThreadBindingManager; - resolveIdleTimeoutMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingIdleTimeoutMs; - resolveInactivityExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingInactivityExpiresAt; - resolveMaxAgeMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeMs; - resolveMaxAgeExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeExpiresAt; - setIdleTimeoutBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingMaxAgeBySessionKey; - unbindBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").unbindThreadBindingsBySessionKey; + getManager: typeof import("../../plugin-sdk/discord.js").getThreadBindingManager; + resolveIdleTimeoutMs: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingIdleTimeoutMs; + resolveInactivityExpiresAt: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingInactivityExpiresAt; + resolveMaxAgeMs: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingMaxAgeMs; + resolveMaxAgeExpiresAt: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingMaxAgeExpiresAt; + setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/discord.js").setThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../plugin-sdk/discord.js").setThreadBindingMaxAgeBySessionKey; + unbindBySessionKey: typeof import("../../plugin-sdk/discord.js").unbindThreadBindingsBySessionKey; }; typing: { - pulse: typeof import("../../../extensions/discord/runtime-api.js").sendTypingDiscord; + pulse: typeof import("../../plugin-sdk/discord.js").sendTypingDiscord; start: (params: { channelId: string; accountId?: string; @@ -128,39 +128,39 @@ export type PluginRuntimeChannel = { }>; }; conversationActions: { - editMessage: typeof import("../../../extensions/discord/runtime-api.js").editMessageDiscord; - deleteMessage: typeof import("../../../extensions/discord/runtime-api.js").deleteMessageDiscord; - pinMessage: typeof import("../../../extensions/discord/runtime-api.js").pinMessageDiscord; - unpinMessage: typeof import("../../../extensions/discord/runtime-api.js").unpinMessageDiscord; - createThread: typeof import("../../../extensions/discord/runtime-api.js").createThreadDiscord; - editChannel: typeof import("../../../extensions/discord/runtime-api.js").editChannelDiscord; + editMessage: typeof import("../../plugin-sdk/discord.js").editMessageDiscord; + deleteMessage: typeof import("../../plugin-sdk/discord.js").deleteMessageDiscord; + pinMessage: typeof import("../../plugin-sdk/discord.js").pinMessageDiscord; + unpinMessage: typeof import("../../plugin-sdk/discord.js").unpinMessageDiscord; + createThread: typeof import("../../plugin-sdk/discord.js").createThreadDiscord; + editChannel: typeof import("../../plugin-sdk/discord.js").editChannelDiscord; }; }; slack: { - listDirectoryGroupsLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryPeersLive; - probeSlack: typeof import("../../../extensions/slack/runtime-api.js").probeSlack; - resolveChannelAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackChannelAllowlist; - resolveUserAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackUserAllowlist; - sendMessageSlack: typeof import("../../../extensions/slack/runtime-api.js").sendMessageSlack; - monitorSlackProvider: typeof import("../../../extensions/slack/runtime-api.js").monitorSlackProvider; - handleSlackAction: typeof import("../../../extensions/slack/runtime-api.js").handleSlackAction; + listDirectoryGroupsLive: typeof import("../../plugin-sdk/slack.js").listSlackDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../plugin-sdk/slack.js").listSlackDirectoryPeersLive; + probeSlack: typeof import("../../plugin-sdk/slack.js").probeSlack; + resolveChannelAllowlist: typeof import("../../plugin-sdk/slack.js").resolveSlackChannelAllowlist; + resolveUserAllowlist: typeof import("../../plugin-sdk/slack.js").resolveSlackUserAllowlist; + sendMessageSlack: typeof import("../../plugin-sdk/slack.js").sendMessageSlack; + monitorSlackProvider: typeof import("../../plugin-sdk/slack.js").monitorSlackProvider; + handleSlackAction: typeof import("../../plugin-sdk/slack.js").handleSlackAction; }; telegram: { - auditGroupMembership: typeof import("../../../extensions/telegram/runtime-api.js").auditTelegramGroupMembership; - collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/runtime-api.js").collectTelegramUnmentionedGroupIds; - probeTelegram: typeof import("../../../extensions/telegram/runtime-api.js").probeTelegram; - resolveTelegramToken: typeof import("../../../extensions/telegram/runtime-api.js").resolveTelegramToken; - sendMessageTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendMessageTelegram; - sendPollTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendPollTelegram; - monitorTelegramProvider: typeof import("../../../extensions/telegram/runtime-api.js").monitorTelegramProvider; - messageActions: typeof import("../../../extensions/telegram/runtime-api.js").telegramMessageActions; + auditGroupMembership: typeof import("../../plugin-sdk/telegram.js").auditTelegramGroupMembership; + collectUnmentionedGroupIds: typeof import("../../plugin-sdk/telegram.js").collectTelegramUnmentionedGroupIds; + probeTelegram: typeof import("../../plugin-sdk/telegram.js").probeTelegram; + resolveTelegramToken: typeof import("../../plugin-sdk/telegram.js").resolveTelegramToken; + sendMessageTelegram: typeof import("../../plugin-sdk/telegram.js").sendMessageTelegram; + sendPollTelegram: typeof import("../../plugin-sdk/telegram.js").sendPollTelegram; + monitorTelegramProvider: typeof import("../../plugin-sdk/telegram.js").monitorTelegramProvider; + messageActions: typeof import("../../plugin-sdk/telegram.js").telegramMessageActions; threadBindings: { - setIdleTimeoutBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingMaxAgeBySessionKey; + setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/telegram.js").setTelegramThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../plugin-sdk/telegram.js").setTelegramThreadBindingMaxAgeBySessionKey; }; typing: { - pulse: typeof import("../../../extensions/telegram/runtime-api.js").sendTypingTelegram; + pulse: typeof import("../../plugin-sdk/telegram.js").sendTypingTelegram; start: (params: { to: string; accountId?: string; @@ -173,8 +173,8 @@ export type PluginRuntimeChannel = { }>; }; conversationActions: { - editMessage: typeof import("../../../extensions/telegram/runtime-api.js").editMessageTelegram; - editReplyMarkup: typeof import("../../../extensions/telegram/runtime-api.js").editMessageReplyMarkupTelegram; + editMessage: typeof import("../../plugin-sdk/telegram.js").editMessageTelegram; + editReplyMarkup: typeof import("../../plugin-sdk/telegram.js").editMessageReplyMarkupTelegram; clearReplyMarkup: ( chatIdInput: string | number, messageIdInput: string | number, @@ -187,10 +187,10 @@ export type PluginRuntimeChannel = { cfg?: ReturnType; }, ) => Promise<{ ok: true; messageId: string; chatId: string }>; - deleteMessage: typeof import("../../../extensions/telegram/runtime-api.js").deleteMessageTelegram; - renameTopic: typeof import("../../../extensions/telegram/runtime-api.js").renameForumTopicTelegram; - pinMessage: typeof import("../../../extensions/telegram/runtime-api.js").pinMessageTelegram; - unpinMessage: typeof import("../../../extensions/telegram/runtime-api.js").unpinMessageTelegram; + deleteMessage: typeof import("../../plugin-sdk/telegram.js").deleteMessageTelegram; + renameTopic: typeof import("../../plugin-sdk/telegram.js").renameForumTopicTelegram; + pinMessage: typeof import("../../plugin-sdk/telegram.js").pinMessageTelegram; + unpinMessage: typeof import("../../plugin-sdk/telegram.js").unpinMessageTelegram; }; }; matrix: { @@ -200,15 +200,15 @@ export type PluginRuntimeChannel = { }; }; signal: { - probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal; - sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal; - monitorSignalProvider: typeof import("../../../extensions/signal/runtime-api.js").monitorSignalProvider; - messageActions: typeof import("../../../extensions/signal/runtime-api.js").signalMessageActions; + probeSignal: typeof import("../../plugin-sdk/signal.js").probeSignal; + sendMessageSignal: typeof import("../../plugin-sdk/signal.js").sendMessageSignal; + monitorSignalProvider: typeof import("../../plugin-sdk/signal.js").monitorSignalProvider; + messageActions: typeof import("../../plugin-sdk/signal.js").signalMessageActions; }; imessage: { - monitorIMessageProvider: typeof import("../../../extensions/imessage/runtime-api.js").monitorIMessageProvider; - probeIMessage: typeof import("../../../extensions/imessage/runtime-api.js").probeIMessage; - sendMessageIMessage: typeof import("../../../extensions/imessage/runtime-api.js").sendMessageIMessage; + monitorIMessageProvider: typeof import("../../plugin-sdk/imessage.js").monitorIMessageProvider; + probeIMessage: typeof import("../../plugin-sdk/imessage.js").probeIMessage; + sendMessageIMessage: typeof import("../../plugin-sdk/imessage.js").sendMessageIMessage; }; whatsapp: { getActiveWebListener: typeof import("./runtime-whatsapp-boundary.js").getActiveWebListener; diff --git a/src/plugins/web-search-providers.shared.ts b/src/plugins/web-search-providers.shared.ts index 29ba9527590..31a90f50915 100644 --- a/src/plugins/web-search-providers.shared.ts +++ b/src/plugins/web-search-providers.shared.ts @@ -3,36 +3,14 @@ import { withBundledPluginEnablementCompat, } from "./bundled-compat.js"; import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js"; -import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; +import { + hasExplicitPluginConfig, + normalizePluginsConfig, + type NormalizedPluginsConfig, +} from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; -export function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { - const plugins = config?.plugins; - if (!plugins) { - return false; - } - if (typeof plugins.enabled === "boolean") { - return true; - } - if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { - return true; - } - if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { - return true; - } - if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) { - return true; - } - if (plugins.entries && Object.keys(plugins.entries).length > 0) { - return true; - } - if (plugins.slots && Object.keys(plugins.slots).length > 0) { - return true; - } - return false; -} - function resolveBundledWebSearchCompatPluginIds(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -52,7 +30,11 @@ function withBundledWebSearchVitestCompat(params: { }): PluginLoadOptions["config"] { const env = params.env ?? process.env; const isVitest = Boolean(env.VITEST || process.env.VITEST); - if (!isVitest || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) { + if ( + !isVitest || + hasExplicitPluginConfig(params.config?.plugins) || + params.pluginIds.length === 0 + ) { return params.config; } diff --git a/src/providers/qwen-portal-oauth.test.ts b/src/providers/qwen-portal-oauth.test.ts deleted file mode 100644 index 4e73062d8fe..00000000000 --- a/src/providers/qwen-portal-oauth.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it, vi, afterEach } from "vitest"; -import { refreshQwenPortalCredentials } from "./qwen-portal-oauth.js"; - -const originalFetch = globalThis.fetch; - -afterEach(() => { - vi.unstubAllGlobals(); - globalThis.fetch = originalFetch; -}); - -describe("refreshQwenPortalCredentials", () => { - const expiredCredentials = () => ({ - access: "old-access", - refresh: "old-refresh", - expires: Date.now() - 1000, - }); - - const runRefresh = async () => await refreshQwenPortalCredentials(expiredCredentials()); - - const stubFetchResponse = (response: unknown) => { - const fetchSpy = vi.fn().mockResolvedValue(response); - vi.stubGlobal("fetch", fetchSpy); - return fetchSpy; - }; - - it("refreshes tokens with a new access token", async () => { - const fetchSpy = stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - access_token: "new-access", - refresh_token: "new-refresh", - expires_in: 3600, - }), - }); - - const result = await runRefresh(); - - expect(fetchSpy).toHaveBeenCalledWith( - "https://chat.qwen.ai/api/v1/oauth2/token", - expect.objectContaining({ - method: "POST", - }), - ); - expect(result.access).toBe("new-access"); - expect(result.refresh).toBe("new-refresh"); - expect(result.expires).toBeGreaterThan(Date.now()); - }); - - it("keeps refresh token when refresh response omits it", async () => { - stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - access_token: "new-access", - expires_in: 1800, - }), - }); - - const result = await runRefresh(); - - expect(result.refresh).toBe("old-refresh"); - }); - - it("keeps refresh token when response sends an empty refresh token", async () => { - stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - access_token: "new-access", - refresh_token: "", - expires_in: 1800, - }), - }); - - const result = await runRefresh(); - - expect(result.refresh).toBe("old-refresh"); - }); - - it("errors when refresh response has invalid expires_in", async () => { - stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - access_token: "new-access", - refresh_token: "new-refresh", - expires_in: 0, - }), - }); - - await expect(runRefresh()).rejects.toThrow( - "Qwen OAuth refresh response missing or invalid expires_in", - ); - }); - - it("errors when refresh token is invalid", async () => { - stubFetchResponse({ - ok: false, - status: 400, - text: async () => "invalid_grant", - }); - - await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh token expired or invalid"); - }); - - it("errors when refresh token is missing before any request", async () => { - await expect( - refreshQwenPortalCredentials({ - access: "old-access", - refresh: " ", - expires: Date.now() - 1000, - }), - ).rejects.toThrow("Qwen OAuth refresh token missing"); - }); - - it("errors when refresh response omits access token", async () => { - stubFetchResponse({ - ok: true, - status: 200, - json: async () => ({ - refresh_token: "new-refresh", - expires_in: 1800, - }), - }); - - await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh response missing access token"); - }); - - it("errors with server payload text for non-400 status", async () => { - stubFetchResponse({ - ok: false, - status: 500, - statusText: "Server Error", - text: async () => "gateway down", - }); - - await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh failed: gateway down"); - }); -}); diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts index e53c1c19391..d19be6bf441 100644 --- a/src/security/audit-channel.runtime.ts +++ b/src/security/audit-channel.runtime.ts @@ -1,7 +1,7 @@ import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, -} from "openclaw/plugin-sdk/telegram"; +} from "../../extensions/telegram/api.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { isDiscordMutableAllowEntry, diff --git a/src/shared/assistant-error-format.ts b/src/shared/assistant-error-format.ts index 6564cf5c641..b07d5b2ac53 100644 --- a/src/shared/assistant-error-format.ts +++ b/src/shared/assistant-error-format.ts @@ -41,7 +41,7 @@ function isErrorPayloadObject(payload: unknown): payload is ErrorPayload { return false; } -function parseApiErrorPayload(raw: string): ErrorPayload | null { +export function parseApiErrorPayload(raw?: string): ErrorPayload | null { if (!raw) { return null; } diff --git a/src/shared/text/auto-linked-file-ref.ts b/src/shared/text/auto-linked-file-ref.ts new file mode 100644 index 00000000000..6fd5693202b --- /dev/null +++ b/src/shared/text/auto-linked-file-ref.ts @@ -0,0 +1,27 @@ +const FILE_REF_EXTENSIONS = ["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"] as const; + +export const FILE_REF_EXTENSIONS_WITH_TLD = new Set(FILE_REF_EXTENSIONS); + +export function isAutoLinkedFileRef(href: string, label: string): boolean { + const stripped = href.replace(/^https?:\/\//i, ""); + if (stripped !== label) { + return false; + } + const dotIndex = label.lastIndexOf("."); + if (dotIndex < 1) { + return false; + } + const ext = label.slice(dotIndex + 1).toLowerCase(); + if (!FILE_REF_EXTENSIONS_WITH_TLD.has(ext)) { + return false; + } + const segments = label.split("/"); + if (segments.length > 1) { + for (let i = 0; i < segments.length - 1; i += 1) { + if (segments[i]?.includes(".")) { + return false; + } + } + } + return true; +} diff --git a/src/utils/parse-json-compat.ts b/src/utils/parse-json-compat.ts new file mode 100644 index 00000000000..2f5ab442526 --- /dev/null +++ b/src/utils/parse-json-compat.ts @@ -0,0 +1,9 @@ +import JSON5 from "json5"; + +export function parseJsonWithJson5Fallback(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return JSON5.parse(raw); + } +} diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index e19ba5d6a6e..273bfd8c8db 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -6,6 +6,7 @@ import type { WebSearchProviderToolDefinition, } from "../plugins/types.js"; import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js"; +import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import { resolveRuntimeWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; @@ -88,6 +89,15 @@ export function listWebSearchProviders(params?: { }); } +export function listConfiguredWebSearchProviders(params?: { + config?: OpenClawConfig; +}): PluginWebSearchProviderEntry[] { + return resolvePluginWebSearchProviders({ + config: params?.config, + bundledAllowlistCompat: true, + }); +} + export function resolveWebSearchProviderId(params: { search?: WebSearchConfig; config?: OpenClawConfig; diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 1fee8c154f4..7ceeab37c23 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; const runTui = vi.hoisted(() => vi.fn(async () => {})); @@ -34,6 +36,18 @@ const readSystemdUserLingerStatus = vi.hoisted(() => const resolveSetupSecretInputString = vi.hoisted(() => vi.fn<() => Promise>(async () => undefined), ); +const resolveExistingKey = vi.hoisted(() => + vi.fn<(config: OpenClawConfig, provider: string) => string | undefined>(() => undefined), +); +const hasExistingKey = vi.hoisted(() => + vi.fn<(config: OpenClawConfig, provider: string) => boolean>(() => false), +); +const hasKeyInEnv = vi.hoisted(() => + vi.fn<(entry: Pick) => boolean>(() => false), +); +const listConfiguredWebSearchProviders = vi.hoisted(() => + vi.fn<(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]>(() => []), +); vi.mock("../commands/onboard-helpers.js", () => ({ detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })), @@ -71,9 +85,14 @@ vi.mock("../commands/health.js", () => ({ vi.mock("../commands/onboard-search.js", () => ({ SEARCH_PROVIDER_OPTIONS: [], - hasExistingKey: vi.fn(() => false), - hasKeyInEnv: vi.fn(() => false), - resolveExistingKey: vi.fn(() => undefined), + resolveSearchProviderOptions: () => [], + hasExistingKey, + hasKeyInEnv, + resolveExistingKey, +})); + +vi.mock("../web-search/runtime.js", () => ({ + listConfiguredWebSearchProviders, })); vi.mock("../daemon/service.js", () => ({ @@ -135,6 +154,21 @@ function createRuntime(): RuntimeEnv { }; } +function createWebSearchProviderEntry( + provider: Pick< + PluginWebSearchProviderEntry, + "id" | "label" | "hint" | "envVars" | "placeholder" | "signupUrl" | "credentialPath" + >, +): PluginWebSearchProviderEntry { + return { + pluginId: `plugin-${provider.id}`, + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + createTool: () => null, + ...provider, + }; +} + function expectFirstOnboardingInstallPlanCallOmitsToken() { const [firstArg] = (buildGatewayInstallPlan.mock.calls.at(0) as [Record] | undefined) ?? []; @@ -161,6 +195,14 @@ describe("finalizeSetupWizard", () => { readSystemdUserLingerStatus.mockResolvedValue({ user: "test-user", linger: "yes" }); resolveSetupSecretInputString.mockReset(); resolveSetupSecretInputString.mockResolvedValue(undefined); + resolveExistingKey.mockReset(); + resolveExistingKey.mockReturnValue(undefined); + hasExistingKey.mockReset(); + hasExistingKey.mockReturnValue(false); + hasKeyInEnv.mockReset(); + hasKeyInEnv.mockReturnValue(false); + listConfiguredWebSearchProviders.mockReset(); + listConfiguredWebSearchProviders.mockReturnValue([]); }); it("resolves gateway password SecretRef for probe and TUI", async () => { @@ -337,4 +379,160 @@ describe("finalizeSetupWizard", () => { expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…"); expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled."); }); + + it("reports selected providers blocked by plugin policy as unavailable", async () => { + const prompter = buildWizardPrompter({ + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); + + await finalizeSetupWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: { + tools: { + web: { + search: { + provider: "firecrawl", + enabled: true, + }, + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining("selected but unavailable under the current plugin policy"), + "Web search", + ); + expect(resolveExistingKey).not.toHaveBeenCalled(); + expect(hasExistingKey).not.toHaveBeenCalled(); + }); + + it("only reports legacy auto-detect for runtime-visible providers", async () => { + listConfiguredWebSearchProviders.mockReturnValue([ + createWebSearchProviderEntry({ + id: "perplexity", + label: "Perplexity Search", + hint: "Fast web answers", + envVars: ["PERPLEXITY_API_KEY"], + placeholder: "pplx-...", + signupUrl: "https://www.perplexity.ai/", + credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", + }), + ]); + hasExistingKey.mockImplementation((_config, provider) => provider === "perplexity"); + + const prompter = buildWizardPrompter({ + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); + + await finalizeSetupWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining("Web search is available via Perplexity Search (auto-detected)."), + "Web search", + ); + }); + + it("uses configured provider resolution instead of the active runtime registry", async () => { + listConfiguredWebSearchProviders.mockReturnValue([ + createWebSearchProviderEntry({ + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + }), + ]); + hasExistingKey.mockImplementation((_config, provider) => provider === "firecrawl"); + + const prompter = buildWizardPrompter({ + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); + + await finalizeSetupWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: { + tools: { + web: { + search: { + provider: "firecrawl", + enabled: true, + }, + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining( + "Web search is enabled, so your agent can look things up online when needed.", + ), + "Web search", + ); + }); }); diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 74738facd63..a3879d985ff 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -30,6 +30,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { restoreTerminalState } from "../terminal/restore.js"; import { runTui } from "../tui/tui.js"; import { resolveUserPath } from "../utils.js"; +import { listConfiguredWebSearchProviders } from "../web-search/runtime.js"; import type { WizardPrompter } from "./prompts.js"; import { setupWizardShellCompletion } from "./setup.completion.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; @@ -483,13 +484,14 @@ export async function finalizeSetupWizard( const webSearchProvider = nextConfig.tools?.web?.search?.provider; const webSearchEnabled = nextConfig.tools?.web?.search?.enabled; + const configuredSearchProviders = listConfiguredWebSearchProviders({ config: nextConfig }); if (webSearchProvider) { - const { SEARCH_PROVIDER_OPTIONS, resolveExistingKey, hasExistingKey, hasKeyInEnv } = + const { resolveExistingKey, hasExistingKey, hasKeyInEnv } = await import("../commands/onboard-search.js"); - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider); + const entry = configuredSearchProviders.find((e) => e.id === webSearchProvider); const label = entry?.label ?? webSearchProvider; - const storedKey = resolveExistingKey(nextConfig, webSearchProvider); - const keyConfigured = hasExistingKey(nextConfig, webSearchProvider); + const storedKey = entry ? resolveExistingKey(nextConfig, webSearchProvider) : undefined; + const keyConfigured = entry ? hasExistingKey(nextConfig, webSearchProvider) : false; const envAvailable = entry ? hasKeyInEnv(entry) : false; const hasKey = keyConfigured || envAvailable; const keySource = storedKey @@ -497,9 +499,20 @@ export async function finalizeSetupWizard( : keyConfigured ? "API key: configured via secret reference." : envAvailable - ? `API key: provided via ${entry?.envKeys.join(" / ")} env var.` + ? `API key: provided via ${entry?.envVars.join(" / ")} env var.` : undefined; - if (webSearchEnabled !== false && hasKey) { + if (!entry) { + await prompter.note( + [ + `Web search provider ${label} is selected but unavailable under the current plugin policy.`, + "web_search will not work until the provider is re-enabled or a different provider is selected.", + ` ${formatCliCommand("openclaw configure --section web")}`, + "", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } else if (webSearchEnabled !== false && hasKey) { await prompter.note( [ "Web search is enabled, so your agent can look things up online when needed.", @@ -536,10 +549,9 @@ export async function finalizeSetupWizard( } else { // Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without // an explicit provider. Runtime auto-detects these, so avoid saying "skipped". - const { SEARCH_PROVIDER_OPTIONS, hasExistingKey, hasKeyInEnv } = - await import("../commands/onboard-search.js"); - const legacyDetected = SEARCH_PROVIDER_OPTIONS.find( - (e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e), + const { hasExistingKey, hasKeyInEnv } = await import("../commands/onboard-search.js"); + const legacyDetected = configuredSearchProviders.find( + (e) => hasExistingKey(nextConfig, e.id) || hasKeyInEnv(e), ); if (legacyDetected) { await prompter.note( diff --git a/test/fixtures/extension-relative-outside-package-inventory.json b/test/fixtures/extension-relative-outside-package-inventory.json index fe51488c706..7768054d4cc 100644 --- a/test/fixtures/extension-relative-outside-package-inventory.json +++ b/test/fixtures/extension-relative-outside-package-inventory.json @@ -1 +1,266 @@ -[] +[ + { + "file": "extensions/bluebubbles/src/runtime-api.ts", + "line": 1, + "kind": "export", + "specifier": "../../../src/plugin-sdk/bluebubbles.js", + "resolvedPath": "src/plugin-sdk/bluebubbles.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/bluebubbles/src/targets.ts", + "line": 8, + "kind": "import", + "specifier": "../../imessage/api.js", + "resolvedPath": "extensions/imessage/api.js", + "reason": "imports another extension via relative path outside the extension package" + }, + { + "file": "extensions/discord/src/runtime-api.ts", + "line": 7, + "kind": "export", + "specifier": "../../../src/plugin-sdk/discord.js", + "resolvedPath": "src/plugin-sdk/discord.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/discord/src/runtime-api.ts", + "line": 22, + "kind": "export", + "specifier": "../../../src/plugin-sdk/discord-core.js", + "resolvedPath": "src/plugin-sdk/discord-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/discord/src/runtime-api.ts", + "line": 23, + "kind": "export", + "specifier": "../../../src/plugin-sdk/discord-core.js", + "resolvedPath": "src/plugin-sdk/discord-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/discord/src/runtime-api.ts", + "line": 30, + "kind": "export", + "specifier": "../../../src/plugin-sdk/discord-core.js", + "resolvedPath": "src/plugin-sdk/discord-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/feishu/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/feishu.js", + "resolvedPath": "src/plugin-sdk/feishu.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/googlechat.js", + "resolvedPath": "src/plugin-sdk/googlechat.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/imessage/runtime-api.ts", + "line": 16, + "kind": "export", + "specifier": "../../src/plugin-sdk/imessage.js", + "resolvedPath": "src/plugin-sdk/imessage.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/irc/src/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../../src/plugin-sdk/irc.js", + "resolvedPath": "src/plugin-sdk/irc.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/line/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/line.js", + "resolvedPath": "src/plugin-sdk/line.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/line/runtime-api.ts", + "line": 13, + "kind": "export", + "specifier": "../../src/plugin-sdk/line-core.js", + "resolvedPath": "src/plugin-sdk/line-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/matrix/runtime-api.ts", + "line": 19, + "kind": "export", + "specifier": "../../src/plugin-sdk/json-store.js", + "resolvedPath": "src/plugin-sdk/json-store.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/matrix/runtime-api.ts", + "line": 28, + "kind": "export", + "specifier": "../../src/plugin-sdk/matrix.js", + "resolvedPath": "src/plugin-sdk/matrix.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/matrix/runtime-api.ts", + "line": 29, + "kind": "export", + "specifier": "../../src/plugin-sdk/matrix.js", + "resolvedPath": "src/plugin-sdk/matrix.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/matrix/src/runtime-api.ts", + "line": 1, + "kind": "export", + "specifier": "../../../src/plugin-sdk/matrix.js", + "resolvedPath": "src/plugin-sdk/matrix.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/mattermost/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/mattermost.js", + "resolvedPath": "src/plugin-sdk/mattermost.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/msteams/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/msteams.js", + "resolvedPath": "src/plugin-sdk/msteams.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/nextcloud-talk/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/nextcloud-talk.js", + "resolvedPath": "src/plugin-sdk/nextcloud-talk.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/nostr/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/nostr.js", + "resolvedPath": "src/plugin-sdk/nostr.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/signal/src/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../../src/plugin-sdk/signal.js", + "resolvedPath": "src/plugin-sdk/signal.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/slack/src/runtime-api.ts", + "line": 12, + "kind": "export", + "specifier": "../../../src/plugin-sdk/slack.js", + "resolvedPath": "src/plugin-sdk/slack.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/slack/src/runtime-api.ts", + "line": 28, + "kind": "export", + "specifier": "../../../src/plugin-sdk/slack-core.js", + "resolvedPath": "src/plugin-sdk/slack-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/telegram/runtime-api.ts", + "line": 10, + "kind": "export", + "specifier": "../../src/plugin-sdk/telegram.js", + "resolvedPath": "src/plugin-sdk/telegram.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/telegram/runtime-api.ts", + "line": 40, + "kind": "export", + "specifier": "../../src/plugin-sdk/telegram.js", + "resolvedPath": "src/plugin-sdk/telegram.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/telegram/runtime-api.ts", + "line": 52, + "kind": "export", + "specifier": "../../src/plugin-sdk/telegram-core.js", + "resolvedPath": "src/plugin-sdk/telegram-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/tlon/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/tlon.js", + "resolvedPath": "src/plugin-sdk/tlon.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/twitch/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/twitch.js", + "resolvedPath": "src/plugin-sdk/twitch.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/voice-call/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/voice-call.js", + "resolvedPath": "src/plugin-sdk/voice-call.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/whatsapp/src/runtime-api.ts", + "line": 19, + "kind": "export", + "specifier": "../../../src/plugin-sdk/whatsapp-core.js", + "resolvedPath": "src/plugin-sdk/whatsapp-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/whatsapp/src/runtime-api.ts", + "line": 34, + "kind": "export", + "specifier": "../../../src/plugin-sdk/whatsapp-shared.js", + "resolvedPath": "src/plugin-sdk/whatsapp-shared.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/zalo/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/zalo.js", + "resolvedPath": "src/plugin-sdk/zalo.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/zalouser/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/zalouser.js", + "resolvedPath": "src/plugin-sdk/zalouser.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + } +] diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index fe51488c706..0894fe0d5b5 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -1 +1,74 @@ -[] +[ + { + "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": 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-matrix.ts", + "line": 4, + "kind": "import", + "specifier": "../../../extensions/matrix/runtime-api.js", + "resolvedPath": "extensions/matrix/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": 5, + "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": 16, + "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" + } +] diff --git a/test/helpers/extensions/discord-provider.test-support.ts b/test/helpers/extensions/discord-provider.test-support.ts index 3c66b4d6743..538e00ae9fa 100644 --- a/test/helpers/extensions/discord-provider.test-support.ts +++ b/test/helpers/extensions/discord-provider.test-support.ts @@ -1,7 +1,7 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/discord"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { Mock } from "vitest"; import { expect, vi } from "vitest"; +import type { OpenClawConfig } from "../../../extensions/discord/src/runtime-api.js"; export type NativeCommandSpecMock = { name: string; @@ -319,6 +319,16 @@ vi.mock("openclaw/plugin-sdk/acp-runtime", async () => { }; }); +vi.mock("openclaw/plugin-sdk/command-auth", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/command-auth", + ); + return { + ...actual, + listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, + listSkillCommandsForAgents: listSkillCommandsForAgentsMock, + }; +}); vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/reply-runtime", @@ -326,8 +336,6 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { return { ...actual, resolveTextChunkLimit: () => 2000, - listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, - listSkillCommandsForAgents: listSkillCommandsForAgentsMock, }; }); diff --git a/test/plugin-extension-import-boundary.test.ts b/test/plugin-extension-import-boundary.test.ts index 254b3613797..bef7bb57838 100644 --- a/test/plugin-extension-import-boundary.test.ts +++ b/test/plugin-extension-import-boundary.test.ts @@ -21,15 +21,18 @@ function readBaseline() { } describe("plugin extension import boundary inventory", () => { - it("keeps web-search-providers out of the remaining inventory", async () => { + it("keeps dedicated web-search registry shims out of the remaining inventory", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); expect(inventory.some((entry) => entry.file === "src/plugins/web-search-providers.ts")).toBe( false, ); + expect( + inventory.some((entry) => entry.file === "src/plugins/bundled-web-search-registry.ts"), + ).toBe(false); }); - it("ignores plugin-sdk boundary shims by scope", async () => { + it("ignores boundary shims by scope", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk/"))).toBe(false); diff --git a/test/scripts/test-runner-manifest.test.ts b/test/scripts/test-runner-manifest.test.ts index cd650ae2aad..0fac87c25e1 100644 --- a/test/scripts/test-runner-manifest.test.ts +++ b/test/scripts/test-runner-manifest.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + dedupeFilesPreserveOrder, + packFilesByDuration, selectMemoryHeavyFiles, selectTimedHeavyFiles, selectUnitHeavyFileGroups, @@ -91,3 +93,44 @@ describe("scripts/test-runner-manifest memory selection", () => { }); }); }); + +describe("dedupeFilesPreserveOrder", () => { + it("removes duplicates while keeping the first-seen order", () => { + expect( + dedupeFilesPreserveOrder([ + "src/b.test.ts", + "src/a.test.ts", + "src/b.test.ts", + "src/c.test.ts", + "src/a.test.ts", + ]), + ).toEqual(["src/b.test.ts", "src/a.test.ts", "src/c.test.ts"]); + }); + + it("filters excluded files before deduping", () => { + expect( + dedupeFilesPreserveOrder( + ["src/a.test.ts", "src/b.test.ts", "src/c.test.ts", "src/b.test.ts"], + new Set(["src/b.test.ts"]), + ), + ).toEqual(["src/a.test.ts", "src/c.test.ts"]); + }); +}); + +describe("packFilesByDuration", () => { + it("packs heavier files into the lightest remaining bucket", () => { + const durationByFile = { + "src/a.test.ts": 100, + "src/b.test.ts": 90, + "src/c.test.ts": 20, + "src/d.test.ts": 10, + } satisfies Record; + + expect( + packFilesByDuration(Object.keys(durationByFile), 2, (file) => durationByFile[file] ?? 0), + ).toEqual([ + ["src/a.test.ts", "src/d.test.ts"], + ["src/b.test.ts", "src/c.test.ts"], + ]); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index f0e1bdc4549..1d4429d48d7 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -29,24 +29,48 @@ import type { } from "../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../src/config/config.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; +import { installProcessWarningFilter } from "../src/infra/warning-filter.js"; +import type { PluginRegistry } from "../src/plugins/registry.js"; import { withIsolatedTestHome } from "./test-env.js"; // Set HOME/state isolation before importing any runtime OpenClaw modules. const testEnv = withIsolatedTestHome(); afterAll(() => testEnv.cleanup()); -const [ - { installProcessWarningFilter }, - { getActivePluginRegistry, setActivePluginRegistry }, - { createTestRegistry }, -] = await Promise.all([ - import("../src/infra/warning-filter.js"), - import("../src/plugins/runtime.js"), - import("../src/test-utils/channel-plugins.js"), -]); - installProcessWarningFilter(); +const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); + +type RegistryState = { + registry: PluginRegistry | null; + httpRouteRegistry: PluginRegistry | null; + httpRouteRegistryPinned: boolean; + key: string | null; + version: number; +}; + +type TestChannelRegistration = { + pluginId: string; + plugin: unknown; + source: string; +}; + +const globalRegistryState = (() => { + const globalState = globalThis as typeof globalThis & { + [REGISTRY_STATE]?: RegistryState; + }; + if (!globalState[REGISTRY_STATE]) { + globalState[REGISTRY_STATE] = { + registry: null, + httpRouteRegistry: null, + httpRouteRegistryPinned: false, + key: null, + version: 0, + }; + } + return globalState[REGISTRY_STATE]; +})(); + const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { return deps?.[id] as ((...args: unknown[]) => Promise) | undefined; }; @@ -127,6 +151,32 @@ const createStubPlugin = (params: { outbound: createStubOutbound(params.id, params.deliveryMode), }); +const createTestRegistry = (channels: TestChannelRegistration[] = []): PluginRegistry => ({ + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: channels as unknown as PluginRegistry["channels"], + channelSetups: channels.map((entry) => ({ + pluginId: entry.pluginId, + plugin: entry.plugin as PluginRegistry["channelSetups"][number]["plugin"], + source: entry.source, + enabled: true, + })), + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + diagnostics: [], +}); + const createDefaultRegistry = () => createTestRegistry([ { @@ -174,17 +224,55 @@ const createDefaultRegistry = () => }, ]); -// Creating a fresh registry before every test is measurable overhead. -// The registry is immutable by default; tests that override it are restored in afterEach. -const DEFAULT_PLUGIN_REGISTRY = createDefaultRegistry(); +let materializedDefaultPluginRegistry: PluginRegistry | null = null; + +function getDefaultPluginRegistry(): PluginRegistry { + materializedDefaultPluginRegistry ??= createDefaultRegistry(); + return materializedDefaultPluginRegistry; +} + +// Most unit suites never touch the plugin registry. Keep the default test registry +// behind a lazy proxy so those files avoid allocating channel fixtures up front. +const DEFAULT_PLUGIN_REGISTRY = new Proxy({} as PluginRegistry, { + defineProperty(_target, property, attributes) { + return Reflect.defineProperty(getDefaultPluginRegistry() as object, property, attributes); + }, + deleteProperty(_target, property) { + return Reflect.deleteProperty(getDefaultPluginRegistry() as object, property); + }, + get(_target, property, receiver) { + return Reflect.get(getDefaultPluginRegistry() as object, property, receiver); + }, + getOwnPropertyDescriptor(_target, property) { + return Reflect.getOwnPropertyDescriptor(getDefaultPluginRegistry() as object, property); + }, + has(_target, property) { + return Reflect.has(getDefaultPluginRegistry() as object, property); + }, + ownKeys() { + return Reflect.ownKeys(getDefaultPluginRegistry() as object); + }, + set(_target, property, value, receiver) { + return Reflect.set(getDefaultPluginRegistry() as object, property, value, receiver); + }, +}); + +function installDefaultPluginRegistry(): void { + globalRegistryState.registry = DEFAULT_PLUGIN_REGISTRY; + if (!globalRegistryState.httpRouteRegistryPinned) { + globalRegistryState.httpRouteRegistry = DEFAULT_PLUGIN_REGISTRY; + } +} beforeAll(() => { - setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY); + installDefaultPluginRegistry(); }); afterEach(() => { - if (getActivePluginRegistry() !== DEFAULT_PLUGIN_REGISTRY) { - setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY); + if (globalRegistryState.registry !== DEFAULT_PLUGIN_REGISTRY) { + installDefaultPluginRegistry(); + globalRegistryState.key = null; + globalRegistryState.version += 1; } // Guard against leaked fake timers across test files/workers. if (vi.isFakeTimers()) { diff --git a/tsconfig.json b/tsconfig.json index bc6439e921f..e2f9e4ff97e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "target": "es2023", "useDefineForClassFields": false, "paths": { + "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], "openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"] diff --git a/tsdown.config.ts b/tsdown.config.ts index 746c6e883bc..304f781d91d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -169,6 +169,7 @@ function buildCoreDistEntries(): Record { entry: "src/entry.ts", // Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports. "cli/daemon-cli": "src/cli/daemon-cli.ts", + extensionAPI: "src/extensionAPI.ts", "infra/warning-filter": "src/infra/warning-filter.ts", "telegram/audit": "extensions/telegram/src/audit.ts", "telegram/token": "extensions/telegram/src/token.ts", @@ -186,6 +187,8 @@ const coreDistEntries = buildCoreDistEntries(); function buildUnifiedDistEntries(): Record { return { ...coreDistEntries, + // Internal compat artifact for the root-alias.cjs lazy loader. + "plugin-sdk/compat": "src/plugin-sdk/compat.ts", ...Object.fromEntries( Object.entries(buildPluginSdkEntrySources()).map(([entry, source]) => [ `plugin-sdk/${entry}`, diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 7c1121e6bb8..6e3db2c6a67 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1062,7 +1062,7 @@ export function renderConfig(props: ConfigProps) { }
- Raw JSON5 + Raw config (JSON/JSON5) ${ sensitiveCount > 0 ? html` @@ -1087,7 +1087,7 @@ export function renderConfig(props: ConfigProps) {