diff --git a/.github/labeler.yml b/.github/labeler.yml index ffe55984ac6..91c202b7ed6 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,7 +6,6 @@ "channel: discord": - changed-files: - any-glob-to-any-file: - - "src/discord/**" - "extensions/discord/**" - "docs/channels/discord.md" "channel: irc": @@ -28,7 +27,6 @@ "channel: imessage": - changed-files: - any-glob-to-any-file: - - "src/imessage/**" - "extensions/imessage/**" - "docs/channels/imessage.md" "channel: line": @@ -64,19 +62,16 @@ "channel: signal": - changed-files: - any-glob-to-any-file: - - "src/signal/**" - "extensions/signal/**" - "docs/channels/signal.md" "channel: slack": - changed-files: - any-glob-to-any-file: - - "src/slack/**" - "extensions/slack/**" - "docs/channels/slack.md" "channel: telegram": - changed-files: - any-glob-to-any-file: - - "src/telegram/**" - "extensions/telegram/**" - "docs/channels/telegram.md" "channel: tlon": @@ -96,7 +91,6 @@ "channel: whatsapp-web": - changed-files: - any-glob-to-any-file: - - "src/web/**" - "extensions/whatsapp/**" - "docs/channels/whatsapp.md" "channel: zalo": diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index ac0a8f728e3..903bba74706 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -69,8 +69,13 @@ jobs: run: pnpm release:check - name: Publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail + if [[ -n "${NODE_AUTH_TOKEN:-}" ]]; then + printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > "$HOME/.npmrc" + fi PACKAGE_VERSION=$(node -p "require('./package.json').version") if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then diff --git a/AGENTS.md b/AGENTS.md index 5f715abc1b0..0b1e17c8b3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,12 +203,17 @@ - Vocabulary: "makeup" = "mac app". - Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested. +- Parallels beta smoke: use `--target-package-spec openclaw@` for the beta artifact, and pin the stable side with both `--install-version ` and `--latest-version ` for upgrade runs. npm dist-tags can move mid-run. +- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane. - Parallels macOS smoke playbook: - `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`. - Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed. - Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. - Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero. + - Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded. - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`. + - All-OS parallel runs should share the host `dist` build via `/tmp/openclaw-parallels-build.lock` instead of rebuilding three times. + - Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails. - Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`. - For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green. - Don’t run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially. @@ -216,10 +221,13 @@ - Parallels Windows smoke playbook: - Preferred automation entrypoint: `pnpm test:parallels:windows`. It restores the snapshot most closely matching `pre-openclaw-native-e2e-2026-03-12`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. - Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero. + - Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded. - Always use `prlctl exec --current-user` for Windows guest runs; plain `prlctl exec` lands in `NT AUTHORITY\SYSTEM` and does not match the real desktop-user install path. - Prefer explicit `npm.cmd` / `openclaw.cmd`. Bare `npm` / `openclaw` in PowerShell can hit the `.ps1` shim and fail under restrictive execution policy. - Use PowerShell only as the transport (`powershell.exe -NoProfile -ExecutionPolicy Bypass`) and call the `.cmd` shims explicitly from inside it. - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-windows.*`. + - Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails. + - Keep Windows onboarding/status text ASCII-clean in logs. Fancy punctuation in banners shows up as mojibake through the current guest PowerShell capture path. - Parallels Linux smoke playbook: - Preferred automation entrypoint: `pnpm test:parallels:linux`. It restores the snapshot most closely matching `fresh` on `Ubuntu 24.04.3 ARM64`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. - Use plain `prlctl exec` on this snapshot. `--current-user` is not the right transport there. @@ -231,6 +239,7 @@ - When you do run Linux gateway checks manually from an interactive guest shell, use `openclaw gateway status --deep --require-rpc` so an RPC miss is a hard failure. - Prefer direct argv guest commands for fetch/install steps (`curl`, `npm install -g`, `openclaw ...`) over nested `bash -lc` quoting; Linux guest quoting through Parallels was the flaky part. - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-linux.*`. + - Current expected outcome on Linux smoke: fresh + upgrade should pass installer and `agent --local`; gateway remains `skipped-no-detached-linux-gateway` on this snapshot and should not be treated as a regression by itself. - Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`. - When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`). - Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 70da05266f5..9ba4346c35f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,32 +6,62 @@ Docs: https://docs.openclaw.ai ### Changes -- Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for `chrome://inspect/#remote-debugging` enablement and direct backlinks to Chrome’s own setup guides. -- Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc. -- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. -- iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman. -- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei. -- Browser/agents: add `browserSession="agent" | "user"` so agent browser calls can explicitly choose the isolated OpenClaw browser or a logged-in user browser, with docs for when user presence and attach approval are required. +- Placeholder: replace with the first 2026.3.14 user-facing change. +- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. + +### Fixes + +- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) +- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. + +## 2026.3.13 + +### Changes + +- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. +- iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman. +- Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for `chrome://inspect/#remote-debugging` enablement and direct backlinks to Chrome’s own setup guides. +- Browser/agents: add built-in `profile="user"` for the logged-in host browser and `profile="chrome-relay"` for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra `browserSession` selector. +- Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc. +- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei. +- Dependencies/pi: bump `@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, and `@mariozechner/pi-tui` to `0.58.0`. +- Cron/sessions: add `sessionTarget: "current"` and `session:` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF. +- Telegram/message send: add `--force-document` so Telegram image and GIF sends can upload as documents without compression. (#45111) Thanks @thepagent. + +### Breaking + +- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei. ### Fixes -- Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0. - Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev. +- Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging `GatewayClient.request()` promises indefinitely. +- Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. - Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang. - Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus. +- Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0. - Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata. +- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. +- Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei. +- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. +- Gateway/status: add `openclaw gateway status --require-rpc` and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green. - macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered `system.run` requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens. - Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. - Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images. -- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. - Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. - Windows/gateway stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart. - Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`. - Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding. - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. -- Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei. -- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. - Slack/probe: keep `auth.test()` bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss. +- Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes. +- Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han. +- Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom. +- macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance. +- Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks. +- macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo. +- Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu. +- Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to `google-vertex` model refs and provider configs so `google-vertex/gemini-3.1-flash-lite` resolves as `gemini-3.1-flash-lite-preview`. (#42435) thanks @scoootscooob. - iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua. - Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08. - Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey. @@ -42,13 +72,10 @@ Docs: https://docs.openclaw.ai - Security/exec approvals: unwrap `env` dispatch wrappers inside shell-segment allowlist resolution on macOS so `env FOO=bar /path/to/bin` resolves against the effective executable instead of the wrapper token. - Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued `$(` substitutions fail closed instead of slipping past command-substitution checks. - Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins. -- Gateway/status: add `openclaw gateway status --require-rpc` and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green. -- Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han. - Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. - Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc. - Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference. - Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97. -- Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei. - Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. - Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello. - Agents/tool warnings: distinguish gated core tools like `apply_patch` from plugin-only unknown entries in `tools.profile` warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin. @@ -57,13 +84,8 @@ Docs: https://docs.openclaw.ai - Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08. -- Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes. -- Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom. -- macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance. -- Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks. -- macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo. -- Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu. -- Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to `google-vertex` model refs and provider configs so `google-vertex/gemini-3.1-flash-lite` resolves as `gemini-3.1-flash-lite-preview`. (#42435) thanks @scoootscooob. +- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic. +- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix ## 2026.3.12 @@ -376,7 +398,6 @@ Docs: https://docs.openclaw.ai - Agents/compaction transcript updates: emit a transcript-update event immediately after successful embedded compaction so downstream listeners observe the post-compact transcript without waiting for a later write. (#25558) thanks @rodrigouroz. - Agents/sessions_spawn: use the target agent workspace for cross-agent spawned runs instead of inheriting the caller workspace, so child sessions load the correct workspace-scoped instructions and persona files. (#40176) Thanks @moshehbenavraham. - ## 2026.3.7 ### Changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87ccbeff4ef..8b9e62a3d74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ Welcome to the lobster tank! 🦞 - **Josh Lehman** - Compaction, Tlon/Urbit subsystem - GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_) -- **Radek Sienkiewicz** - Control UI + WebChat correctness +- **Radek Sienkiewicz** - Docs, Control UI - GitHub [@velvet-shark](https://github.com/velvet-shark) · X: [@velvet_shark](https://twitter.com/velvet_shark) - **Muhammed Mukhthar** - Mattermost, CLI diff --git a/appcast.xml b/appcast.xml index 69632c08b97..c1919972b22 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,82 @@ OpenClaw + + 2026.3.13 + Sat, 14 Mar 2026 05:19:48 +0000 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 2026031390 + 2026.3.13 + 15.0 + OpenClaw 2026.3.13 +

Changes

+
    +
  • Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.
  • +
  • iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show /pair qr instructions on the connect step. (#45054) Thanks @ngutman.
  • +
  • Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for chrome://inspect/#remote-debugging enablement and direct backlinks to Chrome’s own setup guides.
  • +
  • Browser/agents: add built-in profile="user" for the logged-in host browser and profile="chrome-relay" for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra browserSession selector.
  • +
  • Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc.
  • +
  • Docker/timezone override: add OPENCLAW_TZ so docker-setup.sh can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.
  • +
  • Dependencies/pi: bump @mariozechner/pi-agent-core, @mariozechner/pi-ai, @mariozechner/pi-coding-agent, and @mariozechner/pi-tui to 0.58.0.
  • +
+

Fixes

+
    +
  • Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
  • +
  • Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging GatewayClient.request() promises indefinitely.
  • +
  • Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
  • +
  • Ollama/reasoning visibility: stop promoting native thinking and reasoning fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang.
  • +
  • Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus.
  • +
  • Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0.
  • +
  • Browser/existing-session: accept text-only list_pages and new_page responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.
  • +
  • Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.
  • +
  • Gateway/session reset: preserve lastAccountId and lastThreadId across gateway session resets so replies keep routing back to the same account and thread after /reset. (#44773) Thanks @Lanfei.
  • +
  • macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so openclaw onboard --install-daemon no longer false-fails on slower Macs and fresh VM snapshots.
  • +
  • Gateway/status: add openclaw gateway status --require-rpc and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green.
  • +
  • macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered system.run requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.
  • +
  • Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
  • +
  • Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.
  • +
  • Windows/gateway install: bound schtasks calls and fall back to the Startup-folder login item when task creation hangs, so native openclaw gateway install fails fast instead of wedging forever on broken Scheduled Task setups.
  • +
  • Windows/gateway stop: resolve Startup-folder fallback listeners from the installed gateway.cmd port, so openclaw gateway stop now actually kills fallback-launched gateway processes before restart.
  • +
  • Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in gateway status --json instead of falling back to gateway port unknown.
  • +
  • Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale device signature expired fallback noise before succeeding.
  • +
  • Discord/gateway startup: treat plain-text and transient /gateway/bot metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.
  • +
  • Slack/probe: keep auth.test() bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss.
  • +
  • Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes.
  • +
  • Dashboard/chat UI: restore the chat-new-messages class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han.
  • +
  • Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom.
  • +
  • macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.
  • +
  • Discord/allowlists: honor raw guild_id when hydrated guild objects are missing so allowlisted channels and threads like #maintainers no longer get false-dropped before channel allowlist checks.
  • +
  • macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo.
  • +
  • Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu.
  • +
  • Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to google-vertex model refs and provider configs so google-vertex/gemini-3.1-flash-lite resolves as gemini-3.1-flash-lite-preview. (#42435) thanks @scoootscooob.
  • +
  • iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua.
  • +
  • Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
  • +
  • Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
  • +
  • Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed EXTERNAL_UNTRUSTED_CONTENT markers fall back to the existing hardening path instead of bypassing marker normalization.
  • +
  • Security/exec approvals: unwrap more pnpm runtime forms during approval binding, including pnpm --reporter ... exec and direct pnpm node file runs, with matching regression coverage and docs updates.
  • +
  • Security/exec approvals: fail closed for Perl -M and -I approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
  • +
  • Security/exec approvals: recognize PowerShell -File and -f wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing -Command variants.
  • +
  • Security/exec approvals: unwrap env dispatch wrappers inside shell-segment allowlist resolution on macOS so env FOO=bar /path/to/bin resolves against the effective executable instead of the wrapper token.
  • +
  • Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued $( substitutions fail closed instead of slipping past command-substitution checks.
  • +
  • Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins.
  • +
  • Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
  • +
  • Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc.
  • +
  • Agents/OpenAI-compatible compat overrides: respect explicit user models[].compat opt-ins for non-native openai-completions endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference.
  • +
  • Agents/Azure OpenAI startup prompts: rephrase the built-in /new, /reset, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97.
  • +
  • Agents/memory bootstrap: load only one root memory file, preferring MEMORY.md and using memory.md as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei.
  • +
  • Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.
  • +
  • Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello.
  • +
  • Agents/tool warnings: distinguish gated core tools like apply_patch from plugin-only unknown entries in tools.profile warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin.
  • +
  • Config/validation: accept documented agents.list[].params per-agent overrides in strict config validation so openclaw config validate no longer rejects runtime-supported cacheRetention, temperature, and maxTokens settings. (#41171) Thanks @atian8179.
  • +
  • Config/web fetch: restore runtime validation for documented tools.web.fetch.readability and tools.web.fetch.firecrawl settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec.
  • +
  • Signal/config validation: add channels.signal.groups schema support so per-group requireMention, tools, and toolsBySender overrides no longer get rejected during config validation. (#27199) Thanks @unisone.
  • +
  • Config/discovery: accept discovery.wideArea.domain in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh.
  • +
  • Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
  • +
+

View full changelog

+]]>
+ +
2026.3.12 Fri, 13 Mar 2026 04:25:50 +0000 @@ -168,367 +244,5 @@ ]]> - - 2026.3.7 - Sun, 08 Mar 2026 04:42:35 +0000 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 2026030790 - 2026.3.7 - 15.0 - OpenClaw 2026.3.7 -

Changes

-
    -
  • Agents/context engine plugin interface: add ContextEngine plugin slot with full lifecycle hooks (bootstrap, ingest, assemble, compact, afterTurn, prepareSubagentSpawn, onSubagentEnded), slot-based registry with config-driven resolution, LegacyContextEngine wrapper preserving existing compaction behavior, scoped subagent runtime for plugin runtimes via AsyncLocalStorage, and sessions.get gateway method. Enables plugins like lossless-claw to provide alternative context management strategies without modifying core compaction logic. Zero behavior change when no context engine plugin is configured. (#22201) thanks @jalehman.
  • -
  • ACP/persistent channel bindings: add durable Discord channel and Telegram topic binding storage, routing resolution, and CLI/docs support so ACP thread targets survive restarts and can be managed consistently. (#34873) Thanks @dutifulbob.
  • -
  • Telegram/ACP topic bindings: accept Telegram Mac Unicode dash option prefixes in /acp spawn, support Telegram topic thread binding (--thread here|auto), route bound-topic follow-ups to ACP sessions, add actionable Telegram approval buttons with prefixed approval-id resolution, and pin successful bind confirmations in-topic. (#36683) Thanks @huntharo.
  • -
  • Telegram/topic agent routing: support per-topic agentId overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin.
  • -
  • Web UI/i18n: add Spanish (es) locale support in the Control UI, including locale detection, lazy loading, and language picker labels across supported locales. (#35038) Thanks @DaoPromociones.
  • -
  • Onboarding/web search: add provider selection step and full provider list in configure wizard, with SecretRef ref-mode support during onboarding. (#34009) Thanks @kesku and @thewilloftheshadow.
  • -
  • Tools/Web search: switch Perplexity provider to Search API with structured results plus new language/region/time filters. (#33822) Thanks @kesku.
  • -
  • Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant.
  • -
  • Docker/Podman extension dependency baking: add OPENCLAW_EXTENSIONS so container builds can preinstall selected bundled extension npm dependencies into the image for faster and more reproducible startup in container deployments. (#32223) Thanks @sallyom.
  • -
  • Plugins/before_prompt_build system-context fields: add prependSystemContext and appendSystemContext so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin.
  • -
  • Plugins/hook policy: add plugins.entries..hooks.allowPromptInjection, validate unknown typed hook names at runtime, and preserve legacy before_agent_start model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras.
  • -
  • Hooks/Compaction lifecycle: emit session:compact:before and session:compact:after internal events plus plugin compaction callbacks with session/count metadata, so automations can react to compaction runs consistently. (#16788) thanks @vincentkoc.
  • -
  • Agents/compaction post-context configurability: add agents.defaults.compaction.postCompactionSections so deployments can choose which AGENTS.md sections are re-injected after compaction, while preserving legacy fallback behavior when the documented default pair is configured in any order. (#34556) thanks @efe-arv.
  • -
  • TTS/OpenAI-compatible endpoints: add messages.tts.openai.baseUrl config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42.
  • -
  • Slack/DM typing feedback: add channels.slack.typingReaction so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat.
  • -
  • Discord/allowBots mention gating: add allowBots: "mentions" to only accept bot-authored messages that mention the bot. Thanks @thewilloftheshadow.
  • -
  • Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr.
  • -
  • Cron/job snapshot persistence: skip backup during normalization persistence in ensureLoaded so jobs.json.bak keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline.
  • -
  • CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant.
  • -
  • Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras.
  • -
  • Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet.
  • -
  • Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind.
  • -
  • Config/Compaction safeguard tuning: expose agents.defaults.compaction.recentTurnsPreserve and quality-guard retry knobs through the validated config surface and embedded-runner wiring, with regression coverage for real config loading and schema metadata. (#25557) thanks @rodrigouroz.
  • -
  • iOS/App Store Connect release prep: align iOS bundle identifiers under ai.openclaw.client, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman.
  • -
  • Mattermost/model picker: add Telegram-style interactive provider/model browsing for /oc_model and /oc_models, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
  • -
  • Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add OPENCLAW_VARIANT=slim build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.
  • -
  • Google/Gemini 3.1 Flash-Lite: add first-class google/gemini-3.1-flash-lite-preview support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
  • -
-

Breaking

-
    -
  • BREAKING: Gateway auth now requires explicit gateway.auth.mode when both gateway.auth.token and gateway.auth.password are configured (including SecretRefs). Set gateway.auth.mode to token or password before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
  • -
-

Fixes

-
    -
  • Models/MiniMax: stop advertising removed MiniMax-M2.5-Lightning in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as MiniMax-M2.5-highspeed.
  • -
  • Security/Config: fail closed when loadConfig() hits validation or read errors so invalid configs cannot silently fall back to permissive runtime defaults. (#9040) Thanks @joetomasone.
  • -
  • Memory/Hybrid search: preserve negative FTS5 BM25 relevance ordering in bm25RankToScore() so stronger keyword matches rank above weaker ones instead of collapsing or reversing scores. (#33757) Thanks @lsdcc01.
  • -
  • LINE/requireMention group gating: align inbound and reply-stage LINE group policy resolution across raw, group:, and room: keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang.
  • -
  • Onboarding/local setup: default unset local tools.profile to coding instead of messaging, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek.
  • -
  • Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464)
  • -
  • Onboarding/headless Linux daemon probe hardening: treat systemctl --user is-enabled probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web.
  • -
  • Memory/QMD mcporter Windows spawn hardening: when mcporter.cmd launch fails with spawn EINVAL, retry via bare mcporter shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i.
  • -
  • Tools/web_search Brave language-code validation: align search_lang handling with Brave-supported codes (including zh-hans, zh-hant, en-gb, and pt-br), map common alias inputs (zh, ja) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming.
  • -
  • Models/openai-completions streaming compatibility: force compat.supportsUsageInStreaming=false for non-native OpenAI-compatible endpoints during model normalization, preventing usage-only stream chunks from triggering choices[0] parser crashes in provider streams. (#8714) Thanks @nonanon1.
  • -
  • Tools/xAI native web-search collision guard: drop OpenClaw web_search from tool registration when routing to xAI/Grok model providers (including OpenRouter x-ai/*) to avoid duplicate tool-name request failures against provider-native web_search. (#14749) Thanks @realsamrat.
  • -
  • TUI/token copy-safety rendering: treat long credential-like mixed alphanumeric tokens (including quoted forms) as copy-sensitive in render sanitization so formatter hard-wrap guards no longer inject visible spaces into auth-style values before display. (#26710) Thanks @jasonthane.
  • -
  • WhatsApp/self-chat response prefix fallback: stop forcing "[openclaw]" as the implicit outbound response prefix when no identity name or response prefix is configured, so blank/default prefix settings no longer inject branding text unexpectedly in self-chat flows. (#27962) Thanks @ecanmor.
  • -
  • Memory/QMD search result decoding: accept qmd search hits that only include file URIs (for example qmd://collection/path.md) without docid, resolve them through managed collection roots, and keep multi-collection results keyed by file fallback so valid QMD hits no longer collapse to empty memory_search output. (#28181) Thanks @0x76696265.
  • -
  • Memory/QMD collection-name conflict recovery: when qmd collection add fails because another collection already occupies the same path + pattern, detect the conflicting collection from collection list, remove it, and retry add so agent-scoped managed collections are created deterministically instead of being silently skipped; also add warning-only fallback when qmd metadata is unavailable to avoid destructive guesses. (#25496) Thanks @Ramsbaby.
  • -
  • Slack/app_mention race dedupe: when app_mention dispatch wins while same-ts message prepare is still in-flight, suppress the later message dispatch so near-simultaneous Slack deliveries do not produce duplicate replies; keep single-retry behavior and add regression coverage for both dropped and successful message-prepare outcomes. (#37033) Thanks @Takhoffman.
  • -
  • Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy.
  • -
  • TUI/model indicator freshness: prevent stale session snapshots from overwriting freshly patched model selection (and reset per-session freshness when switching session keys) so /model updates reflect immediately instead of lagging by one or more commands. (#21255) Thanks @kowza.
  • -
  • TUI/final-error rendering fallback: when a chat final event has no renderable assistant content but includes envelope errorMessage, render the formatted error text instead of collapsing to "(no output)", preserving actionable failure context in-session. (#14687) Thanks @Mquarmoc.
  • -
  • TUI/session-key alias event matching: treat chat events whose session keys are canonical aliases (for example agent::main vs main) as the same session while preserving cross-agent isolation, so assistant replies no longer disappear or surface in another terminal window due to strict key-form mismatch. (#33937) Thanks @yjh1412.
  • -
  • OpenAI Codex OAuth/login parity: keep openclaw models auth login --provider openai-codex on the built-in path even without provider plugins, preserve Pi-generated authorize URLs without local scope rewriting, and stop validating successful Codex sign-ins against the public OpenAI Responses API after callback. (#37558; follow-up to #36660 and #24720) Thanks @driesvints, @Skippy-Gunboat, and @obviyus.
  • -
  • Agents/config schema lookup: add gateway tool action config.schema.lookup so agents can inspect one config path at a time before edits without loading the full schema into prompt context. (#37266) Thanks @gumadeiras.
  • -
  • Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header ByteString construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.
  • -
  • Kimi Coding/Anthropic tools compatibility: normalize anthropic-messages tool payloads to OpenAI-style tools[].function + compatible tool_choice when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
  • -
  • Heartbeat/workspace-path guardrails: append explicit workspace HEARTBEAT.md path guidance (and docs/heartbeat.md avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
  • -
  • Subagents/kill-complete announce race: when a late subagent-complete lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.
  • -
  • Agents/tool-result cleanup timeout hardening: on embedded runner teardown idle timeouts, clear pending tool-call state without persisting synthetic missing tool result entries, preventing timeout cleanups from poisoning follow-up turns; adds regression coverage for timeout clear-vs-flush behavior. (#37081) Thanks @Coyote-Den.
  • -
  • Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream terminated failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard.
  • -
  • Agents/fallback cooldown probe execution: thread explicit rate-limit cooldown probe intent from model fallback into embedded runner auth-profile selection so same-provider fallback attempts can actually run when all profiles are cooldowned for rate_limit (instead of failing pre-run as No available auth profile), while preserving default cooldown skip behavior and adding regression tests at both fallback and runner layers. (#13623) Thanks @asfura.
  • -
  • Cron/OpenAI Codex OAuth refresh hardening: when openai-codex token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal.
  • -
  • TUI/session isolation for /new: make /new allocate a unique tui- session key instead of resetting the shared agent session, so multiple TUI clients on the same agent stop receiving each other’s replies; also sanitize /new and /reset failure text before rendering in-terminal. Landed from contributor PR #39238 by @widingmarcus-cyber. Thanks @widingmarcus-cyber.
  • -
  • Synology Chat/rate-limit env parsing: honor SYNOLOGY_RATE_LIMIT=0 as an explicit value while still falling back to the default limit for malformed env values instead of partially parsing them. Landed from contributor PR #39197 by @scoootscooob. Thanks @scoootscooob.
  • -
  • Voice-call/OpenAI Realtime STT config defaults: honor explicit vadThreshold: 0 and silenceDurationMs: 0 instead of silently replacing them with defaults. Landed from contributor PR #39196 by @scoootscooob. Thanks @scoootscooob.
  • -
  • Voice-call/OpenAI TTS speed config: honor explicit speed: 0 instead of silently replacing it with the default speed. Landed from contributor PR #39318 by @ql-wade. Thanks @ql-wade.
  • -
  • launchd/runtime PID parsing: reject pid <= 0 from launchctl print so the daemon state parser no longer treats kernel/non-running sentinel values as real process IDs. Landed from contributor PR #39281 by @mvanhorn. Thanks @mvanhorn.
  • -
  • Cron/file permission hardening: enforce owner-only (0600) cron store/backup/run-log files and harden cron store + run-log directories to 0700, including pre-existing directories from older installs. (#36078) Thanks @aerelune.
  • -
  • Gateway/remote WS break-glass hostname support: honor OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 for ws:// hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn.
  • -
  • Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second resolveAgentRoute stalls in large binding configurations. (#36915) Thanks @songchenghao.
  • -
  • Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during sessions.reset/sessions.delete runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693.
  • -
  • Plugin/hook install rollback hardening: stage installs under the canonical install base, validate and run dependency installs before publish, and restore updates by rename instead of deleting the target path, reducing partial-replace and symlink-rebind risk during install failures.
  • -
  • Slack/local file upload allowlist parity: propagate mediaLocalRoots through the Slack send action pipeline so workspace-rooted attachments pass assertLocalMediaAllowed checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin.
  • -
  • Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.
  • -
  • Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent RangeError: Invalid string length on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.
  • -
  • iMessage/cron completion announces: strip leaked inline reply tags (for example [[reply_to:6100]]) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.
  • -
  • Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt.
  • -
  • Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.
  • -
  • Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example @Bot/model and @Bot /reset) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
  • -
  • Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false device token mismatch disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.
  • -
  • Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.
  • -
  • Gateway/service token drift repair: stop persisting shared auth tokens into installed gateway service units, flag stale embedded service tokens for reinstall, and treat tokenless service env as canonical so token rotation/reboot flows stay aligned with config/env resolution. Landed from contributor PR #28428 by @l0cka. Thanks @l0cka.
  • -
  • Control UI/agents-page selection: keep the edited agent selected after saving agent config changes and reloading the agents list, so /agents no longer snaps back to the default agent. Landed from contributor PR #39301 by @MumuTW. Thanks @MumuTW.
  • -
  • Gateway/auth follow-up hardening: preserve systemd EnvironmentFile= precedence/source provenance in daemon audits and doctor repairs, block shared-password override flows from piggybacking cached device tokens, and fail closed when config-first gateway SecretRefs cannot resolve. Follow-up to #39241.
  • -
  • Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing thinking/text strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.
  • -
  • Agents/transcript policy: set preserveSignatures to Anthropic-only handling in resolveTranscriptPolicy so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.
  • -
  • Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok Invalid arguments failures. (openclaw#35355) thanks @Sid-Qin.
  • -
  • Skills/native command deduplication: centralize skill command dedupe by canonical skillName in listSkillCommandsForAgents so duplicate suffixed variants (for example _2) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205.
  • -
  • Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (&, ", <, >, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin.
  • -
  • Linux/WSL2 daemon install hardening: add regression coverage for WSL environment detection, WSL-specific systemd guidance, and systemctl --user is-enabled failure paths so WSL2/headless onboarding keeps treating bus-unavailable probes as non-fatal while preserving real permission errors. Related: #36495. Thanks @vincentkoc.
  • -
  • Linux/systemd status and degraded-session handling: treat degraded-but-reachable systemctl --user status results as available, preserve early errors for truly unavailable user-bus cases, and report externally managed running services as running instead of not installed. Thanks @vincentkoc.
  • -
  • Agents/thinking-tag promotion hardening: guard promoteThinkingTagsToBlocks against malformed assistant content entries (null/undefined) before block.type reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin.
  • -
  • Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid dev placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap serverVersion to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI.
  • -
  • Control UI/markdown parser crash fallback: catch marked.parse() failures and fall back to escaped plain-text
     rendering so malformed recursive markdown no longer crashes Control UI session rendering on load. (#36445) Thanks @BinHPdev.
  • -
  • Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev.
  • -
  • Web UI/config form: treat additionalProperties: true object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai.
  • -
  • Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread message.reply routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
  • -
  • Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so requireMention checks compare against current bot identity instead of stale config names, fixing missed @bot handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.
  • -
  • Security/dependency audit: patch transitive Hono vulnerabilities by pinning hono to 4.12.5 and @hono/node-server to 1.19.10 in production resolution paths. Thanks @shakkernerd.
  • -
  • Security/dependency audit: bump tar to 7.5.10 (from 7.5.9) to address the high-severity hardlink path traversal advisory (GHSA-qffp-2rhf-9h96). Thanks @shakkernerd.
  • -
  • Cron/announce delivery robustness: bypass pending-descendant announce guards for cron completion sends, ensure named-agent announce routes have outbound session entries, and fall back to direct delivery only when an announce send was actually attempted and failed. (from #35185, #32443, #34987) Thanks @Sid-Qin, @scoootscooob, and @bmendonca3.
  • -
  • Cron/announce best-effort fallback: run direct outbound fallback after attempted announce failures even when delivery is configured as best-effort, so Telegram cron sends are not left as attempted-but-undelivered after cron announce delivery failed warnings.
  • -
  • Auto-reply/system events: restore runtime system events to the message timeline (System: lines), preserve think-hint parsing with prepended events, and carry events into deferred followup/collect/steer-backlog prompts to keep cache behavior stable without dropping queued metadata. (#34794) Thanks @anisoptera.
  • -
  • Security/audit account handling: avoid prototype-chain account IDs in audit validation by using own-property checks for accounts. (#34982) Thanks @HOYALIM.
  • -
  • Cron/restart catch-up semantics: replay interrupted recurring jobs and missed immediate cron slots on startup without replaying interrupted one-shot jobs, with guarded missed-slot probing to avoid malformed-schedule startup aborts and duplicate-trigger drift after restart. (from #34466, #34896, #34625, #33206) Thanks @dunamismax, @dsantoreis, @Octane0411, and @Sid-Qin.
  • -
  • Venice/provider onboarding hardening: align per-model Venice completion-token limits with discovery metadata, clamp untrusted discovery values to safe bounds, sync the static Venice fallback catalog with current live model metadata, and disable tool wiring for Venice models that do not support function calling so default Venice setups no longer fail with max_completion_tokens or unsupported-tools 400s. Fixes #38168. Thanks @Sid-Qin, @powermaster888 and @vincentkoc.
  • -
  • Agents/session usage tracking: preserve accumulated usage metadata on embedded Pi runner error exits so failed turns still update session totalTokens from real usage instead of stale prior values. (#34275) thanks @RealKai42.
  • -
  • Slack/reaction thread context routing: carry Slack native DM channel IDs through inbound context and threading tool resolution so reaction targets resolve consistently for DM To=user:* sessions (including toolContext.currentChannelId fallback behavior). (from #34831; overlaps #34440, #34502, #34483, #32754) Thanks @dunamismax.
  • -
  • Subagents/announce completion scoping: scope nested direct-child completion aggregation to the current requester run window, harden frozen completion capture for deterministic descendant synthesis, and route completion announce delivery through parent-agent announce turns with provenance-aware internal events. (#35080) Thanks @tyler6204.
  • -
  • Nodes/system.run approval hardening: use explicit argv-mutation signaling when regenerating prepared rawCommand, and cover the system.run.prepare -> system.run handoff so direct PATH-based nodes.run commands no longer fail with rawCommand does not match command. (#33137) thanks @Sid-Qin.
  • -
  • Models/custom provider headers: propagate models.providers..headers across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin.
  • -
  • Ollama/remote provider auth fallback: synthesize a local runtime auth key for explicitly configured models.providers.ollama entries that omit apiKey, so remote Ollama endpoints run without requiring manual dummy-key setup while preserving env/profile/config key precedence and missing-config failures. (#11283) Thanks @cpreecs.
  • -
  • Ollama/custom provider headers: forward resolved model headers into native Ollama stream requests so header-authenticated Ollama proxies receive configured request headers. (#24337) thanks @echoVic.
  • -
  • Ollama/compaction and summarization: register custom api: "ollama" handling for compaction, branch-style internal summarization, and TTS text summarization on current main, so native Ollama models no longer fail with No API provider registered for api: ollama outside the main run loop. Thanks @JaviLib.
  • -
  • Daemon/systemd install robustness: treat systemctl --user is-enabled exit-code-4 not-found responses as not-enabled by combining stderr/stdout detail parsing, so Ubuntu fresh installs no longer fail with systemctl is-enabled unavailable. (#33634) Thanks @Yuandiaodiaodiao.
  • -
  • Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to agent:main. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc.
  • -
  • Slack/native streaming markdown conversion: stop pre-normalizing text passed to Slack native markdown_text in streaming start/append/stop paths to prevent Markdown style corruption from double conversion. (#34931)
  • -
  • Gateway/HTTP tools invoke media compatibility: preserve raw media payload access for direct /tools/invoke clients by allowing media nodes invoke commands only in HTTP tool context, while keeping agent-context media invoke blocking to prevent base64 prompt bloat. (#34365) Thanks @obviyus.
  • -
  • Security/archive ZIP hardening: extract ZIP entries via same-directory temp files plus atomic rename, then re-open and reject post-rename hardlink alias races outside the destination root.
  • -
  • Agents/Nodes media outputs: add dedicated photos_latest action handling, block media-returning nodes invoke commands, keep metadata-only camera.list invoke allowed, and normalize empty photos_latest results to a consistent response shape to prevent base64 context bloat. (#34332) Thanks @obviyus.
  • -
  • TUI/session-key canonicalization: normalize openclaw tui --session values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc.
  • -
  • iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth.
  • -
  • Skills/workspace boundary hardening: reject workspace and extra-dir skill roots or SKILL.md files whose realpath escapes the configured source root, and skip syncing those escaped skills into sandbox workspaces.
  • -
  • Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant.
  • -
  • gateway: harden shared auth resolution across systemd, discord, and node host (#39241) Thanks @joshavant.
  • -
  • Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant.
  • -
  • Sessions/subagent attachments: remove attachments[].content.maxLength from sessions_spawn schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.
  • -
  • Runtime/tool-state stability: recover from dangling Anthropic tool_use after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.
  • -
  • ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob.
  • -
  • Extensions/media local-root propagation: consistently forward mediaLocalRoots through extension sendMedia adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3.
  • -
  • Gateway/plugin HTTP auth hardening: require gateway auth when any overlapping matched route needs it, block mixed-auth fallthrough at dispatch, and reject mixed-auth exact/prefix route overlaps during plugin registration.
  • -
  • Feishu/video media send contract: keep mp4-like outbound payloads on msg_type: "media" (including reply and reply-in-thread paths) so videos render as media instead of degrading to file-link behavior, while preserving existing non-video file subtype handling. (from #33720, #33808, #33678) Thanks @polooooo, @dingjianrui, and @kevinWangSheng.
  • -
  • Gateway/security default response headers: add Permissions-Policy: camera=(), microphone=(), geolocation=() to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan.
  • -
  • Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into openclaw/plugin-sdk/core and openclaw/plugin-sdk/telegram, and preserve api.runtime reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy.
  • -
  • Plugins/startup performance: reduce bursty plugin discovery/manifest overhead with short in-process caches, skip importing bundled memory plugins that are disabled by slot selection, and speed legacy root openclaw/plugin-sdk compatibility via runtime root-alias routing while preserving backward compatibility. Thanks @gumadeiras.
  • -
  • Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.
  • -
  • Gateway/password CLI hardening: add openclaw gateway run --password-file, warn when inline --password is used because it can leak via process listings, and document env/file-backed password input as the preferred startup path. Fixes #27948. Thanks @vibewrk and @vincentkoc.
  • -
  • Config/heartbeat legacy-path handling: auto-migrate top-level heartbeat into agents.defaults.heartbeat (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan.
  • -
  • Plugins/SDK subpath parity: expand plugin SDK subpaths across bundled channels/extensions (Discord, Slack, Signal, iMessage, WhatsApp, LINE, and bundled companion plugins), with build/export/type/runtime wiring so scoped imports resolve consistently in source and dist while preserving compatibility. (#33737) thanks @gumadeiras.
  • -
  • Google/Gemini Flash model selection: switch built-in gemini-flash defaults and docs/examples from the nonexistent google/gemini-3.1-flash-preview ID to the working google/gemini-3-flash-preview, while normalizing legacy OpenClaw config that still uses the old Flash 3.1 alias.
  • -
  • Plugins/bundled scoped-import migration: migrate bundled plugins from monolithic openclaw/plugin-sdk imports to scoped subpaths (or openclaw/plugin-sdk/core) across registration and startup-sensitive runtime files, add CI/release guardrails to prevent regressions, and keep root openclaw/plugin-sdk support for external/community plugins. Thanks @gumadeiras.
  • -
  • Routing/session duplicate suppression synthesis: align shared session delivery-context inheritance, channel-paired route-field merges, and reply-surface target matching so dmScope=main turns avoid cross-surface duplicate replies while thread-aware forwarding keeps intended routing semantics. (from #33629, #26889, #17337, #33250) Thanks @Yuandiaodiaodiao, @kevinwildenradt, @Glucksberg, and @bmendonca3.
  • -
  • Routing/legacy session route inheritance: preserve external route metadata inheritance for legacy channel session keys (agent::: and ...:thread:) so chat.send does not incorrectly fall back to webchat when valid delivery context exists. Follow-up to #33786.
  • -
  • Routing/legacy route guard tightening: require legacy session-key channel hints to match the saved delivery channel before inheriting external routing metadata, preventing custom namespaced keys like agent::work: from inheriting stale non-webchat routes.
  • -
  • Gateway/internal client routing continuity: prevent webchat/TUI/UI turns from inheriting stale external reply routes by requiring explicit deliver: true for external delivery, keeping main-session external inheritance scoped to non-Webchat/UI clients, and honoring configured session.mainKey when identifying main-session continuity. (from #35321, #34635, #35356) Thanks @alexyyyander and @Octane0411.
  • -
  • Security/auth labels: remove token and API-key snippets from user-facing auth status labels so /status and /models do not expose credential fragments. (#33262) thanks @cu1ch3n.
  • -
  • Models/MiniMax portal vision routing: add MiniMax-VL-01 to the minimax-portal provider, route portal image understanding through the MiniMax VLM endpoint, and align media auto-selection plus Telegram sticker description with the shared portal image provider path. (#33953) Thanks @tars90percent.
  • -
  • Auth/credential semantics: align profile eligibility + probe diagnostics with SecretRef/expiry rules and harden browser download atomic writes. (#33733) thanks @joshavant.
  • -
  • Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown gateway.nodes.denyCommands entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot.
  • -
  • Agents/overload failover handling: classify overloaded provider failures separately from rate limits/status timeouts, add short overload backoff before retry/failover, record overloaded prompt/assistant failures as transient auth-profile cooldowns (with probeable same-provider fallback) instead of treating them like persistent auth/billing failures, and keep one-shot cron retry classification aligned so overloaded fallback summaries still count as transient retries.
  • -
  • Docs/security hardening guidance: document Docker DOCKER-USER + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan.
  • -
  • Docs/security threat-model links: replace relative .md links with Mintlify-compatible root-relative routes in security docs to prevent broken internal navigation. (#27698) thanks @clawdoo.
  • -
  • Plugins/Update integrity drift: avoid false integrity drift prompts when updating npm-installed plugins from unpinned specs, while keeping drift checks for exact pinned versions. (#37179) Thanks @vincentkoc.
  • -
  • iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman.
  • -
  • Gateway/chat.send command scopes: require operator.admin for persistent /config set|unset writes routed through gateway chat clients while keeping /config show available to normal write-scoped operator clients, preserving messaging-channel config command behavior without widening RPC write scope into admin config mutation. Thanks @tdjackey for reporting.
  • -
  • iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman.
  • -
  • iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts.
  • -
  • Docs/tool-loop detection config keys: align docs/tools/loop-detection.md examples and field names with the current tools.loopDetection schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
  • -
  • Gateway/session agent discovery: include disk-scanned agent IDs in listConfiguredAgentIds even when agents.list is configured, so disk-only/ACP agent sessions remain visible in gateway session aggregation and listings. (#32831) thanks @Sid-Qin.
  • -
  • Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.
  • -
  • Discord/Agent-scoped media roots: pass mediaLocalRoots through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow.
  • -
  • Discord/slash command handling: intercept text-based slash commands in channels, register plugin commands as native, and send fallback acknowledgments for empty slash runs so interactions do not hang. Thanks @thewilloftheshadow.
  • -
  • Discord/thread session lifecycle: reset thread-scoped sessions when a thread is archived so reopening a thread starts fresh without deleting transcript history. Thanks @thewilloftheshadow.
  • -
  • Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.
  • -
  • Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
  • -
  • ACP/sandbox spawn parity: block /acp spawn from sandboxed requester sessions with the same host-runtime guard already enforced for sessions_spawn({ runtime: "acp" }), preserving non-sandbox ACP flows while closing the command-path policy gap. Thanks @patte.
  • -
  • Discord/config SecretRef typing: align Discord account token config typing with SecretInput so SecretRef tokens typecheck. (#32490) Thanks @scoootscooob.
  • -
  • Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
  • -
  • Discord/voice decoder fallback: drop the native Opus dependency and use opusscript for voice decoding to avoid native-opus installs. Thanks @thewilloftheshadow.
  • -
  • Discord/auto presence health signal: add runtime availability-driven presence updates plus connected-state reporting to improve health monitoring and operator visibility. (#33277) Thanks @thewilloftheshadow.
  • -
  • HEIC image inputs: accept HEIC/HEIF input_image sources in Gateway HTTP APIs, normalize them to JPEG before provider delivery, and document the expanded default MIME allowlist. Thanks @vincentkoc.
  • -
  • Gateway/HEIC input follow-up: keep non-HEIC input_image MIME handling unchanged, make HEIC tests hermetic, and enforce chat-completions maxTotalImageBytes against post-normalization image payload size. Thanks @vincentkoc.
  • -
  • Telegram/draft-stream boundary stability: materialize DM draft previews at assistant-message/tool boundaries, serialize lane-boundary callbacks before final delivery, and scope preview cleanup to the active preview so multi-step Telegram streams no longer lose, overwrite, or leave stale preview bubbles. (#33842) Thanks @ngutman.
  • -
  • Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
  • -
  • Telegram/DM draft final delivery: materialize text-only sendMessageDraft previews into one permanent final message and skip duplicate final payload sends, while preserving fallback behavior when materialization fails. (#34318) Thanks @Brotherinlaw-13.
  • -
  • Telegram/DM draft duplicate display: clear stale DM draft previews after materializing the real final message, including threadless fallback when DM topic lookup fails, so partial streaming no longer briefly shows duplicate replies. (#36746) Thanks @joelnishanth.
  • -
  • Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress NO_REPLY lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus.
  • -
  • Telegram/native commands commands.allowFrom precedence: make native Telegram commands honor commands.allowFrom as the command-specific authorization source, including group chats, instead of falling back to channel sender allowlists. (#28216) Thanks @toolsbybuddy and @vincentkoc.
  • -
  • Telegram/groupAllowFrom sender-ID validation: restore sender-only runtime validation so negative chat/group IDs remain invalid entries instead of appearing accepted while still being unable to authorize group access. (#37134) Thanks @qiuyuemartin-max and @vincentkoc.
  • -
  • Telegram/native group command auth: authorize native commands in groups and forum topics against groupAllowFrom and per-group/topic sender overrides, while keeping auth rejection replies in the originating topic thread. (#39267) Thanks @edwluo.
  • -
  • Telegram/named-account DMs: restore non-default-account DM routing when a named Telegram account falls back to the default agent by keeping groups fail-closed but deriving a per-account session key for DMs, including identity-link canonicalization and regression coverage for account isolation. (from #32426; fixes #32351) Thanks @chengzhichao-xydt.
  • -
  • Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
  • -
  • Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
  • -
  • Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.
  • -
  • Discord/mention handling: add id-based mention formatting + cached rewrites, resolve inbound mentions to display names, and add optional ignoreOtherMentions gating (excluding @everyone/@here). (#33224) Thanks @thewilloftheshadow.
  • -
  • Discord/media SSRF allowlist: allow Discord CDN hostnames (including wildcard domains) in inbound media SSRF policy to prevent proxy/VPN fake-ip blocks. (#33275) Thanks @thewilloftheshadow.
  • -
  • Telegram/device pairing notifications: auto-arm one-shot notify on /pair qr, auto-ping on new pairing requests, and add manual fallback via /pair approve latest if the ping does not arrive. (#33299) thanks @mbelinky.
  • -
  • Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf
  • -
  • macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (wss://.ts.net) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
  • -
  • iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky.
  • -
  • iOS/Concurrency stability: replace risky shared-state access in camera and gateway connection paths with lock-protected access patterns to reduce crash risk under load. (#33241) thanks @mbelinky.
  • -
  • iOS/Security guardrails: limit production API-key sourcing to app config and make deep-link confirmation prompts safer by coalescing queued requests instead of silently dropping them. (#33031) thanks @mbelinky.
  • -
  • iOS/TTS playback fallback: keep voice playback resilient by switching from PCM to MP3 when provider format support is unavailable, while avoiding sticky fallback on generic local playback errors. (#33032) thanks @mbelinky.
  • -
  • Plugin outbound/text-only adapter compatibility: allow direct-delivery channel plugins that only implement sendText (without sendMedia) to remain outbound-capable, gracefully fall back to text delivery for media payloads when sendMedia is absent, and fail explicitly for media-only payloads with no text fallback. (#32788) thanks @liuxiaopai-ai.
  • -
  • Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add openclaw doctor warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.
  • -
  • Telegram/plugin outbound hook parity: run message_sending + message_sent in Telegram reply delivery, include reply-path hook metadata (mediaUrls, threadId), and report message_sent.success=false when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.
  • -
  • CLI/Coding-agent reliability: switch default claude-cli non-interactive args to --permission-mode bypassPermissions, auto-normalize legacy --dangerously-skip-permissions backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. (#28610, #31149, #34055). Thanks @niceysam, @cryptomaltese and @vincentkoc.
  • -
  • Gateway/OpenAI chat completions: parse active-turn image_url content parts (including parameterized data URIs and guarded URL sources), forward them as multimodal images, accept image-only user turns, enforce per-request image-part/byte budgets, default URL-based image fetches to disabled unless explicitly enabled by config, and redact image base64 data in cache-trace/provider payload diagnostics. (#17685) Thanks @vincentkoc
  • -
  • ACP/ACPX session bootstrap: retry with sessions new when sessions ensure returns no session identifiers so ACP spawns avoid NO_SESSION/ACP_TURN_FAILED failures on affected agents. (#28786, #31338, #34055). Thanks @Sid-Qin and @vincentkoc.
  • -
  • ACP/sessions_spawn parent stream visibility: add streamTo: "parent" for runtime: "acp" to forward initial child-run progress/no-output/completion updates back into the requester session as system events (instead of direct child delivery), and emit a tail-able session-scoped relay log (.acp-stream.jsonl, returned as streamLogPath when available), improving orchestrator visibility for blocked or long-running harness turns. (#34310, #29909; reopened from #34055). Thanks @vincentkoc.
  • -
  • Agents/bootstrap truncation warning handling: unify bootstrap budget/truncation analysis across embedded + CLI runtime, /context, and openclaw doctor; add agents.defaults.bootstrapPromptTruncationWarning (off|once|always, default once) and persist warning-signature metadata so truncation warnings are consistent and deduped across turns. (#32769) Thanks @gumadeiras.
  • -
  • Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.
  • -
  • Agents/Session startup date grounding: substitute YYYY-MM-DD placeholders in startup/post-compaction AGENTS context and append runtime current-time lines for /new and /reset prompts so daily-memory references resolve correctly. (#32381) Thanks @chengzhichao-xydt.
  • -
  • Agents/Compaction template heading alignment: update AGENTS template section names to Session Startup/Red Lines and keep legacy Every Session/Safety fallback extraction so post-compaction context remains intact across template versions. (#25098) thanks @echoVic.
  • -
  • Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone.
  • -
  • Agents/Compaction safeguard structure hardening: require exact fallback summary headings, sanitize untrusted compaction instruction text before prompt embedding, and keep structured sections when preserving all turns. (#25555) thanks @rodrigouroz.
  • -
  • Gateway/status self version reporting: make Gateway self version in openclaw status prefer runtime VERSION (while preserving explicit OPENCLAW_VERSION override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai.
  • -
  • Memory/QMD index isolation: set QMD_CONFIG_DIR alongside XDG_CONFIG_HOME so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind.
  • -
  • Memory/QMD collection safety: stop destructive collection rebinds when QMD collection list only reports names without path metadata, preventing memory search from dropping existing collections if re-add fails. (#36870) Thanks @Adnannnnnnna.
  • -
  • Memory/QMD duplicate-document recovery: detect UNIQUE constraint failed: documents.collection, documents.path update failures, rebuild managed collections once, and retry update so periodic QMD syncs recover instead of failing every run; includes regression coverage to avoid over-matching unrelated unique constraints. (#27649) Thanks @MiscMich.
  • -
  • Memory/local embedding initialization hardening: add regression coverage for transient initialization retry and mixed embedQuery + embedBatch concurrent startup to lock single-flight initialization behavior. (#15639) thanks @SubtleSpark.
  • -
  • CLI/Coding-agent reliability: switch default claude-cli non-interactive args to --permission-mode bypassPermissions, auto-normalize legacy --dangerously-skip-permissions backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc.
  • -
  • ACP/ACPX session bootstrap: retry with sessions new when sessions ensure returns no session identifiers so ACP spawns avoid NO_SESSION/ACP_TURN_FAILED failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc.
  • -
  • LINE/auth boundary hardening synthesis: enforce strict LINE webhook authn/z boundary semantics across pairing-store account scoping, DM/group allowlist separation, fail-closed webhook auth/runtime behavior, and replay/duplication controls (including in-flight replay reservation and post-success dedupe marking). (from #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777) Thanks @bmendonca3, @davidahmann, @harshang03, @haosenwang1018, @liuxiaopai-ai, @coygeek, and @Takhoffman.
  • -
  • LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3.
  • -
  • LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.
  • -
  • LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.
  • -
  • LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.
  • -
  • Mattermost/interactive buttons: add interactive button send/callback support with directory-based channel/user target resolution, and harden callbacks via account-scoped HMAC verification plus sender-scoped DM routing. (#19957) thanks @tonydehnke.
  • -
  • Feishu/groupPolicy legacy alias compatibility: treat legacy groupPolicy: "allowall" as open in both schema parsing and runtime policy checks so intended open-group configs no longer silently drop group messages when groupAllowFrom is empty. (from #36358) Thanks @Sid-Qin.
  • -
  • Mattermost/plugin SDK import policy: replace remaining monolithic openclaw/plugin-sdk imports in Mattermost mention-gating paths/tests with scoped subpaths (openclaw/plugin-sdk/compat and openclaw/plugin-sdk/mattermost) so pnpm check passes lint:plugins:no-monolithic-plugin-sdk-entry-imports on baseline. (#36480) Thanks @Takhoffman.
  • -
  • Telegram/polls: add Telegram poll action support to channel action discovery and tool/CLI poll flows, with multi-account discoverability gated to accounts that can actually execute polls (sendMessage + poll). (#36547) thanks @gumadeiras.
  • -
  • Agents/failover cooldown classification: stop treating generic cooling down text as provider rate_limit so healthy models no longer show false global cooldown/rate-limit warnings while explicit model_cooldown markers still trigger failover. (#32972) thanks @stakeswky.
  • -
  • Agents/failover service-unavailable handling: stop treating bare proxy/CDN service unavailable errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode.
  • -
  • Plugins/HTTP route migration diagnostics: rewrite legacy api.registerHttpHandler(...) loader failures into actionable migration guidance so doctor/plugin diagnostics point operators to api.registerHttpRoute(...) or registerPluginHttpRoute(...). (#36794) Thanks @vincentkoc
  • -
  • Doctor/Heartbeat upgrade diagnostics: warn when heartbeat delivery is configured with an implicit directPolicy so upgrades pin direct/DM behavior explicitly instead of relying on the current default. (#36789) Thanks @vincentkoc.
  • -
  • Agents/current-time UTC anchor: append a machine-readable UTC suffix alongside local Current time: lines in shared cron-style prompt contexts so agents can compare UTC-stamped workspace timestamps without doing timezone math. (#32423) thanks @jriff.
  • -
  • Ollama/local model handling: preserve explicit lower contextWindow / maxTokens overrides during merge refresh, and keep native Ollama streamed replies from surfacing fallback thinking / reasoning text once real content starts streaming. (#39292) Thanks @vincentkoc.
  • -
  • TUI/webchat command-owner scope alignment: treat internal-channel gateway sessions with operator.admin as owner-authorized in command auth, restoring cron/gateway/connector tool access for affected TUI/webchat sessions while keeping external channels on identity-based owner checks. (from #35666, #35673, #35704) Thanks @Naylenv, @Octane0411, and @Sid-Qin.
  • -
  • Discord/inbound timeout isolation: separate inbound worker timeout tracking from listener timeout budgets so queued Discord replies are no longer dropped when listener watchdog windows expire mid-run. (#36602) Thanks @dutifulbob.
  • -
  • Memory/doctor SecretRef handling: treat SecretRef-backed memory-search API keys as configured, and fail embedding setup with explicit unresolved-secret errors instead of crashing. (#36835) Thanks @joshavant.
  • -
  • Memory/flush default prompt: ban timestamped variant filenames during default memory flush runs so durable notes stay in the canonical daily memory/YYYY-MM-DD.md file. (#34951) thanks @zerone0x.
  • -
  • Agents/reply delivery timing: flush embedded Pi block replies before waiting on compaction retries so already-generated assistant replies reach channels before compaction wait completes. (#35489) thanks @Sid-Qin.
  • -
  • Agents/gateway config guidance: stop exposing config.schema through the agent gateway tool, remove prompt/docs guidance that told agents to call it, and keep agents on config.get plus config.patch/config.apply for config changes. (#7382) thanks @kakuteki.
  • -
  • Provider/KiloCode: Keep duplicate models after malformed discovery rows, and strip legacy reasoning_effort when proxy reasoning injection is skipped. (#32352) Thanks @pandemicsyn and @vincentkoc.
  • -
  • Agents/failover: classify periodic provider limit exhaustion text (for example Weekly/Monthly Limit Exhausted) as rate_limit while keeping explicit 402 Payment Required variants in billing, so failover continues without misclassifying billing-wrapped quota errors. (#33813) thanks @zhouhe-xydt.
  • -
  • Mattermost/interactive button callbacks: allow external callback base URLs and stop requiring loopback-origin requests so button clicks work when Mattermost reaches the gateway over Tailscale, LAN, or a reverse proxy. (#37543) thanks @mukhtharcm.
  • -
  • Gateway/chat.send route inheritance: keep explicit external delivery for channel-scoped sessions while preventing shared-main and other channel-agnostic webchat sessions from inheriting stale external routes, so Control UI replies stay on webchat without breaking selected channel-target sessions. (#34669) Thanks @vincentkoc.
  • -
  • Telegram/Discord media upload caps: make outbound uploads honor channel mediaMaxMb config, raise Telegram's default media cap to 100MB, and remove MIME fallback limits that kept some Telegram uploads at 16MB. Thanks @vincentkoc.
  • -
  • Skills/nano-banana-pro resolution override: respect explicit --resolution values during image editing and only auto-detect output size from input images when the flag is omitted. (#36880) Thanks @shuofengzhang and @vincentkoc.
  • -
  • Skills/openai-image-gen CLI validation: validate --background and --style inputs early, normalize supported values, and warn when those flags are ignored for incompatible models. (#36762) Thanks @shuofengzhang and @vincentkoc.
  • -
  • Skills/openai-image-gen output formats: validate --output-format values early, normalize aliases like jpg -> jpeg, and warn when the flag is ignored for incompatible models. (#36648) Thanks @shuofengzhang and @vincentkoc.
  • -
  • ACP/skill env isolation: strip skill-injected API keys from ACP harness child-process environments so tools like Codex CLI keep their own auth flow instead of inheriting billed provider keys from active skills. (#36316) Thanks @taw0002 and @vincentkoc.
  • -
  • WhatsApp media upload caps: make outbound media sends and auto-replies honor channels.whatsapp.mediaMaxMb with per-account overrides so inbound and outbound limits use the same channel config. Thanks @vincentkoc.
  • -
  • Windows/Plugin install: when OpenClaw runs on Windows via Bun and npm-cli.js is not colocated with the runtime binary, fall back to npm.cmd/npx.cmd through the existing cmd.exe wrapper so openclaw plugins install no longer fails with spawn EINVAL. (#38056) Thanks @0xlin2023.
  • -
  • Telegram/send retry classification: retry grammY Network request ... failed after N attempts envelopes in send flows without reclassifying plain Network request ... failed! wrappers as transient, restoring the intended retry path while keeping broad send-context message matching tight. (#38056) Thanks @0xlin2023.
  • -
  • Gateway/probes: keep /health, /healthz, /ready, and /readyz reachable when the Control UI is mounted at /, preserve plugin-owned route precedence on those paths, and make /ready and /readyz report channel-backed readiness with startup grace plus 503 on disconnected managed channels, while /health and /healthz stay shallow liveness probes. (#18446) Thanks @vibecodooor, @mahsumaktas, and @vincentkoc.
  • -
  • Feishu/media downloads: drop invalid timeout fields from SDK method calls now that client-level httpTimeoutMs applies to requests. (#38267) Thanks @ant1eicher and @thewilloftheshadow.
  • -
  • PI embedded runner/Feishu docs: propagate sender identity into embedded attempts so Feishu doc auto-grant restores requester access for embedded-runner executions. (#32915) thanks @cszhouwei.
  • -
  • Agents/usage normalization: normalize missing or partial assistant usage snapshots before compaction accounting so openclaw agent --json no longer crashes when provider payloads omit totalTokens or related usage fields. (#34977) thanks @sp-hk2ldn.
  • -
  • Venice/default model refresh: switch the built-in Venice default to kimi-k2-5, update onboarding aliasing, and refresh Venice provider docs/recommendations to match the current private and anonymized catalog. (from #12964) Fixes #20156. Thanks @sabrinaaquino and @vincentkoc.
  • -
  • Agents/skill API write pacing: add a global prompt guardrail that treats skill-driven external API writes as rate-limited by default, so runners prefer batched writes, avoid tight request loops, and respect 429/Retry-After. Thanks @vincentkoc.
  • -
  • Google Chat/multi-account webhook auth fallback: when channels.googlechat.accounts.default carries shared webhook audience/path settings (for example after config normalization), inherit those defaults for named accounts while preserving top-level and per-account overrides, so inbound webhook verification no longer fails silently for named accounts missing duplicated audience fields. Fixes #38369.
  • -
  • Models/tool probing: raise the tool-capability probe budget from 32 to 256 tokens so reasoning models that spend tokens on thinking before returning a required tool call are less likely to be misclassified as not supporting tools. (#7521) Thanks @jakobdylanc.
  • -
  • Gateway/transient network classification: treat wrapped ...: fetch failed transport messages as transient while avoiding broad matches like Web fetch failed (404): ..., preventing Discord reconnect wrappers from crashing the gateway without suppressing non-network tool failures. (#38530) Thanks @xinhuagu.
  • -
  • ACP/console silent reply suppression: filter ACP NO_REPLY lead fragments and silent-only finals before openclaw agent logging/delivery so console-backed ACP sessions no longer leak NO/NO_REPLY placeholders. (#38436) Thanks @ql-wade.
  • -
  • Feishu/reply delivery reliability: disable block streaming in Feishu reply options so plain-text auto-render replies are no longer silently dropped before final delivery. (#38258) Thanks @xinhuagu.
  • -
  • Agents/reply MEDIA delivery: normalize local assistant MEDIA: paths before block/final delivery, keep media dedupe aligned with message-tool sends, and contain malformed media normalization failures so generated files send reliably instead of falling back to empty responses. (#38572) Thanks @obviyus.
  • -
  • Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing sessionKey rolls to a new sessionId across auto-reply, command, and isolated cron session resolvers, so AGENTS.md/MEMORY.md/USER.md updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm.
  • -
  • Gateway/Telegram polling health monitor: skip stale-socket restarts for Telegram long-polling channels and thread channel identity through shared health evaluation so polling connections are not restarted on the WebSocket stale-socket heuristic. (#38395) Thanks @ql-wade and @Takhoffman.
  • -
  • Daemon/systemd fresh-install probe: check for OpenClaw's managed user unit before running systemctl --user is-enabled, so first-time Linux installs no longer fail on generic missing-unit probe errors. (#38819) Thanks @adaHubble.
  • -
  • Gateway/container lifecycle: allow openclaw gateway stop to SIGTERM unmanaged gateway listeners and openclaw gateway restart to SIGUSR1 a single unmanaged listener when no service manager is installed, so container and supervisor-based deployments are no longer blocked by service disabled no-op responses. Fixes #36137. Thanks @vincentkoc.
  • -
  • Gateway/Windows restart supervision: relaunch task-managed gateways through Scheduled Task with quoted helper-script command paths, distinguish restart-capable supervisors per platform, and stop orphaned Windows gateway children during self-restart. (#38825) Thanks @obviyus.
  • -
  • Telegram/native topic command routing: resolve forum-topic native commands through the same conversation route as inbound messages so topic agentId overrides and bound topic sessions target the active session instead of the default topic-parent session. (#38871) Thanks @obviyus.
  • -
  • Markdown/assistant image hardening: flatten remote markdown images to plain text across the Control UI, exported HTML, and shared Swift chat while keeping inline data:image/... markdown renderable, so model output no longer triggers automatic remote image fetches. (#38895) Thanks @obviyus.
  • -
  • Config/compaction safeguard settings: regression-test agents.defaults.compaction.recentTurnsPreserve through loadConfig() and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz.
  • -
  • iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman.
  • -
  • CLI/Docs memory help accuracy: clarify openclaw memory status --deep behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974.
  • -
  • Auto-reply/allowlist store account scoping: keep /allowlist ... --store writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @tdjackey for reporting and @vincentkoc for the fix.
  • -
  • Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (x-forwarded-for / x-real-ip) and rejecting sec-fetch-site: cross-site; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts.
  • -
  • CLI/bootstrap Node version hint maintenance: replace hardcoded nvm 22 instructions in openclaw.mjs with MIN_NODE_MAJOR interpolation so future minimum-Node bumps keep startup guidance in sync automatically. (#39056) Thanks @onstash.
  • -
  • Discord/native slash command auth: honor commands.allowFrom.discord (and commands.allowFrom["*"]) in guild slash-command pre-dispatch authorization so allowlisted senders are no longer incorrectly rejected as unauthorized. (#38794) Thanks @jskoiz and @thewilloftheshadow.
  • -
  • Outbound/message target normalization: ignore empty legacy to/channelId fields when explicit target is provided so valid target-based sends no longer fail legacy-param validation; includes regression coverage. (#38944) Thanks @Narcooo.
  • -
  • Models/auth token prompts: guard cancelled manual token prompts so Symbol(clack:cancel) values cannot be persisted into auth profiles; adds regression coverage for cancelled models auth paste-token. (#38951) Thanks @MumuTW.
  • -
  • Gateway/loopback announce URLs: treat http:// and https:// aliases with the same loopback/private-network policy as websocket URLs so loopback cron announce delivery no longer fails secure URL validation. (#39064) Thanks @Narcooo.
  • -
  • Models/default provider fallback: when the hardcoded default provider is removed from models.providers, resolve defaults from configured providers instead of reporting stale removed-provider defaults in status output. (#38947) Thanks @davidemanuelDEV.
  • -
  • Agents/cache-trace stability: guard stable stringify against circular references in trace payloads so near-limit payloads no longer crash with Maximum call stack size exceeded; adds regression coverage. (#38935) Thanks @MumuTW.
  • -
  • Extensions/diffs CI stability: add headers to the localReq test helper in extensions/diffs/index.test.ts so forwarding-hint checks no longer crash with req.headers undefined. (supersedes #39063) Thanks @Shennng.
  • -
  • Agents/compaction thresholding: apply agents.defaults.contextTokens cap to the model passed into embedded run and /compact session creation so auto-compaction thresholds use the effective context window, not native model max context. (#39099) Thanks @MumuTW.
  • -
  • Models/merge mode provider precedence: when models.mode: "merge" is active and config explicitly sets a provider baseUrl, keep config as source of truth instead of preserving stale runtime models.json baseUrl values; includes normalized provider-key coverage. (#39103) Thanks @BigUncle.
  • -
  • UI/Control chat tool streaming: render tool events live in webchat without requiring refresh by enabling tool-events capability, fixing stream/event correlation, and resetting/reloading stream state around tool results and terminal events. (#39104) Thanks @jakepresent.
  • -
  • Models/provider apiKey persistence hardening: when a provider apiKey value equals a known provider env var value, persist the canonical env var name into models.json instead of resolved plaintext secrets. (#38889) Thanks @gambletan.
  • -
  • Discord/model picker persistence check: add a short post-dispatch settle delay before reading back session model state so picker confirmations stop reporting false mismatch warnings after successful model switches. (#39105) Thanks @akropp.
  • -
  • Agents/OpenAI WS compat store flag: omit store from response.create payloads when model compat sets supportsStore: false, preventing strict OpenAI-compatible providers from rejecting websocket requests with unknown-field errors. (#39113) Thanks @scoootscooob.
  • -
  • Config/validation log sanitization: sanitize config-validation issue paths/messages before logging so control characters and ANSI escape sequences cannot inject misleading terminal output from crafted config content. (#39116) Thanks @powermaster888.
  • -
  • Agents/compaction counter accuracy: count successful overflow-triggered auto-compactions (willRetry=true) in the compaction counter while still excluding aborted/no-result events, so /status reflects actual safeguard compaction activity. (#39123) Thanks @MumuTW.
  • -
  • Gateway/chat delta ordering: flush buffered assistant deltas before emitting tool start events so pre-tool text is delivered to Control UI before tool cards, avoiding transient text/tool ordering artifacts in streaming. (#39128) Thanks @0xtangping.
  • -
  • Voice-call plugin schema parity: add missing manifest configSchema fields (webhookSecurity, streaming.preStartTimeoutMs|maxPendingConnections|maxPendingConnectionsPerIp|maxConnections, staleCallReaperSeconds) so gateway AJV validation accepts already-supported runtime config instead of failing with additionalProperties errors. (#38892) Thanks @giumex.
  • -
  • Agents/OpenAI WS reconnect retry accounting: avoid double retry scheduling when reconnect failures emit both error and close, so retry budgets track actual reconnect attempts instead of exhausting early. (#39133) Thanks @scoootscooob.
  • -
  • Daemon/Windows schtasks runtime detection: use locale-invariant Last Run Result running codes (0x41301/267009) as the primary running signal so openclaw node status no longer misreports active tasks as stopped on non-English Windows locales. (#39076) Thanks @ademczuk.
  • -
  • Usage/token count formatting: round near-million token counts to millions (1.0m) instead of 1000k, with explicit boundary coverage for 999_499 and 999_500. (#39129) Thanks @CurryMessi.
  • -
  • Gateway/session bootstrap cache invalidation ordering: clear bootstrap snapshots only after active embedded-run shutdown wait completes, preventing dying runs from repopulating stale cache between /new/sessions.reset turns. (#38873) Thanks @MumuTW.
  • -
  • Browser/dispatcher error clarity: preserve dispatcher-side failure context in browser fetch errors while still appending operator guidance and explicit no-retry model hints, preventing misleading "Can't reach service" wrapping and avoiding LLM retry loops. (#39090) Thanks @NewdlDewdl.
  • -
  • Telegram/polling offset safety: confirm persisted offsets before polling startup while validating stored lastUpdateId values as non-negative safe integers (with overflow guards) so malformed offset state cannot cause update skipping/dropping. (#39111) Thanks @MumuTW.
  • -
  • Telegram/status SecretRef read-only resolution: resolve env-backed bot-token SecretRefs in config-only/status inspection while respecting provider source/defaults and env allowlists, so status no longer crashes or reports false-ready tokens for disallowed providers. (#39130) Thanks @neocody.
  • -
  • Agents/OpenAI WS max-token zero forwarding: treat maxTokens: 0 as an explicit value in websocket response.create payloads (instead of dropping it as falsy), with regression coverage for zero-token forwarding. (#39148) Thanks @scoootscooob.
  • -
  • Podman/.env gateway bind precedence: evaluate OPENCLAW_GATEWAY_BIND after sourcing .env in run-openclaw-podman.sh so env-file overrides are honored. (#38785) Thanks @majinyu666.
  • -
  • Models/default alias refresh: bump gpt to openai/gpt-5.4 and Gemini defaults to gemini-3.1 preview aliases (including normalization/default wiring) to track current model IDs. (#38638) Thanks @ademczuk.
  • -
  • Config/env substitution degraded mode: convert missing ${VAR} resolution in config reads from hard-fail to warning-backed degraded behavior, while preventing unresolved placeholders from being accepted as gateway credentials. (#39050) Thanks @akz142857.
  • -
  • Discord inbound listener non-blocking dispatch: make MESSAGE_CREATE listener handoff asynchronous (no per-listener queue blocking), so long runs no longer stall unrelated incoming events. (#39154) Thanks @yaseenkadlemakki.
  • -
  • Daemon/Windows PATH freeze fix: stop persisting install-time PATH snapshots into Scheduled Task scripts so runtime tool lookup follows current host PATH updates; also refresh local TUI history on silent local finals. (#39139) Thanks @Narcooo.
  • -
  • Gateway/systemd service restart hardening: clear stale gateway listeners by explicit run-port before service bind, add restart stale-pid port-override support, tune systemd start/stop/exit handling, and disable detached child mode only in service-managed runtime so cgroup stop semantics clean up descendants reliably. (#38463) Thanks @spirittechie.
  • -
  • Discord/plugin native command aliases: let plugins declare provider-specific slash names so native Discord registration can avoid built-in command collisions; the bundled Talk voice plugin now uses /talkvoice natively on Discord while keeping text /voice.
  • -
  • Daemon/Windows schtasks status normalization: derive runtime state from locale-neutral numeric Last Run Result codes only (without language string matching) and surface unknown when numeric result data is unavailable, preventing locale-specific misclassification drift. (#39153) Thanks @scoootscooob.
  • -
  • Telegram/polling conflict recovery: reset the polling webhookCleared latch on getUpdates 409 conflicts so webhook cleanup re-runs on restart cycles and polling avoids infinite conflict loops. (#39205) Thanks @amittell.
  • -
  • Heartbeat/requests-in-flight scheduling: stop advancing nextDueMs and avoid immediate scheduleNext() timer overrides on requests-in-flight skips, so wake-layer retry cooldowns are honored and heartbeat cadence no longer drifts under sustained contention. (#39182) Thanks @MumuTW.
  • -
  • Memory/SQLite contention resilience: re-apply PRAGMA busy_timeout on every sync-store and QMD connection open so process restarts/reopens no longer revert to immediate SQLITE_BUSY failures under lock contention. (#39183) Thanks @MumuTW.
  • -
  • Gateway/webchat route safety: block webchat/control-ui clients from inheriting stored external delivery routes on channel-scoped sessions (while preserving route inheritance for UI/TUI clients), preventing cross-channel leakage from scoped chats. (#39175) Thanks @widingmarcus-cyber.
  • -
  • Telegram error-surface resilience: return a user-visible fallback reply when dispatch/debounce processing fails instead of going silent, while preserving draft-stream cleanup and best-effort thread-scoped fallback delivery. (#39209) Thanks @riftzen-bit.
  • -
  • Gateway/password auth startup diagnostics: detect unresolved provider-reference objects in gateway.auth.password and fail with a specific bootstrap-secrets error message instead of generic misconfiguration output. (#39230) Thanks @ademczuk.
  • -
  • Agents/OpenAI-responses compatibility: strip unsupported store payload fields when supportsStore=false (including OpenAI-compatible non-OpenAI providers) while preserving server-compaction payload behavior. (#39219) Thanks @ademczuk.
  • -
  • Agents/model fallback visibility: warn when configured model IDs cannot be resolved and fallback is applied, with log-safe sanitization of model text to prevent control-sequence injection in warning output. (#39215) Thanks @ademczuk.
  • -
  • Outbound delivery replay safety: use two-phase delivery ACK markers (.json -> .delivered -> unlink) and startup marker cleanup so crash windows between send and cleanup do not replay already-delivered messages. (#38668) Thanks @Gundam98.
  • -
  • Nodes/system.run approval binding: carry prepared approval plans through gateway forwarding and bind interpreter-style script operands across approval to execution, so post-approval script rewrites are denied while unchanged approved script runs keep working. Thanks @tdjackey for reporting.
  • -
  • Nodes/system.run PowerShell wrapper parsing: treat pwsh/powershell -EncodedCommand forms as shell-wrapper payloads so allowlist mode still requires approval instead of falling back to plain argv analysis. Thanks @tdjackey for reporting.
  • -
  • Control UI/auth error reporting: map generic browser Fetch failed websocket close errors back to actionable gateway auth messages (gateway token mismatch, authentication failed, retry later) so dashboard disconnects stop hiding credential problems. Landed from contributor PR #28608 by @KimGLee. Thanks @KimGLee.
  • -
  • Media/mime unknown-kind handling: return undefined (not "unknown") for missing/unrecognized MIME kinds and use document-size fallback caps for unknown remote media, preventing phantom Signal events from being treated as real messages. (#39199) Thanks @nicolasgrasset.
  • -
  • Nodes/system.run allow-always persistence: honor shell comment semantics during allowlist analysis so #-tailed payloads that never execute are not persisted as trusted follow-up commands. Thanks @tdjackey for reporting.
  • -
  • Signal/inbound attachment fan-in: forward all successfully fetched inbound attachments through MediaPaths/MediaUrls/MediaTypes (instead of only the first), and improve multi-attachment placeholder summaries in mention-gated pending history. (#39212) Thanks @joeykrug.
  • -
  • Nodes/system.run dispatch-wrapper boundary: keep shell-wrapper approval classification active at the depth boundary so env wrapper stacks cannot reach /bin/sh -c execution without the expected approval gate. Thanks @tdjackey for reporting.
  • -
  • Docker/token persistence on reconfigure: reuse the existing .env gateway token during docker-setup.sh reruns and align compose token env defaults, so Docker installs stop silently rotating tokens and breaking existing dashboard sessions. Landed from contributor PR #33097 by @chengzhichao-xydt. Thanks @chengzhichao-xydt.
  • -
  • Agents/strict OpenAI turn ordering: apply assistant-first transcript bootstrap sanitization to strict OpenAI-compatible providers (for example vLLM/Gemma via openai-completions) without adding Google-specific session markers, preventing assistant-first history rejections. (#39252) Thanks @scoootscooob.
  • -
  • Discord/exec approvals gateway auth: pass resolved shared gateway credentials into the Discord exec-approvals gateway client so token-auth installs stop failing approvals with gateway token mismatch. Related to #38179. Thanks @0riginal-claw for the adjacent PR #35147 investigation.
  • -
  • Subagents/workspace inheritance: propagate parent workspace directory to spawned subagent runs so child sessions reliably inherit workspace-scoped instructions (AGENTS.md, SOUL.md, etc.) without exposing workspace override through tool-call arguments. (#39247) Thanks @jasonQin6.
  • -
  • Exec approvals/gateway-node policy: honor explicit ask=off from exec-approvals.json even when runtime defaults are stricter, so trusted full/off setups stop re-prompting on gateway and node exec paths. Landed from contributor PR #26789 by @pandego. Thanks @pandego.
  • -
  • Exec approvals/config fallback: inherit ask from exec-approvals.json when tools.exec.ask is unset, so local full/off defaults no longer fall back to on-miss for exec tool and nodes run. Landed from contributor PR #29187 by @Bartok9. Thanks @Bartok9.
  • -
  • Exec approvals/allow-always shell scripts: persist and match script paths for wrapper invocations like bash scripts/foo.sh while still blocking -c/-s wrapper bypasses. Landed from contributor PR #35137 by @yuweuii. Thanks @yuweuii.
  • -
  • Queue/followup dedupe across drain restarts: dedupe queued redelivery message_id values after queue recreation so busy-session followups no longer duplicate on replayed inbound events. Landed from contributor PR #33168 by @rylena. Thanks @rylena.
  • -
  • Telegram/preview-final edit idempotence: treat message is not modified errors during preview finalization as delivered so partial-stream final replies do not fall back to duplicate sends. Landed from contributor PR #34983 by @HOYALIM. Thanks @HOYALIM.
  • -
  • Telegram/DM streaming transport parity: use message preview transport for all DM streaming lanes so final delivery can edit the active preview instead of sending duplicate finals. Landed from contributor PR #38906 by @gambletan. Thanks @gambletan.
  • -
  • Telegram/DM draft streaming restoration: restore native sendMessageDraft preview transport for DM answer streaming while keeping reasoning on message transport, with regression coverage to keep draft finalization from sending duplicate finals. (#39398) Thanks @obviyus.
  • -
  • Telegram/send retry safety: retry non-idempotent send paths only for pre-connect failures and make custom retry predicates strict, preventing ambiguous reconnect retries from sending duplicate messages. Landed from contributor PR #34238 by @hal-crackbot. Thanks @hal-crackbot.
  • -
  • ACP/run spawn delivery bootstrap: stop reusing requester inline delivery targets for one-shot mode: "run" ACP spawns, so fresh run-mode workers bootstrap in isolation instead of inheriting thread-bound session delivery behavior. (#39014) Thanks @lidamao633.
  • -
  • Discord/DM session-key normalization: rewrite legacy discord:dm:* and phantom direct-message discord:channel: session keys to discord:direct:* when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly.
  • -
  • Discord/native slash session fallback: treat empty configured bound-session keys as missing so /status and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings.
  • -
  • Agents/tool-call dispatch normalization: normalize provider-prefixed tool names before dispatch across toolCall, toolUse, and functionCall blocks, while preserving multi-segment tool suffixes when stripping provider wrappers so malformed-but-recoverable tool names no longer fail with Tool not found. (#39328) Thanks @vincentkoc.
  • -
  • Agents/parallel tool-call compatibility: honor parallel_tool_calls / parallelToolCalls extra params only for openai-completions and openai-responses payloads, preserve higher-precedence alias overrides across config and runtime layers, and ignore invalid non-boolean values so single-tool-call providers like NVIDIA-hosted Kimi stop failing on forced parallel tool-call payloads. (#37048) Thanks @vincentkoc.
  • -
  • Config/invalid-load fail-closed: stop converting INVALID_CONFIG into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc.
  • -
  • Agents/codex-cli sandbox defaults: switch the built-in Codex backend from read-only to workspace-write so spawned coding runs can edit files out of the box. Landed from contributor PR #39336 by @0xtangping. Thanks @0xtangping.
  • -
  • Gateway/health-monitor restart reason labeling: report disconnected instead of stuck for clean channel disconnect restarts, so operator logs distinguish socket drops from genuinely stuck channels. (#36436) Thanks @Sid-Qin.
  • -
  • Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax.
  • -
  • Gateway/Telegram webhook-mode recovery: add webhookCertPath to re-upload self-signed certificates during webhook registration and skip stale-socket detection for webhook-mode channels, so Telegram webhook setups survive health-monitor restarts. Landed from contributor PR #39313 by @fellanH. Thanks @fellanH.
  • -
  • Discord/config schema parity: add channels.discord.agentComponents to the strict Zod config schema so valid agentComponents.enabled settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.
  • -
  • ACPX/MCP session bootstrap: inject configured MCP servers into ACP session/new and session/load for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps.
  • -
  • Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of You. (#39414) Thanks @obviyus.
  • -
-

View full changelog

-]]>
- -
\ No newline at end of file diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index d3761299876..46afccbc3bf 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -80,6 +80,9 @@ android { } isMinifyEnabled = true isShrinkResources = true + ndk { + debugSymbolLevel = "SYMBOL_TABLE" + } proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } debug { @@ -106,6 +109,10 @@ android { "/META-INF/LICENSE*.txt", "DebugProbesKt.bin", "kotlin-tooling-metadata.json", + "org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties", + "org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties", + "org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties", + "org/bouncycastle/x509/CertPathReviewerMessages*.properties", ) } } @@ -170,7 +177,6 @@ dependencies { // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. // R8 will tree-shake unused icons when minify is enabled on release builds. implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.navigation:navigation-compose:2.9.7") debugImplementation("androidx.compose.ui:ui-tooling") @@ -195,7 +201,6 @@ dependencies { implementation("androidx.camera:camera-camera2:1.5.2") implementation("androidx.camera:camera-lifecycle:1.5.2") implementation("androidx.camera:camera-video:1.5.2") - implementation("androidx.camera:camera-view:1.5.2") implementation("com.google.android.gms:play-services-code-scanner:16.1.0") // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. diff --git a/apps/android/app/proguard-rules.pro b/apps/android/app/proguard-rules.pro index 78e4a363919..7c04b96833a 100644 --- a/apps/android/app/proguard-rules.pro +++ b/apps/android/app/proguard-rules.pro @@ -1,26 +1,6 @@ -# ── App classes ─────────────────────────────────────────────────── --keep class ai.openclaw.app.** { *; } - -# ── Bouncy Castle ───────────────────────────────────────────────── --keep class org.bouncycastle.** { *; } -dontwarn org.bouncycastle.** - -# ── CameraX ─────────────────────────────────────────────────────── --keep class androidx.camera.** { *; } - -# ── kotlinx.serialization ──────────────────────────────────────── --keep class kotlinx.serialization.** { *; } --keepclassmembers class * { - @kotlinx.serialization.Serializable *; -} --keepattributes *Annotation*, InnerClasses - -# ── OkHttp ──────────────────────────────────────────────────────── -dontwarn okhttp3.** -dontwarn okio.** --keep class okhttp3.internal.platform.** { *; } - -# ── Misc suppressions ──────────────────────────────────────────── -dontwarn com.sun.jna.** -dontwarn javax.naming.** -dontwarn lombok.Generated diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 128527144ef..80f42e02843 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -176,6 +176,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.requestCanvasRehydrate(source = source, force = true) } + fun refreshHomeCanvasOverviewIfConnected() { + runtime.refreshHomeCanvasOverviewIfConnected() + } + fun loadChat(sessionKey: String) { runtime.loadChat(sessionKey) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index bd94edef93c..dcf1e3bee89 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -210,7 +212,8 @@ class NodeRuntime(context: Context) { private val _isForeground = MutableStateFlow(true) val isForeground: StateFlow = _isForeground.asStateFlow() - private var lastAutoA2uiUrl: String? = null + private var gatewayDefaultAgentId: String? = null + private var gatewayAgents: List = emptyList() private var didAutoRequestCanvasRehydrate = false private val canvasRehydrateSeq = AtomicLong(0) private var operatorConnected = false @@ -232,7 +235,7 @@ class NodeRuntime(context: Context) { updateStatus() micCapture.onGatewayConnectionChanged(true) scope.launch { - refreshBrandingFromGateway() + refreshHomeCanvasOverviewIfConnected() if (voiceReplySpeakerLazy.isInitialized()) { voiceReplySpeaker.refreshConfig() } @@ -270,7 +273,7 @@ class NodeRuntime(context: Context) { _canvasRehydratePending.value = false _canvasRehydrateErrorText.value = null updateStatus() - maybeNavigateToA2uiOnConnect() + showLocalCanvasOnConnect() }, onDisconnected = { message -> _nodeConnected.value = false @@ -396,6 +399,7 @@ class NodeRuntime(context: Context) { _mainSessionKey.value = trimmed talkMode.setMainSessionKey(trimmed) chat.applyMainSessionKey(trimmed) + updateHomeCanvasState() } private fun updateStatus() { @@ -415,6 +419,7 @@ class NodeRuntime(context: Context) { operator.isNotBlank() && operator != "Offline" -> operator else -> node } + updateHomeCanvasState() } private fun resolveMainSessionKey(): String { @@ -422,23 +427,31 @@ class NodeRuntime(context: Context) { return if (trimmed.isEmpty()) "main" else trimmed } - private fun maybeNavigateToA2uiOnConnect() { - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return - val current = canvas.currentUrl()?.trim().orEmpty() - if (current.isEmpty() || current == lastAutoA2uiUrl) { - lastAutoA2uiUrl = a2uiUrl - canvas.navigate(a2uiUrl) - } - } - - private fun showLocalCanvasOnDisconnect() { - lastAutoA2uiUrl = null + private fun showLocalCanvasOnConnect() { _canvasA2uiHydrated.value = false _canvasRehydratePending.value = false _canvasRehydrateErrorText.value = null canvas.navigate("") } + private fun showLocalCanvasOnDisconnect() { + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + canvas.navigate("") + } + + fun refreshHomeCanvasOverviewIfConnected() { + if (!operatorConnected) { + updateHomeCanvasState() + return + } + scope.launch { + refreshBrandingFromGateway() + refreshAgentsFromGateway() + } + } + fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) { scope.launch { if (!_nodeConnected.value) { @@ -602,6 +615,8 @@ class NodeRuntime(context: Context) { canvas.setDebugStatus(status, server ?: remote) } } + + updateHomeCanvasState() } fun setForeground(value: Boolean) { @@ -928,11 +943,177 @@ class NodeRuntime(context: Context) { val parsed = parseHexColorArgb(raw) _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB + updateHomeCanvasState() } catch (_: Throwable) { // ignore } } + private suspend fun refreshAgentsFromGateway() { + if (!operatorConnected) return + try { + val res = operatorSession.request("agents.list", "{}") + val root = json.parseToJsonElement(res).asObjectOrNull() ?: return + val defaultAgentId = root["defaultId"].asStringOrNull()?.trim().orEmpty() + val mainKey = normalizeMainKey(root["mainKey"].asStringOrNull()) + val agents = + (root["agents"] as? JsonArray)?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val id = obj["id"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return@mapNotNull null + val name = obj["name"].asStringOrNull()?.trim() + val emoji = obj["identity"].asObjectOrNull()?.get("emoji").asStringOrNull()?.trim() + GatewayAgentSummary( + id = id, + name = name?.takeIf { it.isNotEmpty() }, + emoji = emoji?.takeIf { it.isNotEmpty() }, + ) + } ?: emptyList() + + gatewayDefaultAgentId = defaultAgentId.ifEmpty { null } + gatewayAgents = agents + applyMainSessionKey(mainKey) + updateHomeCanvasState() + } catch (_: Throwable) { + // ignore + } + } + + private fun updateHomeCanvasState() { + val payload = + try { + json.encodeToString(makeHomeCanvasPayload()) + } catch (_: Throwable) { + null + } + canvas.updateHomeCanvasState(payload) + } + + private fun makeHomeCanvasPayload(): HomeCanvasPayload { + val state = resolveHomeCanvasGatewayState() + val gatewayName = normalized(_serverName.value) + val gatewayAddress = normalized(_remoteAddress.value) + val gatewayLabel = gatewayName ?: gatewayAddress ?: "Gateway" + val activeAgentId = resolveActiveAgentId() + val agents = homeCanvasAgents(activeAgentId) + + return when (state) { + HomeCanvasGatewayState.Connected -> + HomeCanvasPayload( + gatewayState = "connected", + eyebrow = "Connected to $gatewayLabel", + title = "Your agents are ready", + subtitle = + "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", + gatewayLabel = gatewayLabel, + activeAgentName = resolveActiveAgentName(activeAgentId), + activeAgentBadge = agents.firstOrNull { it.isActive }?.badge ?: "OC", + activeAgentCaption = "Selected on this phone", + agentCount = agents.size, + agents = agents.take(6), + footer = "The overview refreshes on reconnect and when this screen opens.", + ) + HomeCanvasGatewayState.Connecting -> + HomeCanvasPayload( + gatewayState = "connecting", + eyebrow = "Reconnecting", + title = "OpenClaw is syncing back up", + subtitle = + "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.", + gatewayLabel = gatewayLabel, + activeAgentName = resolveActiveAgentName(activeAgentId), + activeAgentBadge = "OC", + activeAgentCaption = "Gateway session in progress", + agentCount = agents.size, + agents = agents.take(4), + footer = "If the gateway is reachable, reconnect should complete without intervention.", + ) + HomeCanvasGatewayState.Error, HomeCanvasGatewayState.Offline -> + HomeCanvasPayload( + gatewayState = if (state == HomeCanvasGatewayState.Error) "error" else "offline", + eyebrow = "Welcome to OpenClaw", + title = "Your phone stays quiet until it is needed", + subtitle = + "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.", + gatewayLabel = gatewayLabel, + activeAgentName = "Main", + activeAgentBadge = "OC", + activeAgentCaption = "Connect to load your agents", + agentCount = agents.size, + agents = agents.take(4), + footer = "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.", + ) + } + } + + private fun resolveHomeCanvasGatewayState(): HomeCanvasGatewayState { + val lower = _statusText.value.trim().lowercase() + return when { + _isConnected.value -> HomeCanvasGatewayState.Connected + lower.contains("connecting") || lower.contains("reconnecting") -> HomeCanvasGatewayState.Connecting + lower.contains("error") || lower.contains("failed") -> HomeCanvasGatewayState.Error + else -> HomeCanvasGatewayState.Offline + } + } + + private fun resolveActiveAgentId(): String { + val mainKey = _mainSessionKey.value.trim() + if (mainKey.startsWith("agent:")) { + val agentId = mainKey.removePrefix("agent:").substringBefore(':').trim() + if (agentId.isNotEmpty()) return agentId + } + return gatewayDefaultAgentId?.trim().orEmpty() + } + + private fun resolveActiveAgentName(activeAgentId: String): String { + if (activeAgentId.isNotEmpty()) { + gatewayAgents.firstOrNull { it.id == activeAgentId }?.let { agent -> + return normalized(agent.name) ?: agent.id + } + return activeAgentId + } + return gatewayAgents.firstOrNull()?.let { normalized(it.name) ?: it.id } ?: "Main" + } + + private fun homeCanvasAgents(activeAgentId: String): List { + val defaultAgentId = gatewayDefaultAgentId?.trim().orEmpty() + return gatewayAgents + .map { agent -> + val isActive = activeAgentId.isNotEmpty() && agent.id == activeAgentId + val isDefault = defaultAgentId.isNotEmpty() && agent.id == defaultAgentId + HomeCanvasAgentCard( + id = agent.id, + name = normalized(agent.name) ?: agent.id, + badge = homeCanvasBadge(agent), + caption = + when { + isActive -> "Active on this phone" + isDefault -> "Default agent" + else -> "Ready" + }, + isActive = isActive, + ) + }.sortedWith(compareByDescending { it.isActive }.thenBy { it.name.lowercase() }) + } + + private fun homeCanvasBadge(agent: GatewayAgentSummary): String { + val emoji = normalized(agent.emoji) + if (emoji != null) return emoji + val initials = + (normalized(agent.name) ?: agent.id) + .split(' ', '-', '_') + .filter { it.isNotBlank() } + .take(2) + .mapNotNull { token -> token.firstOrNull()?.uppercaseChar()?.toString() } + .joinToString("") + return if (initials.isNotEmpty()) initials else "OC" + } + + private fun normalized(value: String?): String? { + val trimmed = value?.trim().orEmpty() + return trimmed.ifEmpty { null } + } + private fun triggerCameraFlash() { // Token is used as a pulse trigger; value doesn't matter as long as it changes. _cameraFlashToken.value = SystemClock.elapsedRealtimeNanos() @@ -951,3 +1132,40 @@ class NodeRuntime(context: Context) { } } + +private enum class HomeCanvasGatewayState { + Connected, + Connecting, + Error, + Offline, +} + +private data class GatewayAgentSummary( + val id: String, + val name: String?, + val emoji: String?, +) + +@Serializable +private data class HomeCanvasPayload( + val gatewayState: String, + val eyebrow: String, + val title: String, + val subtitle: String, + val gatewayLabel: String, + val activeAgentName: String, + val activeAgentBadge: String, + val activeAgentCaption: String, + val agentCount: Int, + val agents: List, + val footer: String, +) + +@Serializable +private data class HomeCanvasAgentCard( + val id: String, + val name: String, + val badge: String, + val caption: String, + val isActive: Boolean, +) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt index 9efb2a924d7..0eab9d75a5b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt @@ -34,6 +34,7 @@ class CanvasController { @Volatile private var debugStatusEnabled: Boolean = false @Volatile private var debugStatusTitle: String? = null @Volatile private var debugStatusSubtitle: String? = null + @Volatile private var homeCanvasStateJson: String? = null private val _currentUrl = MutableStateFlow(null) val currentUrl: StateFlow = _currentUrl.asStateFlow() @@ -56,6 +57,7 @@ class CanvasController { this.webView = webView reload() applyDebugStatus() + applyHomeCanvasState() } fun detach(webView: WebView) { @@ -88,6 +90,12 @@ class CanvasController { fun onPageFinished() { applyDebugStatus() + applyHomeCanvasState() + } + + fun updateHomeCanvasState(json: String?) { + homeCanvasStateJson = json + applyHomeCanvasState() } private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { @@ -142,6 +150,22 @@ class CanvasController { } } + private fun applyHomeCanvasState() { + val payload = homeCanvasStateJson ?: "null" + withWebViewOnMain { wv -> + val js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api || typeof api.renderHome !== 'function') return; + api.renderHome($payload); + } catch (_) {} + })(); + """.trimIndent() + wv.evaluateJavascript(js, null) + } + } + suspend fun eval(javaScript: String): String = withContext(Dispatchers.Main) { val wv = webView ?: throw IllegalStateException("no webview") diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt index 0642f9b3a7e..c3a14fe5a54 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt @@ -134,43 +134,14 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) @Composable private fun ScreenTabScreen(viewModel: MainViewModel) { val isConnected by viewModel.isConnected.collectAsState() - val isNodeConnected by viewModel.isNodeConnected.collectAsState() - val canvasUrl by viewModel.canvasCurrentUrl.collectAsState() - val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState() - val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState() - val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState() - val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true - val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated)) - val restoreCtaText = - when { - canvasRehydratePending -> "Restore requested. Waiting for agent…" - !canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!! - else -> "Canvas reset. Tap to restore dashboard." + LaunchedEffect(isConnected) { + if (isConnected) { + viewModel.refreshHomeCanvasOverviewIfConnected() } + } Box(modifier = Modifier.fillMaxSize()) { CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - - if (showRestoreCta) { - Surface( - onClick = { - if (canvasRehydratePending) return@Surface - viewModel.requestCanvasRehydrate(source = "screen_tab_cta") - }, - modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp), - shape = RoundedCornerShape(12.dp), - color = mobileSurface.copy(alpha = 0.9f), - border = BorderStroke(1.dp, mobileBorder), - shadowElevation = 4.dp, - ) { - Text( - text = restoreCtaText, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - style = mobileCallout.copy(fontWeight = FontWeight.Medium), - color = mobileText, - ) - } - } } } diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index db38e86df80..4297bc8ff57 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -1,8 +1,8 @@ // Shared iOS version defaults. // Generated overrides live in build/Version.xcconfig (git-ignored). -OPENCLAW_GATEWAY_VERSION = 0.0.0 -OPENCLAW_MARKETING_VERSION = 0.0.0 -OPENCLAW_BUILD_VERSION = 0 +OPENCLAW_GATEWAY_VERSION = 2026.3.14 +OPENCLAW_MARKETING_VERSION = 2026.3.14 +OPENCLAW_BUILD_VERSION = 202603140 #include? "../build/Version.xcconfig" diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift index 26b64ea7c65..41b98111b4e 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift @@ -16,7 +16,14 @@ extension CronJobEditor { self.agentId = job.agentId ?? "" self.enabled = job.enabled self.deleteAfterRun = job.deleteAfterRun ?? false - self.sessionTarget = job.sessionTarget + switch job.parsedSessionTarget { + case .predefined(let target): + self.sessionTarget = target + self.preservedSessionTargetRaw = nil + case .session(let id): + self.sessionTarget = .isolated + self.preservedSessionTargetRaw = "session:\(id)" + } self.wakeMode = job.wakeMode switch job.schedule { @@ -51,7 +58,7 @@ extension CronJobEditor { self.channel = trimmed.isEmpty ? "last" : trimmed self.to = delivery.to ?? "" self.bestEffortDeliver = delivery.bestEffort ?? false - } else if self.sessionTarget == .isolated { + } else if self.isIsolatedLikeSessionTarget { self.deliveryMode = .announce } } @@ -80,7 +87,7 @@ extension CronJobEditor { "name": name, "enabled": self.enabled, "schedule": schedule, - "sessionTarget": self.sessionTarget.rawValue, + "sessionTarget": self.effectiveSessionTargetRaw, "wakeMode": self.wakeMode.rawValue, "payload": payload, ] @@ -92,7 +99,7 @@ extension CronJobEditor { root["agentId"] = NSNull() } - if self.sessionTarget == .isolated { + if self.isIsolatedLikeSessionTarget { root["delivery"] = self.buildDelivery() } @@ -160,7 +167,7 @@ extension CronJobEditor { } func buildSelectedPayload() throws -> [String: Any] { - if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } + if self.isIsolatedLikeSessionTarget { return self.buildAgentTurnPayload() } switch self.payloadKind { case .systemEvent: let text = self.trimmed(self.systemEventText) @@ -171,7 +178,7 @@ extension CronJobEditor { } func validateSessionTarget(_ payload: [String: Any]) throws { - if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { + if self.effectiveSessionTargetRaw == "main", payload["kind"] as? String == "agentTurn" { throw NSError( domain: "Cron", code: 0, @@ -181,7 +188,7 @@ extension CronJobEditor { ]) } - if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { + if self.effectiveSessionTargetRaw != "main", payload["kind"] as? String == "systemEvent" { throw NSError( domain: "Cron", code: 0, @@ -257,6 +264,17 @@ extension CronJobEditor { return Int(floor(n * factor)) } + var effectiveSessionTargetRaw: String { + if self.sessionTarget == .isolated, let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !preserved.isEmpty { + return preserved + } + return self.sessionTarget.rawValue + } + + var isIsolatedLikeSessionTarget: Bool { + self.effectiveSessionTargetRaw != "main" + } + func formatDuration(ms: Int) -> String { DurationFormattingSupport.conciseDuration(ms: ms) } diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor.swift b/apps/macos/Sources/OpenClaw/CronJobEditor.swift index a7d88a4f2fb..292f3a63284 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor.swift @@ -16,7 +16,7 @@ struct CronJobEditor: View { + "Use an isolated session for agent turns so your main chat stays clean." static let sessionTargetNote = "Main jobs post a system event into the current main session. " - + "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel." + + "Current and isolated-style jobs run agent turns and can announce results to a channel." static let scheduleKindNote = "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." static let isolatedPayloadNote = @@ -29,6 +29,7 @@ struct CronJobEditor: View { @State var agentId: String = "" @State var enabled: Bool = true @State var sessionTarget: CronSessionTarget = .main + @State var preservedSessionTargetRaw: String? @State var wakeMode: CronWakeMode = .now @State var deleteAfterRun: Bool = false @@ -117,6 +118,7 @@ struct CronJobEditor: View { Picker("", selection: self.$sessionTarget) { Text("main").tag(CronSessionTarget.main) Text("isolated").tag(CronSessionTarget.isolated) + Text("current").tag(CronSessionTarget.current) } .labelsHidden() .pickerStyle(.segmented) @@ -209,7 +211,7 @@ struct CronJobEditor: View { GroupBox("Payload") { VStack(alignment: .leading, spacing: 10) { - if self.sessionTarget == .isolated { + if self.isIsolatedLikeSessionTarget { Text(Self.isolatedPayloadNote) .font(.footnote) .foregroundStyle(.secondary) @@ -289,8 +291,11 @@ struct CronJobEditor: View { self.sessionTarget = .isolated } } - .onChange(of: self.sessionTarget) { _, newValue in - if newValue == .isolated { + .onChange(of: self.sessionTarget) { oldValue, newValue in + if oldValue != newValue { + self.preservedSessionTargetRaw = nil + } + if newValue != .main { self.payloadKind = .agentTurn } else if newValue == .main, self.payloadKind == .agentTurn { self.payloadKind = .systemEvent diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index e0ce46c13da..40079453974 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -3,12 +3,39 @@ import Foundation enum CronSessionTarget: String, CaseIterable, Identifiable, Codable { case main case isolated + case current var id: String { self.rawValue } } +enum CronCustomSessionTarget: Codable, Equatable { + case predefined(CronSessionTarget) + case session(id: String) + + var rawValue: String { + switch self { + case .predefined(let target): + return target.rawValue + case .session(let id): + return "session:\(id)" + } + } + + static func from(_ value: String) -> CronCustomSessionTarget { + if let predefined = CronSessionTarget(rawValue: value) { + return .predefined(predefined) + } + if value.hasPrefix("session:") { + let sessionId = String(value.dropFirst(8)) + return .session(id: sessionId) + } + // Fallback to isolated for unknown values + return .predefined(.isolated) + } +} + enum CronWakeMode: String, CaseIterable, Identifiable, Codable { case now case nextHeartbeat = "next-heartbeat" @@ -204,12 +231,69 @@ struct CronJob: Identifiable, Codable, Equatable { let createdAtMs: Int let updatedAtMs: Int let schedule: CronSchedule - let sessionTarget: CronSessionTarget + private let sessionTargetRaw: String let wakeMode: CronWakeMode let payload: CronPayload let delivery: CronDelivery? let state: CronJobState + enum CodingKeys: String, CodingKey { + case id + case agentId + case name + case description + case enabled + case deleteAfterRun + case createdAtMs + case updatedAtMs + case schedule + case sessionTargetRaw = "sessionTarget" + case wakeMode + case payload + case delivery + case state + } + + /// Parsed session target (predefined or custom session ID) + var parsedSessionTarget: CronCustomSessionTarget { + CronCustomSessionTarget.from(self.sessionTargetRaw) + } + + /// Compatibility shim for existing editor/UI code paths that still use the + /// predefined enum. + var sessionTarget: CronSessionTarget { + switch self.parsedSessionTarget { + case .predefined(let target): + return target + case .session: + return .isolated + } + } + + var sessionTargetDisplayValue: String { + self.parsedSessionTarget.rawValue + } + + var transcriptSessionKey: String? { + switch self.parsedSessionTarget { + case .predefined(.main): + return nil + case .predefined(.isolated), .predefined(.current): + return "cron:\(self.id)" + case .session(let id): + return id + } + } + + var supportsAnnounceDelivery: Bool { + switch self.parsedSessionTarget { + case .predefined(.main): + return false + case .predefined(.isolated), .predefined(.current), .session: + return true + } + } + var displayName: String { let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Untitled job" : trimmed diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift index 69655bdc302..85e45928853 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift @@ -18,7 +18,7 @@ extension CronSettings { } } HStack(spacing: 6) { - StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) + StatusPill(text: job.sessionTargetDisplayValue, tint: .secondary) StatusPill(text: job.wakeMode.rawValue, tint: .secondary) if let agentId = job.agentId, !agentId.isEmpty { StatusPill(text: "agent \(agentId)", tint: .secondary) @@ -34,9 +34,9 @@ extension CronSettings { @ViewBuilder func jobContextMenu(_ job: CronJob) -> some View { Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } } - if job.sessionTarget == .isolated { + if let transcriptSessionKey = job.transcriptSessionKey { Button("Open transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + WebChatManager.shared.show(sessionKey: transcriptSessionKey) } } Divider() @@ -75,9 +75,9 @@ extension CronSettings { .labelsHidden() Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } .buttonStyle(.borderedProminent) - if job.sessionTarget == .isolated { + if let transcriptSessionKey = job.transcriptSessionKey { Button("Transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + WebChatManager.shared.show(sessionKey: transcriptSessionKey) } .buttonStyle(.bordered) } @@ -103,7 +103,7 @@ extension CronSettings { if let agentId = job.agentId, !agentId.isEmpty { LabeledContent("Agent") { Text(agentId) } } - LabeledContent("Session") { Text(job.sessionTarget.rawValue) } + LabeledContent("Session") { Text(job.sessionTargetDisplayValue) } LabeledContent("Wake") { Text(job.wakeMode.rawValue) } LabeledContent("Next run") { if let date = job.nextRunDate { @@ -224,7 +224,7 @@ extension CronSettings { HStack(spacing: 8) { if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) } if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } - if job.sessionTarget == .isolated { + if job.supportsAnnounceDelivery { let delivery = job.delivery if let delivery { if delivery.mode == .announce { diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 218d638a7e5..89ebf70beb4 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.3.13 + 2026.3.14 CFBundleVersion - 202603130 + 202603140 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index effa8f3ab81..cb27380416b 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -25,7 +25,9 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules. - Two execution styles: - **Main session**: enqueue a system event, then run on the next heartbeat. - - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none). + - **Isolated**: run a dedicated agent turn in `cron:` or a custom session, with delivery (announce by default or none). + - **Current session**: bind to the session where the cron is created (`sessionTarget: "current"`). + - **Custom session**: run in a persistent named session (`sessionTarget: "session:custom-id"`). - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. - Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`. - Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. @@ -86,6 +88,14 @@ Think of a cron job as: **when** to run + **what** to do. 2. **Choose where it runs** - `sessionTarget: "main"` → run during the next heartbeat with main context. - `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:`. + - `sessionTarget: "current"` → bind to the current session (resolved at creation time to `session:`). + - `sessionTarget: "session:custom-id"` → run in a persistent named session that maintains context across runs. + + Default behavior (unchanged): + - `systemEvent` payloads default to `main` + - `agentTurn` payloads default to `isolated` + + To use current session binding, explicitly set `sessionTarget: "current"`. 3. **Choose the payload** - Main session → `payload.kind = "systemEvent"` @@ -147,12 +157,13 @@ See [Heartbeat](/gateway/heartbeat). #### Isolated jobs (dedicated cron sessions) -Isolated jobs run a dedicated agent turn in session `cron:`. +Isolated jobs run a dedicated agent turn in session `cron:` or a custom session. Key behaviors: - Prompt is prefixed with `[cron: ]` for traceability. -- Each run starts a **fresh session id** (no prior conversation carry-over). +- Each run starts a **fresh session id** (no prior conversation carry-over), unless using a custom session. +- Custom sessions (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries. - Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`). - `delivery.mode` chooses what happens: - `announce`: deliver a summary to the target channel and post a brief summary to the main session. @@ -321,12 +332,42 @@ Recurring, isolated job with delivery: } ``` +Recurring job bound to current session (auto-resolved at creation): + +```json +{ + "name": "Daily standup", + "schedule": { "kind": "cron", "expr": "0 9 * * *" }, + "sessionTarget": "current", + "payload": { + "kind": "agentTurn", + "message": "Summarize yesterday's progress." + } +} +``` + +Recurring job in a custom persistent session: + +```json +{ + "name": "Project monitor", + "schedule": { "kind": "every", "everyMs": 300000 }, + "sessionTarget": "session:project-alpha-monitor", + "payload": { + "kind": "agentTurn", + "message": "Check project status and update the running log." + } +} +``` + Notes: - `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). - `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). - `everyMs` is milliseconds. -- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. +- `sessionTarget`: `"main"`, `"isolated"`, `"current"`, or `"session:"`. +- `"current"` is resolved to `"session:"` at creation time. +- Custom sessions (`session:xxx`) maintain persistent context across runs. - Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`), `delivery`. - `wakeMode` defaults to `"now"` when omitted. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index 9676d960d23..09f9187c368 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -219,13 +219,13 @@ See [Lobster](/tools/lobster) for full usage and examples. Both heartbeat and cron can interact with the main session, but differently: -| | Heartbeat | Cron (main) | Cron (isolated) | -| ------- | ------------------------------- | ------------------------ | -------------------------- | -| Session | Main | Main (via system event) | `cron:` | -| History | Shared | Shared | Fresh each run | -| Context | Full | Full | None (starts clean) | -| Model | Main session model | Main session model | Can override | -| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | +| | Heartbeat | Cron (main) | Cron (isolated) | +| ------- | ------------------------------- | ------------------------ | ----------------------------------------------- | +| Session | Main | Main (via system event) | `cron:` or custom session | +| History | Shared | Shared | Fresh each run (isolated) / Persistent (custom) | +| Context | Full | Full | None (isolated) / Cumulative (custom) | +| Model | Main session model | Main session model | Can override | +| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | ### When to use main session cron diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 8e0ddad92ef..f9ddc151717 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -27,7 +27,7 @@ Related: ## Quick start (local) ```bash -openclaw browser --browser-profile chrome tabs +openclaw browser profiles openclaw browser --browser-profile openclaw start openclaw browser --browser-profile openclaw open https://example.com openclaw browser --browser-profile openclaw snapshot @@ -38,7 +38,8 @@ openclaw browser --browser-profile openclaw snapshot Profiles are named browser routing configs. In practice: - `openclaw`: launches/attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir). -- `chrome`: controls your existing Chrome tab(s) via the Chrome extension relay. +- `user`: controls your existing signed-in Chrome session via Chrome DevTools MCP. +- `chrome-relay`: controls your existing Chrome tab(s) via the Chrome extension relay. ```bash openclaw browser profiles diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 95c20e3aa7c..96367774948 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -126,6 +126,23 @@ openclaw gateway probe openclaw gateway probe --json ``` +Interpretation: + +- `Reachable: yes` means at least one target accepted a WebSocket connect. +- `RPC: ok` means detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded. +- `RPC: limited - missing scope: operator.read` means connect succeeded but detail RPC is scope-limited. This is reported as **degraded** reachability, not full failure. +- Exit code is non-zero only when no probed target is reachable. + +JSON notes (`--json`): + +- Top level: + - `ok`: at least one target is reachable. + - `degraded`: at least one target had scope-limited detail RPC. +- Per target (`targets[].connect`): + - `ok`: reachability after connect + degraded classification. + - `rpcOk`: full detail RPC success. + - `scopeLimited`: detail RPC failed due to missing operator scope. + #### Remote over SSH (Mac app parity) The macOS app “Remote over SSH” mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:`. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 2a58c15cb4d..2f00325b730 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -200,7 +200,7 @@ the workspace is writable. See [Memory](/concepts/memory) and - Legacy `group:` keys are still recognized for migration. - Inbound contexts may still use `group:`; the channel is inferred from `Provider` and normalized to the canonical `agent:::group:` form. - Other sources: - - Cron jobs: `cron:` + - Cron jobs: `cron:` (isolated) or custom `session:` (persistent) - Webhooks: `hook:` (unless explicitly set by the hook) - Node runs: `node-` diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b4a697d5a5a..658a3084437 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2342,7 +2342,7 @@ See [Plugins](/tools/plugin). browser: { enabled: true, evaluateEnabled: true, - defaultProfile: "chrome", + defaultProfile: "user", ssrfPolicy: { dangerouslyAllowPrivateNetwork: true, // default trusted-network mode // allowPrivateNetwork: true, // legacy alias diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index ebea28a6541..f5829454e57 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -289,7 +289,7 @@ Look for: - Valid browser executable path. - CDP profile reachability. -- Extension relay tab attachment for `profile="chrome"`. +- Extension relay tab attachment for `profile="chrome-relay"`. Common signatures: diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 951e1a480d7..a3988c4ea58 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -28,7 +28,7 @@ Good output in one line: - `openclaw status` → shows configured channels and no obvious auth errors. - `openclaw status --all` → full report is present and shareable. -- `openclaw gateway probe` → expected gateway target is reachable. +- `openclaw gateway probe` → expected gateway target is reachable (`Reachable: yes`). `RPC: limited - missing scope: operator.read` is degraded diagnostics, not a connect failure. - `openclaw gateway status` → `Runtime: running` and `RPC probe: ok`. - `openclaw doctor` → no blocking config/service errors. - `openclaw channels status --probe` → channels report `connected` or `ready`. diff --git a/docs/providers/glm.md b/docs/providers/glm.md index f65ea81f9da..64fe39a42df 100644 --- a/docs/providers/glm.md +++ b/docs/providers/glm.md @@ -14,7 +14,17 @@ models are accessed via the `zai` provider and model IDs like `zai/glm-5`. ## CLI setup ```bash -openclaw onboard --auth-choice zai-api-key +# Coding Plan Global, recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-global + +# Coding Plan CN (China region), recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-cn + +# General API +openclaw onboard --auth-choice zai-global + +# General API CN (China region) +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/providers/zai.md b/docs/providers/zai.md index 93313acba3f..6f3aea27020 100644 --- a/docs/providers/zai.md +++ b/docs/providers/zai.md @@ -15,9 +15,17 @@ with a Z.AI API key. ## CLI setup ```bash -openclaw onboard --auth-choice zai-api-key -# or non-interactive -openclaw onboard --zai-api-key "$ZAI_API_KEY" +# Coding Plan Global, recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-global + +# Coding Plan CN (China region), recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-cn + +# General API +openclaw onboard --auth-choice zai-global + +# General API CN (China region) +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 01e6cbc3ff9..1ab51657044 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -123,7 +123,7 @@ curl -s http://127.0.0.1:18791/tabs ### Problem: "Chrome extension relay is running, but no tab is connected" -You’re using the `chrome` profile (extension relay). It expects the OpenClaw +You’re using the `chrome-relay` profile (extension relay). It expects the OpenClaw browser extension to be attached to a live tab. Fix options: @@ -135,5 +135,5 @@ Fix options: Notes: -- The `chrome` profile uses your **system default Chromium browser** when possible. +- The `chrome-relay` profile uses your **system default Chromium browser** when possible. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP. diff --git a/docs/tools/browser-login.md b/docs/tools/browser-login.md index 41c6b8e9cf3..d570b3b2e87 100644 --- a/docs/tools/browser-login.md +++ b/docs/tools/browser-login.md @@ -23,8 +23,8 @@ OpenClaw controls a **dedicated Chrome profile** (named `openclaw`, orange‑tin For agent browser tool calls: - Default choice: the agent should use its isolated `openclaw` browser. -- Use the **user browser** only when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt. -- If you need to force the choice, use `browserSession="agent"` or `browserSession="user"`. +- Use `profile="user"` only when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt. +- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. - If you have multiple user-browser profiles, specify the profile explicitly instead of guessing. Two easy ways to access it: diff --git a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md index d63bb891c48..2e7844860aa 100644 --- a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md +++ b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md @@ -33,7 +33,7 @@ Choose this when: ### Option 2: Chrome extension relay -Use the built-in `chrome` profile plus the OpenClaw Chrome extension. +Use the built-in `chrome-relay` profile plus the OpenClaw Chrome extension. Choose this when: @@ -155,7 +155,7 @@ Example: { browser: { enabled: true, - defaultProfile: "chrome", + defaultProfile: "chrome-relay", relayBindHost: "0.0.0.0", }, } @@ -197,7 +197,7 @@ openclaw browser tabs --browser-profile remote For the extension relay: ```bash -openclaw browser tabs --browser-profile chrome +openclaw browser tabs --browser-profile chrome-relay ``` Good result: diff --git a/docs/tools/browser.md b/docs/tools/browser.md index dea5e915ff3..ebe352036c5 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -18,8 +18,8 @@ Beginner view: - Think of it as a **separate, agent-only browser**. - The `openclaw` profile does **not** touch your personal browser profile. - The agent can **open tabs, read pages, click, and type** in a safe lane. -- The default `chrome` profile uses the **system default Chromium browser** via the - extension relay; switch to `openclaw` for the isolated managed browser. +- The built-in `user` profile attaches to your real signed-in Chrome session; + `chrome-relay` is the explicit extension-relay profile. ## What you get @@ -43,22 +43,22 @@ openclaw browser --browser-profile openclaw snapshot If you get “Browser disabled”, enable it in config (see below) and restart the Gateway. -## Profiles: `openclaw` vs `chrome` +## Profiles: `openclaw` vs `user` vs `chrome-relay` - `openclaw`: managed, isolated browser (no extension required). -- `chrome`: extension relay to your **system browser** (requires the OpenClaw - extension to be attached to a tab). -- `existing-session`: official Chrome MCP attach flow for a running Chrome - profile. +- `user`: built-in Chrome MCP attach profile for your **real signed-in Chrome** + session. +- `chrome-relay`: extension relay to your **system browser** (requires the + OpenClaw extension to be attached to a tab). For agent browser tool calls: - Default: use the isolated `openclaw` browser. -- Use the **user browser** only when existing logged-in sessions matter and the - user is at the computer to click/approve any attach prompt. -- If you need to force the choice, use `browserSession="agent"` or - `browserSession="user"`. -- `profile` is the explicit override when you already know which profile to use. +- Prefer `profile="user"` when existing logged-in sessions matter and the user + is at the computer to click/approve any attach prompt. +- Use `profile="chrome-relay"` only when the user explicitly wants the Chrome + extension / toolbar-button attach flow. +- `profile` is the explicit override when you want a specific browser mode. Set `browser.defaultProfile: "openclaw"` if you want managed mode by default. @@ -88,11 +88,16 @@ Browser settings live in `~/.openclaw/openclaw.json`. profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, - "chrome-live": { + user: { driver: "existing-session", attachOnly: true, color: "#00AA00", }, + "chrome-relay": { + driver: "extension", + cdpUrl: "http://127.0.0.1:18792", + color: "#00AA00", + }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, }, @@ -113,7 +118,7 @@ Notes: - `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility. - `attachOnly: true` means “never launch a local browser; only attach if it is already running.” - `color` + per-profile `color` tint the browser UI so you can see which profile is active. -- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "chrome"` to opt into the Chrome extension relay. +- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "user"` to opt into the signed-in user browser, or `defaultProfile: "chrome-relay"` for the extension relay. - Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP. - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do @@ -287,7 +292,7 @@ OpenClaw supports multiple named profiles (routing configs). Profiles can be: Defaults: - The `openclaw` profile is auto-created if missing. -- The `chrome` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default). +- The `chrome-relay` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default). - Existing-session profiles are opt-in; create them with `--driver existing-session`. - Local CDP ports allocate from **18800–18899** by default. - Deleting a profile moves its local data directory to Trash. @@ -331,8 +336,8 @@ openclaw browser extension install 2. Use it: -- CLI: `openclaw browser --browser-profile chrome tabs` -- Agent tool: `browser` with `browserSession="user"` (or `profile="chrome"`) +- CLI: `openclaw browser --browser-profile chrome-relay tabs` +- Agent tool: `browser` with `profile="chrome-relay"` Optional: if you want a different name or relay port, create your own profile: @@ -348,8 +353,9 @@ Notes: - This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions). - Detach by clicking the extension icon again. -- Agent use: prefer `browserSession="user"` for logged-in sites. The user must be - present to click the extension and attach the tab. +- Agent use: prefer `profile="user"` for logged-in sites. Use `profile="chrome-relay"` + only when you specifically want the extension flow. The user must be present + to click the extension and attach the tab. ## Chrome existing-session via MCP @@ -362,14 +368,12 @@ Official background and setup references: - [Chrome for Developers: Use Chrome DevTools MCP with your browser session](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session) - [Chrome DevTools MCP README](https://github.com/ChromeDevTools/chrome-devtools-mcp) -Create a profile: +Built-in profile: -```bash -openclaw browser create-profile \ - --name chrome-live \ - --driver existing-session \ - --color "#00AA00" -``` +- `user` + +Optional: create your own custom existing-session profile if you want a +different name or color. Then in Chrome: @@ -380,10 +384,10 @@ Then in Chrome: Live attach smoke test: ```bash -openclaw browser --browser-profile chrome-live start -openclaw browser --browser-profile chrome-live status -openclaw browser --browser-profile chrome-live tabs -openclaw browser --browser-profile chrome-live snapshot --format ai +openclaw browser --browser-profile user start +openclaw browser --browser-profile user status +openclaw browser --browser-profile user tabs +openclaw browser --browser-profile user snapshot --format ai ``` What success looks like: @@ -402,9 +406,10 @@ What to check if attach does not work: Agent use: -- Use `browserSession="user"` when you need the user’s logged-in browser state. -- If you know the profile name, pass `profile="chrome-live"` (or your custom - existing-session profile). +- Use `profile="user"` when you need the user’s logged-in browser state. +- If you use a custom existing-session profile, pass that explicit profile name. +- Prefer `profile="user"` over `profile="chrome-relay"` unless the user + explicitly wants the extension / attach-tab flow. - Only choose this mode when the user is at the computer to approve the attach prompt. - the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect` @@ -432,7 +437,7 @@ WSL2 / cross-namespace example: browser: { enabled: true, relayBindHost: "0.0.0.0", - defaultProfile: "chrome", + defaultProfile: "chrome-relay", }, } ``` diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index dcf2150409b..91a6c1240f1 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -62,7 +62,7 @@ After upgrading OpenClaw: ## Use it (set gateway token once) -OpenClaw ships with a built-in browser profile named `chrome` that targets the extension relay on the default port. +OpenClaw ships with a built-in browser profile named `chrome-relay` that targets the extension relay on the default port. Before first attach, open extension Options and set: @@ -71,8 +71,8 @@ Before first attach, open extension Options and set: Use it: -- CLI: `openclaw browser --browser-profile chrome tabs` -- Agent tool: `browser` with `profile="chrome"` +- CLI: `openclaw browser --browser-profile chrome-relay tabs` +- Agent tool: `browser` with `profile="chrome-relay"` If you want a different name or a different relay port, create your own profile: diff --git a/docs/tools/index.md b/docs/tools/index.md index 8dd30819318..bdd9b78456f 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -310,17 +310,16 @@ Profile management: Common parameters: -- `browserSession` (`agent` | `user`) - `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). -- `browserSession="agent"` is the safe default: isolated OpenClaw-managed browser. -- `browserSession="user"` means the real local host browser. Use it only when existing logins/cookies matter and the user is present to click/approve any attach prompt. -- `browserSession="user"` is host-only; do not combine it with sandbox/node targets. - All actions accept optional `profile` parameter for multi-instance support. -- `profile` overrides `browserSession` when both are supplied. +- 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. +- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. +- `profile="user"` and `profile="chrome-relay"` are host-only; do not combine them 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). diff --git a/docs/zh-CN/automation/cron-jobs.md b/docs/zh-CN/automation/cron-jobs.md index 185779a2636..cfdb0c178e1 100644 --- a/docs/zh-CN/automation/cron-jobs.md +++ b/docs/zh-CN/automation/cron-jobs.md @@ -28,7 +28,9 @@ x-i18n: - 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。 - 两种执行方式: - **主会话**:入队一个系统事件,然后在下一次心跳时运行。 - - **隔离式**:在 `cron:` 中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。 + - **隔离式**:在 `cron:` 或自定义会话中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。 + - **当前会话**:绑定到创建定时任务时的会话 (`sessionTarget: "current"`)。 + - **自定义会话**:在持久化的命名会话中运行 (`sessionTarget: "session:custom-id"`)。 - 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳时"。 ## 快速开始(可操作) @@ -83,6 +85,14 @@ openclaw cron add \ 2. **选择运行位置** - `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。 - `sessionTarget: "isolated"` → 在 `cron:` 中运行专用智能体轮次。 + - `sessionTarget: "current"` → 绑定到当前会话(创建时解析为 `session:`)。 + - `sessionTarget: "session:custom-id"` → 在持久化的命名会话中运行,跨运行保持上下文。 + + 默认行为(保持不变): + - `systemEvent` 负载默认使用 `main` + - `agentTurn` 负载默认使用 `isolated` + + 要使用当前会话绑定,需显式设置 `sessionTarget: "current"`。 3. **选择负载** - 主会话 → `payload.kind = "systemEvent"` @@ -129,12 +139,13 @@ Cron 表达式使用 `croner`。如果省略时区,将使用 Gateway网关主 #### 隔离任务(专用定时会话) -隔离任务在会话 `cron:` 中运行专用智能体轮次。 +隔离任务在会话 `cron:` 或自定义会话中运行专用智能体轮次。 关键行为: - 提示以 `[cron: <任务名称>]` 为前缀,便于追踪。 -- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话)。 +- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话),除非使用自定义会话。 +- 自定义会话(`session:xxx`)可跨运行保持上下文,适用于如每日站会等需要基于前次摘要的工作流。 - 如果未指定 `delivery`,隔离任务会默认以“announce”方式投递摘要。 - `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)。 diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 66780c709b1..d3947cc7552 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/acpx", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw ACP runtime backend via acpx", "type": "module", "dependencies": { diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index b2c13701ead..67df516b8d7 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "dependencies": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 9829860d042..fdab55b3da8 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 95eea6a702a..b51ead550ef 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index fe7533683ec..c38da12bfcd 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -1,4 +1,5 @@ import type { IncomingMessage } from "node:http"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { describe, expect, it, vi } from "vitest"; import { createMockServerResponse } from "../../src/test-utils/mock-http-response.js"; import { createTestPluginApi } from "../test-utils/plugin-api.js"; @@ -42,48 +43,46 @@ describe("diffs plugin registration", () => { }); it("applies plugin-config defaults through registered tool and viewer handler", async () => { - let registeredTool: - | { execute?: (toolCallId: string, params: Record) => Promise } - | undefined; - let registeredHttpRouteHandler: - | (( - req: IncomingMessage, - res: ReturnType, - ) => Promise) - | undefined; + type RegisteredTool = { + execute?: (toolCallId: string, params: Record) => Promise; + }; + type RegisteredHttpRouteParams = Parameters[0]; - plugin.register?.( - createTestPluginApi({ - id: "diffs", - name: "Diffs", - description: "Diffs", - source: "test", - config: { - gateway: { - port: 18789, - bind: "loopback", - }, + let registeredTool: RegisteredTool | undefined; + let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined; + + const api = createTestPluginApi({ + id: "diffs", + name: "Diffs", + description: "Diffs", + source: "test", + config: { + gateway: { + port: 18789, + bind: "loopback", }, - pluginConfig: { - defaults: { - mode: "view", - theme: "light", - background: false, - layout: "split", - showLineNumbers: false, - diffIndicators: "classic", - lineSpacing: 2, - }, + }, + pluginConfig: { + defaults: { + mode: "view", + theme: "light", + background: false, + layout: "split", + showLineNumbers: false, + diffIndicators: "classic", + lineSpacing: 2, }, - runtime: {} as never, - registerTool(tool) { - registeredTool = typeof tool === "function" ? undefined : tool; - }, - registerHttpRoute(params) { - registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler; - }, - }), - ); + }, + runtime: {} as never, + registerTool(tool: Parameters[0]) { + registeredTool = typeof tool === "function" ? undefined : tool; + }, + registerHttpRoute(params: RegisteredHttpRouteParams) { + registeredHttpRouteHandler = params.handler; + }, + }); + + plugin.register?.(api as unknown as OpenClawPluginApi); const result = await registeredTool?.execute?.("tool-1", { before: "one\n", diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index 391a6893173..b92b16052b8 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diffs", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw diff viewer plugin", "type": "module", diff --git a/extensions/diffs/src/render.test.ts b/extensions/diffs/src/render.test.ts index f46a2c9abe9..006b239a39f 100644 --- a/extensions/diffs/src/render.test.ts +++ b/extensions/diffs/src/render.test.ts @@ -23,8 +23,7 @@ describe("renderDiffDocument", () => { expect(rendered.html).toContain("data-openclaw-diff-root"); expect(rendered.html).toContain("src/example.ts"); expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js"); - expect(rendered.imageHtml).not.toContain("/plugins/diffs/assets/viewer.js"); - expect(rendered.imageHtml).toContain('data-openclaw-diffs-ready="true"'); + expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js"); expect(rendered.imageHtml).toContain("max-width: 960px;"); expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;"); expect(rendered.html).toContain("min-height: 100vh;"); diff --git a/extensions/diffs/src/render.ts b/extensions/diffs/src/render.ts index fb3d089c90a..364252c0b3b 100644 --- a/extensions/diffs/src/render.ts +++ b/extensions/diffs/src/render.ts @@ -1,5 +1,12 @@ -import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs"; -import { parsePatchFiles } from "@pierre/diffs"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import type { + FileContents, + FileDiffMetadata, + SupportedLanguages, + ThemeRegistrationResolved, +} from "@pierre/diffs"; +import { RegisteredCustomThemes, parsePatchFiles } from "@pierre/diffs"; import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr"; import type { DiffInput, @@ -13,6 +20,45 @@ import { VIEWER_LOADER_PATH } from "./viewer-assets.js"; const DEFAULT_FILE_NAME = "diff.txt"; const MAX_PATCH_FILE_COUNT = 128; const MAX_PATCH_TOTAL_LINES = 120_000; +const diffsRequire = createRequire(import.meta.resolve("@pierre/diffs")); + +let pierreThemesPatched = false; + +function createThemeLoader( + themeName: "pierre-dark" | "pierre-light", + themePath: string, +): () => Promise { + let cachedTheme: ThemeRegistrationResolved | undefined; + return async () => { + if (cachedTheme) { + return cachedTheme; + } + const raw = await fs.readFile(themePath, "utf8"); + const parsed = JSON.parse(raw) as Record; + cachedTheme = { + ...parsed, + name: themeName, + } as ThemeRegistrationResolved; + return cachedTheme; + }; +} + +function patchPierreThemeLoadersForNode24(): void { + if (pierreThemesPatched) { + return; + } + try { + const darkThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-dark.json"); + const lightThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-light.json"); + RegisteredCustomThemes.set("pierre-dark", createThemeLoader("pierre-dark", darkThemePath)); + RegisteredCustomThemes.set("pierre-light", createThemeLoader("pierre-light", lightThemePath)); + pierreThemesPatched = true; + } catch { + // Keep upstream loaders if theme files cannot be resolved. + } +} + +patchPierreThemeLoadersForNode24(); function escapeCssString(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); @@ -195,14 +241,6 @@ function renderDiffCard(payload: DiffViewerPayload): string { `; } -function renderStaticDiffCard(prerenderedHTML: string): string { - return `
- - - -
`; -} - function buildHtmlDocument(params: { title: string; bodyHtml: string; @@ -211,7 +249,7 @@ function buildHtmlDocument(params: { runtimeMode: "viewer" | "image"; }): string { return ` - + @@ -303,7 +341,7 @@ function buildHtmlDocument(params: { ${params.bodyHtml} - ${params.runtimeMode === "viewer" ? `` : ""} + `; } @@ -314,16 +352,12 @@ type RenderedSection = { }; function buildRenderedSection(params: { - viewerPrerenderedHtml: string; - imagePrerenderedHtml: string; - payload: Omit; + viewerPayload: DiffViewerPayload; + imagePayload: DiffViewerPayload; }): RenderedSection { return { - viewer: renderDiffCard({ - prerenderedHTML: params.viewerPrerenderedHtml, - ...params.payload, - }), - image: renderStaticDiffCard(params.imagePrerenderedHtml), + viewer: renderDiffCard(params.viewerPayload), + image: renderDiffCard(params.imagePayload), }; } @@ -355,21 +389,20 @@ async function renderBeforeAfterDiff( }; const { viewerOptions, imageOptions } = buildRenderVariants(options); const [viewerResult, imageResult] = await Promise.all([ - preloadMultiFileDiff({ + preloadMultiFileDiffWithFallback({ oldFile, newFile, options: viewerOptions, }), - preloadMultiFileDiff({ + preloadMultiFileDiffWithFallback({ oldFile, newFile, options: imageOptions, }), ]); const section = buildRenderedSection({ - viewerPrerenderedHtml: viewerResult.prerenderedHTML, - imagePrerenderedHtml: imageResult.prerenderedHTML, - payload: { + viewerPayload: { + prerenderedHTML: viewerResult.prerenderedHTML, oldFile: viewerResult.oldFile, newFile: viewerResult.newFile, options: viewerOptions, @@ -378,6 +411,16 @@ async function renderBeforeAfterDiff( newFile: viewerResult.newFile, }), }, + imagePayload: { + prerenderedHTML: imageResult.prerenderedHTML, + oldFile: imageResult.oldFile, + newFile: imageResult.newFile, + options: imageOptions, + langs: buildPayloadLanguages({ + oldFile: imageResult.oldFile, + newFile: imageResult.newFile, + }), + }, }); return { @@ -410,24 +453,29 @@ async function renderPatchDiff( const sections = await Promise.all( files.map(async (fileDiff) => { const [viewerResult, imageResult] = await Promise.all([ - preloadFileDiff({ + preloadFileDiffWithFallback({ fileDiff, options: viewerOptions, }), - preloadFileDiff({ + preloadFileDiffWithFallback({ fileDiff, options: imageOptions, }), ]); return buildRenderedSection({ - viewerPrerenderedHtml: viewerResult.prerenderedHTML, - imagePrerenderedHtml: imageResult.prerenderedHTML, - payload: { + viewerPayload: { + prerenderedHTML: viewerResult.prerenderedHTML, fileDiff: viewerResult.fileDiff, options: viewerOptions, langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }), }, + imagePayload: { + prerenderedHTML: imageResult.prerenderedHTML, + fileDiff: imageResult.fileDiff, + options: imageOptions, + langs: buildPayloadLanguages({ fileDiff: imageResult.fileDiff }), + }, }); }), ); @@ -468,3 +516,49 @@ export async function renderDiffDocument( inputKind: input.kind, }; } + +type PreloadedFileDiffResult = Awaited>; +type PreloadedMultiFileDiffResult = Awaited>; + +function shouldFallbackToClientHydration(error: unknown): boolean { + return ( + error instanceof TypeError && + error.message.includes('needs an import attribute of "type: json"') + ); +} + +async function preloadFileDiffWithFallback(params: { + fileDiff: FileDiffMetadata; + options: DiffViewerOptions; +}): Promise { + try { + return await preloadFileDiff(params); + } catch (error) { + if (!shouldFallbackToClientHydration(error)) { + throw error; + } + return { + fileDiff: params.fileDiff, + prerenderedHTML: "", + }; + } +} + +async function preloadMultiFileDiffWithFallback(params: { + oldFile: FileContents; + newFile: FileContents; + options: DiffViewerOptions; +}): Promise { + try { + return await preloadMultiFileDiff(params); + } catch (error) { + if (!shouldFallbackToClientHydration(error)) { + throw error; + } + return { + oldFile: params.oldFile, + newFile: params.newFile, + prerenderedHTML: "", + }; + } +} diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 056b10c0643..2f845727274 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -57,7 +57,7 @@ describe("diffs tool", () => { const cleanupSpy = vi.spyOn(store, "scheduleCleanup"); const screenshotter = createPngScreenshotter({ assertHtml: (html) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); + expect(html).toContain("/plugins/diffs/assets/viewer.js"); }, assertImage: (image) => { expect(image).toMatchObject({ @@ -332,13 +332,13 @@ describe("diffs tool", () => { const html = await store.readHtml(id); expect(html).toContain('body data-theme="light"'); expect(html).toContain("--diffs-font-size: 17px;"); - expect(html).toContain('--diffs-font-family: "JetBrains Mono"'); + expect(html).toContain("JetBrains Mono"); }); it("prefers explicit tool params over configured defaults", async () => { const screenshotter = createPngScreenshotter({ assertHtml: (html) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); + expect(html).toContain("/plugins/diffs/assets/viewer.js"); }, assertImage: (image) => { expect(image).toMatchObject({ diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 337e6fd90a5..a85eb37b85f 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/src/discord/account-inspect.test.ts b/extensions/discord/src/account-inspect.test.ts similarity index 98% rename from src/discord/account-inspect.test.ts rename to extensions/discord/src/account-inspect.test.ts index 0e8303635f9..eda0b6cc0e0 100644 --- a/src/discord/account-inspect.test.ts +++ b/extensions/discord/src/account-inspect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { inspectDiscordAccount } from "./account-inspect.js"; function asConfig(value: unknown): OpenClawConfig { diff --git a/src/discord/account-inspect.ts b/extensions/discord/src/account-inspect.ts similarity index 90% rename from src/discord/account-inspect.ts rename to extensions/discord/src/account-inspect.ts index 53357ffd636..d99f87aeb56 100644 --- a/src/discord/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,7 +1,10 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { DiscordAccountConfig } from "../config/types.discord.js"; -import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordAccountConfig } from "../../../src/config/types.discord.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/src/discord/accounts.test.ts b/extensions/discord/src/accounts.test.ts similarity index 100% rename from src/discord/accounts.test.ts rename to extensions/discord/src/accounts.test.ts diff --git a/src/discord/accounts.ts b/extensions/discord/src/accounts.ts similarity index 86% rename from src/discord/accounts.ts rename to extensions/discord/src/accounts.ts index b4e71c78343..6cd1699f192 100644 --- a/src/discord/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,9 +1,9 @@ -import { createAccountActionGate } from "../channels/plugins/account-action-gate.js"; -import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; +import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts new file mode 100644 index 00000000000..80cd97217ae --- /dev/null +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -0,0 +1,451 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + parseAvailableTags, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import { + isDiscordModerationAction, + readDiscordModerationCommand, +} from "../../../../src/agents/tools/discord-actions-moderation-shared.js"; +import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; +import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; + +type Ctx = Pick< + ChannelMessageActionContext, + "action" | "params" | "cfg" | "accountId" | "requesterSenderId" +>; + +export async function tryHandleDiscordMessageActionGuildAdmin(params: { + ctx: Ctx; + resolveChannelId: () => string; + readParentIdParam: (params: Record) => string | null | undefined; +}): Promise | undefined> { + const { ctx, resolveChannelId, readParentIdParam } = params; + const { action, params: actionParams, cfg } = ctx; + const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId"); + + if (action === "member-info") { + const userId = readStringParam(actionParams, "userId", { required: true }); + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "memberInfo", accountId: accountId ?? undefined, guildId, userId }, + cfg, + ); + } + + if (action === "role-info") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "roleInfo", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "emoji-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "emojiList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "emoji-upload") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "emojiName", { required: true }); + const mediaUrl = readStringParam(actionParams, "media", { + required: true, + trim: false, + }); + const roleIds = readStringArrayParam(actionParams, "roleIds"); + return await handleDiscordAction( + { + action: "emojiUpload", + accountId: accountId ?? undefined, + guildId, + name, + mediaUrl, + roleIds, + }, + cfg, + ); + } + + if (action === "sticker-upload") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "stickerName", { + required: true, + }); + const description = readStringParam(actionParams, "stickerDesc", { + required: true, + }); + const tags = readStringParam(actionParams, "stickerTags", { + required: true, + }); + const mediaUrl = readStringParam(actionParams, "media", { + required: true, + trim: false, + }); + return await handleDiscordAction( + { + action: "stickerUpload", + accountId: accountId ?? undefined, + guildId, + name, + description, + tags, + mediaUrl, + }, + cfg, + ); + } + + if (action === "role-add" || action === "role-remove") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const userId = readStringParam(actionParams, "userId", { required: true }); + const roleId = readStringParam(actionParams, "roleId", { required: true }); + return await handleDiscordAction( + { + action: action === "role-add" ? "roleAdd" : "roleRemove", + accountId: accountId ?? undefined, + guildId, + userId, + roleId, + }, + cfg, + ); + } + + if (action === "channel-info") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelInfo", accountId: accountId ?? undefined, channelId }, + cfg, + ); + } + + if (action === "channel-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "channel-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "name", { required: true }); + const type = readNumberParam(actionParams, "type", { integer: true }); + const parentId = readParentIdParam(actionParams); + const topic = readStringParam(actionParams, "topic"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; + return await handleDiscordAction( + { + action: "channelCreate", + accountId: accountId ?? undefined, + guildId, + name, + type: type ?? undefined, + parentId: parentId ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + nsfw, + }, + cfg, + ); + } + + if (action === "channel-edit") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + const name = readStringParam(actionParams, "name"); + const topic = readStringParam(actionParams, "topic"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + const parentId = readParentIdParam(actionParams); + const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; + const rateLimitPerUser = readNumberParam(actionParams, "rateLimitPerUser", { + integer: true, + }); + const archived = typeof actionParams.archived === "boolean" ? actionParams.archived : undefined; + const locked = typeof actionParams.locked === "boolean" ? actionParams.locked : undefined; + const autoArchiveDuration = readNumberParam(actionParams, "autoArchiveDuration", { + integer: true, + }); + const availableTags = parseAvailableTags(actionParams.availableTags); + return await handleDiscordAction( + { + action: "channelEdit", + accountId: accountId ?? undefined, + channelId, + name: name ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + parentId: parentId === undefined ? undefined : parentId, + nsfw, + rateLimitPerUser: rateLimitPerUser ?? undefined, + archived, + locked, + autoArchiveDuration: autoArchiveDuration ?? undefined, + availableTags, + }, + cfg, + ); + } + + if (action === "channel-delete") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelDelete", accountId: accountId ?? undefined, channelId }, + cfg, + ); + } + + if (action === "channel-move") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + const parentId = readParentIdParam(actionParams); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "channelMove", + accountId: accountId ?? undefined, + guildId, + channelId, + parentId: parentId === undefined ? undefined : parentId, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "name", { required: true }); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "categoryCreate", + accountId: accountId ?? undefined, + guildId, + name, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-edit") { + const categoryId = readStringParam(actionParams, "categoryId", { + required: true, + }); + const name = readStringParam(actionParams, "name"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "categoryEdit", + accountId: accountId ?? undefined, + categoryId, + name: name ?? undefined, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-delete") { + const categoryId = readStringParam(actionParams, "categoryId", { + required: true, + }); + return await handleDiscordAction( + { action: "categoryDelete", accountId: accountId ?? undefined, categoryId }, + cfg, + ); + } + + if (action === "voice-status") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const userId = readStringParam(actionParams, "userId", { required: true }); + return await handleDiscordAction( + { action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId }, + cfg, + ); + } + + if (action === "event-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "eventList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "event-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "eventName", { required: true }); + const startTime = readStringParam(actionParams, "startTime", { + required: true, + }); + const endTime = readStringParam(actionParams, "endTime"); + const description = readStringParam(actionParams, "desc"); + const channelId = readStringParam(actionParams, "channelId"); + const location = readStringParam(actionParams, "location"); + const entityType = readStringParam(actionParams, "eventType"); + return await handleDiscordAction( + { + action: "eventCreate", + accountId: accountId ?? undefined, + guildId, + name, + startTime, + endTime, + description, + channelId, + location, + entityType, + }, + cfg, + ); + } + + if (isDiscordModerationAction(action)) { + const moderation = readDiscordModerationCommand(action, { + ...actionParams, + durationMinutes: readNumberParam(actionParams, "durationMin", { integer: true }), + deleteMessageDays: readNumberParam(actionParams, "deleteDays", { + integer: true, + }), + }); + const senderUserId = ctx.requesterSenderId?.trim() || undefined; + return await handleDiscordAction( + { + action: moderation.action, + accountId: accountId ?? undefined, + guildId: moderation.guildId, + userId: moderation.userId, + durationMinutes: moderation.durationMinutes, + until: moderation.until, + reason: moderation.reason, + deleteMessageDays: moderation.deleteMessageDays, + senderUserId, + }, + cfg, + ); + } + + // Some actions are conceptually "admin", but still act on a resolved channel. + if (action === "thread-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const channelId = readStringParam(actionParams, "channelId"); + const includeArchived = + typeof actionParams.includeArchived === "boolean" ? actionParams.includeArchived : undefined; + const before = readStringParam(actionParams, "before"); + const limit = readNumberParam(actionParams, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "threadList", + accountId: accountId ?? undefined, + guildId, + channelId, + includeArchived, + before, + limit, + }, + cfg, + ); + } + + if (action === "thread-reply") { + const content = readStringParam(actionParams, "message", { + required: true, + }); + const mediaUrl = readStringParam(actionParams, "media", { trim: false }); + const replyTo = readStringParam(actionParams, "replyTo"); + + // `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`. + // Prefer `threadId` when present to avoid accidentally replying in the parent channel. + const threadId = readStringParam(actionParams, "threadId"); + const channelId = threadId ?? resolveChannelId(); + + return await handleDiscordAction( + { + action: "threadReply", + accountId: accountId ?? undefined, + channelId, + content, + mediaUrl: mediaUrl ?? undefined, + replyTo: replyTo ?? undefined, + }, + cfg, + ); + } + + if (action === "search") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const query = readStringParam(actionParams, "query", { required: true }); + return await handleDiscordAction( + { + action: "searchMessages", + accountId: accountId ?? undefined, + guildId, + content: query, + channelId: readStringParam(actionParams, "channelId"), + channelIds: readStringArrayParam(actionParams, "channelIds"), + authorId: readStringParam(actionParams, "authorId"), + authorIds: readStringArrayParam(actionParams, "authorIds"), + limit: readNumberParam(actionParams, "limit", { integer: true }), + }, + cfg, + ); + } + + return undefined; +} diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts new file mode 100644 index 00000000000..b0842ce25b2 --- /dev/null +++ b/extensions/discord/src/actions/handle-action.ts @@ -0,0 +1,295 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-actions-shared.js"; +import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; +import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js"; +import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; +import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js"; +import { resolveDiscordChannelId } from "../targets.js"; +import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; + +const providerId = "discord"; + +export async function handleDiscordMessageAction( + ctx: Pick< + ChannelMessageActionContext, + | "action" + | "params" + | "cfg" + | "accountId" + | "requesterSenderId" + | "toolContext" + | "mediaLocalRoots" + >, +): Promise> { + const { action, params, cfg } = ctx; + const accountId = ctx.accountId ?? readStringParam(params, "accountId"); + const actionOptions = { + mediaLocalRoots: ctx.mediaLocalRoots, + } as const; + + const resolveChannelId = () => + resolveDiscordChannelId( + readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }), + ); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const asVoice = readBooleanParam(params, "asVoice") === true; + const rawComponents = params.components; + const hasComponents = + Boolean(rawComponents) && + (typeof rawComponents === "function" || typeof rawComponents === "object"); + const components = hasComponents ? rawComponents : undefined; + const content = readStringParam(params, "message", { + required: !asVoice && !hasComponents, + allowEmpty: true, + }); + // Support media, path, and filePath for media URL + const mediaUrl = + readStringParam(params, "media", { trim: false }) ?? + readStringParam(params, "path", { trim: false }) ?? + readStringParam(params, "filePath", { trim: false }); + const filename = readStringParam(params, "filename"); + const replyTo = readStringParam(params, "replyTo"); + const rawEmbeds = params.embeds; + const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; + const silent = readBooleanParam(params, "silent") === true; + const sessionKey = readStringParam(params, "__sessionKey"); + const agentId = readStringParam(params, "__agentId"); + return await handleDiscordAction( + { + action: "sendMessage", + accountId: accountId ?? undefined, + to, + content, + mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, + replyTo: replyTo ?? undefined, + components, + embeds, + asVoice, + silent, + __sessionKey: sessionKey ?? undefined, + __agentId: agentId ?? undefined, + }, + cfg, + actionOptions, + ); + } + + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const answers = readStringArrayParam(params, "pollOption", { required: true }); + const allowMultiselect = readBooleanParam(params, "pollMulti"); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + return await handleDiscordAction( + { + action: "poll", + accountId: accountId ?? undefined, + to, + question, + answers, + allowMultiselect, + durationHours: durationHours ?? undefined, + content: readStringParam(params, "message"), + }, + cfg, + actionOptions, + ); + } + + if (action === "react") { + const messageIdRaw = resolveReactionMessageId({ args: params, toolContext: ctx.toolContext }); + const messageId = messageIdRaw != null ? String(messageIdRaw).trim() : ""; + if (!messageId) { + throw new Error( + "messageId required. Provide messageId explicitly or react to the current inbound message.", + ); + } + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = readBooleanParam(params, "remove"); + return await handleDiscordAction( + { + action: "react", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + emoji, + remove, + }, + cfg, + actionOptions, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(params, "messageId", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "reactions", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + limit, + }, + cfg, + actionOptions, + ); + } + + if (action === "read") { + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "readMessages", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + limit, + before: readStringParam(params, "before"), + after: readStringParam(params, "after"), + around: readStringParam(params, "around"), + }, + cfg, + actionOptions, + ); + } + + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { required: true }); + const content = readStringParam(params, "message", { required: true }); + return await handleDiscordAction( + { + action: "editMessage", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + content, + }, + cfg, + actionOptions, + ); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { required: true }); + return await handleDiscordAction( + { + action: "deleteMessage", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + }, + cfg, + actionOptions, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" ? undefined : readStringParam(params, "messageId", { required: true }); + return await handleDiscordAction( + { + action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + }, + cfg, + actionOptions, + ); + } + + if (action === "permissions") { + return await handleDiscordAction( + { + action: "permissions", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + }, + cfg, + actionOptions, + ); + } + + if (action === "thread-create") { + const name = readStringParam(params, "threadName", { required: true }); + const messageId = readStringParam(params, "messageId"); + const content = readStringParam(params, "message"); + const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { + integer: true, + }); + const appliedTags = readStringArrayParam(params, "appliedTags"); + return await handleDiscordAction( + { + action: "threadCreate", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + name, + messageId, + content, + autoArchiveMinutes, + appliedTags: appliedTags ?? undefined, + }, + cfg, + actionOptions, + ); + } + + if (action === "sticker") { + const stickerIds = + readStringArrayParam(params, "stickerId", { + required: true, + label: "sticker-id", + }) ?? []; + return await handleDiscordAction( + { + action: "sticker", + accountId: accountId ?? undefined, + to: readStringParam(params, "to", { required: true }), + stickerIds, + content: readStringParam(params, "message"), + }, + cfg, + actionOptions, + ); + } + + if (action === "set-presence") { + return await handleDiscordAction( + { + action: "setPresence", + accountId: accountId ?? undefined, + status: readStringParam(params, "status"), + activityType: readStringParam(params, "activityType"), + activityName: readStringParam(params, "activityName"), + activityUrl: readStringParam(params, "activityUrl"), + activityState: readStringParam(params, "activityState"), + }, + cfg, + actionOptions, + ); + } + + const adminResult = await tryHandleDiscordMessageActionGuildAdmin({ + ctx, + resolveChannelId, + readParentIdParam: readDiscordParentIdParam, + }); + if (adminResult !== undefined) { + return adminResult; + } + + throw new Error(`Action ${String(action)} is not supported for provider ${providerId}.`); +} diff --git a/src/discord/api.test.ts b/extensions/discord/src/api.test.ts similarity index 96% rename from src/discord/api.test.ts rename to extensions/discord/src/api.test.ts index 4c9f1a9c0c1..5b0e648aa1d 100644 --- a/src/discord/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js"; import { fetchDiscord } from "./api.js"; import { jsonResponse } from "./test-http-helpers.js"; diff --git a/src/discord/api.ts b/extensions/discord/src/api.ts similarity index 97% rename from src/discord/api.ts rename to extensions/discord/src/api.ts index f8a88a50252..cead5eb8cea 100644 --- a/src/discord/api.ts +++ b/extensions/discord/src/api.ts @@ -1,5 +1,5 @@ -import { resolveFetch } from "../infra/fetch.js"; -import { resolveRetryConfig, retryAsync, type RetryConfig } from "../infra/retry.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; +import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../../src/infra/retry.js"; const DISCORD_API_BASE = "https://discord.com/api/v10"; const DISCORD_API_RETRY_DEFAULTS = { diff --git a/src/discord/audit.test.ts b/extensions/discord/src/audit.test.ts similarity index 92% rename from src/discord/audit.test.ts rename to extensions/discord/src/audit.test.ts index 55339b03381..c1b276f320b 100644 --- a/src/discord/audit.test.ts +++ b/extensions/discord/src/audit.test.ts @@ -27,7 +27,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, @@ -73,7 +73,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual(["111"]); @@ -98,7 +98,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual([]); @@ -127,7 +127,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual(["111"]); diff --git a/src/discord/audit.ts b/extensions/discord/src/audit.ts similarity index 96% rename from src/discord/audit.ts rename to extensions/discord/src/audit.ts index d2a6477e47f..a5a226c5550 100644 --- a/src/discord/audit.ts +++ b/extensions/discord/src/audit.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../config/types.js"; -import { isRecord } from "../utils.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../../../src/config/types.js"; +import { isRecord } from "../../../src/utils.js"; import { inspectDiscordAccount } from "./account-inspect.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts new file mode 100644 index 00000000000..bf35b788e3e --- /dev/null +++ b/extensions/discord/src/channel-actions.ts @@ -0,0 +1,140 @@ +import { + createUnionActionGate, + listTokenSourcedAccounts, +} from "../../../src/channels/plugins/actions/shared.js"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "../../../src/channels/plugins/types.js"; +import type { DiscordActionConfig } from "../../../src/config/types.discord.js"; +import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; +import { handleDiscordMessageAction } from "./actions/handle-action.js"; + +export const discordMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)); + if (accounts.length === 0) { + return []; + } + // Union of all accounts' action gates (any account enabling an action makes it available) + const gate = createUnionActionGate(accounts, (account) => + createDiscordActionGate({ + cfg, + accountId: account.accountId, + }), + ); + const isEnabled = (key: keyof DiscordActionConfig, defaultValue = true) => + gate(key, defaultValue); + const actions = new Set(["send"]); + if (isEnabled("polls")) { + actions.add("poll"); + } + if (isEnabled("reactions")) { + actions.add("react"); + actions.add("reactions"); + } + if (isEnabled("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (isEnabled("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (isEnabled("permissions")) { + actions.add("permissions"); + } + if (isEnabled("threads")) { + actions.add("thread-create"); + actions.add("thread-list"); + actions.add("thread-reply"); + } + if (isEnabled("search")) { + actions.add("search"); + } + if (isEnabled("stickers")) { + actions.add("sticker"); + } + if (isEnabled("memberInfo")) { + actions.add("member-info"); + } + if (isEnabled("roleInfo")) { + actions.add("role-info"); + } + if (isEnabled("reactions")) { + actions.add("emoji-list"); + } + if (isEnabled("emojiUploads")) { + actions.add("emoji-upload"); + } + if (isEnabled("stickerUploads")) { + actions.add("sticker-upload"); + } + if (isEnabled("roles", false)) { + actions.add("role-add"); + actions.add("role-remove"); + } + if (isEnabled("channelInfo")) { + actions.add("channel-info"); + actions.add("channel-list"); + } + if (isEnabled("channels")) { + actions.add("channel-create"); + actions.add("channel-edit"); + actions.add("channel-delete"); + actions.add("channel-move"); + actions.add("category-create"); + actions.add("category-edit"); + actions.add("category-delete"); + } + if (isEnabled("voiceStatus")) { + actions.add("voice-status"); + } + if (isEnabled("events")) { + actions.add("event-list"); + actions.add("event-create"); + } + if (isEnabled("moderation", false)) { + actions.add("timeout"); + actions.add("kick"); + actions.add("ban"); + } + if (isEnabled("presence", false)) { + actions.add("set-presence"); + } + return Array.from(actions); + }, + extractToolSend: ({ args }) => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action === "sendMessage") { + const to = typeof args.to === "string" ? args.to : undefined; + return to ? { to } : null; + } + if (action === "threadReply") { + const channelId = typeof args.channelId === "string" ? args.channelId.trim() : ""; + return channelId ? { to: `channel:${channelId}` } : null; + } + return null; + }, + handleAction: async ({ + action, + params, + cfg, + accountId, + requesterSenderId, + toolContext, + mediaLocalRoots, + }) => { + return await handleDiscordMessageAction({ + action, + params, + cfg, + accountId, + requesterSenderId, + toolContext, + mediaLocalRoots, + }); + }, +}; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index c6852a63469..c910e56342d 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -37,8 +37,13 @@ import { type ChannelPlugin, type ResolvedDiscordAccount, } from "openclaw/plugin-sdk/discord"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { getDiscordRuntime } from "./runtime.js"; +type DiscordSendFn = ReturnType< + typeof getDiscordRuntime +>["channel"]["discord"]["sendMessageDiscord"]; + const meta = getChatChannelMeta("discord"); const discordMessageActions: ChannelMessageActionAdapter = { @@ -300,7 +305,9 @@ export const discordPlugin: ChannelPlugin = { pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { - const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, @@ -321,7 +328,9 @@ export const discordPlugin: ChannelPlugin = { replyToId, silent, }) => { - const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, diff --git a/src/discord/chunk.test.ts b/extensions/discord/src/chunk.test.ts similarity index 98% rename from src/discord/chunk.test.ts rename to extensions/discord/src/chunk.test.ts index d33262c4767..3c667c0fc9f 100644 --- a/src/discord/chunk.test.ts +++ b/extensions/discord/src/chunk.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { countLines, hasBalancedFences } from "../test-utils/chunk-test-helpers.js"; +import { countLines, hasBalancedFences } from "../../../src/test-utils/chunk-test-helpers.js"; import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js"; describe("chunkDiscordText", () => { diff --git a/src/discord/chunk.ts b/extensions/discord/src/chunk.ts similarity index 98% rename from src/discord/chunk.ts rename to extensions/discord/src/chunk.ts index 242d5c74c2d..a814c10d2c8 100644 --- a/src/discord/chunk.ts +++ b/extensions/discord/src/chunk.ts @@ -1,4 +1,4 @@ -import { chunkMarkdownTextWithMode, type ChunkMode } from "../auto-reply/chunk.js"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../src/auto-reply/chunk.js"; export type ChunkDiscordTextOpts = { /** Max characters per Discord message. Default: 2000. */ diff --git a/src/discord/client.test.ts b/extensions/discord/src/client.test.ts similarity index 96% rename from src/discord/client.test.ts rename to extensions/discord/src/client.test.ts index 3dc156670e7..416fa7c903a 100644 --- a/src/discord/client.test.ts +++ b/extensions/discord/src/client.test.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { createDiscordRestClient } from "./client.js"; describe("createDiscordRestClient", () => { diff --git a/src/discord/client.ts b/extensions/discord/src/client.ts similarity index 90% rename from src/discord/client.ts rename to extensions/discord/src/client.ts index 62d917cebb6..2e8d53799a6 100644 --- a/src/discord/client.ts +++ b/extensions/discord/src/client.ts @@ -1,8 +1,8 @@ import { RequestClient } from "@buape/carbon"; -import { loadConfig } from "../config/config.js"; -import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-policy.js"; -import type { RetryConfig } from "../infra/retry.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { createDiscordRetryRunner, type RetryRunner } from "../../../src/infra/retry-policy.js"; +import type { RetryConfig } from "../../../src/infra/retry.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { mergeDiscordAccountConfig, resolveDiscordAccount, diff --git a/src/discord/components-registry.ts b/extensions/discord/src/components-registry.ts similarity index 100% rename from src/discord/components-registry.ts rename to extensions/discord/src/components-registry.ts diff --git a/src/discord/components.test.ts b/extensions/discord/src/components.test.ts similarity index 100% rename from src/discord/components.test.ts rename to extensions/discord/src/components.test.ts diff --git a/src/discord/components.ts b/extensions/discord/src/components.ts similarity index 100% rename from src/discord/components.ts rename to extensions/discord/src/components.ts diff --git a/src/discord/directory-cache.ts b/extensions/discord/src/directory-cache.ts similarity index 97% rename from src/discord/directory-cache.ts rename to extensions/discord/src/directory-cache.ts index 4cb17865eae..d1a85767216 100644 --- a/src/discord/directory-cache.ts +++ b/extensions/discord/src/directory-cache.ts @@ -1,4 +1,4 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/account-id.js"; const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000; const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/; diff --git a/src/discord/directory-live.test.ts b/extensions/discord/src/directory-live.test.ts similarity index 97% rename from src/discord/directory-live.test.ts rename to extensions/discord/src/directory-live.test.ts index e6f19d448d8..8ba3bc52c4a 100644 --- a/src/discord/directory-live.test.ts +++ b/extensions/discord/src/directory-live.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; +import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; const mocks = vi.hoisted(() => ({ fetchDiscord: vi.fn(), diff --git a/src/discord/directory-live.ts b/extensions/discord/src/directory-live.ts similarity index 95% rename from src/discord/directory-live.ts rename to extensions/discord/src/directory-live.ts index d57d3e775a9..af55475a43e 100644 --- a/src/discord/directory-live.ts +++ b/extensions/discord/src/directory-live.ts @@ -1,5 +1,5 @@ -import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; -import type { ChannelDirectoryEntry } from "../channels/plugins/types.js"; +import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; +import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js"; import { resolveDiscordAccount } from "./accounts.js"; import { fetchDiscord } from "./api.js"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; diff --git a/src/discord/draft-chunking.ts b/extensions/discord/src/draft-chunking.ts similarity index 78% rename from src/discord/draft-chunking.ts rename to extensions/discord/src/draft-chunking.ts index 76231bc8397..ce4048379d1 100644 --- a/src/discord/draft-chunking.ts +++ b/extensions/discord/src/draft-chunking.ts @@ -1,8 +1,8 @@ -import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { getChannelDock } from "../channels/dock.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { getChannelDock } from "../../../src/channels/dock.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; const DEFAULT_DISCORD_DRAFT_STREAM_MIN = 200; const DEFAULT_DISCORD_DRAFT_STREAM_MAX = 800; diff --git a/src/discord/draft-stream.ts b/extensions/discord/src/draft-stream.ts similarity index 97% rename from src/discord/draft-stream.ts rename to extensions/discord/src/draft-stream.ts index 0281d4c0227..db9089f6176 100644 --- a/src/discord/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 "../channels/draft-stream-controls.js"; +import { createFinalizableDraftLifecycle } from "../../../src/channels/draft-stream-controls.js"; /** Discord messages cap at 2000 characters. */ const DISCORD_STREAM_MAX_CHARS = 2000; diff --git a/src/discord/exec-approvals.ts b/extensions/discord/src/exec-approvals.ts similarity index 72% rename from src/discord/exec-approvals.ts rename to extensions/discord/src/exec-approvals.ts index f4be9a22e0c..5640805705a 100644 --- a/src/discord/exec-approvals.ts +++ b/extensions/discord/src/exec-approvals.ts @@ -1,6 +1,6 @@ -import type { ReplyPayload } from "../auto-reply/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { getExecApprovalReplyMetadata } from "../../../src/infra/exec-approval-reply.js"; import { resolveDiscordAccount } from "./accounts.js"; export function isDiscordExecApprovalClientEnabled(params: { diff --git a/src/discord/gateway-logging.test.ts b/extensions/discord/src/gateway-logging.test.ts similarity index 96% rename from src/discord/gateway-logging.test.ts rename to extensions/discord/src/gateway-logging.test.ts index 762cf5d160b..e6fc4d0f714 100644 --- a/src/discord/gateway-logging.test.ts +++ b/extensions/discord/src/gateway-logging.test.ts @@ -1,11 +1,11 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -vi.mock("../globals.js", () => ({ +vi.mock("../../../src/globals.js", () => ({ logVerbose: vi.fn(), })); -import { logVerbose } from "../globals.js"; +import { logVerbose } from "../../../src/globals.js"; import { attachDiscordGatewayLogging } from "./gateway-logging.js"; const makeRuntime = () => ({ diff --git a/src/discord/gateway-logging.ts b/extensions/discord/src/gateway-logging.ts similarity index 94% rename from src/discord/gateway-logging.ts rename to extensions/discord/src/gateway-logging.ts index 916952020be..18ce32909ef 100644 --- a/src/discord/gateway-logging.ts +++ b/extensions/discord/src/gateway-logging.ts @@ -1,6 +1,6 @@ import type { EventEmitter } from "node:events"; -import { logVerbose } from "../globals.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { logVerbose } from "../../../src/globals.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; type GatewayEmitter = Pick; diff --git a/src/discord/guilds.ts b/extensions/discord/src/guilds.ts similarity index 100% rename from src/discord/guilds.ts rename to extensions/discord/src/guilds.ts diff --git a/src/discord/mentions.test.ts b/extensions/discord/src/mentions.test.ts similarity index 100% rename from src/discord/mentions.test.ts rename to extensions/discord/src/mentions.test.ts diff --git a/src/discord/mentions.ts b/extensions/discord/src/mentions.ts similarity index 100% rename from src/discord/mentions.ts rename to extensions/discord/src/mentions.ts diff --git a/src/discord/monitor.gateway.test.ts b/extensions/discord/src/monitor.gateway.test.ts similarity index 100% rename from src/discord/monitor.gateway.test.ts rename to extensions/discord/src/monitor.gateway.test.ts diff --git a/src/discord/monitor.gateway.ts b/extensions/discord/src/monitor.gateway.ts similarity index 100% rename from src/discord/monitor.gateway.ts rename to extensions/discord/src/monitor.gateway.ts diff --git a/src/discord/monitor.test.ts b/extensions/discord/src/monitor.test.ts similarity index 98% rename from src/discord/monitor.test.ts rename to extensions/discord/src/monitor.test.ts index d3289155699..40f14a00551 100644 --- a/src/discord/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -1,6 +1,6 @@ import { ChannelType, type Guild } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { typedCases } from "../test-utils/typed-cases.js"; +import { typedCases } from "../../../src/test-utils/typed-cases.js"; import { allowListMatches, buildDiscordMediaPayload, @@ -22,7 +22,7 @@ import { DiscordMessageListener, DiscordReactionListener } from "./monitor/liste const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), })); @@ -157,7 +157,9 @@ describe("DiscordMessageListener", () => { const logger = { warn: vi.fn(), error: vi.fn(), - } as unknown as ReturnType; + } as unknown as ReturnType< + typeof import("../../../src/logging/subsystem.js").createSubsystemLogger + >; const handler = vi.fn(async () => { throw new Error("boom"); }); @@ -178,7 +180,9 @@ describe("DiscordMessageListener", () => { const logger = { warn: vi.fn(), error: vi.fn(), - } as unknown as ReturnType; + } as unknown as ReturnType< + typeof import("../../../src/logging/subsystem.js").createSubsystemLogger + >; const listener = new DiscordMessageListener(handler, logger); const handlePromise = listener.handle( @@ -888,11 +892,11 @@ const { enqueueSystemEventSpy, resolveAgentRouteMock } = vi.hoisted(() => ({ })), })); -vi.mock("../infra/system-events.js", () => ({ +vi.mock("../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: enqueueSystemEventSpy, })); -vi.mock("../routing/resolve-route.js", () => ({ +vi.mock("../../../src/routing/resolve-route.js", () => ({ resolveAgentRoute: resolveAgentRouteMock, })); @@ -973,9 +977,9 @@ function makeReactionListenerParams(overrides?: { guildEntries?: Record; }) { return { - cfg: {} as ReturnType, + cfg: {} as ReturnType, accountId: "acc-1", - runtime: {} as import("../runtime.js").RuntimeEnv, + runtime: {} as import("../../../src/runtime.js").RuntimeEnv, botUserId: overrides?.botUserId ?? "bot-1", dmEnabled: overrides?.dmEnabled ?? true, groupDmEnabled: overrides?.groupDmEnabled ?? true, @@ -990,7 +994,9 @@ function makeReactionListenerParams(overrides?: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), - } as unknown as ReturnType, + } as unknown as ReturnType< + typeof import("../../../src/logging/subsystem.js").createSubsystemLogger + >, }; } diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts similarity index 96% rename from src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts rename to extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts index b85ec0c060d..6461fcef756 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts +++ b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts @@ -2,7 +2,7 @@ import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; +import { createReplyDispatcherWithTyping } from "../../../src/auto-reply/reply/reply-dispatcher.js"; import { dispatchMock, readAllowFromStoreMock, @@ -14,8 +14,8 @@ import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.j import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; const loadConfigMock = vi.fn(); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: (...args: unknown[]) => loadConfigMock(...args), @@ -63,7 +63,7 @@ beforeEach(() => { const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000; -type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; +type LoadedConfig = ReturnType<(typeof import("../../../src/config/config.js"))["loadConfig"]>; let createDiscordMessageHandler: typeof import("./monitor.js").createDiscordMessageHandler; let createDiscordNativeCommand: typeof import("./monitor.js").createDiscordNativeCommand; @@ -322,7 +322,7 @@ describe("discord tool result dispatch", () => { channels: { discord: { dm: { enabled: true, policy: "open" } }, }, - } as ReturnType; + } as ReturnType; const command = createDiscordNativeCommand({ command: { @@ -451,7 +451,7 @@ describe("discord tool result dispatch", () => { const cfg = { ...createDefaultThreadConfig(), routing: { allowFrom: [] }, - } as ReturnType; + } as ReturnType; const handler = await createHandler(cfg); diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts similarity index 98% rename from src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts rename to extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts index 70d7fd53708..d1340f49852 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -12,7 +12,7 @@ import { createDiscordMessageHandler } from "./monitor/message-handler.js"; import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js"; import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; -type Config = ReturnType; +type Config = ReturnType; beforeEach(() => { __resetDiscordChannelInfoCacheForTest(); diff --git a/src/discord/monitor.tool-result.test-harness.ts b/extensions/discord/src/monitor.tool-result.test-harness.ts similarity index 72% rename from src/discord/monitor.tool-result.test-harness.ts rename to extensions/discord/src/monitor.tool-result.test-harness.ts index 0d4596b3281..700e9a63df3 100644 --- a/src/discord/monitor.tool-result.test-harness.ts +++ b/extensions/discord/src/monitor.tool-result.test-harness.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; export const sendMock: MockFn = vi.fn(); export const reactMock: MockFn = vi.fn(); @@ -15,8 +15,8 @@ vi.mock("./send.js", () => ({ }, })); -vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), @@ -36,10 +36,10 @@ function createPairingStoreMocks() { }; } -vi.mock("../pairing/pairing-store.js", () => createPairingStoreMocks()); +vi.mock("../../../src/pairing/pairing-store.js", () => createPairingStoreMocks()); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), diff --git a/src/discord/monitor.ts b/extensions/discord/src/monitor.ts similarity index 100% rename from src/discord/monitor.ts rename to extensions/discord/src/monitor.ts diff --git a/src/discord/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts similarity index 96% rename from src/discord/monitor/agent-components.ts rename to extensions/discord/src/monitor/agent-components.ts index 80239ea51d7..e954c372bb1 100644 --- a/src/discord/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -17,32 +17,35 @@ import { } from "@buape/carbon"; import type { APIStringSelectComponent } from "discord-api-types/v10"; import { ButtonStyle, ChannelType } from "discord-api-types/v10"; -import { resolveHumanDelayConfig } from "../../agents/identity.js"; -import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; -import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; -import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; -import { recordInboundSession } from "../../channels/session.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; -import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import type { DiscordAccountConfig } from "../../config/types.discord.js"; -import { logVerbose } from "../../globals.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { logDebug, logError } from "../../logger.js"; -import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; +import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; +import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../../../src/auto-reply/envelope.js"; +import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +import { createReplyReferencePlanner } from "../../../../src/auto-reply/reply/reply-reference.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; +import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; +import { recordInboundSession } from "../../../../src/channels/session.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; +import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; +import { logDebug, logError } from "../../../../src/logger.js"; +import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, -} from "../../security/dm-policy-shared.js"; +} from "../../../../src/security/dm-policy-shared.js"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import { diff --git a/src/discord/monitor/agent-components.wildcard.test.ts b/extensions/discord/src/monitor/agent-components.wildcard.test.ts similarity index 100% rename from src/discord/monitor/agent-components.wildcard.test.ts rename to extensions/discord/src/monitor/agent-components.wildcard.test.ts diff --git a/src/discord/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts similarity index 98% rename from src/discord/monitor/allow-list.ts rename to extensions/discord/src/monitor/allow-list.ts index 353ab8635be..6391ad5c3a5 100644 --- a/src/discord/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -1,12 +1,12 @@ import type { Guild, User } from "@buape/carbon"; -import type { AllowlistMatch } from "../../channels/allowlist-match.js"; +import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js"; import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, resolveChannelMatchConfig, type ChannelMatchSource, -} from "../../channels/channel-config.js"; -import { evaluateGroupRouteAccessForPolicy } from "../../plugin-sdk/group-access.js"; +} from "../../../../src/channels/channel-config.js"; +import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js"; import { formatDiscordUserTag } from "./format.js"; export type DiscordAllowList = { diff --git a/src/discord/monitor/auto-presence.test.ts b/extensions/discord/src/monitor/auto-presence.test.ts similarity index 98% rename from src/discord/monitor/auto-presence.test.ts rename to extensions/discord/src/monitor/auto-presence.test.ts index d901a76d642..3e81b523bc9 100644 --- a/src/discord/monitor/auto-presence.test.ts +++ b/extensions/discord/src/monitor/auto-presence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import type { AuthProfileStore } from "../../../../src/agents/auth-profiles.js"; import { createDiscordAutoPresenceController, resolveDiscordAutoPresenceDecision, diff --git a/src/discord/monitor/auto-presence.ts b/extensions/discord/src/monitor/auto-presence.ts similarity index 97% rename from src/discord/monitor/auto-presence.ts rename to extensions/discord/src/monitor/auto-presence.ts index 8c139382dc6..60e5619e348 100644 --- a/src/discord/monitor/auto-presence.ts +++ b/extensions/discord/src/monitor/auto-presence.ts @@ -6,9 +6,12 @@ import { resolveProfilesUnavailableReason, type AuthProfileFailureReason, type AuthProfileStore, -} from "../../agents/auth-profiles.js"; -import type { DiscordAccountConfig, DiscordAutoPresenceConfig } from "../../config/config.js"; -import { warn } from "../../globals.js"; +} from "../../../../src/agents/auth-profiles.js"; +import type { + DiscordAccountConfig, + DiscordAutoPresenceConfig, +} from "../../../../src/config/config.js"; +import { warn } from "../../../../src/globals.js"; import { resolveDiscordPresenceUpdate } from "./presence.js"; const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4; diff --git a/src/discord/monitor/commands.test.ts b/extensions/discord/src/monitor/commands.test.ts similarity index 100% rename from src/discord/monitor/commands.test.ts rename to extensions/discord/src/monitor/commands.test.ts diff --git a/src/discord/monitor/commands.ts b/extensions/discord/src/monitor/commands.ts similarity index 67% rename from src/discord/monitor/commands.ts rename to extensions/discord/src/monitor/commands.ts index 96a277785df..a9bb9c1548e 100644 --- a/src/discord/monitor/commands.ts +++ b/extensions/discord/src/monitor/commands.ts @@ -1,4 +1,4 @@ -import type { DiscordSlashCommandConfig } from "../../config/types.discord.js"; +import type { DiscordSlashCommandConfig } from "../../../../src/config/types.discord.js"; export function resolveDiscordSlashCommandConfig( raw?: DiscordSlashCommandConfig, diff --git a/src/discord/monitor/dm-command-auth.test.ts b/extensions/discord/src/monitor/dm-command-auth.test.ts similarity index 100% rename from src/discord/monitor/dm-command-auth.test.ts rename to extensions/discord/src/monitor/dm-command-auth.test.ts diff --git a/src/discord/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts similarity index 95% rename from src/discord/monitor/dm-command-auth.ts rename to extensions/discord/src/monitor/dm-command-auth.ts index 2a9e18be0b0..2fa02d9d605 100644 --- a/src/discord/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -1,9 +1,9 @@ -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, type DmGroupAccessDecision, -} from "../../security/dm-policy-shared.js"; +} from "../../../../src/security/dm-policy-shared.js"; import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js"; const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"]; diff --git a/src/discord/monitor/dm-command-decision.test.ts b/extensions/discord/src/monitor/dm-command-decision.test.ts similarity index 100% rename from src/discord/monitor/dm-command-decision.test.ts rename to extensions/discord/src/monitor/dm-command-decision.test.ts diff --git a/src/discord/monitor/dm-command-decision.ts b/extensions/discord/src/monitor/dm-command-decision.ts similarity index 88% rename from src/discord/monitor/dm-command-decision.ts rename to extensions/discord/src/monitor/dm-command-decision.ts index d5b533bfdaa..8c15e7cac11 100644 --- a/src/discord/monitor/dm-command-decision.ts +++ b/extensions/discord/src/monitor/dm-command-decision.ts @@ -1,5 +1,5 @@ -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; import type { DiscordDmCommandAccess } from "./dm-command-auth.js"; export async function handleDiscordDmCommandDecision(params: { diff --git a/src/discord/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts similarity index 98% rename from src/discord/monitor/exec-approvals.test.ts rename to extensions/discord/src/monitor/exec-approvals.test.ts index c7cb72b82ec..be3ead1d400 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -4,8 +4,8 @@ import path from "node:path"; import type { ButtonInteraction, ComponentData } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { clearSessionStoreCacheForTest } from "../../config/sessions.js"; -import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; +import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js"; +import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js"; import { buildExecApprovalCustomId, extractDiscordChannelId, @@ -76,7 +76,7 @@ vi.mock("../send.shared.js", async (importOriginal) => { }; }); -vi.mock("../../gateway/client.js", () => ({ +vi.mock("../../../../src/gateway/client.js", () => ({ GatewayClient: class { private params: Record; constructor(params: Record) { @@ -96,11 +96,11 @@ vi.mock("../../gateway/client.js", () => ({ }, })); -vi.mock("../../gateway/connection-auth.js", () => ({ +vi.mock("../../../../src/gateway/connection-auth.js", () => ({ resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth, })); -vi.mock("../../logger.js", () => ({ +vi.mock("../../../../src/logger.js", () => ({ logDebug: vi.fn(), logError: vi.fn(), })); diff --git a/src/discord/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts similarity index 95% rename from src/discord/monitor/exec-approvals.ts rename to extensions/discord/src/monitor/exec-approvals.ts index 8dd3156e991..e5fda7682a9 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -10,24 +10,30 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; -import type { OpenClawConfig } from "../../config/config.js"; -import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; -import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; -import { GatewayClient } from "../../gateway/client.js"; -import { createOperatorApprovalsGatewayClient } from "../../gateway/operator-approvals-client.js"; -import type { EventFrame } from "../../gateway/protocol/index.js"; -import { resolveExecApprovalCommandDisplay } from "../../infra/exec-approval-command-display.js"; -import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { loadSessionStore, resolveStorePath } from "../../../../src/config/sessions.js"; +import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js"; +import { GatewayClient } from "../../../../src/gateway/client.js"; +import { createOperatorApprovalsGatewayClient } from "../../../../src/gateway/operator-approvals-client.js"; +import type { EventFrame } from "../../../../src/gateway/protocol/index.js"; +import { resolveExecApprovalCommandDisplay } from "../../../../src/infra/exec-approval-command-display.js"; +import { getExecApprovalApproverDmNoticeText } from "../../../../src/infra/exec-approval-reply.js"; import type { ExecApprovalDecision, ExecApprovalRequest, ExecApprovalResolved, -} from "../../infra/exec-approvals.js"; -import { logDebug, logError } from "../../logger.js"; -import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import { compileSafeRegex, testRegexWithBoundedInput } from "../../security/safe-regex.js"; -import { normalizeMessageChannel } from "../../utils/message-channel.js"; +} from "../../../../src/infra/exec-approvals.js"; +import { logDebug, logError } from "../../../../src/logger.js"; +import { + normalizeAccountId, + resolveAgentIdFromSessionKey, +} from "../../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { + compileSafeRegex, + testRegexWithBoundedInput, +} from "../../../../src/security/safe-regex.js"; +import { normalizeMessageChannel } from "../../../../src/utils/message-channel.js"; import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; diff --git a/src/discord/monitor/format.ts b/extensions/discord/src/monitor/format.ts similarity index 100% rename from src/discord/monitor/format.ts rename to extensions/discord/src/monitor/format.ts diff --git a/src/discord/monitor/gateway-error-guard.test.ts b/extensions/discord/src/monitor/gateway-error-guard.test.ts similarity index 100% rename from src/discord/monitor/gateway-error-guard.test.ts rename to extensions/discord/src/monitor/gateway-error-guard.test.ts diff --git a/src/discord/monitor/gateway-error-guard.ts b/extensions/discord/src/monitor/gateway-error-guard.ts similarity index 100% rename from src/discord/monitor/gateway-error-guard.ts rename to extensions/discord/src/monitor/gateway-error-guard.ts diff --git a/src/discord/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts similarity index 95% rename from src/discord/monitor/gateway-plugin.ts rename to extensions/discord/src/monitor/gateway-plugin.ts index b4030bcb386..1799c16d79e 100644 --- a/src/discord/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -3,9 +3,9 @@ import type { APIGatewayBotInfo } from "discord-api-types/v10"; import { HttpsProxyAgent } from "https-proxy-agent"; import { ProxyAgent, fetch as undiciFetch } from "undici"; import WebSocket from "ws"; -import type { DiscordAccountConfig } from "../../config/types.js"; -import { danger } from "../../globals.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { DiscordAccountConfig } from "../../../../src/config/types.js"; +import { danger } from "../../../../src/globals.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot"; const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; @@ -20,7 +20,7 @@ type DiscordGatewayFetch = ( ) => Promise; export function resolveDiscordGatewayIntents( - intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig, + intentsConfig?: import("../../../../src/config/types.discord.js").DiscordIntentsConfig, ): number { let intents = GatewayIntents.Guilds | diff --git a/src/discord/monitor/gateway-registry.ts b/extensions/discord/src/monitor/gateway-registry.ts similarity index 100% rename from src/discord/monitor/gateway-registry.ts rename to extensions/discord/src/monitor/gateway-registry.ts diff --git a/src/discord/monitor/inbound-context.test.ts b/extensions/discord/src/monitor/inbound-context.test.ts similarity index 100% rename from src/discord/monitor/inbound-context.test.ts rename to extensions/discord/src/monitor/inbound-context.test.ts diff --git a/src/discord/monitor/inbound-context.ts b/extensions/discord/src/monitor/inbound-context.ts similarity index 94% rename from src/discord/monitor/inbound-context.ts rename to extensions/discord/src/monitor/inbound-context.ts index 516746583fa..26b2a07f03e 100644 --- a/src/discord/monitor/inbound-context.ts +++ b/extensions/discord/src/monitor/inbound-context.ts @@ -1,4 +1,4 @@ -import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; +import { buildUntrustedChannelMetadata } from "../../../../src/security/channel-metadata.js"; import { resolveDiscordOwnerAllowFrom, type DiscordChannelConfigResolved, diff --git a/src/discord/monitor/inbound-job.test.ts b/extensions/discord/src/monitor/inbound-job.test.ts similarity index 100% rename from src/discord/monitor/inbound-job.test.ts rename to extensions/discord/src/monitor/inbound-job.test.ts diff --git a/src/discord/monitor/inbound-job.ts b/extensions/discord/src/monitor/inbound-job.ts similarity index 100% rename from src/discord/monitor/inbound-job.ts rename to extensions/discord/src/monitor/inbound-job.ts diff --git a/src/discord/monitor/inbound-worker.ts b/extensions/discord/src/monitor/inbound-worker.ts similarity index 91% rename from src/discord/monitor/inbound-worker.ts rename to extensions/discord/src/monitor/inbound-worker.ts index eb4337cb913..214eb6a8020 100644 --- a/src/discord/monitor/inbound-worker.ts +++ b/extensions/discord/src/monitor/inbound-worker.ts @@ -1,7 +1,7 @@ -import { createRunStateMachine } from "../../channels/run-state-machine.js"; -import { danger } from "../../globals.js"; -import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts"; -import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; +import { createRunStateMachine } from "../../../../src/channels/run-state-machine.js"; +import { danger } from "../../../../src/globals.js"; +import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts"; +import { KeyedAsyncQueue } from "../../../../src/plugin-sdk/keyed-async-queue.js"; import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js"; import type { RuntimeEnv } from "./message-handler.preflight.types.js"; import { processDiscordMessage } from "./message-handler.process.js"; diff --git a/src/discord/monitor/listeners.test.ts b/extensions/discord/src/monitor/listeners.test.ts similarity index 100% rename from src/discord/monitor/listeners.test.ts rename to extensions/discord/src/monitor/listeners.test.ts diff --git a/src/discord/monitor/listeners.ts b/extensions/discord/src/monitor/listeners.ts similarity index 96% rename from src/discord/monitor/listeners.ts rename to extensions/discord/src/monitor/listeners.ts index ea6f7b3c628..b0dd33543b0 100644 --- a/src/discord/monitor/listeners.ts +++ b/extensions/discord/src/monitor/listeners.ts @@ -8,16 +8,16 @@ import { ThreadUpdateListener, type User, } from "@buape/carbon"; -import type { OpenClawConfig } from "../../config/config.js"; -import { danger, logVerbose } from "../../globals.js"; -import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { danger, logVerbose } from "../../../../src/globals.js"; +import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts"; +import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; +import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, -} from "../../security/dm-policy-shared.js"; +} from "../../../../src/security/dm-policy-shared.js"; import { isDiscordGroupAllowedByPolicy, normalizeDiscordAllowList, @@ -36,9 +36,11 @@ import { isThreadArchived } from "./thread-bindings.discord-api.js"; import { closeDiscordThreadSessions } from "./thread-session-close.js"; import { normalizeDiscordListenerTimeoutMs, runDiscordTaskWithTimeout } from "./timeouts.js"; -type LoadedConfig = ReturnType; -type RuntimeEnv = import("../../runtime.js").RuntimeEnv; -type Logger = ReturnType; +type LoadedConfig = ReturnType; +type RuntimeEnv = import("../../../../src/runtime.js").RuntimeEnv; +type Logger = ReturnType< + typeof import("../../../../src/logging/subsystem.js").createSubsystemLogger +>; export type DiscordMessageEvent = Parameters[0]; diff --git a/src/discord/monitor/message-handler.bot-self-filter.test.ts b/extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts similarity index 100% rename from src/discord/monitor/message-handler.bot-self-filter.test.ts rename to extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts diff --git a/src/discord/monitor/message-handler.inbound-contract.test.ts b/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts similarity index 91% rename from src/discord/monitor/message-handler.inbound-contract.test.ts rename to extensions/discord/src/monitor/message-handler.inbound-contract.test.ts index b6a3c8f85f1..97d18985460 100644 --- a/src/discord/monitor/message-handler.inbound-contract.test.ts +++ b/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { inboundCtxCapture as capture } from "../../../test/helpers/inbound-contract-dispatch-mock.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; +import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js"; +import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; import { processDiscordMessage } from "./message-handler.process.js"; import { diff --git a/src/discord/monitor/message-handler.module-test-helpers.ts b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts similarity index 85% rename from src/discord/monitor/message-handler.module-test-helpers.ts rename to extensions/discord/src/monitor/message-handler.module-test-helpers.ts index fce7580e912..83174ad5621 100644 --- a/src/discord/monitor/message-handler.module-test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import type { MockFn } from "../../test-utils/vitest-mock-fn.js"; +import type { MockFn } from "../../../../src/test-utils/vitest-mock-fn.js"; export const preflightDiscordMessageMock: MockFn = vi.fn(); export const processDiscordMessageMock: MockFn = vi.fn(); diff --git a/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts similarity index 90% rename from src/discord/monitor/message-handler.preflight.acp-bindings.test.ts rename to extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts index 984c9e4cb20..01bac15e856 100644 --- a/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts @@ -3,14 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn()); const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn()); -vi.mock("../../acp/persistent-bindings.js", () => ({ +vi.mock("../../../../src/acp/persistent-bindings.js", () => ({ ensureConfiguredAcpBindingSession: (...args: unknown[]) => ensureConfiguredAcpBindingSessionMock(...args), resolveConfiguredAcpBindingRecord: (...args: unknown[]) => resolveConfiguredAcpBindingRecordMock(...args), })); -import { __testing as sessionBindingTesting } from "../../infra/outbound/session-binding-service.js"; +import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; import { createDiscordMessage, @@ -70,7 +70,9 @@ function createBasePreflightParams(overrides?: Record) { cfg: DEFAULT_PREFLIGHT_CFG, discordConfig: { allowBots: true, - } as NonNullable["discord"], + } as NonNullable< + import("../../../../src/config/config.js").OpenClawConfig["channels"] + >["discord"], data: createGuildEvent({ channelId: CHANNEL_ID, guildId: GUILD_ID, @@ -82,7 +84,9 @@ function createBasePreflightParams(overrides?: Record) { }), discordConfig: { allowBots: true, - } as NonNullable["discord"], + } as NonNullable< + import("../../../../src/config/config.js").OpenClawConfig["channels"] + >["discord"], ...overrides, } satisfies Parameters[0]; } diff --git a/src/discord/monitor/message-handler.preflight.test-helpers.ts b/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts similarity index 95% rename from src/discord/monitor/message-handler.preflight.test-helpers.ts rename to extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts index 147483171b0..24895d287f7 100644 --- a/src/discord/monitor/message-handler.preflight.test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts @@ -1,5 +1,5 @@ import { ChannelType } from "@buape/carbon"; -import type { OpenClawConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; import type { preflightDiscordMessage } from "./message-handler.preflight.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; @@ -90,7 +90,7 @@ export function createDiscordPreflightArgs(params: { discordConfig: params.discordConfig, accountId: "default", token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, + runtime: {} as import("../../../../src/runtime.js").RuntimeEnv, botUserId: params.botUserId ?? "openclaw-bot", guildHistories: new Map(), historyLimit: 0, diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts similarity index 96% rename from src/discord/monitor/message-handler.preflight.test.ts rename to extensions/discord/src/monitor/message-handler.preflight.test.ts index e5ddfe158ef..a7a5ff2f6ef 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const transcribeFirstAudioMock = vi.hoisted(() => vi.fn()); -vi.mock("../../media-understanding/audio-preflight.js", () => ({ +vi.mock("../../../../src/media-understanding/audio-preflight.js", () => ({ transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args), })); import { __testing as sessionBindingTesting, registerSessionBindingAdapter, -} from "../../infra/outbound/session-binding-service.js"; +} from "../../../../src/infra/outbound/session-binding-service.js"; import { preflightDiscordMessage, resolvePreflightMentionRequirement, @@ -32,7 +32,7 @@ import { function createThreadBinding( overrides?: Partial< - import("../../infra/outbound/session-binding-service.js").SessionBindingRecord + import("../../../../src/infra/outbound/session-binding-service.js").SessionBindingRecord >, ) { return { @@ -54,11 +54,11 @@ function createThreadBinding( webhookToken: "tok-1", }, ...overrides, - } satisfies import("../../infra/outbound/session-binding-service.js").SessionBindingRecord; + } satisfies import("../../../../src/infra/outbound/session-binding-service.js").SessionBindingRecord; } function createPreflightArgs(params: { - cfg: import("../../config/config.js").OpenClawConfig; + cfg: import("../../../../src/config/config.js").OpenClawConfig; discordConfig: DiscordConfig; data: DiscordMessageEvent; client: DiscordClient; @@ -94,7 +94,7 @@ async function runThreadBoundPreflight(params: { threadId: string; parentId: string; message: import("@buape/carbon").Message; - threadBinding: import("../../infra/outbound/session-binding-service.js").SessionBindingRecord; + threadBinding: import("../../../../src/infra/outbound/session-binding-service.js").SessionBindingRecord; discordConfig: DiscordConfig; registerBindingAdapter?: boolean; }) { @@ -136,7 +136,7 @@ async function runGuildPreflight(params: { guildId: string; message: import("@buape/carbon").Message; discordConfig: DiscordConfig; - cfg?: import("../../config/config.js").OpenClawConfig; + cfg?: import("../../../../src/config/config.js").OpenClawConfig; guildEntries?: Parameters[0]["guildEntries"]; includeGuildObject?: boolean; }) { @@ -318,7 +318,7 @@ describe("preflightDiscordMessage", () => { createPreflightArgs({ cfg: { ...DEFAULT_PREFLIGHT_CFG, - } as import("../../config/config.js").OpenClawConfig, + } as import("../../../../src/config/config.js").OpenClawConfig, discordConfig: { allowBots: true, } as DiscordConfig, @@ -577,7 +577,7 @@ describe("preflightDiscordMessage", () => { mentionPatterns: ["openclaw"], }, }, - } as import("../../config/config.js").OpenClawConfig, + } as import("../../../../src/config/config.js").OpenClawConfig, discordConfig: {} as DiscordConfig, data: createGuildEvent({ channelId, diff --git a/src/discord/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts similarity index 95% rename from src/discord/monitor/message-handler.preflight.ts rename to extensions/discord/src/monitor/message-handler.preflight.ts index 65bf6d85c46..d88b0cd03ec 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -2,34 +2,34 @@ import { ChannelType, MessageType, type User } from "@buape/carbon"; import { ensureConfiguredAcpRouteReady, resolveConfiguredAcpRoute, -} from "../../acp/persistent-bindings.route.js"; -import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js"; +} from "../../../../src/acp/persistent-bindings.route.js"; +import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js"; +import { shouldHandleTextCommands } from "../../../../src/auto-reply/commands-registry.js"; import { recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "../../auto-reply/reply/history.js"; +} from "../../../../src/auto-reply/reply/history.js"; import { buildMentionRegexes, matchesMentionWithExplicit, -} from "../../auto-reply/reply/mentions.js"; -import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; -import { resolveControlCommandGate } from "../../channels/command-gating.js"; -import { logInboundDrop } from "../../channels/logging.js"; -import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js"; -import { loadConfig } from "../../config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; -import { logVerbose, shouldLogVerbose } from "../../globals.js"; -import { recordChannelActivity } from "../../infra/channel-activity.js"; +} from "../../../../src/auto-reply/reply/mentions.js"; +import { formatAllowlistMatchMeta } from "../../../../src/channels/allowlist-match.js"; +import { resolveControlCommandGate } from "../../../../src/channels/command-gating.js"; +import { logInboundDrop } from "../../../../src/channels/logging.js"; +import { resolveMentionGatingWithBypass } from "../../../../src/channels/mention-gating.js"; +import { loadConfig } from "../../../../src/config/config.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; +import { recordChannelActivity } from "../../../../src/infra/channel-activity.js"; import { getSessionBindingService, type SessionBindingRecord, -} from "../../infra/outbound/session-binding-service.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { logDebug } from "../../logger.js"; -import { getChildLogger } from "../../logging.js"; -import { buildPairingReply } from "../../pairing/pairing-messages.js"; -import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +} from "../../../../src/infra/outbound/session-binding-service.js"; +import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; +import { logDebug } from "../../../../src/logger.js"; +import { getChildLogger } from "../../../../src/logging.js"; +import { buildPairingReply } from "../../../../src/pairing/pairing-messages.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../../src/routing/session-key.js"; import { fetchPluralKitMessageInfo } from "../pluralkit.js"; import { sendMessageDiscord } from "../send.js"; import { diff --git a/src/discord/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts similarity index 83% rename from src/discord/monitor/message-handler.preflight.types.ts rename to extensions/discord/src/monitor/message-handler.preflight.types.ts index 015a695229a..a123a22dcaa 100644 --- a/src/discord/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -1,8 +1,8 @@ import type { ChannelType, Client, User } from "@buape/carbon"; -import type { HistoryEntry } from "../../auto-reply/reply/history.js"; -import type { ReplyToMode } from "../../config/config.js"; -import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; -import type { resolveAgentRoute } from "../../routing/resolve-route.js"; +import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js"; +import type { ReplyToMode } from "../../../../src/config/config.js"; +import type { SessionBindingRecord } from "../../../../src/infra/outbound/session-binding-service.js"; +import type { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js"; import type { DiscordChannelInfo } from "./message-utils.js"; import type { DiscordThreadBindingLookup } from "./reply-delivery.js"; @@ -11,15 +11,15 @@ import type { DiscordSenderIdentity } from "./sender-identity.js"; export type { DiscordSenderIdentity } from "./sender-identity.js"; import type { DiscordThreadChannel } from "./threading.js"; -export type LoadedConfig = ReturnType; -export type RuntimeEnv = import("../../runtime.js").RuntimeEnv; +export type LoadedConfig = ReturnType; +export type RuntimeEnv = import("../../../../src/runtime.js").RuntimeEnv; export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; type DiscordMessagePreflightSharedFields = { cfg: LoadedConfig; discordConfig: NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] + import("../../../../src/config/config.js").OpenClawConfig["channels"] >["discord"]; accountId: string; token: string; diff --git a/src/discord/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts similarity index 98% rename from src/discord/monitor/message-handler.process.test.ts rename to extensions/discord/src/monitor/message-handler.process.test.ts index 96c9a65df9c..fc04211a38f 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_EMOJIS } from "../../channels/status-reactions.js"; +import { DEFAULT_EMOJIS } from "../../../../src/channels/status-reactions.js"; import { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides, @@ -84,11 +84,11 @@ vi.mock("./reply-delivery.js", () => ({ deliverDiscordReply: deliveryMocks.deliverDiscordReply, })); -vi.mock("../../auto-reply/dispatch.js", () => ({ +vi.mock("../../../../src/auto-reply/dispatch.js", () => ({ dispatchInboundMessage, })); -vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ +vi.mock("../../../../src/auto-reply/reply/reply-dispatcher.js", () => ({ createReplyDispatcherWithTyping: vi.fn( (opts: { deliver: (payload: unknown, info: { kind: string }) => Promise | void }) => ({ dispatcher: { @@ -112,11 +112,11 @@ vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ ), })); -vi.mock("../../channels/session.js", () => ({ +vi.mock("../../../../src/channels/session.js", () => ({ recordInboundSession, })); -vi.mock("../../config/sessions.js", () => ({ +vi.mock("../../../../src/config/sessions.js", () => ({ readSessionUpdatedAt: configSessionsMocks.readSessionUpdatedAt, resolveStorePath: configSessionsMocks.resolveStorePath, })); diff --git a/src/discord/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts similarity index 92% rename from src/discord/monitor/message-handler.process.ts rename to extensions/discord/src/monitor/message-handler.process.ts index 36978628b7a..dc86c3720ef 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -1,37 +1,40 @@ import { ChannelType, type RequestClient } from "@buape/carbon"; -import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js"; -import { EmbeddedBlockChunker } from "../../agents/pi-embedded-block-chunker.js"; -import { resolveChunkMode } from "../../auto-reply/chunk.js"; -import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; -import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; +import { resolveAckReaction, resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; +import { EmbeddedBlockChunker } from "../../../../src/agents/pi-embedded-block-chunker.js"; +import { resolveChunkMode } from "../../../../src/auto-reply/chunk.js"; +import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../../../src/auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, -} from "../../auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import { shouldAckReaction as shouldAckReactionGate } from "../../channels/ack-reactions.js"; -import { logTypingFailure, logAckFailure } from "../../channels/logging.js"; -import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; -import { recordInboundSession } from "../../channels/session.js"; +} from "../../../../src/auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; +import { createReplyDispatcherWithTyping } from "../../../../src/auto-reply/reply/reply-dispatcher.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import { shouldAckReaction as shouldAckReactionGate } from "../../../../src/channels/ack-reactions.js"; +import { logTypingFailure, logAckFailure } from "../../../../src/channels/logging.js"; +import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; +import { recordInboundSession } from "../../../../src/channels/session.js"; import { createStatusReactionController, DEFAULT_TIMING, type StatusReactionAdapter, -} from "../../channels/status-reactions.js"; -import { createTypingCallbacks } from "../../channels/typing.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; -import { resolveDiscordPreviewStreamMode } from "../../config/discord-preview-streaming.js"; -import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; -import { convertMarkdownTables } from "../../markdown/tables.js"; -import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; -import { buildAgentSessionKey } from "../../routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../../routing/session-key.js"; -import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; -import { truncateUtf16Safe } from "../../utils.js"; +} from "../../../../src/channels/status-reactions.js"; +import { createTypingCallbacks } from "../../../../src/channels/typing.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +import { resolveDiscordPreviewStreamMode } from "../../../../src/config/discord-preview-streaming.js"; +import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; +import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; +import { convertMarkdownTables } from "../../../../src/markdown/tables.js"; +import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js"; +import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js"; +import { resolveThreadSessionKeys } from "../../../../src/routing/session-key.js"; +import { stripReasoningTagsFromText } from "../../../../src/shared/text/reasoning-tags.js"; +import { truncateUtf16Safe } from "../../../../src/utils.js"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; diff --git a/src/discord/monitor/message-handler.queue.test.ts b/extensions/discord/src/monitor/message-handler.queue.test.ts similarity index 100% rename from src/discord/monitor/message-handler.queue.test.ts rename to extensions/discord/src/monitor/message-handler.queue.test.ts diff --git a/src/discord/monitor/message-handler.test-harness.ts b/extensions/discord/src/monitor/message-handler.test-harness.ts similarity index 100% rename from src/discord/monitor/message-handler.test-harness.ts rename to extensions/discord/src/monitor/message-handler.test-harness.ts diff --git a/src/discord/monitor/message-handler.test-helpers.ts b/extensions/discord/src/monitor/message-handler.test-helpers.ts similarity index 96% rename from src/discord/monitor/message-handler.test-helpers.ts rename to extensions/discord/src/monitor/message-handler.test-helpers.ts index 6084fc1a00e..04bfb9b603c 100644 --- a/src/discord/monitor/message-handler.test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.test-helpers.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.js"; +import type { OpenClawConfig } from "../../../../src/config/types.js"; import type { createDiscordMessageHandler } from "./message-handler.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; diff --git a/src/discord/monitor/message-handler.ts b/extensions/discord/src/monitor/message-handler.ts similarity index 96% rename from src/discord/monitor/message-handler.ts rename to extensions/discord/src/monitor/message-handler.ts index 02a65041983..2c9745a8bf0 100644 --- a/src/discord/monitor/message-handler.ts +++ b/extensions/discord/src/monitor/message-handler.ts @@ -2,9 +2,9 @@ import type { Client } from "@buape/carbon"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "../../channels/inbound-debounce-policy.js"; -import { resolveOpenProviderRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; -import { danger } from "../../globals.js"; +} from "../../../../src/channels/inbound-debounce-policy.js"; +import { resolveOpenProviderRuntimeGroupPolicy } from "../../../../src/config/runtime-group-policy.js"; +import { danger } from "../../../../src/globals.js"; import { buildDiscordInboundJob } from "./inbound-job.js"; import { createDiscordInboundWorker } from "./inbound-worker.js"; import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js"; diff --git a/src/discord/monitor/message-utils.test.ts b/extensions/discord/src/monitor/message-utils.test.ts similarity index 99% rename from src/discord/monitor/message-utils.test.ts rename to extensions/discord/src/monitor/message-utils.test.ts index acb9708ae21..0a29fc5b0ab 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/extensions/discord/src/monitor/message-utils.test.ts @@ -5,15 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const fetchRemoteMedia = vi.fn(); const saveMediaBuffer = vi.fn(); -vi.mock("../../media/fetch.js", () => ({ +vi.mock("../../../../src/media/fetch.js", () => ({ fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), })); -vi.mock("../../media/store.js", () => ({ +vi.mock("../../../../src/media/store.js", () => ({ saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), })); -vi.mock("../../globals.js", () => ({ +vi.mock("../../../../src/globals.js", () => ({ logVerbose: () => {}, })); diff --git a/src/discord/monitor/message-utils.ts b/extensions/discord/src/monitor/message-utils.ts similarity index 98% rename from src/discord/monitor/message-utils.ts rename to extensions/discord/src/monitor/message-utils.ts index b26f8d68eee..ae37d6615fd 100644 --- a/src/discord/monitor/message-utils.ts +++ b/extensions/discord/src/monitor/message-utils.ts @@ -1,10 +1,10 @@ import type { ChannelType, Client, Message } from "@buape/carbon"; import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; -import { buildMediaPayload } from "../../channels/plugins/media-payload.js"; -import { logVerbose } from "../../globals.js"; -import type { SsrFPolicy } from "../../infra/net/ssrf.js"; -import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js"; -import { saveMediaBuffer } from "../../media/store.js"; +import { buildMediaPayload } from "../../../../src/channels/plugins/media-payload.js"; +import { logVerbose } from "../../../../src/globals.js"; +import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js"; +import { fetchRemoteMedia, type FetchLike } from "../../../../src/media/fetch.js"; +import { saveMediaBuffer } from "../../../../src/media/store.js"; const DISCORD_CDN_HOSTNAMES = [ "cdn.discordapp.com", diff --git a/src/discord/monitor/model-picker-preferences.test.ts b/extensions/discord/src/monitor/model-picker-preferences.test.ts similarity index 100% rename from src/discord/monitor/model-picker-preferences.test.ts rename to extensions/discord/src/monitor/model-picker-preferences.test.ts diff --git a/src/discord/monitor/model-picker-preferences.ts b/extensions/discord/src/monitor/model-picker-preferences.ts similarity index 91% rename from src/discord/monitor/model-picker-preferences.ts rename to extensions/discord/src/monitor/model-picker-preferences.ts index 2702e8db253..e75ce013403 100644 --- a/src/discord/monitor/model-picker-preferences.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.ts @@ -1,11 +1,14 @@ import os from "node:os"; import path from "node:path"; -import { normalizeProviderId } from "../../agents/model-selection.js"; -import { resolveStateDir } from "../../config/paths.js"; -import { withFileLock } from "../../infra/file-lock.js"; -import { resolveRequiredHomeDir } from "../../infra/home-dir.js"; -import { readJsonFileWithFallback, writeJsonFileAtomically } from "../../plugin-sdk/json-store.js"; -import { normalizeAccountId as normalizeSharedAccountId } from "../../routing/account-id.js"; +import { normalizeProviderId } from "../../../../src/agents/model-selection.js"; +import { resolveStateDir } from "../../../../src/config/paths.js"; +import { withFileLock } from "../../../../src/infra/file-lock.js"; +import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js"; +import { + readJsonFileWithFallback, + writeJsonFileAtomically, +} from "../../../../src/plugin-sdk/json-store.js"; +import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js"; const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = { retries: { diff --git a/src/discord/monitor/model-picker.test-utils.ts b/extensions/discord/src/monitor/model-picker.test-utils.ts similarity index 88% rename from src/discord/monitor/model-picker.test-utils.ts rename to extensions/discord/src/monitor/model-picker.test-utils.ts index 04e9eac3824..8d9a9dd3197 100644 --- a/src/discord/monitor/model-picker.test-utils.ts +++ b/extensions/discord/src/monitor/model-picker.test-utils.ts @@ -1,4 +1,4 @@ -import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js"; +import type { ModelsProviderData } from "../../../../src/auto-reply/reply/commands-models.js"; export function createModelsProviderData( entries: Record, diff --git a/src/discord/monitor/model-picker.test.ts b/extensions/discord/src/monitor/model-picker.test.ts similarity index 99% rename from src/discord/monitor/model-picker.test.ts rename to extensions/discord/src/monitor/model-picker.test.ts index 834fc4ff124..99b5d8cb244 100644 --- a/src/discord/monitor/model-picker.test.ts +++ b/extensions/discord/src/monitor/model-picker.test.ts @@ -1,8 +1,8 @@ import { serializePayload } from "@buape/carbon"; import { ComponentType } from "discord-api-types/v10"; import { describe, expect, it, vi } from "vitest"; -import * as modelsCommandModule from "../../auto-reply/reply/commands-models.js"; -import type { OpenClawConfig } from "../../config/config.js"; +import * as modelsCommandModule from "../../../../src/auto-reply/reply/commands-models.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; import { DISCORD_CUSTOM_ID_MAX_CHARS, DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE, diff --git a/src/discord/monitor/model-picker.ts b/extensions/discord/src/monitor/model-picker.ts similarity index 99% rename from src/discord/monitor/model-picker.ts rename to extensions/discord/src/monitor/model-picker.ts index 7d552d38650..fb9226ac899 100644 --- a/src/discord/monitor/model-picker.ts +++ b/extensions/discord/src/monitor/model-picker.ts @@ -11,12 +11,12 @@ import { } from "@buape/carbon"; import type { APISelectMenuOption } from "discord-api-types/v10"; import { ButtonStyle } from "discord-api-types/v10"; -import { normalizeProviderId } from "../../agents/model-selection.js"; +import { normalizeProviderId } from "../../../../src/agents/model-selection.js"; import { buildModelsProviderData, type ModelsProviderData, -} from "../../auto-reply/reply/commands-models.js"; -import type { OpenClawConfig } from "../../config/config.js"; +} from "../../../../src/auto-reply/reply/commands-models.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk"; export const DISCORD_CUSTOM_ID_MAX_CHARS = 100; diff --git a/src/discord/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts similarity index 97% rename from src/discord/monitor/monitor.test.ts rename to extensions/discord/src/monitor/monitor.test.ts index 8a7f2dafbb0..b4d5478f921 100644 --- a/src/discord/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -7,9 +7,9 @@ import type { import type { Client } from "@buape/carbon"; import type { GatewayPresenceUpdate } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { DiscordAccountConfig } from "../../config/types.discord.js"; -import { buildAgentSessionKey } from "../../routing/resolve-route.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js"; +import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js"; import { clearDiscordComponentEntries, registerDiscordComponentEntries, @@ -54,20 +54,20 @@ const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn()); const resolveStorePathMock = vi.hoisted(() => vi.fn()); let lastDispatchCtx: Record | undefined; -vi.mock("../../pairing/pairing-store.js", () => ({ +vi.mock("../../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), })); -vi.mock("../../infra/system-events.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), }; }); -vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({ +vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args), })); @@ -75,12 +75,12 @@ vi.mock("./reply-delivery.js", () => ({ deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args), })); -vi.mock("../../channels/session.js", () => ({ +vi.mock("../../../../src/channels/session.js", () => ({ recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), })); -vi.mock("../../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args), diff --git a/src/discord/monitor/native-command-context.test.ts b/extensions/discord/src/monitor/native-command-context.test.ts similarity index 100% rename from src/discord/monitor/native-command-context.test.ts rename to extensions/discord/src/monitor/native-command-context.test.ts diff --git a/src/discord/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts similarity index 94% rename from src/discord/monitor/native-command-context.ts rename to extensions/discord/src/monitor/native-command-context.ts index 1d798906571..fc650827d45 100644 --- a/src/discord/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -1,5 +1,5 @@ -import type { CommandArgs } from "../../auto-reply/commands-registry.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; +import type { CommandArgs } from "../../../../src/auto-reply/commands-registry.js"; +import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js"; import { buildDiscordInboundAccessContext } from "./inbound-context.js"; diff --git a/src/discord/monitor/native-command.commands-allowfrom.test.ts b/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts similarity index 93% rename from src/discord/monitor/native-command.commands-allowfrom.test.ts rename to extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts index 5144eb74267..92efa3eaecd 100644 --- a/src/discord/monitor/native-command.commands-allowfrom.test.ts +++ b/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts @@ -1,10 +1,10 @@ import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; -import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { DiscordAccountConfig } from "../../config/types.discord.js"; -import * as pluginCommandsModule from "../../plugins/commands.js"; +import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js"; +import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js"; +import * as pluginCommandsModule from "../../../../src/plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; import { createMockCommandInteraction, diff --git a/src/discord/monitor/native-command.model-picker.test.ts b/extensions/discord/src/monitor/native-command.model-picker.test.ts similarity index 96% rename from src/discord/monitor/native-command.model-picker.test.ts rename to extensions/discord/src/monitor/native-command.model-picker.test.ts index 22d9fd94730..0faba40c2d3 100644 --- a/src/discord/monitor/native-command.model-picker.test.ts +++ b/extensions/discord/src/monitor/native-command.model-picker.test.ts @@ -1,15 +1,15 @@ import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as commandRegistryModule from "../../auto-reply/commands-registry.js"; +import * as commandRegistryModule from "../../../../src/auto-reply/commands-registry.js"; import type { ChatCommandDefinition, CommandArgsParsing, -} from "../../auto-reply/commands-registry.types.js"; -import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js"; -import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import * as globalsModule from "../../globals.js"; -import * as timeoutModule from "../../utils/with-timeout.js"; +} from "../../../../src/auto-reply/commands-registry.types.js"; +import type { ModelsProviderData } from "../../../../src/auto-reply/reply/commands-models.js"; +import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import * as globalsModule from "../../../../src/globals.js"; +import * as timeoutModule from "../../../../src/utils/with-timeout.js"; import * as modelPickerPreferencesModule from "./model-picker-preferences.js"; import * as modelPickerModule from "./model-picker.js"; import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js"; diff --git a/src/discord/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts similarity index 93% rename from src/discord/monitor/native-command.options.test.ts rename to extensions/discord/src/monitor/native-command.options.test.ts index 808f9cf001b..f287b085704 100644 --- a/src/discord/monitor/native-command.options.test.ts +++ b/extensions/discord/src/monitor/native-command.options.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { listNativeCommandSpecs } from "../../auto-reply/commands-registry.js"; -import type { OpenClawConfig, loadConfig } from "../../config/config.js"; +import { listNativeCommandSpecs } from "../../../../src/auto-reply/commands-registry.js"; +import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js"; import { createDiscordNativeCommand } from "./native-command.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts similarity index 93% rename from src/discord/monitor/native-command.plugin-dispatch.test.ts rename to extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index c35dbceb466..4ac49c92119 100644 --- a/src/discord/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -1,9 +1,9 @@ import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; -import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import * as pluginCommandsModule from "../../plugins/commands.js"; +import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js"; +import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import * as pluginCommandsModule from "../../../../src/plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; import { createMockCommandInteraction, @@ -12,9 +12,9 @@ import { import { createNoopThreadBindingManager } from "./thread-bindings.js"; type ResolveConfiguredAcpBindingRecordFn = - typeof import("../../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord; + typeof import("../../../../src/acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord; type EnsureConfiguredAcpBindingSessionFn = - typeof import("../../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession; + typeof import("../../../../src/acp/persistent-bindings.js").ensureConfiguredAcpBindingSession; const persistentBindingMocks = vi.hoisted(() => ({ resolveConfiguredAcpBindingRecord: vi.fn(() => null), @@ -24,8 +24,9 @@ const persistentBindingMocks = vi.hoisted(() => ({ })), })); -vi.mock("../../acp/persistent-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/acp/persistent-bindings.js", async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord, diff --git a/src/discord/monitor/native-command.test-helpers.ts b/extensions/discord/src/monitor/native-command.test-helpers.ts similarity index 100% rename from src/discord/monitor/native-command.test-helpers.ts rename to extensions/discord/src/monitor/native-command.test-helpers.ts diff --git a/src/discord/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts similarity index 96% rename from src/discord/monitor/native-command.ts rename to extensions/discord/src/monitor/native-command.ts index 51f3e3e6973..bc038927d9c 100644 --- a/src/discord/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -17,17 +17,17 @@ import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10 import { ensureConfiguredAcpRouteReady, resolveConfiguredAcpRoute, -} from "../../acp/persistent-bindings.route.js"; -import { resolveHumanDelayConfig } from "../../agents/identity.js"; -import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import { resolveCommandAuthorization } from "../../auto-reply/command-auth.js"; +} from "../../../../src/acp/persistent-bindings.route.js"; +import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; +import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; +import { resolveCommandAuthorization } from "../../../../src/auto-reply/command-auth.js"; import type { ChatCommandDefinition, CommandArgDefinition, CommandArgValues, CommandArgs, NativeCommandSpec, -} from "../../auto-reply/commands-registry.js"; +} from "../../../../src/auto-reply/commands-registry.js"; import { buildCommandTextFromArgs, findCommandByNativeName, @@ -36,26 +36,26 @@ import { resolveCommandArgChoices, resolveCommandArgMenu, serializeCommandArgs, -} from "../../auto-reply/commands-registry.js"; -import { resolveStoredModelOverride } from "../../auto-reply/reply/model-selection.js"; -import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; -import { resolveNativeCommandSessionTargets } from "../../channels/native-command-session-targets.js"; -import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; -import type { OpenClawConfig, loadConfig } from "../../config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; -import { resolveOpenProviderRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; -import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; -import { logVerbose } from "../../globals.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; -import { buildPairingReply } from "../../pairing/pairing-messages.js"; -import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js"; -import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; -import { chunkItems } from "../../utils/chunk-items.js"; -import { withTimeout } from "../../utils/with-timeout.js"; -import { loadWebMedia } from "../../web/media.js"; +} from "../../../../src/auto-reply/commands-registry.js"; +import { resolveStoredModelOverride } from "../../../../src/auto-reply/reply/model-selection.js"; +import { dispatchReplyWithDispatcher } from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; +import { resolveNativeCommandSessionTargets } from "../../../../src/channels/native-command-session-targets.js"; +import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; +import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +import { resolveOpenProviderRuntimeGroupPolicy } from "../../../../src/config/runtime-group-policy.js"; +import { loadSessionStore, resolveStorePath } from "../../../../src/config/sessions.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; +import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js"; +import { buildPairingReply } from "../../../../src/pairing/pairing-messages.js"; +import { executePluginCommand, matchPluginCommand } from "../../../../src/plugins/commands.js"; +import type { ResolvedAgentRoute } from "../../../../src/routing/resolve-route.js"; +import { chunkItems } from "../../../../src/utils/chunk-items.js"; +import { withTimeout } from "../../../../src/utils/with-timeout.js"; +import { loadWebMedia } from "../../../whatsapp/src/media.js"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { diff --git a/src/discord/monitor/preflight-audio.ts b/extensions/discord/src/monitor/preflight-audio.ts similarity index 90% rename from src/discord/monitor/preflight-audio.ts rename to extensions/discord/src/monitor/preflight-audio.ts index 307abcc6b43..f52e2b0df93 100644 --- a/src/discord/monitor/preflight-audio.ts +++ b/extensions/discord/src/monitor/preflight-audio.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import { logVerbose } from "../../globals.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { logVerbose } from "../../../../src/globals.js"; type DiscordAudioAttachment = { content_type?: string; @@ -50,7 +50,8 @@ export async function resolveDiscordPreflightAudioMentionContext(params: { }; } try { - const { transcribeFirstAudio } = await import("../../media-understanding/audio-preflight.js"); + const { transcribeFirstAudio } = + await import("../../../../src/media-understanding/audio-preflight.js"); if (params.abortSignal?.aborted) { return { hasAudioAttachment, diff --git a/src/discord/monitor/presence-cache.ts b/extensions/discord/src/monitor/presence-cache.ts similarity index 100% rename from src/discord/monitor/presence-cache.ts rename to extensions/discord/src/monitor/presence-cache.ts diff --git a/src/discord/monitor/presence.test.ts b/extensions/discord/src/monitor/presence.test.ts similarity index 100% rename from src/discord/monitor/presence.test.ts rename to extensions/discord/src/monitor/presence.test.ts diff --git a/src/discord/monitor/presence.ts b/extensions/discord/src/monitor/presence.ts similarity index 95% rename from src/discord/monitor/presence.ts rename to extensions/discord/src/monitor/presence.ts index ed52ea7b014..b13a21dc2f1 100644 --- a/src/discord/monitor/presence.ts +++ b/extensions/discord/src/monitor/presence.ts @@ -1,5 +1,5 @@ import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; -import type { DiscordAccountConfig } from "../../config/config.js"; +import type { DiscordAccountConfig } from "../../../../src/config/config.js"; const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4; const CUSTOM_STATUS_NAME = "Custom Status"; diff --git a/src/discord/monitor/provider.allowlist.test.ts b/extensions/discord/src/monitor/provider.allowlist.test.ts similarity index 98% rename from src/discord/monitor/provider.allowlist.test.ts rename to extensions/discord/src/monitor/provider.allowlist.test.ts index 417cb5e4563..0d34b65c1f7 100644 --- a/src/discord/monitor/provider.allowlist.test.ts +++ b/extensions/discord/src/monitor/provider.allowlist.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; const { resolveDiscordChannelAllowlistMock, resolveDiscordUserAllowlistMock } = vi.hoisted(() => ({ resolveDiscordChannelAllowlistMock: vi.fn( diff --git a/src/discord/monitor/provider.allowlist.ts b/extensions/discord/src/monitor/provider.allowlist.ts similarity index 96% rename from src/discord/monitor/provider.allowlist.ts rename to extensions/discord/src/monitor/provider.allowlist.ts index e1f52c0c3f5..3f108e443ea 100644 --- a/src/discord/monitor/provider.allowlist.ts +++ b/extensions/discord/src/monitor/provider.allowlist.ts @@ -4,11 +4,11 @@ import { canonicalizeAllowlistWithResolvedIds, patchAllowlistUsersInConfigEntries, summarizeMapping, -} from "../../channels/allowlists/resolve-utils.js"; -import type { DiscordGuildEntry } from "../../config/types.discord.js"; -import { formatErrorMessage } from "../../infra/errors.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import { normalizeStringEntries } from "../../shared/string-normalization.js"; +} from "../../../../src/channels/allowlists/resolve-utils.js"; +import type { DiscordGuildEntry } from "../../../../src/config/types.discord.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { normalizeStringEntries } from "../../../../src/shared/string-normalization.js"; import { resolveDiscordChannelAllowlist } from "../resolve-channels.js"; import { resolveDiscordUserAllowlist } from "../resolve-users.js"; diff --git a/src/discord/monitor/provider.group-policy.test.ts b/extensions/discord/src/monitor/provider.group-policy.test.ts similarity index 93% rename from src/discord/monitor/provider.group-policy.test.ts rename to extensions/discord/src/monitor/provider.group-policy.test.ts index 9fe01fd0a31..995c6f66e31 100644 --- a/src/discord/monitor/provider.group-policy.test.ts +++ b/extensions/discord/src/monitor/provider.group-policy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; import { __testing } from "./provider.js"; describe("resolveDiscordRuntimeGroupPolicy", () => { diff --git a/src/discord/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts similarity index 99% rename from src/discord/monitor/provider.lifecycle.test.ts rename to extensions/discord/src/monitor/provider.lifecycle.test.ts index 0209cf350f9..f03dce881c2 100644 --- a/src/discord/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import type { Client } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js"; const { diff --git a/src/discord/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts similarity index 97% rename from src/discord/monitor/provider.lifecycle.ts rename to extensions/discord/src/monitor/provider.lifecycle.ts index ffc78b40676..4d2130c3a5d 100644 --- a/src/discord/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -1,9 +1,9 @@ import type { Client } from "@buape/carbon"; import type { GatewayPlugin } from "@buape/carbon/gateway"; -import { createArmableStallWatchdog } from "../../channels/transport/stall-watchdog.js"; -import { createConnectedChannelStatusPatch } from "../../gateway/channel-status-patches.js"; -import { danger } from "../../globals.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { createArmableStallWatchdog } from "../../../../src/channels/transport/stall-watchdog.js"; +import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; +import { danger } from "../../../../src/globals.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import { attachDiscordGatewayLogging } from "../gateway-logging.js"; import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js"; import type { DiscordVoiceManager } from "../voice/manager.js"; diff --git a/src/discord/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts similarity index 100% rename from src/discord/monitor/provider.proxy.test.ts rename to extensions/discord/src/monitor/provider.proxy.test.ts diff --git a/src/discord/monitor/provider.rest-proxy.test.ts b/extensions/discord/src/monitor/provider.rest-proxy.test.ts similarity index 100% rename from src/discord/monitor/provider.rest-proxy.test.ts rename to extensions/discord/src/monitor/provider.rest-proxy.test.ts diff --git a/src/discord/monitor/provider.skill-dedupe.test.ts b/extensions/discord/src/monitor/provider.skill-dedupe.test.ts similarity index 100% rename from src/discord/monitor/provider.skill-dedupe.test.ts rename to extensions/discord/src/monitor/provider.skill-dedupe.test.ts diff --git a/src/discord/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts similarity index 93% rename from src/discord/monitor/provider.test.ts rename to extensions/discord/src/monitor/provider.test.ts index bdbb62f7eb2..10d310b9a20 100644 --- a/src/discord/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -1,8 +1,8 @@ import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AcpRuntimeError } from "../../acp/runtime/errors.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { AcpRuntimeError } from "../../../../src/acp/runtime/errors.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; type NativeCommandSpecMock = { name: string; @@ -139,11 +139,13 @@ vi.mock("@buape/carbon", () => { retryAfter: number; scope: string | null; bucket: string | null; - constructor(response: Response, body: { message: string; retry_after: number; code?: number }) { + constructor( + response: Response, + body: { message: string; retry_after: number; global: boolean }, + ) { super(body.message); - this.discordCode = body.code; this.retryAfter = body.retry_after; - this.scope = response.headers.get("X-RateLimit-Scope"); + this.scope = body.global ? "global" : response.headers.get("X-RateLimit-Scope"); this.bucket = response.headers.get("X-RateLimit-Bucket"); } } @@ -178,58 +180,58 @@ vi.mock("@buape/carbon/voice", () => ({ VoicePlugin: class VoicePlugin {}, })); -vi.mock("../../auto-reply/chunk.js", () => ({ +vi.mock("../../../../src/auto-reply/chunk.js", () => ({ resolveTextChunkLimit: () => 2000, })); -vi.mock("../../acp/control-plane/manager.js", () => ({ +vi.mock("../../../../src/acp/control-plane/manager.js", () => ({ getAcpSessionManager: () => ({ getSessionStatus: getAcpSessionStatusMock, }), })); -vi.mock("../../auto-reply/commands-registry.js", () => ({ +vi.mock("../../../../src/auto-reply/commands-registry.js", () => ({ listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, })); -vi.mock("../../auto-reply/skill-commands.js", () => ({ +vi.mock("../../../../src/auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents: listSkillCommandsForAgentsMock, })); -vi.mock("../../config/commands.js", () => ({ +vi.mock("../../../../src/config/commands.js", () => ({ isNativeCommandsExplicitlyDisabled: () => false, resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock, resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock, })); -vi.mock("../../config/config.js", () => ({ +vi.mock("../../../../src/config/config.js", () => ({ loadConfig: () => ({}), })); -vi.mock("../../globals.js", () => ({ +vi.mock("../../../../src/globals.js", () => ({ danger: (v: string) => v, logVerbose: vi.fn(), shouldLogVerbose: () => false, warn: (v: string) => v, })); -vi.mock("../../infra/errors.js", () => ({ +vi.mock("../../../../src/infra/errors.js", () => ({ formatErrorMessage: (err: unknown) => String(err), })); -vi.mock("../../infra/retry-policy.js", () => ({ +vi.mock("../../../../src/infra/retry-policy.js", () => ({ createDiscordRetryRunner: () => async (run: () => Promise) => run(), })); -vi.mock("../../logging/subsystem.js", () => ({ +vi.mock("../../../../src/logging/subsystem.js", () => ({ createSubsystemLogger: () => ({ info: vi.fn(), error: vi.fn() }), })); -vi.mock("../../plugins/commands.js", () => ({ +vi.mock("../../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: getPluginCommandSpecsMock, })); -vi.mock("../../runtime.js", () => ({ +vi.mock("../../../../src/runtime.js", () => ({ createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }), })); @@ -778,22 +780,22 @@ describe("monitorDiscordProvider", () => { const { RateLimitError } = await import("@buape/carbon"); const { monitorDiscordProvider } = await import("./provider.js"); const runtime = baseRuntime(); - clientHandleDeployRequestMock.mockRejectedValueOnce( - new RateLimitError( - new Response(null, { - status: 429, - headers: { - "X-RateLimit-Scope": "shared", - "X-RateLimit-Bucket": "bucket-1", - }, - }), - { - message: "Max number of daily application command creates has been reached (200)", - retry_after: 193.632, - code: 30034, + const rateLimitError = new RateLimitError( + new Response(null, { + status: 429, + headers: { + "X-RateLimit-Scope": "shared", + "X-RateLimit-Bucket": "bucket-1", }, - ), + }), + { + message: "Max number of daily application command creates has been reached (200)", + retry_after: 193.632, + global: false, + }, ); + rateLimitError.discordCode = 30034; + clientHandleDeployRequestMock.mockRejectedValueOnce(rateLimitError); await monitorDiscordProvider({ config: baseConfig(), @@ -804,7 +806,7 @@ describe("monitorDiscordProvider", () => { expect(clientFetchUserMock).toHaveBeenCalledWith("@me"); expect(monitorLifecycleMock).toHaveBeenCalledTimes(1); expect(runtime.log).toHaveBeenCalledWith( - expect.stringContaining("daily application command create limit reached"), + expect.stringContaining("native command deploy skipped"), ); }); diff --git a/src/discord/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts similarity index 96% rename from src/discord/monitor/provider.ts rename to extensions/discord/src/monitor/provider.ts index 1d2256e5d12..8fa3335fa3a 100644 --- a/src/discord/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -11,39 +11,39 @@ import { import { GatewayCloseCodes, type GatewayPlugin } from "@buape/carbon/gateway"; import { VoicePlugin } from "@buape/carbon/voice"; import { Routes } from "discord-api-types/v10"; -import { getAcpSessionManager } from "../../acp/control-plane/manager.js"; -import { isAcpRuntimeError } from "../../acp/runtime/errors.js"; -import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; -import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js"; -import type { HistoryEntry } from "../../auto-reply/reply/history.js"; -import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; +import { getAcpSessionManager } from "../../../../src/acp/control-plane/manager.js"; +import { isAcpRuntimeError } from "../../../../src/acp/runtime/errors.js"; +import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; +import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js"; +import { listNativeCommandSpecsForConfig } from "../../../../src/auto-reply/commands-registry.js"; +import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js"; +import { listSkillCommandsForAgents } from "../../../../src/auto-reply/skill-commands.js"; import { resolveThreadBindingIdleTimeoutMs, resolveThreadBindingMaxAgeMs, resolveThreadBindingsEnabled, -} from "../../channels/thread-bindings-policy.js"; +} from "../../../../src/channels/thread-bindings-policy.js"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, -} from "../../config/commands.js"; -import type { OpenClawConfig, ReplyToMode } from "../../config/config.js"; -import { loadConfig } from "../../config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; +} from "../../../../src/config/commands.js"; +import type { OpenClawConfig, ReplyToMode } from "../../../../src/config/config.js"; +import { loadConfig } from "../../../../src/config/config.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; import { GROUP_POLICY_BLOCKED_LABEL, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../../config/runtime-group-policy.js"; -import { createConnectedChannelStatusPatch } from "../../gateway/channel-status-patches.js"; -import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; -import { formatErrorMessage } from "../../infra/errors.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { getPluginCommandSpecs } from "../../plugins/commands.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; -import { summarizeStringEntries } from "../../shared/string-sample.js"; +} from "../../../../src/config/runtime-group-policy.js"; +import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; +import { danger, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; +import { getPluginCommandSpecs } from "../../../../src/plugins/commands.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; +import { summarizeStringEntries } from "../../../../src/shared/string-sample.js"; import { resolveDiscordAccount } from "../accounts.js"; import { getDiscordGatewayEmitter } from "../monitor.gateway.js"; import { fetchDiscordApplicationId } from "../probe.js"; diff --git a/src/discord/monitor/reply-context.ts b/extensions/discord/src/monitor/reply-context.ts similarity index 100% rename from src/discord/monitor/reply-context.ts rename to extensions/discord/src/monitor/reply-context.ts diff --git a/src/discord/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts similarity index 99% rename from src/discord/monitor/reply-delivery.test.ts rename to extensions/discord/src/monitor/reply-delivery.test.ts index 6f6b7fcaaaf..bd4d0e91dfd 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import { deliverDiscordReply } from "./reply-delivery.js"; import { __testing as threadBindingTesting, diff --git a/src/discord/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts similarity index 95% rename from src/discord/monitor/reply-delivery.ts rename to extensions/discord/src/monitor/reply-delivery.ts index d34381454e9..07e5c9e06c5 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -1,13 +1,13 @@ import type { RequestClient } from "@buape/carbon"; -import { resolveAgentAvatar } from "../../agents/identity-avatar.js"; -import type { ChunkMode } from "../../auto-reply/chunk.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { MarkdownTableMode, ReplyToMode } from "../../config/types.base.js"; -import { createDiscordRetryRunner, type RetryRunner } from "../../infra/retry-policy.js"; -import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../infra/retry.js"; -import { convertMarkdownTables } from "../../markdown/tables.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { resolveAgentAvatar } from "../../../../src/agents/identity-avatar.js"; +import type { ChunkMode } from "../../../../src/auto-reply/chunk.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { MarkdownTableMode, ReplyToMode } from "../../../../src/config/types.base.js"; +import { createDiscordRetryRunner, type RetryRunner } from "../../../../src/infra/retry-policy.js"; +import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../../../src/infra/retry.js"; +import { convertMarkdownTables } from "../../../../src/markdown/tables.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import { resolveDiscordAccount } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; diff --git a/src/discord/monitor/rest-fetch.ts b/extensions/discord/src/monitor/rest-fetch.ts similarity index 79% rename from src/discord/monitor/rest-fetch.ts rename to extensions/discord/src/monitor/rest-fetch.ts index 55cd5ff0a18..83be5a98325 100644 --- a/src/discord/monitor/rest-fetch.ts +++ b/extensions/discord/src/monitor/rest-fetch.ts @@ -1,7 +1,7 @@ import { ProxyAgent, fetch as undiciFetch } from "undici"; -import { danger } from "../../globals.js"; -import { wrapFetchWithAbortSignal } from "../../infra/fetch.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { danger } from "../../../../src/globals.js"; +import { wrapFetchWithAbortSignal } from "../../../../src/infra/fetch.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; export function resolveDiscordRestFetch( proxyUrl: string | undefined, diff --git a/src/discord/monitor/route-resolution.test.ts b/extensions/discord/src/monitor/route-resolution.test.ts similarity index 95% rename from src/discord/monitor/route-resolution.test.ts rename to extensions/discord/src/monitor/route-resolution.test.ts index 3518355165b..6fab967cde0 100644 --- a/src/discord/monitor/route-resolution.test.ts +++ b/extensions/discord/src/monitor/route-resolution.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { ResolvedAgentRoute } from "../../../../src/routing/resolve-route.js"; import { buildDiscordRoutePeer, resolveDiscordBoundConversationRoute, diff --git a/src/discord/monitor/route-resolution.ts b/extensions/discord/src/monitor/route-resolution.ts similarity index 93% rename from src/discord/monitor/route-resolution.ts rename to extensions/discord/src/monitor/route-resolution.ts index 2e65ff63919..aacbebbd51e 100644 --- a/src/discord/monitor/route-resolution.ts +++ b/extensions/discord/src/monitor/route-resolution.ts @@ -1,11 +1,11 @@ -import type { OpenClawConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; import { deriveLastRoutePolicy, resolveAgentRoute, type ResolvedAgentRoute, type RoutePeer, -} from "../../routing/resolve-route.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +} from "../../../../src/routing/resolve-route.js"; +import { resolveAgentIdFromSessionKey } from "../../../../src/routing/session-key.js"; export function buildDiscordRoutePeer(params: { isDirectMessage: boolean; diff --git a/src/discord/monitor/sender-identity.ts b/extensions/discord/src/monitor/sender-identity.ts similarity index 100% rename from src/discord/monitor/sender-identity.ts rename to extensions/discord/src/monitor/sender-identity.ts diff --git a/src/discord/monitor/status.ts b/extensions/discord/src/monitor/status.ts similarity index 100% rename from src/discord/monitor/status.ts rename to extensions/discord/src/monitor/status.ts diff --git a/src/discord/monitor/system-events.ts b/extensions/discord/src/monitor/system-events.ts similarity index 100% rename from src/discord/monitor/system-events.ts rename to extensions/discord/src/monitor/system-events.ts diff --git a/src/discord/monitor/thread-bindings.config.ts b/extensions/discord/src/monitor/thread-bindings.config.ts similarity index 85% rename from src/discord/monitor/thread-bindings.config.ts rename to extensions/discord/src/monitor/thread-bindings.config.ts index 364ac9900a2..830d54d0d1b 100644 --- a/src/discord/monitor/thread-bindings.config.ts +++ b/extensions/discord/src/monitor/thread-bindings.config.ts @@ -2,9 +2,9 @@ import { resolveThreadBindingIdleTimeoutMs, resolveThreadBindingMaxAgeMs, resolveThreadBindingsEnabled, -} from "../../channels/thread-bindings-policy.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { normalizeAccountId } from "../../routing/session-key.js"; +} from "../../../../src/channels/thread-bindings-policy.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { normalizeAccountId } from "../../../../src/routing/session-key.js"; export { resolveThreadBindingIdleTimeoutMs, diff --git a/src/discord/monitor/thread-bindings.discord-api.test.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts similarity index 98% rename from src/discord/monitor/thread-bindings.discord-api.test.ts rename to extensions/discord/src/monitor/thread-bindings.discord-api.test.ts index 5b455da9e5d..eb085235da7 100644 --- a/src/discord/monitor/thread-bindings.discord-api.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts @@ -1,6 +1,6 @@ import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; import type { ThreadBindingRecord } from "./thread-bindings.types.js"; const hoisted = vi.hoisted(() => { diff --git a/src/discord/monitor/thread-bindings.discord-api.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.ts similarity index 98% rename from src/discord/monitor/thread-bindings.discord-api.ts rename to extensions/discord/src/monitor/thread-bindings.discord-api.ts index 2a59075cf46..38360b27728 100644 --- a/src/discord/monitor/thread-bindings.discord-api.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.ts @@ -1,6 +1,6 @@ import { ChannelType, Routes } from "discord-api-types/v10"; -import type { OpenClawConfig } from "../../config/config.js"; -import { logVerbose } from "../../globals.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { logVerbose } from "../../../../src/globals.js"; import { createDiscordRestClient } from "../client.js"; import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; import { createThreadDiscord } from "../send.messages.js"; diff --git a/src/discord/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts similarity index 99% rename from src/discord/monitor/thread-bindings.lifecycle.test.ts rename to extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index 6d37dcc1c2a..013952e7c71 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -6,7 +6,7 @@ import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, type OpenClawConfig, -} from "../../config/config.js"; +} from "../../../../src/config/config.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); @@ -52,9 +52,14 @@ vi.mock("../send.messages.js", () => ({ createThreadDiscord: hoisted.createThreadDiscord, })); -vi.mock("../../acp/runtime/session-meta.js", () => ({ - readAcpSessionEntry: hoisted.readAcpSessionEntry, -})); +vi.mock("../../../../src/acp/runtime/session-meta.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readAcpSessionEntry: hoisted.readAcpSessionEntry, + }; +}); const { __testing, diff --git a/src/discord/monitor/thread-bindings.lifecycle.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts similarity index 97% rename from src/discord/monitor/thread-bindings.lifecycle.ts rename to extensions/discord/src/monitor/thread-bindings.lifecycle.ts index faf5603c48d..d7389d68439 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts @@ -1,6 +1,9 @@ -import { readAcpSessionEntry, type AcpSessionStoreEntry } from "../../acp/runtime/session-meta.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { normalizeAccountId } from "../../routing/session-key.js"; +import { + readAcpSessionEntry, + type AcpSessionStoreEntry, +} from "../../../../src/acp/runtime/session-meta.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { normalizeAccountId } from "../../../../src/routing/session-key.js"; import { parseDiscordTarget } from "../targets.js"; import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js"; import { getThreadBindingManager } from "./thread-bindings.manager.js"; diff --git a/src/discord/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts similarity index 98% rename from src/discord/monitor/thread-bindings.manager.ts rename to extensions/discord/src/monitor/thread-bindings.manager.ts index 43ee414c2a5..6595f053ea9 100644 --- a/src/discord/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -1,14 +1,17 @@ import { Routes } from "discord-api-types/v10"; -import { resolveThreadBindingConversationIdFromBindingId } from "../../channels/thread-binding-id.js"; -import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js"; -import { logVerbose } from "../../globals.js"; +import { resolveThreadBindingConversationIdFromBindingId } from "../../../../src/channels/thread-binding-id.js"; +import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../../../src/config/config.js"; +import { logVerbose } from "../../../../src/globals.js"; import { registerSessionBindingAdapter, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, -} from "../../infra/outbound/session-binding-service.js"; -import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +} from "../../../../src/infra/outbound/session-binding-service.js"; +import { + normalizeAccountId, + resolveAgentIdFromSessionKey, +} from "../../../../src/routing/session-key.js"; import { createDiscordRestClient } from "../client.js"; import { createThreadForBinding, diff --git a/src/discord/monitor/thread-bindings.messages.ts b/extensions/discord/src/monitor/thread-bindings.messages.ts similarity index 70% rename from src/discord/monitor/thread-bindings.messages.ts rename to extensions/discord/src/monitor/thread-bindings.messages.ts index 2460ac07020..3fc122cbe71 100644 --- a/src/discord/monitor/thread-bindings.messages.ts +++ b/extensions/discord/src/monitor/thread-bindings.messages.ts @@ -3,4 +3,4 @@ export { resolveThreadBindingFarewellText, resolveThreadBindingIntroText, resolveThreadBindingThreadName, -} from "../../channels/thread-bindings-messages.js"; +} from "../../../../src/channels/thread-bindings-messages.js"; diff --git a/src/discord/monitor/thread-bindings.persona.test.ts b/extensions/discord/src/monitor/thread-bindings.persona.test.ts similarity index 100% rename from src/discord/monitor/thread-bindings.persona.test.ts rename to extensions/discord/src/monitor/thread-bindings.persona.test.ts diff --git a/src/discord/monitor/thread-bindings.persona.ts b/extensions/discord/src/monitor/thread-bindings.persona.ts similarity index 91% rename from src/discord/monitor/thread-bindings.persona.ts rename to extensions/discord/src/monitor/thread-bindings.persona.ts index bb7485f15d1..6798df009e0 100644 --- a/src/discord/monitor/thread-bindings.persona.ts +++ b/extensions/discord/src/monitor/thread-bindings.persona.ts @@ -1,4 +1,4 @@ -import { SYSTEM_MARK } from "../../infra/system-message.js"; +import { SYSTEM_MARK } from "../../../../src/infra/system-message.js"; import type { ThreadBindingRecord } from "./thread-bindings.types.js"; const THREAD_BINDING_PERSONA_MAX_CHARS = 80; diff --git a/src/discord/monitor/thread-bindings.shared-state.test.ts b/extensions/discord/src/monitor/thread-bindings.shared-state.test.ts similarity index 100% rename from src/discord/monitor/thread-bindings.shared-state.test.ts rename to extensions/discord/src/monitor/thread-bindings.shared-state.test.ts diff --git a/src/discord/monitor/thread-bindings.state.ts b/extensions/discord/src/monitor/thread-bindings.state.ts similarity index 98% rename from src/discord/monitor/thread-bindings.state.ts rename to extensions/discord/src/monitor/thread-bindings.state.ts index a5d865b2c09..892d7a46293 100644 --- a/src/discord/monitor/thread-bindings.state.ts +++ b/extensions/discord/src/monitor/thread-bindings.state.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveStateDir } from "../../config/paths.js"; -import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; -import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { resolveStateDir } from "../../../../src/config/paths.js"; +import { loadJsonFile, saveJsonFile } from "../../../../src/infra/json-file.js"; +import { + normalizeAccountId, + resolveAgentIdFromSessionKey, +} from "../../../../src/routing/session-key.js"; import { DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, DEFAULT_THREAD_BINDING_MAX_AGE_MS, diff --git a/src/discord/monitor/thread-bindings.ts b/extensions/discord/src/monitor/thread-bindings.ts similarity index 100% rename from src/discord/monitor/thread-bindings.ts rename to extensions/discord/src/monitor/thread-bindings.ts diff --git a/src/discord/monitor/thread-bindings.types.ts b/extensions/discord/src/monitor/thread-bindings.types.ts similarity index 100% rename from src/discord/monitor/thread-bindings.types.ts rename to extensions/discord/src/monitor/thread-bindings.types.ts diff --git a/src/discord/monitor/thread-session-close.test.ts b/extensions/discord/src/monitor/thread-session-close.test.ts similarity index 98% rename from src/discord/monitor/thread-session-close.test.ts rename to extensions/discord/src/monitor/thread-session-close.test.ts index 292d66889cf..1f70084facf 100644 --- a/src/discord/monitor/thread-session-close.test.ts +++ b/extensions/discord/src/monitor/thread-session-close.test.ts @@ -6,7 +6,7 @@ const hoisted = vi.hoisted(() => { return { updateSessionStore, resolveStorePath }; }); -vi.mock("../../config/sessions.js", () => ({ +vi.mock("../../../../src/config/sessions.js", () => ({ updateSessionStore: hoisted.updateSessionStore, resolveStorePath: hoisted.resolveStorePath, })); diff --git a/src/discord/monitor/thread-session-close.ts b/extensions/discord/src/monitor/thread-session-close.ts similarity index 92% rename from src/discord/monitor/thread-session-close.ts rename to extensions/discord/src/monitor/thread-session-close.ts index 1a5f6dd22f8..234a886d96e 100644 --- a/src/discord/monitor/thread-session-close.ts +++ b/extensions/discord/src/monitor/thread-session-close.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveStorePath, updateSessionStore } from "../../config/sessions.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { resolveStorePath, updateSessionStore } from "../../../../src/config/sessions.js"; /** * Marks every session entry in the store whose key contains {@link threadId} diff --git a/src/discord/monitor/threading.auto-thread.test.ts b/extensions/discord/src/monitor/threading.auto-thread.test.ts similarity index 100% rename from src/discord/monitor/threading.auto-thread.test.ts rename to extensions/discord/src/monitor/threading.auto-thread.test.ts diff --git a/src/discord/monitor/threading.parent-info.test.ts b/extensions/discord/src/monitor/threading.parent-info.test.ts similarity index 100% rename from src/discord/monitor/threading.parent-info.test.ts rename to extensions/discord/src/monitor/threading.parent-info.test.ts diff --git a/src/discord/monitor/threading.starter.test.ts b/extensions/discord/src/monitor/threading.starter.test.ts similarity index 100% rename from src/discord/monitor/threading.starter.test.ts rename to extensions/discord/src/monitor/threading.starter.test.ts diff --git a/src/discord/monitor/threading.ts b/extensions/discord/src/monitor/threading.ts similarity index 97% rename from src/discord/monitor/threading.ts rename to extensions/discord/src/monitor/threading.ts index 7fc96225330..035354b98af 100644 --- a/src/discord/monitor/threading.ts +++ b/extensions/discord/src/monitor/threading.ts @@ -1,10 +1,10 @@ import { ChannelType, type Client } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; -import type { ReplyToMode } from "../../config/config.js"; -import { logVerbose } from "../../globals.js"; -import { buildAgentSessionKey } from "../../routing/resolve-route.js"; -import { truncateUtf16Safe } from "../../utils.js"; +import { createReplyReferencePlanner } from "../../../../src/auto-reply/reply/reply-reference.js"; +import type { ReplyToMode } from "../../../../src/config/config.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js"; +import { truncateUtf16Safe } from "../../../../src/utils.js"; import type { DiscordChannelConfigResolved } from "./allow-list.js"; import type { DiscordMessageEvent } from "./listeners.js"; import { diff --git a/src/discord/monitor/timeouts.ts b/extensions/discord/src/monitor/timeouts.ts similarity index 100% rename from src/discord/monitor/timeouts.ts rename to extensions/discord/src/monitor/timeouts.ts diff --git a/src/discord/monitor/typing.ts b/extensions/discord/src/monitor/typing.ts similarity index 100% rename from src/discord/monitor/typing.ts rename to extensions/discord/src/monitor/typing.ts diff --git a/extensions/discord/src/normalize.ts b/extensions/discord/src/normalize.ts new file mode 100644 index 00000000000..231cba8e5dc --- /dev/null +++ b/extensions/discord/src/normalize.ts @@ -0,0 +1,47 @@ +import { parseDiscordTarget } from "./targets.js"; + +export function normalizeDiscordMessagingTarget(raw: string): string | undefined { + // Default bare IDs to channels so routing is stable across tool actions. + const target = parseDiscordTarget(raw, { defaultKind: "channel" }); + return target?.normalized; +} + +/** + * Normalize a Discord outbound target for delivery. Bare numeric IDs are + * prefixed with "channel:" to avoid the ambiguous-target error in + * parseDiscordTarget. All other formats pass through unchanged. + */ +export function normalizeDiscordOutboundTarget( + to?: string, +): { ok: true; to: string } | { ok: false; error: Error } { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + 'Discord recipient is required. Use "channel:" for channels or "user:" for DMs.', + ), + }; + } + if (/^\d+$/.test(trimmed)) { + return { ok: true, to: `channel:${trimmed}` }; + } + return { ok: true, to: trimmed }; +} + +export function looksLikeDiscordTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + if (/^<@!?\d+>$/.test(trimmed)) { + return true; + } + if (/^(user|channel|discord):/i.test(trimmed)) { + return true; + } + if (/^\d{6,}$/.test(trimmed)) { + return true; + } + return false; +} diff --git a/extensions/discord/src/onboarding.ts b/extensions/discord/src/onboarding.ts new file mode 100644 index 00000000000..f4883b1254f --- /dev/null +++ b/extensions/discord/src/onboarding.ts @@ -0,0 +1,319 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; +import { configureChannelAccessWithAllowlist } from "../../../src/channels/plugins/onboarding/channel-access-configure.js"; +import { + applySingleTokenPromptResult, + parseMentionOrPrefixedId, + noteChannelLookupFailure, + noteChannelLookupSummary, + patchChannelConfigForAccount, + promptLegacyChannelAllowFrom, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + runSingleChannelSecretStep, + setAccountGroupPolicyForChannel, + setLegacyChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "./accounts.js"; +import { normalizeDiscordSlug } from "./monitor/allow-list.js"; +import { + resolveDiscordChannelAllowlist, + type DiscordChannelResolution, +} from "./resolve-channels.js"; +import { resolveDiscordUserAllowlist } from "./resolve-users.js"; + +const channel = "discord" as const; + +async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Discord Developer Portal → Applications → New Application", + "2) Bot → Add Bot → Reset Token → copy token", + "3) OAuth2 → URL Generator → scope 'bot' → invite to your server", + "Tip: enable Message Content Intent if you need message text. (Bot → Privileged Gateway Intents → Message Content Intent)", + `Docs: ${formatDocsLink("/discord", "discord")}`, + ].join("\n"), + "Discord bot token", + ); +} + +function setDiscordGuildChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + entries: Array<{ + guildKey: string; + channelKey?: string; + }>, +): OpenClawConfig { + const baseGuilds = + accountId === DEFAULT_ACCOUNT_ID + ? (cfg.channels?.discord?.guilds ?? {}) + : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); + const guilds: Record = { ...baseGuilds }; + for (const entry of entries) { + const guildKey = entry.guildKey || "*"; + const existing = guilds[guildKey] ?? {}; + if (entry.channelKey) { + const channels = { ...existing.channels }; + channels[entry.channelKey] = { allow: true }; + guilds[guildKey] = { ...existing, channels }; + } else { + guilds[guildKey] = existing; + } + } + return patchChannelConfigForAccount({ + cfg, + channel: "discord", + accountId, + patch: { guilds }, + }); +} + +async function promptDiscordAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), + }); + const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId }); + const token = resolved.token; + const existing = + params.cfg.channels?.discord?.allowFrom ?? params.cfg.channels?.discord?.dm?.allowFrom ?? []; + const parseId = (value: string) => + parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@!?(\d+)>$/, + prefixPattern: /^(user:|discord:)/i, + idPattern: /^\d+$/, + }); + + return promptLegacyChannelAllowFrom({ + cfg: params.cfg, + channel: "discord", + prompter: params.prompter, + existing, + token, + noteTitle: "Discord allowlist", + noteLines: [ + "Allowlist Discord DMs by username (we resolve to user ids).", + "Examples:", + "- 123456789012345678", + "- @alice", + "- alice#1234", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/discord", "discord")}`, + ], + message: "Discord allowFrom (usernames or ids)", + placeholder: "@alice, 123456789012345678", + parseId, + invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.", + resolveEntries: ({ token, entries }) => + resolveDiscordUserAllowlist({ + token, + entries, + }), + }); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Discord", + channel, + policyKey: "channels.discord.dmPolicy", + allowFromKey: "channels.discord.allowFrom", + getCurrent: (cfg) => + cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => + setLegacyChannelDmPolicyWithAllowFrom({ + cfg, + channel: "discord", + dmPolicy: policy, + }), + promptAllowFrom: promptDiscordAllowFrom, +}; + +export const discordOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const configured = listDiscordAccountIds(cfg).some((accountId) => { + const account = inspectDiscordAccount({ cfg, accountId }); + return account.configured; + }); + return { + channel, + configured, + statusLines: [`Discord: ${configured ? "configured" : "needs token"}`], + selectionHint: configured ? "configured" : "needs token", + quickstartScore: configured ? 2 : 1, + }; + }, + configure: async ({ cfg, prompter, options, accountOverrides, shouldPromptAccountIds }) => { + const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg); + const discordAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Discord", + accountOverride: accountOverrides.discord, + shouldPromptAccountIds, + listAccountIds: listDiscordAccountIds, + defaultAccountId: defaultDiscordAccountId, + }); + + let next = cfg; + const resolvedAccount = resolveDiscordAccount({ + cfg: next, + accountId: discordAccountId, + }); + const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID; + const tokenStep = await runSingleChannelSecretStep({ + cfg: next, + prompter, + providerHint: "discord", + credentialLabel: "Discord bot token", + secretInputMode: options?.secretInputMode, + accountConfigured: Boolean(resolvedAccount.token), + hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.token), + allowEnv, + envValue: process.env.DISCORD_BOT_TOKEN, + envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?", + keepPrompt: "Discord token already configured. Keep it?", + inputPrompt: "Enter Discord bot token", + preferredEnvVar: allowEnv ? "DISCORD_BOT_TOKEN" : undefined, + onMissingConfigured: async () => await noteDiscordTokenHelp(prompter), + applyUseEnv: async (cfg) => + applySingleTokenPromptResult({ + cfg, + channel: "discord", + accountId: discordAccountId, + tokenPatchKey: "token", + tokenResult: { useEnv: true, token: null }, + }), + applySet: async (cfg, value) => + applySingleTokenPromptResult({ + cfg, + channel: "discord", + accountId: discordAccountId, + tokenPatchKey: "token", + tokenResult: { useEnv: false, token: value }, + }), + }); + next = tokenStep.cfg; + + const currentEntries = Object.entries(resolvedAccount.config.guilds ?? {}).flatMap( + ([guildKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) { + const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey; + return [input]; + } + return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); + }, + ); + next = await configureChannelAccessWithAllowlist({ + cfg: next, + prompter, + label: "Discord channels", + currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist", + currentEntries, + placeholder: "My Server/#general, guildId/channelId, #support", + updatePrompt: Boolean(resolvedAccount.config.guilds), + setPolicy: (cfg, policy) => + setAccountGroupPolicyForChannel({ + cfg, + channel: "discord", + accountId: discordAccountId, + groupPolicy: policy, + }), + resolveAllowlist: async ({ cfg, entries }) => { + const accountWithTokens = resolveDiscordAccount({ + cfg, + accountId: discordAccountId, + }); + let resolved: DiscordChannelResolution[] = entries.map((input) => ({ + input, + resolved: false, + })); + const activeToken = accountWithTokens.token || tokenStep.resolvedValue || ""; + if (activeToken && entries.length > 0) { + try { + resolved = await resolveDiscordChannelAllowlist({ + token: activeToken, + entries, + }); + const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId); + const resolvedGuilds = resolved.filter( + (entry) => entry.resolved && entry.guildId && !entry.channelId, + ); + const unresolved = resolved + .filter((entry) => !entry.resolved) + .map((entry) => entry.input); + await noteChannelLookupSummary({ + prompter, + label: "Discord channels", + resolvedSections: [ + { + title: "Resolved channels", + values: resolvedChannels + .map((entry) => entry.channelId) + .filter((value): value is string => Boolean(value)), + }, + { + title: "Resolved guilds", + values: resolvedGuilds + .map((entry) => entry.guildId) + .filter((value): value is string => Boolean(value)), + }, + ], + unresolved, + }); + } catch (err) { + await noteChannelLookupFailure({ + prompter, + label: "Discord channels", + error: err, + }); + } + } + return resolved; + }, + applyAllowlist: ({ cfg, resolved }) => { + const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = []; + for (const entry of resolved) { + const guildKey = + entry.guildId ?? + (entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ?? + "*"; + const channelKey = + entry.channelId ?? + (entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined); + if (!channelKey && guildKey === "*") { + continue; + } + allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) }); + } + return setDiscordGuildChannelAllowlist(cfg, discordAccountId, allowlistEntries); + }, + }); + + return { cfg: next, accountId: discordAccountId }; + }, + dmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; diff --git a/src/channels/plugins/outbound/discord.sendpayload.test.ts b/extensions/discord/src/outbound-adapter.sendpayload.test.ts similarity index 81% rename from src/channels/plugins/outbound/discord.sendpayload.test.ts rename to extensions/discord/src/outbound-adapter.sendpayload.test.ts index 168f8d8d927..ae5d86f8700 100644 --- a/src/channels/plugins/outbound/discord.sendpayload.test.ts +++ b/extensions/discord/src/outbound-adapter.sendpayload.test.ts @@ -1,10 +1,10 @@ import { describe, vi } from "vitest"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; import { installSendPayloadContractSuite, primeSendMock, -} from "../../../test-utils/send-payload-contract.js"; -import { discordOutbound } from "./discord.js"; +} from "../../../src/test-utils/send-payload-contract.js"; +import { discordOutbound } from "./outbound-adapter.js"; function createHarness(params: { payload: ReplyPayload; diff --git a/src/channels/plugins/outbound/discord.test.ts b/extensions/discord/src/outbound-adapter.test.ts similarity index 93% rename from src/channels/plugins/outbound/discord.test.ts rename to extensions/discord/src/outbound-adapter.test.ts index b6a618f4b5f..3321a9cb59b 100644 --- a/src/channels/plugins/outbound/discord.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { normalizeDiscordOutboundTarget } from "../normalize/discord.js"; +import { normalizeDiscordOutboundTarget } from "./normalize.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscordMock = vi.fn(); @@ -14,8 +14,8 @@ const hoisted = vi.hoisted(() => { }; }); -vi.mock("../../../discord/send.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./send.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args), @@ -25,16 +25,15 @@ vi.mock("../../../discord/send.js", async (importOriginal) => { }; }); -vi.mock("../../../discord/monitor/thread-bindings.js", async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock("./monitor/thread-bindings.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, getThreadBindingManager: (...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args), }; }); -const { discordOutbound } = await import("./discord.js"); +const { discordOutbound } = await import("./outbound-adapter.js"); const DEFAULT_DISCORD_SEND_RESULT = { channel: "discord", diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts new file mode 100644 index 00000000000..cea9bdb3cee --- /dev/null +++ b/extensions/discord/src/outbound-adapter.ts @@ -0,0 +1,143 @@ +import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js"; +import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; +import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js"; +import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js"; +import { normalizeDiscordOutboundTarget } from "./normalize.js"; +import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js"; + +function resolveDiscordOutboundTarget(params: { + to: string; + threadId?: string | number | null; +}): string { + if (params.threadId == null) { + return params.to; + } + const threadId = String(params.threadId).trim(); + if (!threadId) { + return params.to; + } + return `channel:${threadId}`; +} + +function resolveDiscordWebhookIdentity(params: { + identity?: OutboundIdentity; + binding: ThreadBindingRecord; +}): { username?: string; avatarUrl?: string } { + const usernameRaw = params.identity?.name?.trim(); + const fallbackUsername = params.binding.label?.trim() || params.binding.agentId; + const username = (usernameRaw || fallbackUsername || "").slice(0, 80) || undefined; + const avatarUrl = params.identity?.avatarUrl?.trim() || undefined; + return { username, avatarUrl }; +} + +async function maybeSendDiscordWebhookText(params: { + cfg?: OpenClawConfig; + text: string; + threadId?: string | number | null; + accountId?: string | null; + identity?: OutboundIdentity; + replyToId?: string | null; +}): Promise<{ messageId: string; channelId: string } | null> { + if (params.threadId == null) { + return null; + } + const threadId = String(params.threadId).trim(); + if (!threadId) { + return null; + } + const manager = getThreadBindingManager(params.accountId ?? undefined); + if (!manager) { + return null; + } + const binding = manager.getByThreadId(threadId); + if (!binding?.webhookId || !binding?.webhookToken) { + return null; + } + const persona = resolveDiscordWebhookIdentity({ + identity: params.identity, + binding, + }); + const result = await sendWebhookMessageDiscord(params.text, { + webhookId: binding.webhookId, + webhookToken: binding.webhookToken, + accountId: binding.accountId, + threadId: binding.threadId, + cfg: params.cfg, + replyTo: params.replyToId ?? undefined, + username: persona.username, + avatarUrl: persona.avatarUrl, + }); + return result; +} + +export const discordOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: null, + textChunkLimit: 2000, + pollMaxOptions: 10, + resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), + sendPayload: async (ctx) => + await sendTextMediaPayload({ channel: "discord", ctx, adapter: discordOutbound }), + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { + if (!silent) { + const webhookResult = await maybeSendDiscordWebhookText({ + cfg, + text, + threadId, + accountId, + identity, + replyToId, + }).catch(() => null); + if (webhookResult) { + return { channel: "discord", ...webhookResult }; + } + } + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + const target = resolveDiscordOutboundTarget({ to, threadId }); + const result = await send(target, text, { + verbose: false, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }); + return { channel: "discord", ...result }; + }, + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + silent, + }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + const target = resolveDiscordOutboundTarget({ to, threadId }); + const result = await send(target, text, { + verbose: false, + mediaUrl, + mediaLocalRoots, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }); + return { channel: "discord", ...result }; + }, + sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => { + const target = resolveDiscordOutboundTarget({ to, threadId }); + return await sendPollDiscord(target, poll, { + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }); + }, +}; diff --git a/src/discord/pluralkit.test.ts b/extensions/discord/src/pluralkit.test.ts similarity index 100% rename from src/discord/pluralkit.test.ts rename to extensions/discord/src/pluralkit.test.ts diff --git a/src/discord/pluralkit.ts b/extensions/discord/src/pluralkit.ts similarity index 95% rename from src/discord/pluralkit.ts rename to extensions/discord/src/pluralkit.ts index 7e19df6e2d9..e328fb27eff 100644 --- a/src/discord/pluralkit.ts +++ b/extensions/discord/src/pluralkit.ts @@ -1,4 +1,4 @@ -import { resolveFetch } from "../infra/fetch.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; const PLURALKIT_API_BASE = "https://api.pluralkit.me/v2"; diff --git a/src/discord/probe.intents.test.ts b/extensions/discord/src/probe.intents.test.ts similarity index 100% rename from src/discord/probe.intents.test.ts rename to extensions/discord/src/probe.intents.test.ts diff --git a/src/discord/probe.parse-token.test.ts b/extensions/discord/src/probe.parse-token.test.ts similarity index 100% rename from src/discord/probe.parse-token.test.ts rename to extensions/discord/src/probe.parse-token.test.ts diff --git a/src/discord/probe.ts b/extensions/discord/src/probe.ts similarity index 97% rename from src/discord/probe.ts rename to extensions/discord/src/probe.ts index 5f743b8b404..b434cd8c78d 100644 --- a/src/discord/probe.ts +++ b/extensions/discord/src/probe.ts @@ -1,6 +1,6 @@ -import type { BaseProbeResult } from "../channels/plugins/types.js"; -import { resolveFetch } from "../infra/fetch.js"; -import { fetchWithTimeout } from "../utils/fetch-timeout.js"; +import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; +import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_API_BASE = "https://discord.com/api/v10"; diff --git a/src/discord/resolve-allowlist-common.test.ts b/extensions/discord/src/resolve-allowlist-common.test.ts similarity index 100% rename from src/discord/resolve-allowlist-common.test.ts rename to extensions/discord/src/resolve-allowlist-common.test.ts diff --git a/src/discord/resolve-allowlist-common.ts b/extensions/discord/src/resolve-allowlist-common.ts similarity index 100% rename from src/discord/resolve-allowlist-common.ts rename to extensions/discord/src/resolve-allowlist-common.ts diff --git a/src/discord/resolve-channels.test.ts b/extensions/discord/src/resolve-channels.test.ts similarity index 99% rename from src/discord/resolve-channels.test.ts rename to extensions/discord/src/resolve-channels.test.ts index 70fa4f74aa3..fb46792aaaa 100644 --- a/src/discord/resolve-channels.test.ts +++ b/extensions/discord/src/resolve-channels.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js"; import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; import { jsonResponse, urlToString } from "./test-http-helpers.js"; diff --git a/src/discord/resolve-channels.ts b/extensions/discord/src/resolve-channels.ts similarity index 100% rename from src/discord/resolve-channels.ts rename to extensions/discord/src/resolve-channels.ts diff --git a/src/discord/resolve-users.test.ts b/extensions/discord/src/resolve-users.test.ts similarity index 98% rename from src/discord/resolve-users.test.ts rename to extensions/discord/src/resolve-users.test.ts index 123de666dcb..d788b77ebe0 100644 --- a/src/discord/resolve-users.test.ts +++ b/extensions/discord/src/resolve-users.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { jsonResponse, urlToString } from "./test-http-helpers.js"; diff --git a/src/discord/resolve-users.ts b/extensions/discord/src/resolve-users.ts similarity index 100% rename from src/discord/resolve-users.ts rename to extensions/discord/src/resolve-users.ts diff --git a/src/discord/send.channels.ts b/extensions/discord/src/send.channels.ts similarity index 100% rename from src/discord/send.channels.ts rename to extensions/discord/src/send.channels.ts diff --git a/src/discord/send.components.test.ts b/extensions/discord/src/send.components.test.ts similarity index 89% rename from src/discord/send.components.test.ts rename to extensions/discord/src/send.components.test.ts index 84e02e47b12..1da4cc964dd 100644 --- a/src/discord/send.components.test.ts +++ b/extensions/discord/src/send.components.test.ts @@ -6,8 +6,10 @@ import { makeDiscordRest } from "./send.test-harness.js"; const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } }))); -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); +vi.mock("../../../src/config/config.js", async () => { + const actual = await vi.importActual( + "../../../src/config/config.js", + ); return { ...actual, loadConfig: (..._args: unknown[]) => loadConfigMock(), diff --git a/src/discord/send.components.ts b/extensions/discord/src/send.components.ts similarity index 95% rename from src/discord/send.components.ts rename to extensions/discord/src/send.components.ts index 5cdbee1b90c..9212e383ed7 100644 --- a/src/discord/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -5,9 +5,9 @@ import { type RequestClient, } from "@buape/carbon"; import { ChannelType, Routes } from "discord-api-types/v10"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { recordChannelActivity } from "../infra/channel-activity.js"; -import { loadWebMedia } from "../web/media.js"; +import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; +import { recordChannelActivity } from "../../../src/infra/channel-activity.js"; +import { loadWebMedia } from "../../whatsapp/src/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { registerDiscordComponentEntries } from "./components-registry.js"; import { diff --git a/src/discord/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts similarity index 99% rename from src/discord/send.creates-thread.test.ts rename to extensions/discord/src/send.creates-thread.test.ts index 3fd70b99882..c1012816d22 100644 --- a/src/discord/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -18,7 +18,7 @@ import { } from "./send.js"; import { makeDiscordRest } from "./send.test-harness.js"; -vi.mock("../web/media.js", async () => { +vi.mock("../../whatsapp/src/media.js", async () => { const { discordWebMediaMockFactory } = await import("./send.test-harness.js"); return discordWebMediaMockFactory(); }); diff --git a/src/discord/send.emojis-stickers.ts b/extensions/discord/src/send.emojis-stickers.ts similarity index 97% rename from src/discord/send.emojis-stickers.ts rename to extensions/discord/src/send.emojis-stickers.ts index a6e42182631..601b8372e74 100644 --- a/src/discord/send.emojis-stickers.ts +++ b/extensions/discord/src/send.emojis-stickers.ts @@ -1,5 +1,5 @@ import { Routes } from "discord-api-types/v10"; -import { loadWebMediaRaw } from "../web/media.js"; +import { loadWebMediaRaw } from "../../whatsapp/src/media.js"; import { normalizeEmojiName, resolveDiscordRest } from "./send.shared.js"; import type { DiscordEmojiUpload, DiscordReactOpts, DiscordStickerUpload } from "./send.types.js"; import { DISCORD_MAX_EMOJI_BYTES, DISCORD_MAX_STICKER_BYTES } from "./send.types.js"; diff --git a/src/discord/send.guild.ts b/extensions/discord/src/send.guild.ts similarity index 100% rename from src/discord/send.guild.ts rename to extensions/discord/src/send.guild.ts diff --git a/src/discord/send.messages.ts b/extensions/discord/src/send.messages.ts similarity index 100% rename from src/discord/send.messages.ts rename to extensions/discord/src/send.messages.ts diff --git a/src/discord/send.outbound.ts b/extensions/discord/src/send.outbound.ts similarity index 95% rename from src/discord/send.outbound.ts rename to extensions/discord/src/send.outbound.ts index 8234291e7ed..8f7b743e0d0 100644 --- a/src/discord/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -3,18 +3,18 @@ import fs from "node:fs/promises"; import path from "node:path"; import { serializePayload, type MessagePayloadObject, type RequestClient } from "@buape/carbon"; import { ChannelType, Routes } from "discord-api-types/v10"; -import { resolveChunkMode } from "../auto-reply/chunk.js"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { recordChannelActivity } from "../infra/channel-activity.js"; -import type { RetryConfig } from "../infra/retry.js"; -import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -import { convertMarkdownTables } from "../markdown/tables.js"; -import { maxBytesForKind } from "../media/constants.js"; -import { extensionForMime } from "../media/mime.js"; -import { unlinkIfExists } from "../media/temp-files.js"; -import type { PollInput } from "../polls.js"; -import { loadWebMediaRaw } from "../web/media.js"; +import { resolveChunkMode } from "../../../src/auto-reply/chunk.js"; +import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { recordChannelActivity } from "../../../src/infra/channel-activity.js"; +import type { RetryConfig } from "../../../src/infra/retry.js"; +import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; +import { convertMarkdownTables } from "../../../src/markdown/tables.js"; +import { maxBytesForKind } from "../../../src/media/constants.js"; +import { extensionForMime } from "../../../src/media/mime.js"; +import { unlinkIfExists } from "../../../src/media/temp-files.js"; +import type { PollInput } from "../../../src/polls.js"; +import { loadWebMediaRaw } from "../../whatsapp/src/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { rewriteDiscordKnownMentions } from "./mentions.js"; import { diff --git a/src/discord/send.permissions.authz.test.ts b/extensions/discord/src/send.permissions.authz.test.ts similarity index 100% rename from src/discord/send.permissions.authz.test.ts rename to extensions/discord/src/send.permissions.authz.test.ts diff --git a/src/discord/send.permissions.ts b/extensions/discord/src/send.permissions.ts similarity index 100% rename from src/discord/send.permissions.ts rename to extensions/discord/src/send.permissions.ts diff --git a/src/discord/send.reactions.ts b/extensions/discord/src/send.reactions.ts similarity index 98% rename from src/discord/send.reactions.ts rename to extensions/discord/src/send.reactions.ts index 436d64ac5b2..26353a7acb5 100644 --- a/src/discord/send.reactions.ts +++ b/extensions/discord/src/send.reactions.ts @@ -1,5 +1,5 @@ import { Routes } from "discord-api-types/v10"; -import { loadConfig } from "../config/config.js"; +import { loadConfig } from "../../../src/config/config.js"; import { buildReactionIdentifier, createDiscordClient, diff --git a/src/discord/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts similarity index 99% rename from src/discord/send.sends-basic-channel-messages.test.ts rename to extensions/discord/src/send.sends-basic-channel-messages.test.ts index 58b8e3799b7..7d0f359f90a 100644 --- a/src/discord/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -1,6 +1,6 @@ import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { loadWebMedia } from "../web/media.js"; +import { loadWebMedia } from "../../whatsapp/src/media.js"; import { __resetDiscordDirectoryCacheForTest, rememberDiscordDirectoryUser, @@ -21,7 +21,7 @@ import { } from "./send.js"; import { makeDiscordRest } from "./send.test-harness.js"; -vi.mock("../web/media.js", async () => { +vi.mock("../../whatsapp/src/media.js", async () => { const { discordWebMediaMockFactory } = await import("./send.test-harness.js"); return discordWebMediaMockFactory(); }); diff --git a/src/discord/send.shared.ts b/extensions/discord/src/send.shared.ts similarity index 96% rename from src/discord/send.shared.ts rename to extensions/discord/src/send.shared.ts index a90f0ffe01f..f1a7fd4c28e 100644 --- a/src/discord/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -9,12 +9,16 @@ import { import { PollLayoutType } from "discord-api-types/payloads/v10"; import type { RESTAPIPoll } from "discord-api-types/rest/v10"; import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10"; -import type { ChunkMode } from "../auto-reply/chunk.js"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import type { RetryRunner } from "../infra/retry-policy.js"; -import { buildOutboundMediaLoadOptions } from "../media/load-options.js"; -import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js"; -import { loadWebMedia } from "../web/media.js"; +import type { ChunkMode } from "../../../src/auto-reply/chunk.js"; +import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; +import type { RetryRunner } from "../../../src/infra/retry-policy.js"; +import { buildOutboundMediaLoadOptions } from "../../../src/media/load-options.js"; +import { + normalizePollDurationHours, + normalizePollInput, + type PollInput, +} from "../../../src/polls.js"; +import { loadWebMedia } from "../../whatsapp/src/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { chunkDiscordTextWithMode } from "./chunk.js"; import { createDiscordClient, resolveDiscordRest } from "./client.js"; diff --git a/src/discord/send.test-harness.ts b/extensions/discord/src/send.test-harness.ts similarity index 94% rename from src/discord/send.test-harness.ts rename to extensions/discord/src/send.test-harness.ts index eceb7882c0a..f3c5ae36842 100644 --- a/src/discord/send.test-harness.ts +++ b/extensions/discord/src/send.test-harness.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; type DiscordWebMediaMockFactoryResult = { loadWebMedia: MockFn; diff --git a/src/discord/send.ts b/extensions/discord/src/send.ts similarity index 100% rename from src/discord/send.ts rename to extensions/discord/src/send.ts diff --git a/src/discord/send.types.ts b/extensions/discord/src/send.types.ts similarity index 96% rename from src/discord/send.types.ts rename to extensions/discord/src/send.types.ts index 2dc29921f7e..189c9434d1e 100644 --- a/src/discord/send.types.ts +++ b/extensions/discord/src/send.types.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; -import type { OpenClawConfig } from "../config/config.js"; -import type { RetryConfig } from "../infra/retry.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { RetryConfig } from "../../../src/infra/retry.js"; export class DiscordSendError extends Error { kind?: "missing-permissions" | "dm-blocked"; diff --git a/src/discord/send.webhook-activity.test.ts b/extensions/discord/src/send.webhook-activity.test.ts similarity index 83% rename from src/discord/send.webhook-activity.test.ts rename to extensions/discord/src/send.webhook-activity.test.ts index c51ba3b814d..04354936050 100644 --- a/src/discord/send.webhook-activity.test.ts +++ b/extensions/discord/src/send.webhook-activity.test.ts @@ -4,16 +4,16 @@ import { sendWebhookMessageDiscord } from "./send.js"; const recordChannelActivityMock = vi.hoisted(() => vi.fn()); const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ channels: { discord: {} } }))); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => loadConfigMock(), }; }); -vi.mock("../infra/channel-activity.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/infra/channel-activity.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, recordChannelActivity: (...args: unknown[]) => recordChannelActivityMock(...args), diff --git a/src/discord/session-key-normalization.test.ts b/extensions/discord/src/session-key-normalization.test.ts similarity index 100% rename from src/discord/session-key-normalization.test.ts rename to extensions/discord/src/session-key-normalization.test.ts diff --git a/src/discord/session-key-normalization.ts b/extensions/discord/src/session-key-normalization.ts similarity index 87% rename from src/discord/session-key-normalization.ts rename to extensions/discord/src/session-key-normalization.ts index 67d267aac21..7e47fe012dd 100644 --- a/src/discord/session-key-normalization.ts +++ b/extensions/discord/src/session-key-normalization.ts @@ -1,5 +1,5 @@ -import type { MsgContext } from "../auto-reply/templating.js"; -import { normalizeChatType } from "../channels/chat-type.js"; +import type { MsgContext } from "../../../src/auto-reply/templating.js"; +import { normalizeChatType } from "../../../src/channels/chat-type.js"; export function normalizeExplicitDiscordSessionKey( sessionKey: string, diff --git a/extensions/discord/src/status-issues.ts b/extensions/discord/src/status-issues.ts new file mode 100644 index 00000000000..baf2551c0f8 --- /dev/null +++ b/extensions/discord/src/status-issues.ts @@ -0,0 +1,169 @@ +import { + appendMatchMetadata, + asString, + isRecord, + resolveEnabledConfiguredAccountId, +} from "../../../src/channels/plugins/status-issues/shared.js"; +import type { + ChannelAccountSnapshot, + ChannelStatusIssue, +} from "../../../src/channels/plugins/types.js"; + +type DiscordIntentSummary = { + messageContent?: "enabled" | "limited" | "disabled"; +}; + +type DiscordApplicationSummary = { + intents?: DiscordIntentSummary; +}; + +type DiscordAccountStatus = { + accountId?: unknown; + enabled?: unknown; + configured?: unknown; + application?: unknown; + audit?: unknown; +}; + +type DiscordPermissionsAuditSummary = { + unresolvedChannels?: number; + channels?: Array<{ + channelId: string; + ok?: boolean; + missing?: string[]; + error?: string | null; + matchKey?: string; + matchSource?: string; + }>; +}; + +function readDiscordAccountStatus(value: ChannelAccountSnapshot): DiscordAccountStatus | null { + if (!isRecord(value)) { + return null; + } + return { + accountId: value.accountId, + enabled: value.enabled, + configured: value.configured, + application: value.application, + audit: value.audit, + }; +} + +function readDiscordApplicationSummary(value: unknown): DiscordApplicationSummary { + if (!isRecord(value)) { + return {}; + } + const intentsRaw = value.intents; + if (!isRecord(intentsRaw)) { + return {}; + } + return { + intents: { + messageContent: + intentsRaw.messageContent === "enabled" || + intentsRaw.messageContent === "limited" || + intentsRaw.messageContent === "disabled" + ? intentsRaw.messageContent + : undefined, + }, + }; +} + +function readDiscordPermissionsAuditSummary(value: unknown): DiscordPermissionsAuditSummary { + if (!isRecord(value)) { + return {}; + } + const unresolvedChannels = + typeof value.unresolvedChannels === "number" && Number.isFinite(value.unresolvedChannels) + ? value.unresolvedChannels + : undefined; + const channelsRaw = value.channels; + const channels = Array.isArray(channelsRaw) + ? (channelsRaw + .map((entry) => { + if (!isRecord(entry)) { + return null; + } + const channelId = asString(entry.channelId); + if (!channelId) { + return null; + } + const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; + const missing = Array.isArray(entry.missing) + ? entry.missing.map((v) => asString(v)).filter(Boolean) + : undefined; + const error = asString(entry.error) ?? null; + const matchKey = asString(entry.matchKey) ?? undefined; + const matchSource = asString(entry.matchSource) ?? undefined; + return { + channelId, + ok, + missing: missing?.length ? missing : undefined, + error, + matchKey, + matchSource, + }; + }) + .filter(Boolean) as DiscordPermissionsAuditSummary["channels"]) + : undefined; + return { unresolvedChannels, channels }; +} + +export function collectDiscordStatusIssues( + accounts: ChannelAccountSnapshot[], +): ChannelStatusIssue[] { + const issues: ChannelStatusIssue[] = []; + for (const entry of accounts) { + const account = readDiscordAccountStatus(entry); + if (!account) { + continue; + } + const accountId = resolveEnabledConfiguredAccountId(account); + if (!accountId) { + continue; + } + + const app = readDiscordApplicationSummary(account.application); + const messageContent = app.intents?.messageContent; + if (messageContent === "disabled") { + issues.push({ + channel: "discord", + accountId, + kind: "intent", + message: "Message Content Intent is disabled. Bot may not see normal channel messages.", + fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.", + }); + } + + const audit = readDiscordPermissionsAuditSummary(account.audit); + if (audit.unresolvedChannels && audit.unresolvedChannels > 0) { + issues.push({ + channel: "discord", + accountId, + kind: "config", + message: `Some configured guild channels are not numeric IDs (unresolvedChannels=${audit.unresolvedChannels}). Permission audit can only check numeric channel IDs.`, + fix: "Use numeric channel IDs as keys in channels.discord.guilds.*.channels (then rerun channels status --probe).", + }); + } + for (const channel of audit.channels ?? []) { + if (channel.ok === true) { + continue; + } + const missing = channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : ""; + const error = channel.error ? `: ${channel.error}` : ""; + const baseMessage = `Channel ${channel.channelId} permission check failed.${missing}${error}`; + issues.push({ + channel: "discord", + accountId, + kind: "permissions", + message: appendMatchMetadata(baseMessage, { + matchKey: channel.matchKey, + matchSource: channel.matchSource, + }), + fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).", + }); + } + } + return issues; +} diff --git a/src/discord/targets.test.ts b/extensions/discord/src/targets.test.ts similarity index 96% rename from src/discord/targets.test.ts rename to extensions/discord/src/targets.test.ts index bf3535ac811..527e0164ba8 100644 --- a/src/discord/targets.test.ts +++ b/extensions/discord/src/targets.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { normalizeDiscordMessagingTarget } from "../channels/plugins/normalize/discord.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js"; +import { normalizeDiscordMessagingTarget } from "./normalize.js"; import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js"; vi.mock("./directory-live.js", () => ({ diff --git a/src/discord/targets.ts b/extensions/discord/src/targets.ts similarity index 97% rename from src/discord/targets.ts rename to extensions/discord/src/targets.ts index 2be2b970724..198660dceff 100644 --- a/src/discord/targets.ts +++ b/extensions/discord/src/targets.ts @@ -1,4 +1,4 @@ -import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; +import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; import { buildMessagingTarget, parseMentionPrefixOrAtUserTarget, @@ -6,7 +6,7 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, -} from "../channels/targets.js"; +} from "../../../src/channels/targets.js"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js"; diff --git a/src/discord/test-http-helpers.ts b/extensions/discord/src/test-http-helpers.ts similarity index 100% rename from src/discord/test-http-helpers.ts rename to extensions/discord/src/test-http-helpers.ts diff --git a/src/discord/token.test.ts b/extensions/discord/src/token.test.ts similarity index 97% rename from src/discord/token.test.ts rename to extensions/discord/src/token.test.ts index 33268eb699d..4c40fc93805 100644 --- a/src/discord/token.test.ts +++ b/extensions/discord/src/token.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveDiscordToken } from "./token.js"; describe("resolveDiscordToken", () => { diff --git a/src/discord/token.ts b/extensions/discord/src/token.ts similarity index 86% rename from src/discord/token.ts rename to extensions/discord/src/token.ts index 59501798335..8f942c6920f 100644 --- a/src/discord/token.ts +++ b/extensions/discord/src/token.ts @@ -1,7 +1,7 @@ -import type { BaseTokenResolution } from "../channels/plugins/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { normalizeResolvedSecretInputString } from "../config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { BaseTokenResolution } from "../../../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; export type DiscordTokenSource = "env" | "config" | "none"; diff --git a/src/discord/ui.ts b/extensions/discord/src/ui.ts similarity index 95% rename from src/discord/ui.ts rename to extensions/discord/src/ui.ts index d4238deac2e..ed4cc9d4fa6 100644 --- a/src/discord/ui.ts +++ b/extensions/discord/src/ui.ts @@ -1,5 +1,5 @@ import { Container } from "@buape/carbon"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { inspectDiscordAccount } from "./account-inspect.js"; const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2"; diff --git a/src/discord/voice-message.test.ts b/extensions/discord/src/voice-message.test.ts similarity index 98% rename from src/discord/voice-message.test.ts rename to extensions/discord/src/voice-message.test.ts index 51a177f059f..c6b6224b739 100644 --- a/src/discord/voice-message.test.ts +++ b/extensions/discord/src/voice-message.test.ts @@ -77,7 +77,7 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); -vi.mock("../infra/tmp-openclaw-dir.js", () => ({ +vi.mock("../../../src/infra/tmp-openclaw-dir.js", () => ({ resolvePreferredOpenClawTmpDir: () => "/tmp", })); diff --git a/src/discord/voice-message.ts b/extensions/discord/src/voice-message.ts similarity index 96% rename from src/discord/voice-message.ts rename to extensions/discord/src/voice-message.ts index fcda7113793..6f77ebc7bd9 100644 --- a/src/discord/voice-message.ts +++ b/extensions/discord/src/voice-message.ts @@ -14,11 +14,15 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { RateLimitError, type RequestClient } from "@buape/carbon"; -import type { RetryRunner } from "../infra/retry-policy.js"; -import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -import { parseFfprobeCodecAndSampleRate, runFfmpeg, runFfprobe } from "../media/ffmpeg-exec.js"; -import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "../media/ffmpeg-limits.js"; -import { unlinkIfExists } from "../media/temp-files.js"; +import type { RetryRunner } from "../../../src/infra/retry-policy.js"; +import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; +import { + parseFfprobeCodecAndSampleRate, + runFfmpeg, + runFfprobe, +} from "../../../src/media/ffmpeg-exec.js"; +import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "../../../src/media/ffmpeg-limits.js"; +import { unlinkIfExists } from "../../../src/media/temp-files.js"; const DISCORD_VOICE_MESSAGE_FLAG = 1 << 13; const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12; diff --git a/src/discord/voice/command.test.ts b/extensions/discord/src/voice/command.test.ts similarity index 100% rename from src/discord/voice/command.test.ts rename to extensions/discord/src/voice/command.test.ts diff --git a/src/discord/voice/command.ts b/extensions/discord/src/voice/command.ts similarity index 97% rename from src/discord/voice/command.ts rename to extensions/discord/src/voice/command.ts index 754a0f3622a..26ef7b9bbe5 100644 --- a/src/discord/voice/command.ts +++ b/extensions/discord/src/voice/command.ts @@ -10,10 +10,10 @@ import { ChannelType as DiscordChannelType, type APIApplicationCommandChannelOption, } from "discord-api-types/v10"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; -import type { DiscordAccountConfig } from "../../config/types.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +import type { DiscordAccountConfig } from "../../../../src/config/types.js"; import { formatMention } from "../mentions.js"; import { isDiscordGroupAllowedByPolicy, diff --git a/src/discord/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts similarity index 98% rename from src/discord/voice/manager.e2e.test.ts rename to extensions/discord/src/voice/manager.e2e.test.ts index ff1aca6ca25..17d21ff7414 100644 --- a/src/discord/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -95,15 +95,15 @@ vi.mock("@discordjs/voice", () => ({ joinVoiceChannel: joinVoiceChannelMock, })); -vi.mock("../../routing/resolve-route.js", () => ({ +vi.mock("../../../../src/routing/resolve-route.js", () => ({ resolveAgentRoute: resolveAgentRouteMock, })); -vi.mock("../../commands/agent.js", () => ({ +vi.mock("../../../../src/commands/agent.js", () => ({ agentCommandFromIngress: agentCommandMock, })); -vi.mock("../../media-understanding/runner.js", () => ({ +vi.mock("../../../../src/media-understanding/runner.js", () => ({ buildProviderRegistry: buildProviderRegistryMock, createMediaAttachmentCache: createMediaAttachmentCacheMock, normalizeMediaAttachments: normalizeMediaAttachmentsMock, diff --git a/src/discord/voice/manager.runtime.ts b/extensions/discord/src/voice/manager.runtime.ts similarity index 100% rename from src/discord/voice/manager.runtime.ts rename to extensions/discord/src/voice/manager.runtime.ts diff --git a/src/discord/voice/manager.ts b/extensions/discord/src/voice/manager.ts similarity index 96% rename from src/discord/voice/manager.ts rename to extensions/discord/src/voice/manager.ts index abec26d900d..90c6c3bb1e6 100644 --- a/src/discord/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -16,26 +16,26 @@ import { type AudioPlayer, type VoiceConnection, } from "@discordjs/voice"; -import { resolveAgentDir } from "../../agents/agent-scope.js"; -import type { MsgContext } from "../../auto-reply/templating.js"; -import { agentCommandFromIngress } from "../../commands/agent.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; -import type { DiscordAccountConfig, TtsConfig } from "../../config/types.js"; -import { logVerbose, shouldLogVerbose } from "../../globals.js"; -import { formatErrorMessage } from "../../infra/errors.js"; -import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { resolveAgentDir } from "../../../../src/agents/agent-scope.js"; +import type { MsgContext } from "../../../../src/auto-reply/templating.js"; +import { agentCommandFromIngress } from "../../../../src/commands/agent.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +import type { DiscordAccountConfig, TtsConfig } from "../../../../src/config/types.js"; +import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import { resolvePreferredOpenClawTmpDir } from "../../../../src/infra/tmp-openclaw-dir.js"; +import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; import { buildProviderRegistry, createMediaAttachmentCache, normalizeMediaAttachments, runCapability, -} from "../../media-understanding/runner.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import { parseTtsDirectives } from "../../tts/tts-core.js"; -import { resolveTtsConfig, textToSpeech, type ResolvedTtsConfig } from "../../tts/tts.js"; +} from "../../../../src/media-understanding/runner.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { parseTtsDirectives } from "../../../../src/tts/tts-core.js"; +import { resolveTtsConfig, textToSpeech, type ResolvedTtsConfig } from "../../../../src/tts/tts.js"; import { formatMention } from "../mentions.js"; import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js"; import { formatDiscordUserTag } from "../monitor/format.js"; diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index d44131fa4cf..805dd389b0a 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index a5c5fd54652..61ae5be803c 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 8b6f42e371c..3514ac52b90 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,15 +1,12 @@ { "name": "@openclaw/googlechat", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", "dependencies": { "google-auth-library": "^10.6.1" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "peerDependencies": { "openclaw": ">=2026.3.11" }, diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 0f8ca0ac9dd..c0988ee601c 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/src/imessage/accounts.ts b/extensions/imessage/src/accounts.ts similarity index 85% rename from src/imessage/accounts.ts rename to extensions/imessage/src/accounts.ts index d0ed6a9218c..f370fd54860 100644 --- a/src/imessage/accounts.ts +++ b/extensions/imessage/src/accounts.ts @@ -1,8 +1,8 @@ -import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { IMessageAccountConfig } from "../config/types.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { IMessageAccountConfig } from "../../../src/config/types.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; export type ResolvedIMessageAccount = { accountId: string; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 17023599eb1..2394f80ec62 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -29,6 +29,7 @@ import { type ChannelPlugin, type ResolvedIMessageAccount, } from "openclaw/plugin-sdk/imessage"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getIMessageRuntime } from "./runtime.js"; @@ -59,11 +60,12 @@ async function sendIMessageOutbound(params: { mediaUrl?: string; mediaLocalRoots?: readonly string[]; accountId?: string; - deps?: { sendIMessage?: IMessageSendFn }; + deps?: { [channelId: string]: unknown }; replyToId?: string; }) { const send = - params.deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; + resolveOutboundSendDep(params.deps, "imessage") ?? + getIMessageRuntime().channel.imessage.sendMessageIMessage; const maxBytes = resolveChannelMediaMaxBytes({ cfg: params.cfg, resolveChannelLimitMb: ({ cfg, accountId }) => diff --git a/src/imessage/client.ts b/extensions/imessage/src/client.ts similarity index 98% rename from src/imessage/client.ts rename to extensions/imessage/src/client.ts index d4ec458a7e9..efe9e5deb3b 100644 --- a/src/imessage/client.ts +++ b/extensions/imessage/src/client.ts @@ -1,7 +1,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { createInterface, type Interface } from "node:readline"; -import type { RuntimeEnv } from "../runtime.js"; -import { resolveUserPath } from "../utils.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { resolveUserPath } from "../../../src/utils.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; export type IMessageRpcError = { diff --git a/src/imessage/constants.ts b/extensions/imessage/src/constants.ts similarity index 100% rename from src/imessage/constants.ts rename to extensions/imessage/src/constants.ts diff --git a/src/imessage/monitor.gating.test.ts b/extensions/imessage/src/monitor.gating.test.ts similarity index 99% rename from src/imessage/monitor.gating.test.ts rename to extensions/imessage/src/monitor.gating.test.ts index 36a324e009b..2e564cc30cf 100644 --- a/src/imessage/monitor.gating.test.ts +++ b/extensions/imessage/src/monitor.gating.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { buildIMessageInboundContext, resolveIMessageInboundDecision, diff --git a/src/imessage/monitor.shutdown.unhandled-rejection.test.ts b/extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts similarity index 100% rename from src/imessage/monitor.shutdown.unhandled-rejection.test.ts rename to extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts diff --git a/src/imessage/monitor.ts b/extensions/imessage/src/monitor.ts similarity index 100% rename from src/imessage/monitor.ts rename to extensions/imessage/src/monitor.ts diff --git a/src/imessage/monitor/abort-handler.ts b/extensions/imessage/src/monitor/abort-handler.ts similarity index 100% rename from src/imessage/monitor/abort-handler.ts rename to extensions/imessage/src/monitor/abort-handler.ts diff --git a/src/imessage/monitor/deliver.test.ts b/extensions/imessage/src/monitor/deliver.test.ts similarity index 93% rename from src/imessage/monitor/deliver.test.ts rename to extensions/imessage/src/monitor/deliver.test.ts index 9db03d6ace5..75d18eec71e 100644 --- a/src/imessage/monitor/deliver.test.ts +++ b/extensions/imessage/src/monitor/deliver.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; const sendMessageIMessageMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "imsg-1" }), @@ -14,20 +14,20 @@ vi.mock("../send.js", () => ({ sendMessageIMessageMock(to, message, opts), })); -vi.mock("../../auto-reply/chunk.js", () => ({ +vi.mock("../../../../src/auto-reply/chunk.js", () => ({ chunkTextWithMode: (text: string) => chunkTextWithModeMock(text), resolveChunkMode: () => resolveChunkModeMock(), })); -vi.mock("../../config/config.js", () => ({ +vi.mock("../../../../src/config/config.js", () => ({ loadConfig: () => ({}), })); -vi.mock("../../config/markdown-tables.js", () => ({ +vi.mock("../../../../src/config/markdown-tables.js", () => ({ resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(), })); -vi.mock("../../markdown/tables.js", () => ({ +vi.mock("../../../../src/markdown/tables.js", () => ({ convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text), })); diff --git a/src/imessage/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts similarity index 83% rename from src/imessage/monitor/deliver.ts rename to extensions/imessage/src/monitor/deliver.ts index fc949d3cfc1..e8db8c0cac9 100644 --- a/src/imessage/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -1,9 +1,9 @@ -import { chunkTextWithMode, resolveChunkMode } from "../../auto-reply/chunk.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import { loadConfig } from "../../config/config.js"; -import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; -import { convertMarkdownTables } from "../../markdown/tables.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { chunkTextWithMode, resolveChunkMode } from "../../../../src/auto-reply/chunk.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import { loadConfig } from "../../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; +import { convertMarkdownTables } from "../../../../src/markdown/tables.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import type { createIMessageRpcClient } from "../client.js"; import { sendMessageIMessage } from "../send.js"; import type { SentMessageCache } from "./echo-cache.js"; diff --git a/src/imessage/monitor/echo-cache.ts b/extensions/imessage/src/monitor/echo-cache.ts similarity index 100% rename from src/imessage/monitor/echo-cache.ts rename to extensions/imessage/src/monitor/echo-cache.ts diff --git a/src/imessage/monitor/inbound-processing.test.ts b/extensions/imessage/src/monitor/inbound-processing.test.ts similarity index 98% rename from src/imessage/monitor/inbound-processing.test.ts rename to extensions/imessage/src/monitor/inbound-processing.test.ts index d2adc37bf74..4575a28de36 100644 --- a/src/imessage/monitor/inbound-processing.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { sanitizeTerminalText } from "../../terminal/safe-text.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { sanitizeTerminalText } from "../../../../src/terminal/safe-text.js"; import { describeIMessageEchoDropLog, resolveIMessageInboundDecision, diff --git a/src/imessage/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts similarity index 94% rename from src/imessage/monitor/inbound-processing.ts rename to extensions/imessage/src/monitor/inbound-processing.ts index fcef1fd53c9..af900e21b40 100644 --- a/src/imessage/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -1,31 +1,34 @@ -import { hasControlCommand } from "../../auto-reply/command-detection.js"; +import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js"; import { formatInboundEnvelope, formatInboundFromLabel, resolveEnvelopeFormatOptions, type EnvelopeFormatOptions, -} from "../../auto-reply/envelope.js"; +} from "../../../../src/auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "../../auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; -import { resolveDualTextControlCommandGate } from "../../channels/command-gating.js"; -import { logInboundDrop } from "../../channels/logging.js"; -import type { OpenClawConfig } from "../../config/config.js"; +} from "../../../../src/auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../../../../src/auto-reply/reply/mentions.js"; +import { resolveDualTextControlCommandGate } from "../../../../src/channels/command-gating.js"; +import { logInboundDrop } from "../../../../src/channels/logging.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, -} from "../../config/group-policy.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; +} from "../../../../src/config/group-policy.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; import { DM_GROUP_ACCESS_REASON, resolveDmGroupAccessWithLists, -} from "../../security/dm-policy-shared.js"; -import { sanitizeTerminalText } from "../../terminal/safe-text.js"; -import { truncateUtf16Safe } from "../../utils.js"; +} from "../../../../src/security/dm-policy-shared.js"; +import { sanitizeTerminalText } from "../../../../src/terminal/safe-text.js"; +import { truncateUtf16Safe } from "../../../../src/utils.js"; import { formatIMessageChatTarget, isAllowedIMessageSender, diff --git a/src/imessage/monitor/loop-rate-limiter.test.ts b/extensions/imessage/src/monitor/loop-rate-limiter.test.ts similarity index 100% rename from src/imessage/monitor/loop-rate-limiter.test.ts rename to extensions/imessage/src/monitor/loop-rate-limiter.test.ts diff --git a/src/imessage/monitor/loop-rate-limiter.ts b/extensions/imessage/src/monitor/loop-rate-limiter.ts similarity index 100% rename from src/imessage/monitor/loop-rate-limiter.ts rename to extensions/imessage/src/monitor/loop-rate-limiter.ts diff --git a/src/imessage/monitor/monitor-provider.echo-cache.test.ts b/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts similarity index 100% rename from src/imessage/monitor/monitor-provider.echo-cache.test.ts rename to extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts diff --git a/src/imessage/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts similarity index 92% rename from src/imessage/monitor/monitor-provider.ts rename to extensions/imessage/src/monitor/monitor-provider.ts index 1324529cbff..e3c062cd814 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -1,42 +1,42 @@ import fs from "node:fs/promises"; -import { resolveHumanDelayConfig } from "../../agents/identity.js"; -import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; +import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; +import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; +import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js"; import { clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, -} from "../../auto-reply/reply/history.js"; -import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; +} from "../../../../src/auto-reply/reply/history.js"; +import { createReplyDispatcher } from "../../../../src/auto-reply/reply/reply-dispatcher.js"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "../../channels/inbound-debounce-policy.js"; -import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; -import { recordInboundSession } from "../../channels/session.js"; -import { loadConfig } from "../../config/config.js"; +} from "../../../../src/channels/inbound-debounce-policy.js"; +import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; +import { recordInboundSession } from "../../../../src/channels/session.js"; +import { loadConfig } from "../../../../src/config/config.js"; import { resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../../config/runtime-group-policy.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; -import { normalizeScpRemoteHost } from "../../infra/scp-host.js"; -import { waitForTransportReady } from "../../infra/transport-ready.js"; +} from "../../../../src/config/runtime-group-policy.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; +import { danger, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; +import { normalizeScpRemoteHost } from "../../../../src/infra/scp-host.js"; +import { waitForTransportReady } from "../../../../src/infra/transport-ready.js"; import { isInboundPathAllowed, resolveIMessageAttachmentRoots, resolveIMessageRemoteAttachmentRoots, -} from "../../media/inbound-path-policy.js"; -import { kindFromMime } from "../../media/mime.js"; -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; +} from "../../../../src/media/inbound-path-policy.js"; +import { kindFromMime } from "../../../../src/media/mime.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; import { readChannelAllowFromStore, upsertChannelPairingRequest, -} from "../../pairing/pairing-store.js"; -import { resolvePinnedMainDmOwnerFromAllowlist } from "../../security/dm-policy-shared.js"; -import { truncateUtf16Safe } from "../../utils.js"; +} from "../../../../src/pairing/pairing-store.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../src/security/dm-policy-shared.js"; +import { truncateUtf16Safe } from "../../../../src/utils.js"; import { resolveIMessageAccount } from "../accounts.js"; import { createIMessageRpcClient } from "../client.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js"; diff --git a/src/imessage/monitor/parse-notification.ts b/extensions/imessage/src/monitor/parse-notification.ts similarity index 100% rename from src/imessage/monitor/parse-notification.ts rename to extensions/imessage/src/monitor/parse-notification.ts diff --git a/src/imessage/monitor/provider.group-policy.test.ts b/extensions/imessage/src/monitor/provider.group-policy.test.ts similarity index 91% rename from src/imessage/monitor/provider.group-policy.test.ts rename to extensions/imessage/src/monitor/provider.group-policy.test.ts index 58812ad5711..d6a7b1f880b 100644 --- a/src/imessage/monitor/provider.group-policy.test.ts +++ b/extensions/imessage/src/monitor/provider.group-policy.test.ts @@ -1,5 +1,5 @@ import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; import { __testing } from "./monitor-provider.js"; describe("resolveIMessageRuntimeGroupPolicy", () => { diff --git a/src/imessage/monitor/reflection-guard.test.ts b/extensions/imessage/src/monitor/reflection-guard.test.ts similarity index 100% rename from src/imessage/monitor/reflection-guard.test.ts rename to extensions/imessage/src/monitor/reflection-guard.test.ts diff --git a/src/imessage/monitor/reflection-guard.ts b/extensions/imessage/src/monitor/reflection-guard.ts similarity index 95% rename from src/imessage/monitor/reflection-guard.ts rename to extensions/imessage/src/monitor/reflection-guard.ts index 97a329315e8..0af95d957cc 100644 --- a/src/imessage/monitor/reflection-guard.ts +++ b/extensions/imessage/src/monitor/reflection-guard.ts @@ -4,7 +4,7 @@ * bounced back as a new inbound message — creating an echo loop. */ -import { findCodeRegions, isInsideCode } from "../../shared/text/code-regions.js"; +import { findCodeRegions, isInsideCode } from "../../../../src/shared/text/code-regions.js"; const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/; const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/i; diff --git a/src/imessage/monitor/runtime.ts b/extensions/imessage/src/monitor/runtime.ts similarity index 76% rename from src/imessage/monitor/runtime.ts rename to extensions/imessage/src/monitor/runtime.ts index 72066272d6c..e4fe6ae4336 100644 --- a/src/imessage/monitor/runtime.ts +++ b/extensions/imessage/src/monitor/runtime.ts @@ -1,5 +1,5 @@ -import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; -import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; +import { normalizeStringEntries } from "../../../../src/shared/string-normalization.js"; import type { MonitorIMessageOpts } from "./types.js"; export function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv { diff --git a/src/imessage/monitor/sanitize-outbound.test.ts b/extensions/imessage/src/monitor/sanitize-outbound.test.ts similarity index 100% rename from src/imessage/monitor/sanitize-outbound.test.ts rename to extensions/imessage/src/monitor/sanitize-outbound.test.ts diff --git a/src/imessage/monitor/sanitize-outbound.ts b/extensions/imessage/src/monitor/sanitize-outbound.ts similarity index 90% rename from src/imessage/monitor/sanitize-outbound.ts rename to extensions/imessage/src/monitor/sanitize-outbound.ts index 9fe1664e1eb..83eb75a8da2 100644 --- a/src/imessage/monitor/sanitize-outbound.ts +++ b/extensions/imessage/src/monitor/sanitize-outbound.ts @@ -1,4 +1,4 @@ -import { stripAssistantInternalScaffolding } from "../../shared/text/assistant-visible-text.js"; +import { stripAssistantInternalScaffolding } from "../../../../src/shared/text/assistant-visible-text.js"; /** * Patterns that indicate assistant-internal metadata leaked into text. diff --git a/src/imessage/monitor/self-chat-cache.test.ts b/extensions/imessage/src/monitor/self-chat-cache.test.ts similarity index 100% rename from src/imessage/monitor/self-chat-cache.test.ts rename to extensions/imessage/src/monitor/self-chat-cache.test.ts diff --git a/src/imessage/monitor/self-chat-cache.ts b/extensions/imessage/src/monitor/self-chat-cache.ts similarity index 100% rename from src/imessage/monitor/self-chat-cache.ts rename to extensions/imessage/src/monitor/self-chat-cache.ts diff --git a/src/imessage/monitor/types.ts b/extensions/imessage/src/monitor/types.ts similarity index 87% rename from src/imessage/monitor/types.ts rename to extensions/imessage/src/monitor/types.ts index 2f13b3ecfb9..074c7c34c9f 100644 --- a/src/imessage/monitor/types.ts +++ b/extensions/imessage/src/monitor/types.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; export type IMessageAttachment = { original_path?: string | null; diff --git a/src/imessage/probe.test.ts b/extensions/imessage/src/probe.test.ts similarity index 91% rename from src/imessage/probe.test.ts rename to extensions/imessage/src/probe.test.ts index adee76063bb..5d676327c11 100644 --- a/src/imessage/probe.test.ts +++ b/extensions/imessage/src/probe.test.ts @@ -5,11 +5,11 @@ const detectBinaryMock = vi.hoisted(() => vi.fn()); const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); const createIMessageRpcClientMock = vi.hoisted(() => vi.fn()); -vi.mock("../commands/onboard-helpers.js", () => ({ +vi.mock("../../../src/commands/onboard-helpers.js", () => ({ detectBinary: (...args: unknown[]) => detectBinaryMock(...args), })); -vi.mock("../process/exec.js", () => ({ +vi.mock("../../../src/process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); diff --git a/src/imessage/probe.ts b/extensions/imessage/src/probe.ts similarity index 90% rename from src/imessage/probe.ts rename to extensions/imessage/src/probe.ts index 9c33a471ab0..1b6ab665d09 100644 --- a/src/imessage/probe.ts +++ b/extensions/imessage/src/probe.ts @@ -1,8 +1,8 @@ -import type { BaseProbeResult } from "../channels/plugins/types.js"; -import { detectBinary } from "../commands/onboard-helpers.js"; -import { loadConfig } from "../config/config.js"; -import { runCommandWithTimeout } from "../process/exec.js"; -import type { RuntimeEnv } from "../runtime.js"; +import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; +import { detectBinary } from "../../../src/commands/onboard-helpers.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { runCommandWithTimeout } from "../../../src/process/exec.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import { createIMessageRpcClient } from "./client.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; diff --git a/src/imessage/send.test.ts b/extensions/imessage/src/send.test.ts similarity index 100% rename from src/imessage/send.test.ts rename to extensions/imessage/src/send.test.ts diff --git a/src/imessage/send.ts b/extensions/imessage/src/send.ts similarity index 94% rename from src/imessage/send.ts rename to extensions/imessage/src/send.ts index efa3fca3366..5bc02b6bb7f 100644 --- a/src/imessage/send.ts +++ b/extensions/imessage/src/send.ts @@ -1,8 +1,8 @@ -import { loadConfig } from "../config/config.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { convertMarkdownTables } from "../markdown/tables.js"; -import { kindFromMime } from "../media/mime.js"; -import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { convertMarkdownTables } from "../../../src/markdown/tables.js"; +import { kindFromMime } from "../../../src/media/mime.js"; +import { resolveOutboundAttachmentFromUrl } from "../../../src/media/outbound-attachment.js"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; diff --git a/src/imessage/target-parsing-helpers.ts b/extensions/imessage/src/target-parsing-helpers.ts similarity index 98% rename from src/imessage/target-parsing-helpers.ts rename to extensions/imessage/src/target-parsing-helpers.ts index ba00590e6d5..95ccc3682ce 100644 --- a/src/imessage/target-parsing-helpers.ts +++ b/extensions/imessage/src/target-parsing-helpers.ts @@ -1,4 +1,4 @@ -import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js"; +import { isAllowedParsedChatSender } from "../../../src/plugin-sdk/allow-from.js"; export type ServicePrefix = { prefix: string; service: TService }; diff --git a/src/imessage/targets.test.ts b/extensions/imessage/src/targets.test.ts similarity index 100% rename from src/imessage/targets.test.ts rename to extensions/imessage/src/targets.test.ts diff --git a/src/imessage/targets.ts b/extensions/imessage/src/targets.ts similarity index 98% rename from src/imessage/targets.ts rename to extensions/imessage/src/targets.ts index e709f1064e4..a376a6e7f45 100644 --- a/src/imessage/targets.ts +++ b/extensions/imessage/src/targets.ts @@ -1,4 +1,4 @@ -import { normalizeE164 } from "../utils.js"; +import { normalizeE164 } from "../../../src/utils.js"; import { createAllowedChatSenderMatcher, type ChatSenderAllowParams, diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 85a04dcdaea..8d162b9ac20 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw IRC channel plugin", "type": "module", "dependencies": { diff --git a/extensions/line/package.json b/extensions/line/package.json index e9e691ac8b8..85bfac7f0ac 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index ac792d4a8d2..6b19e5cb4b2 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index d18581200db..915e5d5c3de 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.3.13", + "version": "2026.3.14", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "dependencies": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 4e4ac1f71fe..5e6a7ed5327 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 764e1795e1a..5b973b88635 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,10 +1,10 @@ { "name": "@openclaw/matrix", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { - "@mariozechner/pi-agent-core": "0.57.1", + "@mariozechner/pi-agent-core": "0.58.0", "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", "@vector-im/matrix-bot-sdk": "0.8.0-element.3", "markdown-it": "14.1.1", diff --git a/extensions/matrix/src/outbound.test.ts b/extensions/matrix/src/outbound.test.ts index e0b62c1c00b..081c5572837 100644 --- a/extensions/matrix/src/outbound.test.ts +++ b/extensions/matrix/src/outbound.test.ts @@ -88,7 +88,7 @@ describe("matrixOutbound cfg threading", () => { ); }); - it("passes resolved cfg through injected deps.sendMatrix", async () => { + it("passes resolved cfg through injected deps.matrix", async () => { const cfg = { channels: { matrix: { @@ -96,7 +96,7 @@ describe("matrixOutbound cfg threading", () => { }, }, } as OpenClawConfig; - const sendMatrix = vi.fn(async () => ({ + const matrix = vi.fn(async () => ({ messageId: "evt-injected", roomId: "!room:example", })); @@ -105,13 +105,13 @@ describe("matrixOutbound cfg threading", () => { cfg, to: "room:!room:example", text: "hello via deps", - deps: { sendMatrix }, + deps: { matrix }, accountId: "default", threadId: "$thread", replyToId: "$reply", }); - expect(sendMatrix).toHaveBeenCalledWith( + expect(matrix).toHaveBeenCalledWith( "room:!room:example", "hello via deps", expect.objectContaining({ diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index be4f8d3426d..1018fd0c2e5 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -1,4 +1,5 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import { getMatrixRuntime } from "./runtime.js"; @@ -8,7 +9,8 @@ export const matrixOutbound: ChannelOutboundAdapter = { chunkerMode: "markdown", textChunkLimit: 4000, sendText: async ({ cfg, to, text, deps, replyToId, threadId, accountId }) => { - const send = deps?.sendMatrix ?? sendMessageMatrix; + const send = + resolveOutboundSendDep(deps, "matrix") ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await send(to, text, { @@ -24,7 +26,8 @@ export const matrixOutbound: ChannelOutboundAdapter = { }; }, sendMedia: async ({ cfg, to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { - const send = deps?.sendMatrix ?? sendMessageMatrix; + const send = + resolveOutboundSendDep(deps, "matrix") ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await send(to, text, { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index bc8c14f458f..17f8add1b1f 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Mattermost channel plugin", "type": "module", "dependencies": { diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index c188a8e6719..5ac333b2e6c 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -355,6 +355,53 @@ describe("mattermostPlugin", () => { }), ); }); + + it("uses threadId as fallback when replyToId is absent (sendText)", async () => { + const sendText = mattermostPlugin.outbound?.sendText; + if (!sendText) { + return; + } + + await sendText({ + to: "channel:CHAN1", + text: "hello", + accountId: "default", + threadId: "post-root", + } as any); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); + + it("uses threadId as fallback when replyToId is absent (sendMedia)", async () => { + const sendMedia = mattermostPlugin.outbound?.sendMedia; + if (!sendMedia) { + return; + } + + await sendMedia({ + to: "channel:CHAN1", + text: "caption", + mediaUrl: "https://example.com/image.png", + accountId: "default", + threadId: "post-root", + } as any); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "caption", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); }); describe("config", () => { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index c872b8d5085..45c4d863c7c 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -390,21 +390,30 @@ export const mattermostPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { const result = await sendMessageMattermost(to, text, { cfg, accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), }); return { channel: "mattermost", ...result }; }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, replyToId }) => { + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + replyToId, + threadId, + }) => { const result = await sendMessageMattermost(to, text, { cfg, accountId: accountId ?? undefined, mediaUrl, mediaLocalRoots, - replyToId: replyToId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), }); return { channel: "mattermost", ...result }; }, diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index 969bff3e07c..a6a8d1dbca8 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/memory-core", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "peerDependencies": { "openclaw": ">=2026.3.11" }, diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 9e1af0d7df2..3f387bee4f4 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index bd61f8c9f65..093d42dad1d 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 229656712f8..4fb831f9278 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index f14baa64f3a..4784334d1d5 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 9f3f55c6414..4241e166872 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,4 +1,5 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/msteams"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { getMSTeamsRuntime } from "./runtime.js"; import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; @@ -10,13 +11,24 @@ export const msteamsOutbound: ChannelOutboundAdapter = { textChunkLimit: 4000, pollMaxOptions: 12, sendText: async ({ cfg, to, text, deps }) => { - const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text })); + type SendFn = ( + to: string, + text: string, + ) => Promise<{ messageId: string; conversationId: string }>; + const send = + resolveOutboundSendDep(deps, "msteams") ?? + ((to, text) => sendMessageMSTeams({ cfg, to, text })); const result = await send(to, text); return { channel: "msteams", ...result }; }, sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { + type SendFn = ( + to: string, + text: string, + opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, + ) => Promise<{ messageId: string; conversationId: string }>; const send = - deps?.sendMSTeams ?? + resolveOutboundSendDep(deps, "msteams") ?? ((to, text, opts) => sendMessageMSTeams({ cfg, diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 6c7957a5b25..c217d0f0ce7 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "dependencies": { diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index 0e59b1cb08e..c8cdc11422e 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 1c3499f3481..19ef7cc03e7 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { diff --git a/extensions/ollama/package.json b/extensions/ollama/package.json index 5bdf5fd688e..61a8227c3ed 100644 --- a/extensions/ollama/package.json +++ b/extensions/ollama/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/ollama-provider", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Ollama provider plugin", "type": "module", diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index f8f0e97cef3..69272781198 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/sglang/package.json b/extensions/sglang/package.json index 6b38cfafb60..d64495bd110 100644 --- a/extensions/sglang/package.json +++ b/extensions/sglang/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/sglang-provider", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw SGLang provider plugin", "type": "module", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 95a4879cc82..67d6eae6506 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/src/signal/accounts.ts b/extensions/signal/src/accounts.ts similarity index 84% rename from src/signal/accounts.ts rename to extensions/signal/src/accounts.ts index ed5732b9155..edcfa4c1d64 100644 --- a/src/signal/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -1,8 +1,8 @@ -import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { SignalAccountConfig } from "../config/types.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { SignalAccountConfig } from "../../../src/config/types.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 89dfb8c9a48..f763f0c6769 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -30,6 +30,7 @@ import { type ChannelPlugin, type ResolvedSignalAccount, } from "openclaw/plugin-sdk/signal"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { getSignalRuntime } from "./runtime.js"; const signalMessageActions: ChannelMessageActionAdapter = { @@ -84,9 +85,11 @@ async function sendSignalOutbound(params: { mediaUrl?: string; mediaLocalRoots?: readonly string[]; accountId?: string; - deps?: { sendSignal?: SignalSendFn }; + deps?: { [channelId: string]: unknown }; }) { - const send = params.deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal; + const send = + resolveOutboundSendDep(params.deps, "signal") ?? + getSignalRuntime().channel.signal.sendMessageSignal; const maxBytes = resolveChannelMediaMaxBytes({ cfg: params.cfg, resolveChannelLimitMb: ({ cfg, accountId }) => diff --git a/src/signal/client.test.ts b/extensions/signal/src/client.test.ts similarity index 92% rename from src/signal/client.test.ts rename to extensions/signal/src/client.test.ts index 109ec5f9494..9313bb17573 100644 --- a/src/signal/client.test.ts +++ b/extensions/signal/src/client.test.ts @@ -3,15 +3,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const fetchWithTimeoutMock = vi.fn(); const resolveFetchMock = vi.fn(); -vi.mock("../infra/fetch.js", () => ({ +vi.mock("../../../src/infra/fetch.js", () => ({ resolveFetch: (...args: unknown[]) => resolveFetchMock(...args), })); -vi.mock("../infra/secure-random.js", () => ({ +vi.mock("../../../src/infra/secure-random.js", () => ({ generateSecureUuid: () => "test-id", })); -vi.mock("../utils/fetch-timeout.js", () => ({ +vi.mock("../../../src/utils/fetch-timeout.js", () => ({ fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args), })); diff --git a/src/signal/client.ts b/extensions/signal/src/client.ts similarity index 96% rename from src/signal/client.ts rename to extensions/signal/src/client.ts index 198e1ad450b..394aec4e297 100644 --- a/src/signal/client.ts +++ b/extensions/signal/src/client.ts @@ -1,6 +1,6 @@ -import { resolveFetch } from "../infra/fetch.js"; -import { generateSecureUuid } from "../infra/secure-random.js"; -import { fetchWithTimeout } from "../utils/fetch-timeout.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; +import { generateSecureUuid } from "../../../src/infra/secure-random.js"; +import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; export type SignalRpcOptions = { baseUrl: string; diff --git a/src/signal/daemon.ts b/extensions/signal/src/daemon.ts similarity index 98% rename from src/signal/daemon.ts rename to extensions/signal/src/daemon.ts index 93f116d466e..d53597a296b 100644 --- a/src/signal/daemon.ts +++ b/extensions/signal/src/daemon.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import type { RuntimeEnv } from "../runtime.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; export type SignalDaemonOpts = { cliPath: string; diff --git a/src/signal/format.chunking.test.ts b/extensions/signal/src/format.chunking.test.ts similarity index 100% rename from src/signal/format.chunking.test.ts rename to extensions/signal/src/format.chunking.test.ts diff --git a/src/signal/format.links.test.ts b/extensions/signal/src/format.links.test.ts similarity index 100% rename from src/signal/format.links.test.ts rename to extensions/signal/src/format.links.test.ts diff --git a/src/signal/format.test.ts b/extensions/signal/src/format.test.ts similarity index 100% rename from src/signal/format.test.ts rename to extensions/signal/src/format.test.ts diff --git a/src/signal/format.ts b/extensions/signal/src/format.ts similarity index 98% rename from src/signal/format.ts rename to extensions/signal/src/format.ts index 8f35a34f2da..2180693293e 100644 --- a/src/signal/format.ts +++ b/extensions/signal/src/format.ts @@ -1,10 +1,10 @@ -import type { MarkdownTableMode } from "../config/types.base.js"; +import type { MarkdownTableMode } from "../../../src/config/types.base.js"; import { chunkMarkdownIR, markdownToIR, type MarkdownIR, type MarkdownStyle, -} from "../markdown/ir.js"; +} from "../../../src/markdown/ir.js"; type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER"; diff --git a/src/signal/format.visual.test.ts b/extensions/signal/src/format.visual.test.ts similarity index 100% rename from src/signal/format.visual.test.ts rename to extensions/signal/src/format.visual.test.ts diff --git a/src/signal/identity.test.ts b/extensions/signal/src/identity.test.ts similarity index 100% rename from src/signal/identity.test.ts rename to extensions/signal/src/identity.test.ts diff --git a/src/signal/identity.ts b/extensions/signal/src/identity.ts similarity index 96% rename from src/signal/identity.ts rename to extensions/signal/src/identity.ts index 965a9c88f0a..c39b0dd5eaa 100644 --- a/src/signal/identity.ts +++ b/extensions/signal/src/identity.ts @@ -1,5 +1,5 @@ -import { evaluateSenderGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; -import { normalizeE164 } from "../utils.js"; +import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js"; +import { normalizeE164 } from "../../../src/utils.js"; export type SignalSender = | { kind: "phone"; raw: string; e164: string } diff --git a/src/signal/index.ts b/extensions/signal/src/index.ts similarity index 100% rename from src/signal/index.ts rename to extensions/signal/src/index.ts diff --git a/src/signal/monitor.test.ts b/extensions/signal/src/monitor.test.ts similarity index 100% rename from src/signal/monitor.test.ts rename to extensions/signal/src/monitor.test.ts diff --git a/src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts b/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts similarity index 100% rename from src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts rename to extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts diff --git a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts similarity index 98% rename from src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts rename to extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index a06d17d61d9..2fedef73b33 100644 --- a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { peekSystemEvents } from "../infra/system-events.js"; -import { resolveAgentRoute } from "../routing/resolve-route.js"; -import { normalizeE164 } from "../utils.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { peekSystemEvents } from "../../../src/infra/system-events.js"; +import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; +import { normalizeE164 } from "../../../src/utils.js"; import type { SignalDaemonExitEvent } from "./daemon.js"; import { createMockSignalDaemonHandle, diff --git a/src/signal/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts similarity index 80% rename from src/signal/monitor.tool-result.test-harness.ts rename to extensions/signal/src/monitor.tool-result.test-harness.ts index 95220805698..252e039b0fb 100644 --- a/src/signal/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -1,7 +1,7 @@ import { beforeEach, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { resetSystemEventsForTest } from "../infra/system-events.js"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; +import { resetSystemEventsForTest } from "../../../src/infra/system-events.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js"; type SignalToolResultTestMocks = { @@ -68,15 +68,15 @@ export function createMockSignalDaemonHandle( }; } -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => config, }; }); -vi.mock("../auto-reply/reply.js", () => ({ +vi.mock("../../../src/auto-reply/reply.js", () => ({ getReplyFromConfig: (...args: unknown[]) => replyMock(...args), })); @@ -86,17 +86,21 @@ vi.mock("./send.js", () => ({ sendReadReceiptSignal: vi.fn().mockResolvedValue(true), })); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), })); -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), - updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), + updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), + readSessionUpdatedAt: vi.fn(() => undefined), + recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), + }; +}); vi.mock("./client.js", () => ({ streamSignalEvents: (...args: unknown[]) => streamMock(...args), @@ -112,7 +116,7 @@ vi.mock("./daemon.js", async (importOriginal) => { }; }); -vi.mock("../infra/transport-ready.js", () => ({ +vi.mock("../../../src/infra/transport-ready.js", () => ({ waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args), })); diff --git a/src/signal/monitor.ts b/extensions/signal/src/monitor.ts similarity index 93% rename from src/signal/monitor.ts rename to extensions/signal/src/monitor.ts index 13812593c63..3febfe740d4 100644 --- a/src/signal/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -1,20 +1,27 @@ -import { chunkTextWithMode, resolveChunkMode, resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; -import type { ReplyPayload } from "../auto-reply/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig } from "../config/config.js"; +import { + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, +} from "../../../src/auto-reply/chunk.js"; +import { + DEFAULT_GROUP_HISTORY_LIMIT, + type HistoryEntry, +} from "../../../src/auto-reply/reply/history.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { loadConfig } from "../../../src/config/config.js"; import { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../config/runtime-group-policy.js"; -import type { SignalReactionNotificationMode } from "../config/types.js"; -import type { BackoffPolicy } from "../infra/backoff.js"; -import { waitForTransportReady } from "../infra/transport-ready.js"; -import { saveMediaBuffer } from "../media/store.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../runtime.js"; -import { normalizeStringEntries } from "../shared/string-normalization.js"; -import { normalizeE164 } from "../utils.js"; +} from "../../../src/config/runtime-group-policy.js"; +import type { SignalReactionNotificationMode } from "../../../src/config/types.js"; +import type { BackoffPolicy } from "../../../src/infra/backoff.js"; +import { waitForTransportReady } from "../../../src/infra/transport-ready.js"; +import { saveMediaBuffer } from "../../../src/media/store.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "../../../src/runtime.js"; +import { normalizeStringEntries } from "../../../src/shared/string-normalization.js"; +import { normalizeE164 } from "../../../src/utils.js"; import { resolveSignalAccount } from "./accounts.js"; import { signalCheck, signalRpcRequest } from "./client.js"; import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js"; diff --git a/src/signal/monitor/access-policy.ts b/extensions/signal/src/monitor/access-policy.ts similarity index 92% rename from src/signal/monitor/access-policy.ts rename to extensions/signal/src/monitor/access-policy.ts index e836868ec8d..72555186031 100644 --- a/src/signal/monitor/access-policy.ts +++ b/extensions/signal/src/monitor/access-policy.ts @@ -1,9 +1,9 @@ -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, -} from "../../security/dm-policy-shared.js"; +} from "../../../../src/security/dm-policy-shared.js"; import { isSignalSenderAllowed, type SignalSender } from "../identity.js"; type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; diff --git a/src/signal/monitor/event-handler.inbound-contract.test.ts b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts similarity index 95% rename from src/signal/monitor/event-handler.inbound-contract.test.ts rename to extensions/signal/src/monitor/event-handler.inbound-contract.test.ts index 88be22ea5b4..62593156756 100644 --- a/src/signal/monitor/event-handler.inbound-contract.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; -import type { MsgContext } from "../../auto-reply/templating.js"; +import type { MsgContext } from "../../../../src/auto-reply/templating.js"; +import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; import { createSignalEventHandler } from "./event-handler.js"; import { createBaseSignalEventHandlerDeps, @@ -34,8 +34,8 @@ vi.mock("../send.js", () => ({ sendReadReceiptSignal: sendReadReceiptMock, })); -vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, dispatchInboundMessage: dispatchInboundMessageMock, @@ -44,7 +44,7 @@ vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { }; }); -vi.mock("../../pairing/pairing-store.js", () => ({ +vi.mock("../../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn().mockResolvedValue([]), upsertChannelPairingRequest: vi.fn(), })); diff --git a/src/signal/monitor/event-handler.mention-gating.test.ts b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts similarity index 95% rename from src/signal/monitor/event-handler.mention-gating.test.ts rename to extensions/signal/src/monitor/event-handler.mention-gating.test.ts index 38dedf5a813..05836c43975 100644 --- a/src/signal/monitor/event-handler.mention-gating.test.ts +++ b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { buildDispatchInboundCaptureMock } from "../../../test/helpers/dispatch-inbound-capture.js"; -import type { MsgContext } from "../../auto-reply/templating.js"; -import type { OpenClawConfig } from "../../config/types.js"; +import type { MsgContext } from "../../../../src/auto-reply/templating.js"; +import type { OpenClawConfig } from "../../../../src/config/types.js"; +import { buildDispatchInboundCaptureMock } from "../../../../test/helpers/dispatch-inbound-capture.js"; import { createBaseSignalEventHandlerDeps, createSignalReceiveEvent, @@ -18,8 +18,8 @@ function getCapturedCtx() { return capturedCtx as SignalMsgContext; } -vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); return buildDispatchInboundCaptureMock(actual, (ctx) => { capturedCtx = ctx as SignalMsgContext; }); diff --git a/src/signal/monitor/event-handler.test-harness.ts b/extensions/signal/src/monitor/event-handler.test-harness.ts similarity index 100% rename from src/signal/monitor/event-handler.test-harness.ts rename to extensions/signal/src/monitor/event-handler.test-harness.ts diff --git a/src/signal/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts similarity index 93% rename from src/signal/monitor/event-handler.ts rename to extensions/signal/src/monitor/event-handler.ts index c67e680b7ba..36eb0e8d276 100644 --- a/src/signal/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -1,41 +1,44 @@ -import { resolveHumanDelayConfig } from "../../agents/identity.js"; -import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; +import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; +import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js"; +import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js"; import { formatInboundEnvelope, formatInboundFromLabel, resolveEnvelopeFormatOptions, -} from "../../auto-reply/envelope.js"; +} from "../../../../src/auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, recordPendingHistoryEntryIfEnabled, -} from "../../auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; -import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; -import { resolveControlCommandGate } from "../../channels/command-gating.js"; +} from "../../../../src/auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../../../../src/auto-reply/reply/mentions.js"; +import { createReplyDispatcherWithTyping } from "../../../../src/auto-reply/reply/reply-dispatcher.js"; +import { resolveControlCommandGate } from "../../../../src/channels/command-gating.js"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "../../channels/inbound-debounce-policy.js"; -import { logInboundDrop, logTypingFailure } from "../../channels/logging.js"; -import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js"; -import { normalizeSignalMessagingTarget } from "../../channels/plugins/normalize/signal.js"; -import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; -import { recordInboundSession } from "../../channels/session.js"; -import { createTypingCallbacks } from "../../channels/typing.js"; -import { resolveChannelGroupRequireMention } from "../../config/group-policy.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { kindFromMime } from "../../media/mime.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; +} from "../../../../src/channels/inbound-debounce-policy.js"; +import { logInboundDrop, logTypingFailure } from "../../../../src/channels/logging.js"; +import { resolveMentionGatingWithBypass } from "../../../../src/channels/mention-gating.js"; +import { normalizeSignalMessagingTarget } from "../../../../src/channels/plugins/normalize/signal.js"; +import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; +import { recordInboundSession } from "../../../../src/channels/session.js"; +import { createTypingCallbacks } from "../../../../src/channels/typing.js"; +import { resolveChannelGroupRequireMention } from "../../../../src/config/group-policy.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; +import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; +import { kindFromMime } from "../../../../src/media/mime.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; import { DM_GROUP_ACCESS_REASON, resolvePinnedMainDmOwnerFromAllowlist, -} from "../../security/dm-policy-shared.js"; -import { normalizeE164 } from "../../utils.js"; +} from "../../../../src/security/dm-policy-shared.js"; +import { normalizeE164 } from "../../../../src/utils.js"; import { formatSignalPairingIdLine, formatSignalSenderDisplay, diff --git a/src/signal/monitor/event-handler.types.ts b/extensions/signal/src/monitor/event-handler.types.ts similarity index 88% rename from src/signal/monitor/event-handler.types.ts rename to extensions/signal/src/monitor/event-handler.types.ts index a7f3c6b1d1a..c1d0b0b3881 100644 --- a/src/signal/monitor/event-handler.types.ts +++ b/extensions/signal/src/monitor/event-handler.types.ts @@ -1,8 +1,12 @@ -import type { HistoryEntry } from "../../auto-reply/reply/history.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { DmPolicy, GroupPolicy, SignalReactionNotificationMode } from "../../config/types.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { + DmPolicy, + GroupPolicy, + SignalReactionNotificationMode, +} from "../../../../src/config/types.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import type { SignalSender } from "../identity.js"; export type SignalEnvelope = { diff --git a/src/signal/monitor/mentions.ts b/extensions/signal/src/monitor/mentions.ts similarity index 100% rename from src/signal/monitor/mentions.ts rename to extensions/signal/src/monitor/mentions.ts diff --git a/src/signal/probe.test.ts b/extensions/signal/src/probe.test.ts similarity index 100% rename from src/signal/probe.test.ts rename to extensions/signal/src/probe.test.ts diff --git a/src/signal/probe.ts b/extensions/signal/src/probe.ts similarity index 94% rename from src/signal/probe.ts rename to extensions/signal/src/probe.ts index 924f997015e..bf200effd6d 100644 --- a/src/signal/probe.ts +++ b/extensions/signal/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "../channels/plugins/types.js"; +import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; import { signalCheck, signalRpcRequest } from "./client.js"; export type SignalProbe = BaseProbeResult & { diff --git a/src/signal/reaction-level.ts b/extensions/signal/src/reaction-level.ts similarity index 89% rename from src/signal/reaction-level.ts rename to extensions/signal/src/reaction-level.ts index f3bd2ad7454..884bccec58e 100644 --- a/src/signal/reaction-level.ts +++ b/extensions/signal/src/reaction-level.ts @@ -1,9 +1,9 @@ -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveReactionLevel, type ReactionLevel, type ResolvedReactionLevel, -} from "../utils/reaction-level.js"; +} from "../../../src/utils/reaction-level.js"; import { resolveSignalAccount } from "./accounts.js"; export type SignalReactionLevel = ReactionLevel; diff --git a/src/signal/rpc-context.ts b/extensions/signal/src/rpc-context.ts similarity index 92% rename from src/signal/rpc-context.ts rename to extensions/signal/src/rpc-context.ts index f46ec3b124d..54c123cc6be 100644 --- a/src/signal/rpc-context.ts +++ b/extensions/signal/src/rpc-context.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { loadConfig } from "../../../src/config/config.js"; import { resolveSignalAccount } from "./accounts.js"; export function resolveSignalRpcContext( diff --git a/src/signal/send-reactions.test.ts b/extensions/signal/src/send-reactions.test.ts similarity index 93% rename from src/signal/send-reactions.test.ts rename to extensions/signal/src/send-reactions.test.ts index 84d0dc53fbf..47f0bbd8814 100644 --- a/src/signal/send-reactions.test.ts +++ b/extensions/signal/src/send-reactions.test.ts @@ -3,8 +3,8 @@ import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js"; const rpcMock = vi.fn(); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => ({}), diff --git a/src/signal/send-reactions.ts b/extensions/signal/src/send-reactions.ts similarity index 97% rename from src/signal/send-reactions.ts rename to extensions/signal/src/send-reactions.ts index dba41bb8b7d..a5000ca9e8f 100644 --- a/src/signal/send-reactions.ts +++ b/extensions/signal/src/send-reactions.ts @@ -2,8 +2,8 @@ * Signal reactions via signal-cli JSON-RPC API */ -import { loadConfig } from "../config/config.js"; -import type { OpenClawConfig } from "../config/config.js"; +import { loadConfig } from "../../../src/config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveSignalAccount } from "./accounts.js"; import { signalRpcRequest } from "./client.js"; import { resolveSignalRpcContext } from "./rpc-context.js"; diff --git a/src/signal/send.ts b/extensions/signal/src/send.ts similarity index 95% rename from src/signal/send.ts rename to extensions/signal/src/send.ts index 9dc4ef97917..bb953680290 100644 --- a/src/signal/send.ts +++ b/extensions/signal/src/send.ts @@ -1,7 +1,7 @@ -import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { kindFromMime } from "../media/mime.js"; -import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js"; +import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { kindFromMime } from "../../../src/media/mime.js"; +import { resolveOutboundAttachmentFromUrl } from "../../../src/media/outbound-attachment.js"; import { resolveSignalAccount } from "./accounts.js"; import { signalRpcRequest } from "./client.js"; import { markdownToSignalText, type SignalTextStyleRange } from "./format.js"; diff --git a/src/signal/sse-reconnect.ts b/extensions/signal/src/sse-reconnect.ts similarity index 86% rename from src/signal/sse-reconnect.ts rename to extensions/signal/src/sse-reconnect.ts index f119388f3d1..240ec7a4beb 100644 --- a/src/signal/sse-reconnect.ts +++ b/extensions/signal/src/sse-reconnect.ts @@ -1,7 +1,7 @@ -import { logVerbose, shouldLogVerbose } from "../globals.js"; -import type { BackoffPolicy } from "../infra/backoff.js"; -import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { logVerbose, shouldLogVerbose } from "../../../src/globals.js"; +import type { BackoffPolicy } from "../../../src/infra/backoff.js"; +import { computeBackoff, sleepWithAbort } from "../../../src/infra/backoff.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import { type SignalSseEvent, streamSignalEvents } from "./client.js"; const DEFAULT_RECONNECT_POLICY: BackoffPolicy = { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 6fbcfb6f122..183cdce7ad4 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/src/slack/account-inspect.ts b/extensions/slack/src/account-inspect.ts similarity index 93% rename from src/slack/account-inspect.ts rename to extensions/slack/src/account-inspect.ts index 34b4a13fb23..85fde407cbb 100644 --- a/src/slack/account-inspect.ts +++ b/extensions/slack/src/account-inspect.ts @@ -1,7 +1,10 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; -import type { SlackAccountConfig } from "../config/types.slack.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; +import type { SlackAccountConfig } from "../../../src/config/types.slack.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { mergeSlackAccountConfig, diff --git a/src/slack/account-surface-fields.ts b/extensions/slack/src/account-surface-fields.ts similarity index 89% rename from src/slack/account-surface-fields.ts rename to extensions/slack/src/account-surface-fields.ts index 8e2293e213a..8913a9859fe 100644 --- a/src/slack/account-surface-fields.ts +++ b/extensions/slack/src/account-surface-fields.ts @@ -1,4 +1,4 @@ -import type { SlackAccountConfig } from "../config/types.js"; +import type { SlackAccountConfig } from "../../../src/config/types.js"; export type SlackAccountSurfaceFields = { groupPolicy?: SlackAccountConfig["groupPolicy"]; diff --git a/src/slack/accounts.test.ts b/extensions/slack/src/accounts.test.ts similarity index 100% rename from src/slack/accounts.test.ts rename to extensions/slack/src/accounts.test.ts diff --git a/src/slack/accounts.ts b/extensions/slack/src/accounts.ts similarity index 89% rename from src/slack/accounts.ts rename to extensions/slack/src/accounts.ts index 6e5aed59fa2..294bbf8956b 100644 --- a/src/slack/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -1,9 +1,9 @@ -import { normalizeChatType } from "../channels/chat-type.js"; -import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { SlackAccountConfig } from "../config/types.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { normalizeChatType } from "../../../src/channels/chat-type.js"; +import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { SlackAccountConfig } from "../../../src/config/types.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; diff --git a/src/slack/actions.blocks.test.ts b/extensions/slack/src/actions.blocks.test.ts similarity index 100% rename from src/slack/actions.blocks.test.ts rename to extensions/slack/src/actions.blocks.test.ts diff --git a/src/slack/actions.download-file.test.ts b/extensions/slack/src/actions.download-file.test.ts similarity index 100% rename from src/slack/actions.download-file.test.ts rename to extensions/slack/src/actions.download-file.test.ts diff --git a/src/slack/actions.read.test.ts b/extensions/slack/src/actions.read.test.ts similarity index 100% rename from src/slack/actions.read.test.ts rename to extensions/slack/src/actions.read.test.ts diff --git a/src/slack/actions.ts b/extensions/slack/src/actions.ts similarity index 99% rename from src/slack/actions.ts rename to extensions/slack/src/actions.ts index 2ae36e6b0d4..ba422ac50f2 100644 --- a/src/slack/actions.ts +++ b/extensions/slack/src/actions.ts @@ -1,6 +1,6 @@ import type { Block, KnownBlock, WebClient } from "@slack/web-api"; -import { loadConfig } from "../config/config.js"; -import { logVerbose } from "../globals.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { logVerbose } from "../../../src/globals.js"; import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; diff --git a/src/slack/blocks-fallback.test.ts b/extensions/slack/src/blocks-fallback.test.ts similarity index 100% rename from src/slack/blocks-fallback.test.ts rename to extensions/slack/src/blocks-fallback.test.ts diff --git a/src/slack/blocks-fallback.ts b/extensions/slack/src/blocks-fallback.ts similarity index 100% rename from src/slack/blocks-fallback.ts rename to extensions/slack/src/blocks-fallback.ts diff --git a/src/slack/blocks-input.test.ts b/extensions/slack/src/blocks-input.test.ts similarity index 100% rename from src/slack/blocks-input.test.ts rename to extensions/slack/src/blocks-input.test.ts diff --git a/src/slack/blocks-input.ts b/extensions/slack/src/blocks-input.ts similarity index 100% rename from src/slack/blocks-input.ts rename to extensions/slack/src/blocks-input.ts diff --git a/src/slack/blocks.test-helpers.ts b/extensions/slack/src/blocks.test-helpers.ts similarity index 95% rename from src/slack/blocks.test-helpers.ts rename to extensions/slack/src/blocks.test-helpers.ts index f9bd0269858..50f7d66b04d 100644 --- a/src/slack/blocks.test-helpers.ts +++ b/extensions/slack/src/blocks.test-helpers.ts @@ -17,7 +17,7 @@ export type SlackSendTestClient = WebClient & { }; export function installSlackBlockTestMocks() { - vi.mock("../config/config.js", () => ({ + vi.mock("../../../src/config/config.js", () => ({ loadConfig: () => ({}), })); diff --git a/src/slack/channel-migration.test.ts b/extensions/slack/src/channel-migration.test.ts similarity index 100% rename from src/slack/channel-migration.test.ts rename to extensions/slack/src/channel-migration.test.ts diff --git a/src/slack/channel-migration.ts b/extensions/slack/src/channel-migration.ts similarity index 92% rename from src/slack/channel-migration.ts rename to extensions/slack/src/channel-migration.ts index 09017e0617f..e78ade084d4 100644 --- a/src/slack/channel-migration.ts +++ b/extensions/slack/src/channel-migration.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { SlackChannelConfig } from "../config/types.slack.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { SlackChannelConfig } from "../../../src/config/types.slack.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; type SlackChannels = Record; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 17209b6e4d1..d288963efc6 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,6 +38,7 @@ import { type ChannelPlugin, type ResolvedSlackAccount, } from "openclaw/plugin-sdk/slack"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getSlackRuntime } from "./runtime.js"; @@ -77,11 +78,13 @@ type SlackSendFn = ReturnType["channel"]["slack"]["sendM function resolveSlackSendContext(params: { cfg: Parameters[0]["cfg"]; accountId?: string; - deps?: { sendSlack?: SlackSendFn }; + deps?: { [channelId: string]: unknown }; replyToId?: string | number | null; threadId?: string | number | null; }) { - const send = params.deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; + const send = + resolveOutboundSendDep(params.deps, "slack") ?? + getSlackRuntime().channel.slack.sendMessageSlack; const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); const token = getTokenForOperation(account, "write"); const botToken = account.botToken?.trim(); diff --git a/src/slack/client.test.ts b/extensions/slack/src/client.test.ts similarity index 100% rename from src/slack/client.test.ts rename to extensions/slack/src/client.test.ts diff --git a/src/slack/client.ts b/extensions/slack/src/client.ts similarity index 100% rename from src/slack/client.ts rename to extensions/slack/src/client.ts diff --git a/src/slack/directory-live.ts b/extensions/slack/src/directory-live.ts similarity index 96% rename from src/slack/directory-live.ts rename to extensions/slack/src/directory-live.ts index bb105bae5ab..225548c646d 100644 --- a/src/slack/directory-live.ts +++ b/extensions/slack/src/directory-live.ts @@ -1,5 +1,5 @@ -import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; -import type { ChannelDirectoryEntry } from "../channels/plugins/types.js"; +import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; +import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js"; import { resolveSlackAccount } from "./accounts.js"; import { createSlackWebClient } from "./client.js"; diff --git a/src/slack/draft-stream.test.ts b/extensions/slack/src/draft-stream.test.ts similarity index 100% rename from src/slack/draft-stream.test.ts rename to extensions/slack/src/draft-stream.test.ts diff --git a/src/slack/draft-stream.ts b/extensions/slack/src/draft-stream.ts similarity index 97% rename from src/slack/draft-stream.ts rename to extensions/slack/src/draft-stream.ts index b482ebd5820..bb80ff8d536 100644 --- a/src/slack/draft-stream.ts +++ b/extensions/slack/src/draft-stream.ts @@ -1,4 +1,4 @@ -import { createDraftStreamLoop } from "../channels/draft-stream-loop.js"; +import { createDraftStreamLoop } from "../../../src/channels/draft-stream-loop.js"; import { deleteSlackMessage, editSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; diff --git a/src/slack/format.test.ts b/extensions/slack/src/format.test.ts similarity index 100% rename from src/slack/format.test.ts rename to extensions/slack/src/format.test.ts diff --git a/src/slack/format.ts b/extensions/slack/src/format.ts similarity index 95% rename from src/slack/format.ts rename to extensions/slack/src/format.ts index baf8f804374..69aeaa6b3b9 100644 --- a/src/slack/format.ts +++ b/extensions/slack/src/format.ts @@ -1,6 +1,6 @@ -import type { MarkdownTableMode } from "../config/types.base.js"; -import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js"; -import { renderMarkdownWithMarkers } from "../markdown/render.js"; +import type { MarkdownTableMode } from "../../../src/config/types.base.js"; +import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../../../src/markdown/ir.js"; +import { renderMarkdownWithMarkers } from "../../../src/markdown/render.js"; // Escape special characters for Slack mrkdwn format. // Preserve Slack's angle-bracket tokens so mentions and links stay intact. diff --git a/src/slack/http/index.ts b/extensions/slack/src/http/index.ts similarity index 100% rename from src/slack/http/index.ts rename to extensions/slack/src/http/index.ts diff --git a/src/slack/http/registry.test.ts b/extensions/slack/src/http/registry.test.ts similarity index 100% rename from src/slack/http/registry.test.ts rename to extensions/slack/src/http/registry.test.ts diff --git a/src/slack/http/registry.ts b/extensions/slack/src/http/registry.ts similarity index 100% rename from src/slack/http/registry.ts rename to extensions/slack/src/http/registry.ts diff --git a/src/slack/index.ts b/extensions/slack/src/index.ts similarity index 100% rename from src/slack/index.ts rename to extensions/slack/src/index.ts diff --git a/src/slack/interactive-replies.test.ts b/extensions/slack/src/interactive-replies.test.ts similarity index 93% rename from src/slack/interactive-replies.test.ts rename to extensions/slack/src/interactive-replies.test.ts index 5222a4fc873..69557c4855b 100644 --- a/src/slack/interactive-replies.test.ts +++ b/extensions/slack/src/interactive-replies.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; describe("isSlackInteractiveRepliesEnabled", () => { diff --git a/src/slack/interactive-replies.ts b/extensions/slack/src/interactive-replies.ts similarity index 94% rename from src/slack/interactive-replies.ts rename to extensions/slack/src/interactive-replies.ts index 399c186cfdc..31784bd3b40 100644 --- a/src/slack/interactive-replies.ts +++ b/extensions/slack/src/interactive-replies.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { listSlackAccountIds, resolveSlackAccount } from "./accounts.js"; function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean { diff --git a/src/slack/message-actions.test.ts b/extensions/slack/src/message-actions.test.ts similarity index 89% rename from src/slack/message-actions.test.ts rename to extensions/slack/src/message-actions.test.ts index 71d8e72ebbc..5453ca9c1c8 100644 --- a/src/slack/message-actions.test.ts +++ b/extensions/slack/src/message-actions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { listSlackMessageActions } from "./message-actions.js"; describe("listSlackMessageActions", () => { diff --git a/src/slack/message-actions.ts b/extensions/slack/src/message-actions.ts similarity index 87% rename from src/slack/message-actions.ts rename to extensions/slack/src/message-actions.ts index 5c5a4ba928e..8e2a293f166 100644 --- a/src/slack/message-actions.ts +++ b/extensions/slack/src/message-actions.ts @@ -1,6 +1,9 @@ -import { createActionGate } from "../agents/tools/common.js"; -import type { ChannelMessageActionName, ChannelToolSend } from "../channels/plugins/types.js"; -import type { OpenClawConfig } from "../config/config.js"; +import { createActionGate } from "../../../src/agents/tools/common.js"; +import type { + ChannelMessageActionName, + ChannelToolSend, +} from "../../../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { listEnabledSlackAccounts } from "./accounts.js"; export function listSlackMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { diff --git a/src/slack/modal-metadata.test.ts b/extensions/slack/src/modal-metadata.test.ts similarity index 100% rename from src/slack/modal-metadata.test.ts rename to extensions/slack/src/modal-metadata.test.ts diff --git a/src/slack/modal-metadata.ts b/extensions/slack/src/modal-metadata.ts similarity index 100% rename from src/slack/modal-metadata.ts rename to extensions/slack/src/modal-metadata.ts diff --git a/src/slack/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts similarity index 89% rename from src/slack/monitor.test-helpers.ts rename to extensions/slack/src/monitor.test-helpers.ts index 17b868fa972..e065e2a96b8 100644 --- a/src/slack/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -148,15 +148,15 @@ export function resetSlackTestState(config: Record = defaultSla getSlackHandlers()?.clear(); } -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => slackTestState.config, }; }); -vi.mock("../auto-reply/reply.js", () => ({ +vi.mock("../../../src/auto-reply/reply.js", () => ({ getReplyFromConfig: (...args: unknown[]) => slackTestState.replyMock(...args), })); @@ -174,19 +174,23 @@ vi.mock("./send.js", () => ({ sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args), })); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => slackTestState.readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => slackTestState.upsertPairingRequestMock(...args), })); -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), - updateLastRoute: (...args: unknown[]) => slackTestState.updateLastRouteMock(...args), - resolveSessionKey: vi.fn(), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), + updateLastRoute: (...args: unknown[]) => slackTestState.updateLastRouteMock(...args), + resolveSessionKey: vi.fn(), + readSessionUpdatedAt: vi.fn(() => undefined), + recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), + }; +}); vi.mock("@slack/bolt", () => { const handlers = new Map(); diff --git a/src/slack/monitor.test.ts b/extensions/slack/src/monitor.test.ts similarity index 100% rename from src/slack/monitor.test.ts rename to extensions/slack/src/monitor.test.ts diff --git a/src/slack/monitor.threading.missing-thread-ts.test.ts b/extensions/slack/src/monitor.threading.missing-thread-ts.test.ts similarity index 97% rename from src/slack/monitor.threading.missing-thread-ts.test.ts rename to extensions/slack/src/monitor.threading.missing-thread-ts.test.ts index 69117616a4f..99944e04d3c 100644 --- a/src/slack/monitor.threading.missing-thread-ts.test.ts +++ b/extensions/slack/src/monitor.threading.missing-thread-ts.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; import { flush, getSlackClient, diff --git a/src/slack/monitor.tool-result.test.ts b/extensions/slack/src/monitor.tool-result.test.ts similarity index 98% rename from src/slack/monitor.tool-result.test.ts rename to extensions/slack/src/monitor.tool-result.test.ts index 53eb45918f9..3be5fa30dbd 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/extensions/slack/src/monitor.tool-result.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; +import { HISTORY_CONTEXT_MARKER } from "../../../src/auto-reply/reply/history.js"; +import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; +import { CURRENT_MESSAGE_MARKER } from "../../../src/auto-reply/reply/mentions.js"; import { defaultSlackTestConfig, getSlackTestState, diff --git a/src/slack/monitor.ts b/extensions/slack/src/monitor.ts similarity index 100% rename from src/slack/monitor.ts rename to extensions/slack/src/monitor.ts diff --git a/src/slack/monitor/allow-list.test.ts b/extensions/slack/src/monitor/allow-list.test.ts similarity index 100% rename from src/slack/monitor/allow-list.test.ts rename to extensions/slack/src/monitor/allow-list.test.ts diff --git a/src/slack/monitor/allow-list.ts b/extensions/slack/src/monitor/allow-list.ts similarity index 96% rename from src/slack/monitor/allow-list.ts rename to extensions/slack/src/monitor/allow-list.ts index 36417f22839..0e800047502 100644 --- a/src/slack/monitor/allow-list.ts +++ b/extensions/slack/src/monitor/allow-list.ts @@ -2,12 +2,12 @@ import { compileAllowlist, resolveCompiledAllowlistMatch, type AllowlistMatch, -} from "../../channels/allowlist-match.js"; +} from "../../../../src/channels/allowlist-match.js"; import { normalizeHyphenSlug, normalizeStringEntries, normalizeStringEntriesLower, -} from "../../shared/string-normalization.js"; +} from "../../../../src/shared/string-normalization.js"; const SLACK_SLUG_CACHE_MAX = 512; const slackSlugCache = new Map(); diff --git a/src/slack/monitor/auth.test.ts b/extensions/slack/src/monitor/auth.test.ts similarity index 97% rename from src/slack/monitor/auth.test.ts rename to extensions/slack/src/monitor/auth.test.ts index 20a46756cd9..8c86646dd06 100644 --- a/src/slack/monitor/auth.test.ts +++ b/extensions/slack/src/monitor/auth.test.ts @@ -3,7 +3,7 @@ import type { SlackMonitorContext } from "./context.js"; const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn()); -vi.mock("../../pairing/pairing-store.js", () => ({ +vi.mock("../../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readChannelAllowFromStoreMock(...args), })); diff --git a/src/slack/monitor/auth.ts b/extensions/slack/src/monitor/auth.ts similarity index 98% rename from src/slack/monitor/auth.ts rename to extensions/slack/src/monitor/auth.ts index b303e6c6bad..5022a94ad18 100644 --- a/src/slack/monitor/auth.ts +++ b/extensions/slack/src/monitor/auth.ts @@ -1,4 +1,4 @@ -import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js"; +import { readStoreAllowFromForDmPolicy } from "../../../../src/security/dm-policy-shared.js"; import { allowListMatches, normalizeAllowList, diff --git a/src/slack/monitor/channel-config.ts b/extensions/slack/src/monitor/channel-config.ts similarity index 97% rename from src/slack/monitor/channel-config.ts rename to extensions/slack/src/monitor/channel-config.ts index 88db84b33f4..e5f380a7102 100644 --- a/src/slack/monitor/channel-config.ts +++ b/extensions/slack/src/monitor/channel-config.ts @@ -3,8 +3,8 @@ import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, type ChannelMatchSource, -} from "../../channels/channel-config.js"; -import type { SlackReactionNotificationMode } from "../../config/config.js"; +} from "../../../../src/channels/channel-config.js"; +import type { SlackReactionNotificationMode } from "../../../../src/config/config.js"; import type { SlackMessageEvent } from "../types.js"; import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; diff --git a/src/slack/monitor/channel-type.ts b/extensions/slack/src/monitor/channel-type.ts similarity index 100% rename from src/slack/monitor/channel-type.ts rename to extensions/slack/src/monitor/channel-type.ts diff --git a/src/slack/monitor/commands.ts b/extensions/slack/src/monitor/commands.ts similarity index 93% rename from src/slack/monitor/commands.ts rename to extensions/slack/src/monitor/commands.ts index a50b75704eb..25fbaeb1007 100644 --- a/src/slack/monitor/commands.ts +++ b/extensions/slack/src/monitor/commands.ts @@ -1,4 +1,4 @@ -import type { SlackSlashCommandConfig } from "../../config/config.js"; +import type { SlackSlashCommandConfig } from "../../../../src/config/config.js"; /** * Strip Slack mentions (<@U123>, <@U123|name>) so command detection works on diff --git a/src/slack/monitor/context.test.ts b/extensions/slack/src/monitor/context.test.ts similarity index 94% rename from src/slack/monitor/context.test.ts rename to extensions/slack/src/monitor/context.test.ts index 11692fc0d52..b3694315af1 100644 --- a/src/slack/monitor/context.test.ts +++ b/extensions/slack/src/monitor/context.test.ts @@ -1,7 +1,7 @@ import type { App } from "@slack/bolt"; import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import { createSlackMonitorContext } from "./context.js"; function createTestContext() { diff --git a/src/slack/monitor/context.ts b/extensions/slack/src/monitor/context.ts similarity index 94% rename from src/slack/monitor/context.ts rename to extensions/slack/src/monitor/context.ts index fd8882e2827..ad485a5c202 100644 --- a/src/slack/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -1,14 +1,17 @@ import type { App } from "@slack/bolt"; -import type { HistoryEntry } from "../../auto-reply/reply/history.js"; -import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; -import type { OpenClawConfig, SlackReactionNotificationMode } from "../../config/config.js"; -import { resolveSessionKey, type SessionScope } from "../../config/sessions.js"; -import type { DmPolicy, GroupPolicy } from "../../config/types.js"; -import { logVerbose } from "../../globals.js"; -import { createDedupeCache } from "../../infra/dedupe.js"; -import { getChildLogger } from "../../logging.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js"; +import { formatAllowlistMatchMeta } from "../../../../src/channels/allowlist-match.js"; +import type { + OpenClawConfig, + SlackReactionNotificationMode, +} from "../../../../src/config/config.js"; +import { resolveSessionKey, type SessionScope } from "../../../../src/config/sessions.js"; +import type { DmPolicy, GroupPolicy } from "../../../../src/config/types.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { createDedupeCache } from "../../../../src/infra/dedupe.js"; +import { getChildLogger } from "../../../../src/logging.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import type { SlackMessageEvent } from "../types.js"; import { normalizeAllowList, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; import type { SlackChannelConfigEntries } from "./channel-config.js"; @@ -50,7 +53,7 @@ export type SlackMonitorContext = { replyToMode: "off" | "first" | "all"; threadHistoryScope: "thread" | "channel"; threadInheritParent: boolean; - slashCommand: Required; + slashCommand: Required; textLimit: number; ackReactionScope: string; typingReaction: string; diff --git a/src/slack/monitor/dm-auth.ts b/extensions/slack/src/monitor/dm-auth.ts similarity index 88% rename from src/slack/monitor/dm-auth.ts rename to extensions/slack/src/monitor/dm-auth.ts index f11a2aa51f7..20d850d869a 100644 --- a/src/slack/monitor/dm-auth.ts +++ b/extensions/slack/src/monitor/dm-auth.ts @@ -1,6 +1,6 @@ -import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; +import { formatAllowlistMatchMeta } from "../../../../src/channels/allowlist-match.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; import { resolveSlackAllowListMatch } from "./allow-list.js"; import type { SlackMonitorContext } from "./context.js"; diff --git a/src/slack/monitor/events.ts b/extensions/slack/src/monitor/events.ts similarity index 100% rename from src/slack/monitor/events.ts rename to extensions/slack/src/monitor/events.ts diff --git a/src/slack/monitor/events/channels.test.ts b/extensions/slack/src/monitor/events/channels.test.ts similarity index 97% rename from src/slack/monitor/events/channels.test.ts rename to extensions/slack/src/monitor/events/channels.test.ts index 1c4bec094d2..7b8bbbad69d 100644 --- a/src/slack/monitor/events/channels.test.ts +++ b/extensions/slack/src/monitor/events/channels.test.ts @@ -4,7 +4,7 @@ import { createSlackSystemEventTestHarness } from "./system-event-test-harness.j const enqueueSystemEventMock = vi.fn(); -vi.mock("../../../infra/system-events.js", () => ({ +vi.mock("../../../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), })); diff --git a/src/slack/monitor/events/channels.ts b/extensions/slack/src/monitor/events/channels.ts similarity index 93% rename from src/slack/monitor/events/channels.ts rename to extensions/slack/src/monitor/events/channels.ts index 3241eda41fd..283b6648cf9 100644 --- a/src/slack/monitor/events/channels.ts +++ b/extensions/slack/src/monitor/events/channels.ts @@ -1,8 +1,8 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { resolveChannelConfigWrites } from "../../../channels/plugins/config-writes.js"; -import { loadConfig, writeConfigFile } from "../../../config/config.js"; -import { danger, warn } from "../../../globals.js"; -import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { resolveChannelConfigWrites } from "../../../../../src/channels/plugins/config-writes.js"; +import { loadConfig, writeConfigFile } from "../../../../../src/config/config.js"; +import { danger, warn } from "../../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; import { migrateSlackChannelConfig } from "../../channel-migration.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/src/slack/monitor/events/interactions.modal.ts b/extensions/slack/src/monitor/events/interactions.modal.ts similarity index 98% rename from src/slack/monitor/events/interactions.modal.ts rename to extensions/slack/src/monitor/events/interactions.modal.ts index 99d1a3711b6..48e163c317f 100644 --- a/src/slack/monitor/events/interactions.modal.ts +++ b/extensions/slack/src/monitor/events/interactions.modal.ts @@ -1,4 +1,4 @@ -import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/src/slack/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts similarity index 99% rename from src/slack/monitor/events/interactions.test.ts rename to extensions/slack/src/monitor/events/interactions.test.ts index 21fd6d173d4..6de5ce3f229 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -3,7 +3,7 @@ import { registerSlackInteractionEvents } from "./interactions.js"; const enqueueSystemEventMock = vi.fn(); -vi.mock("../../../infra/system-events.js", () => ({ +vi.mock("../../../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), })); diff --git a/src/slack/monitor/events/interactions.ts b/extensions/slack/src/monitor/events/interactions.ts similarity index 99% rename from src/slack/monitor/events/interactions.ts rename to extensions/slack/src/monitor/events/interactions.ts index b82c30d8571..1d542fd9665 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/extensions/slack/src/monitor/events/interactions.ts @@ -1,6 +1,6 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; -import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; import { truncateSlackText } from "../../truncate.js"; import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/src/slack/monitor/events/members.test.ts b/extensions/slack/src/monitor/events/members.test.ts similarity index 97% rename from src/slack/monitor/events/members.test.ts rename to extensions/slack/src/monitor/events/members.test.ts index 168beca65ed..29cd840cff8 100644 --- a/src/slack/monitor/events/members.test.ts +++ b/extensions/slack/src/monitor/events/members.test.ts @@ -10,11 +10,11 @@ const memberMocks = vi.hoisted(() => ({ readAllow: vi.fn(), })); -vi.mock("../../../infra/system-events.js", () => ({ +vi.mock("../../../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: memberMocks.enqueue, })); -vi.mock("../../../pairing/pairing-store.js", () => ({ +vi.mock("../../../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: memberMocks.readAllow, })); diff --git a/src/slack/monitor/events/members.ts b/extensions/slack/src/monitor/events/members.ts similarity index 94% rename from src/slack/monitor/events/members.ts rename to extensions/slack/src/monitor/events/members.ts index 27dd2968a66..490c0bf6f04 100644 --- a/src/slack/monitor/events/members.ts +++ b/extensions/slack/src/monitor/events/members.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../globals.js"; -import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { danger } from "../../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackMemberChannelEvent } from "../types.js"; import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; diff --git a/src/slack/monitor/events/message-subtype-handlers.test.ts b/extensions/slack/src/monitor/events/message-subtype-handlers.test.ts similarity index 100% rename from src/slack/monitor/events/message-subtype-handlers.test.ts rename to extensions/slack/src/monitor/events/message-subtype-handlers.test.ts diff --git a/src/slack/monitor/events/message-subtype-handlers.ts b/extensions/slack/src/monitor/events/message-subtype-handlers.ts similarity index 100% rename from src/slack/monitor/events/message-subtype-handlers.ts rename to extensions/slack/src/monitor/events/message-subtype-handlers.ts diff --git a/src/slack/monitor/events/messages.test.ts b/extensions/slack/src/monitor/events/messages.test.ts similarity index 98% rename from src/slack/monitor/events/messages.test.ts rename to extensions/slack/src/monitor/events/messages.test.ts index f22b24a44c7..a0e18125d8a 100644 --- a/src/slack/monitor/events/messages.test.ts +++ b/extensions/slack/src/monitor/events/messages.test.ts @@ -8,11 +8,11 @@ import { const messageQueueMock = vi.fn(); const messageAllowMock = vi.fn(); -vi.mock("../../../infra/system-events.js", () => ({ +vi.mock("../../../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => messageQueueMock(...args), })); -vi.mock("../../../pairing/pairing-store.js", () => ({ +vi.mock("../../../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => messageAllowMock(...args), })); diff --git a/src/slack/monitor/events/messages.ts b/extensions/slack/src/monitor/events/messages.ts similarity index 96% rename from src/slack/monitor/events/messages.ts rename to extensions/slack/src/monitor/events/messages.ts index 04a1b311958..b950d5d19ea 100644 --- a/src/slack/monitor/events/messages.ts +++ b/extensions/slack/src/monitor/events/messages.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../globals.js"; -import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { danger } from "../../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; import type { SlackAppMentionEvent, SlackMessageEvent } from "../../types.js"; import { normalizeSlackChannelType } from "../channel-type.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/src/slack/monitor/events/pins.test.ts b/extensions/slack/src/monitor/events/pins.test.ts similarity index 97% rename from src/slack/monitor/events/pins.test.ts rename to extensions/slack/src/monitor/events/pins.test.ts index 352b7d03a2b..0517508bb2a 100644 --- a/src/slack/monitor/events/pins.test.ts +++ b/extensions/slack/src/monitor/events/pins.test.ts @@ -8,10 +8,10 @@ import { const pinEnqueueMock = vi.hoisted(() => vi.fn()); const pinAllowMock = vi.hoisted(() => vi.fn()); -vi.mock("../../../infra/system-events.js", () => { +vi.mock("../../../../../src/infra/system-events.js", () => { return { enqueueSystemEvent: pinEnqueueMock }; }); -vi.mock("../../../pairing/pairing-store.js", () => ({ +vi.mock("../../../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: pinAllowMock, })); diff --git a/src/slack/monitor/events/pins.ts b/extensions/slack/src/monitor/events/pins.ts similarity index 94% rename from src/slack/monitor/events/pins.ts rename to extensions/slack/src/monitor/events/pins.ts index e3d076d8d7f..f051270624c 100644 --- a/src/slack/monitor/events/pins.ts +++ b/extensions/slack/src/monitor/events/pins.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../globals.js"; -import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { danger } from "../../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackPinEvent } from "../types.js"; import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; diff --git a/src/slack/monitor/events/reactions.test.ts b/extensions/slack/src/monitor/events/reactions.test.ts similarity index 97% rename from src/slack/monitor/events/reactions.test.ts rename to extensions/slack/src/monitor/events/reactions.test.ts index 3581d8b5380..26f16579c05 100644 --- a/src/slack/monitor/events/reactions.test.ts +++ b/extensions/slack/src/monitor/events/reactions.test.ts @@ -8,13 +8,13 @@ import { const reactionQueueMock = vi.fn(); const reactionAllowMock = vi.fn(); -vi.mock("../../../infra/system-events.js", () => { +vi.mock("../../../../../src/infra/system-events.js", () => { return { enqueueSystemEvent: (...args: unknown[]) => reactionQueueMock(...args), }; }); -vi.mock("../../../pairing/pairing-store.js", () => { +vi.mock("../../../../../src/pairing/pairing-store.js", () => { return { readChannelAllowFromStore: (...args: unknown[]) => reactionAllowMock(...args), }; diff --git a/src/slack/monitor/events/reactions.ts b/extensions/slack/src/monitor/events/reactions.ts similarity index 94% rename from src/slack/monitor/events/reactions.ts rename to extensions/slack/src/monitor/events/reactions.ts index b3633ce33d3..439c15e6d12 100644 --- a/src/slack/monitor/events/reactions.ts +++ b/extensions/slack/src/monitor/events/reactions.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../globals.js"; -import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { danger } from "../../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackReactionEvent } from "../types.js"; import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; diff --git a/src/slack/monitor/events/system-event-context.ts b/extensions/slack/src/monitor/events/system-event-context.ts similarity index 95% rename from src/slack/monitor/events/system-event-context.ts rename to extensions/slack/src/monitor/events/system-event-context.ts index 0c89ec2ce47..278dd2324d7 100644 --- a/src/slack/monitor/events/system-event-context.ts +++ b/extensions/slack/src/monitor/events/system-event-context.ts @@ -1,4 +1,4 @@ -import { logVerbose } from "../../../globals.js"; +import { logVerbose } from "../../../../../src/globals.js"; import { authorizeSlackSystemEventSender } from "../auth.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/src/slack/monitor/events/system-event-test-harness.ts b/extensions/slack/src/monitor/events/system-event-test-harness.ts similarity index 100% rename from src/slack/monitor/events/system-event-test-harness.ts rename to extensions/slack/src/monitor/events/system-event-test-harness.ts diff --git a/src/slack/monitor/external-arg-menu-store.ts b/extensions/slack/src/monitor/external-arg-menu-store.ts similarity index 96% rename from src/slack/monitor/external-arg-menu-store.ts rename to extensions/slack/src/monitor/external-arg-menu-store.ts index 8ea66b2fed9..e2cbf68479d 100644 --- a/src/slack/monitor/external-arg-menu-store.ts +++ b/extensions/slack/src/monitor/external-arg-menu-store.ts @@ -1,4 +1,4 @@ -import { generateSecureToken } from "../../infra/secure-random.js"; +import { generateSecureToken } from "../../../../src/infra/secure-random.js"; const SLACK_EXTERNAL_ARG_MENU_TOKEN_BYTES = 18; const SLACK_EXTERNAL_ARG_MENU_TOKEN_LENGTH = Math.ceil( diff --git a/src/slack/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts similarity index 98% rename from src/slack/monitor/media.test.ts rename to extensions/slack/src/monitor/media.test.ts index c521360fde7..f745f205950 100644 --- a/src/slack/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as ssrf from "../../infra/net/ssrf.js"; -import * as mediaFetch from "../../media/fetch.js"; -import type { SavedMedia } from "../../media/store.js"; -import * as mediaStore from "../../media/store.js"; -import { mockPinnedHostnameResolution } from "../../test-helpers/ssrf.js"; -import { type FetchMock, withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import * as ssrf from "../../../../src/infra/net/ssrf.js"; +import * as mediaFetch from "../../../../src/media/fetch.js"; +import type { SavedMedia } from "../../../../src/media/store.js"; +import * as mediaStore from "../../../../src/media/store.js"; +import { mockPinnedHostnameResolution } from "../../../../src/test-helpers/ssrf.js"; +import { type FetchMock, withFetchPreconnect } from "../../../../src/test-utils/fetch-mock.js"; import { fetchWithSlackAuth, resolveSlackAttachmentContent, diff --git a/src/slack/monitor/media.ts b/extensions/slack/src/monitor/media.ts similarity index 97% rename from src/slack/monitor/media.ts rename to extensions/slack/src/monitor/media.ts index a3c8ab5a244..7c5a619129f 100644 --- a/src/slack/monitor/media.ts +++ b/extensions/slack/src/monitor/media.ts @@ -1,9 +1,9 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; -import { normalizeHostname } from "../../infra/net/hostname.js"; -import type { FetchLike } from "../../media/fetch.js"; -import { fetchRemoteMedia } from "../../media/fetch.js"; -import { saveMediaBuffer } from "../../media/store.js"; -import { resolveRequestUrl } from "../../plugin-sdk/request-url.js"; +import { normalizeHostname } from "../../../../src/infra/net/hostname.js"; +import type { FetchLike } from "../../../../src/media/fetch.js"; +import { fetchRemoteMedia } from "../../../../src/media/fetch.js"; +import { saveMediaBuffer } from "../../../../src/media/store.js"; +import { resolveRequestUrl } from "../../../../src/plugin-sdk/request-url.js"; import type { SlackAttachment, SlackFile } from "../types.js"; function isSlackHostname(hostname: string): boolean { diff --git a/src/slack/monitor/message-handler.app-mention-race.test.ts b/extensions/slack/src/monitor/message-handler.app-mention-race.test.ts similarity index 98% rename from src/slack/monitor/message-handler.app-mention-race.test.ts rename to extensions/slack/src/monitor/message-handler.app-mention-race.test.ts index 8c6afb15a8b..a6b972f2e7d 100644 --- a/src/slack/monitor/message-handler.app-mention-race.test.ts +++ b/extensions/slack/src/monitor/message-handler.app-mention-race.test.ts @@ -8,7 +8,7 @@ const prepareSlackMessageMock = >(); const dispatchPreparedSlackMessageMock = vi.fn<(prepared: unknown) => Promise>(); -vi.mock("../../channels/inbound-debounce-policy.js", () => ({ +vi.mock("../../../../src/channels/inbound-debounce-policy.js", () => ({ shouldDebounceTextInbound: () => false, createChannelInboundDebouncer: (params: { onFlush: ( diff --git a/src/slack/monitor/message-handler.debounce-key.test.ts b/extensions/slack/src/monitor/message-handler.debounce-key.test.ts similarity index 100% rename from src/slack/monitor/message-handler.debounce-key.test.ts rename to extensions/slack/src/monitor/message-handler.debounce-key.test.ts diff --git a/src/slack/monitor/message-handler.test.ts b/extensions/slack/src/monitor/message-handler.test.ts similarity index 98% rename from src/slack/monitor/message-handler.test.ts rename to extensions/slack/src/monitor/message-handler.test.ts index 1417ca3e6ec..cfea959f4d0 100644 --- a/src/slack/monitor/message-handler.test.ts +++ b/extensions/slack/src/monitor/message-handler.test.ts @@ -7,7 +7,7 @@ const resolveThreadTsMock = vi.fn(async ({ message }: { message: Record ({ +vi.mock("../../../../src/auto-reply/inbound-debounce.js", () => ({ resolveInboundDebounceMs: () => 10, createInboundDebouncer: () => ({ enqueue: (entry: unknown) => enqueueMock(entry), diff --git a/src/slack/monitor/message-handler.ts b/extensions/slack/src/monitor/message-handler.ts similarity index 99% rename from src/slack/monitor/message-handler.ts rename to extensions/slack/src/monitor/message-handler.ts index 02961dd16c9..37e0eb23bd3 100644 --- a/src/slack/monitor/message-handler.ts +++ b/extensions/slack/src/monitor/message-handler.ts @@ -1,7 +1,7 @@ import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "../../channels/inbound-debounce-policy.js"; +} from "../../../../src/channels/inbound-debounce-policy.js"; import type { ResolvedSlackAccount } from "../accounts.js"; import type { SlackMessageEvent } from "../types.js"; import { stripSlackMentionsForCommandDetection } from "./commands.js"; diff --git a/src/slack/monitor/message-handler/dispatch.streaming.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.streaming.test.ts similarity index 100% rename from src/slack/monitor/message-handler/dispatch.streaming.test.ts rename to extensions/slack/src/monitor/message-handler/dispatch.streaming.test.ts diff --git a/src/slack/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts similarity index 93% rename from src/slack/monitor/message-handler/dispatch.ts rename to extensions/slack/src/monitor/message-handler/dispatch.ts index 029d110f0b9..17681de7890 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -1,16 +1,16 @@ -import { resolveHumanDelayConfig } from "../../../agents/identity.js"; -import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js"; -import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.js"; -import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js"; -import { logAckFailure, logTypingFailure } from "../../../channels/logging.js"; -import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js"; -import { createTypingCallbacks } from "../../../channels/typing.js"; -import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; -import { resolveAgentOutboundIdentity } from "../../../infra/outbound/identity.js"; -import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../security/dm-policy-shared.js"; +import { resolveHumanDelayConfig } from "../../../../../src/agents/identity.js"; +import { dispatchInboundMessage } from "../../../../../src/auto-reply/dispatch.js"; +import { clearHistoryEntriesIfEnabled } from "../../../../../src/auto-reply/reply/history.js"; +import { createReplyDispatcherWithTyping } from "../../../../../src/auto-reply/reply/reply-dispatcher.js"; +import type { ReplyPayload } from "../../../../../src/auto-reply/types.js"; +import { removeAckReactionAfterReply } from "../../../../../src/channels/ack-reactions.js"; +import { logAckFailure, logTypingFailure } from "../../../../../src/channels/logging.js"; +import { createReplyPrefixOptions } from "../../../../../src/channels/reply-prefix.js"; +import { createTypingCallbacks } from "../../../../../src/channels/typing.js"; +import { resolveStorePath, updateLastRoute } from "../../../../../src/config/sessions.js"; +import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js"; +import { resolveAgentOutboundIdentity } from "../../../../../src/infra/outbound/identity.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../../src/security/dm-policy-shared.js"; import { reactSlackMessage, removeSlackReaction } from "../../actions.js"; import { createSlackDraftStream } from "../../draft-stream.js"; import { normalizeSlackOutboundText } from "../../format.js"; diff --git a/src/slack/monitor/message-handler/prepare-content.ts b/extensions/slack/src/monitor/message-handler/prepare-content.ts similarity index 98% rename from src/slack/monitor/message-handler/prepare-content.ts rename to extensions/slack/src/monitor/message-handler/prepare-content.ts index 2f3ad1a4e06..e1db426ad7e 100644 --- a/src/slack/monitor/message-handler/prepare-content.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-content.ts @@ -1,4 +1,4 @@ -import { logVerbose } from "../../../globals.js"; +import { logVerbose } from "../../../../../src/globals.js"; import type { SlackFile, SlackMessageEvent } from "../../types.js"; import { MAX_SLACK_MEDIA_FILES, diff --git a/src/slack/monitor/message-handler/prepare-thread-context.ts b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts similarity index 93% rename from src/slack/monitor/message-handler/prepare-thread-context.ts rename to extensions/slack/src/monitor/message-handler/prepare-thread-context.ts index f25aa881629..9673e8d72cc 100644 --- a/src/slack/monitor/message-handler/prepare-thread-context.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts @@ -1,6 +1,6 @@ -import { formatInboundEnvelope } from "../../../auto-reply/envelope.js"; -import { readSessionUpdatedAt } from "../../../config/sessions.js"; -import { logVerbose } from "../../../globals.js"; +import { formatInboundEnvelope } from "../../../../../src/auto-reply/envelope.js"; +import { readSessionUpdatedAt } from "../../../../../src/config/sessions.js"; +import { logVerbose } from "../../../../../src/globals.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; @@ -30,7 +30,7 @@ export async function resolveSlackThreadContextData(params: { storePath: string; sessionKey: string; envelopeOptions: ReturnType< - typeof import("../../../auto-reply/envelope.js").resolveEnvelopeFormatOptions + typeof import("../../../../../src/auto-reply/envelope.js").resolveEnvelopeFormatOptions >; effectiveDirectMedia: SlackMediaResult[] | null; }): Promise { diff --git a/src/slack/monitor/message-handler/prepare.test-helpers.ts b/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts similarity index 93% rename from src/slack/monitor/message-handler/prepare.test-helpers.ts rename to extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts index 39cbaeb4db0..cdc7a3bc411 100644 --- a/src/slack/monitor/message-handler/prepare.test-helpers.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts @@ -1,6 +1,6 @@ import type { App } from "@slack/bolt"; -import type { OpenClawConfig } from "../../../config/config.js"; -import type { RuntimeEnv } from "../../../runtime.js"; +import type { OpenClawConfig } from "../../../../../src/config/config.js"; +import type { RuntimeEnv } from "../../../../../src/runtime.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import { createSlackMonitorContext } from "../context.js"; diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts similarity index 98% rename from src/slack/monitor/message-handler/prepare.test.ts rename to extensions/slack/src/monitor/message-handler/prepare.test.ts index a5007831a2b..a6858e529af 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -3,10 +3,10 @@ import os from "node:os"; import path from "node:path"; import type { App } from "@slack/bolt"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { resolveAgentRoute } from "../../../routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; +import type { OpenClawConfig } from "../../../../../src/config/config.js"; +import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; +import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js"; +import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/src/slack/monitor/message-handler/prepare.thread-session-key.test.ts b/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts similarity index 98% rename from src/slack/monitor/message-handler/prepare.thread-session-key.test.ts rename to extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts index 56207795357..ea3a1935766 100644 --- a/src/slack/monitor/message-handler/prepare.thread-session-key.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts @@ -1,6 +1,6 @@ import type { App } from "@slack/bolt"; import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; +import type { OpenClawConfig } from "../../../../../src/config/config.js"; import type { SlackMessageEvent } from "../../types.js"; import { prepareSlackMessage } from "./prepare.js"; import { createInboundSlackTestContext, createSlackTestAccount } from "./prepare.test-helpers.js"; diff --git a/src/slack/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts similarity index 94% rename from src/slack/monitor/message-handler/prepare.ts rename to extensions/slack/src/monitor/message-handler/prepare.ts index f0b3127e450..ba18b008d37 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -1,35 +1,35 @@ -import { resolveAckReaction } from "../../../agents/identity.js"; -import { hasControlCommand } from "../../../auto-reply/command-detection.js"; -import { shouldHandleTextCommands } from "../../../auto-reply/commands-registry.js"; +import { resolveAckReaction } from "../../../../../src/agents/identity.js"; +import { hasControlCommand } from "../../../../../src/auto-reply/command-detection.js"; +import { shouldHandleTextCommands } from "../../../../../src/auto-reply/commands-registry.js"; import { formatInboundEnvelope, resolveEnvelopeFormatOptions, -} from "../../../auto-reply/envelope.js"; +} from "../../../../../src/auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, -} from "../../../auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; +} from "../../../../../src/auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionWithExplicit, -} from "../../../auto-reply/reply/mentions.js"; -import type { FinalizedMsgContext } from "../../../auto-reply/templating.js"; +} from "../../../../../src/auto-reply/reply/mentions.js"; +import type { FinalizedMsgContext } from "../../../../../src/auto-reply/templating.js"; import { shouldAckReaction as shouldAckReactionGate, type AckReactionScope, -} from "../../../channels/ack-reactions.js"; -import { resolveControlCommandGate } from "../../../channels/command-gating.js"; -import { resolveConversationLabel } from "../../../channels/conversation-label.js"; -import { logInboundDrop } from "../../../channels/logging.js"; -import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js"; -import { recordInboundSession } from "../../../channels/session.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../../config/sessions.js"; -import { logVerbose, shouldLogVerbose } from "../../../globals.js"; -import { enqueueSystemEvent } from "../../../infra/system-events.js"; -import { resolveAgentRoute } from "../../../routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; -import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../security/dm-policy-shared.js"; +} from "../../../../../src/channels/ack-reactions.js"; +import { resolveControlCommandGate } from "../../../../../src/channels/command-gating.js"; +import { resolveConversationLabel } from "../../../../../src/channels/conversation-label.js"; +import { logInboundDrop } from "../../../../../src/channels/logging.js"; +import { resolveMentionGatingWithBypass } from "../../../../../src/channels/mention-gating.js"; +import { recordInboundSession } from "../../../../../src/channels/session.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../../../src/config/sessions.js"; +import { logVerbose, shouldLogVerbose } from "../../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; +import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; +import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../../src/security/dm-policy-shared.js"; import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; import { sendMessageSlack } from "../../send.js"; diff --git a/src/slack/monitor/message-handler/types.ts b/extensions/slack/src/monitor/message-handler/types.ts similarity index 81% rename from src/slack/monitor/message-handler/types.ts rename to extensions/slack/src/monitor/message-handler/types.ts index c99380d8b20..cd1e2bdc40c 100644 --- a/src/slack/monitor/message-handler/types.ts +++ b/extensions/slack/src/monitor/message-handler/types.ts @@ -1,5 +1,5 @@ -import type { FinalizedMsgContext } from "../../../auto-reply/templating.js"; -import type { ResolvedAgentRoute } from "../../../routing/resolve-route.js"; +import type { FinalizedMsgContext } from "../../../../../src/auto-reply/templating.js"; +import type { ResolvedAgentRoute } from "../../../../../src/routing/resolve-route.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackChannelConfigResolved } from "../channel-config.js"; diff --git a/src/slack/monitor/monitor.test.ts b/extensions/slack/src/monitor/monitor.test.ts similarity index 99% rename from src/slack/monitor/monitor.test.ts rename to extensions/slack/src/monitor/monitor.test.ts index 7e7dfd11129..6741700ba5c 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/extensions/slack/src/monitor/monitor.test.ts @@ -1,7 +1,7 @@ import type { App } from "@slack/bolt"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import type { SlackMessageEvent } from "../types.js"; import { resolveSlackChannelConfig } from "./channel-config.js"; import { createSlackMonitorContext, normalizeSlackChannelType } from "./context.js"; diff --git a/src/slack/monitor/mrkdwn.ts b/extensions/slack/src/monitor/mrkdwn.ts similarity index 100% rename from src/slack/monitor/mrkdwn.ts rename to extensions/slack/src/monitor/mrkdwn.ts diff --git a/src/slack/monitor/policy.ts b/extensions/slack/src/monitor/policy.ts similarity index 80% rename from src/slack/monitor/policy.ts rename to extensions/slack/src/monitor/policy.ts index cb1204910ec..ab5d9230a62 100644 --- a/src/slack/monitor/policy.ts +++ b/extensions/slack/src/monitor/policy.ts @@ -1,4 +1,4 @@ -import { evaluateGroupRouteAccessForPolicy } from "../../plugin-sdk/group-access.js"; +import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js"; export function isSlackChannelAllowedByPolicy(params: { groupPolicy: "open" | "disabled" | "allowlist"; diff --git a/src/slack/monitor/provider.auth-errors.test.ts b/extensions/slack/src/monitor/provider.auth-errors.test.ts similarity index 100% rename from src/slack/monitor/provider.auth-errors.test.ts rename to extensions/slack/src/monitor/provider.auth-errors.test.ts diff --git a/src/slack/monitor/provider.group-policy.test.ts b/extensions/slack/src/monitor/provider.group-policy.test.ts similarity index 90% rename from src/slack/monitor/provider.group-policy.test.ts rename to extensions/slack/src/monitor/provider.group-policy.test.ts index e71e25eb565..392003ad5f5 100644 --- a/src/slack/monitor/provider.group-policy.test.ts +++ b/extensions/slack/src/monitor/provider.group-policy.test.ts @@ -1,5 +1,5 @@ import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; import { __testing } from "./provider.js"; describe("resolveSlackRuntimeGroupPolicy", () => { diff --git a/src/slack/monitor/provider.reconnect.test.ts b/extensions/slack/src/monitor/provider.reconnect.test.ts similarity index 100% rename from src/slack/monitor/provider.reconnect.test.ts rename to extensions/slack/src/monitor/provider.reconnect.test.ts diff --git a/src/slack/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts similarity index 94% rename from src/slack/monitor/provider.ts rename to extensions/slack/src/monitor/provider.ts index 3db3d3690fa..149d33bbf15 100644 --- a/src/slack/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -1,30 +1,30 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import SlackBolt from "@slack/bolt"; -import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js"; +import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; +import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js"; import { addAllowlistUserEntriesFromConfigEntry, buildAllowlistResolutionSummary, mergeAllowlist, patchAllowlistUsersInConfigEntries, summarizeMapping, -} from "../../channels/allowlists/resolve-utils.js"; -import { loadConfig } from "../../config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; +} from "../../../../src/channels/allowlists/resolve-utils.js"; +import { loadConfig } from "../../../../src/config/config.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; import { resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../../config/runtime-group-policy.js"; -import type { SessionScope } from "../../config/sessions.js"; -import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; -import { createConnectedChannelStatusPatch } from "../../gateway/channel-status-patches.js"; -import { warn } from "../../globals.js"; -import { computeBackoff, sleepWithAbort } from "../../infra/backoff.js"; -import { installRequestBodyLimitGuard } from "../../infra/http-body.js"; -import { normalizeMainKey } from "../../routing/session-key.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; -import { normalizeStringEntries } from "../../shared/string-normalization.js"; +} from "../../../../src/config/runtime-group-policy.js"; +import type { SessionScope } from "../../../../src/config/sessions.js"; +import { normalizeResolvedSecretInputString } from "../../../../src/config/types.secrets.js"; +import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; +import { warn } from "../../../../src/globals.js"; +import { computeBackoff, sleepWithAbort } from "../../../../src/infra/backoff.js"; +import { installRequestBodyLimitGuard } from "../../../../src/infra/http-body.js"; +import { normalizeMainKey } from "../../../../src/routing/session-key.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; +import { normalizeStringEntries } from "../../../../src/shared/string-normalization.js"; import { resolveSlackAccount } from "../accounts.js"; import { resolveSlackWebClientOptions } from "../client.js"; import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js"; diff --git a/src/slack/monitor/reconnect-policy.ts b/extensions/slack/src/monitor/reconnect-policy.ts similarity index 100% rename from src/slack/monitor/reconnect-policy.ts rename to extensions/slack/src/monitor/reconnect-policy.ts diff --git a/src/slack/monitor/replies.test.ts b/extensions/slack/src/monitor/replies.test.ts similarity index 100% rename from src/slack/monitor/replies.test.ts rename to extensions/slack/src/monitor/replies.test.ts diff --git a/src/slack/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts similarity index 91% rename from src/slack/monitor/replies.ts rename to extensions/slack/src/monitor/replies.ts index 4c19ac9625c..deb3ccab571 100644 --- a/src/slack/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -1,10 +1,10 @@ -import type { ChunkMode } from "../../auto-reply/chunk.js"; -import { chunkMarkdownTextWithMode } from "../../auto-reply/chunk.js"; -import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import type { MarkdownTableMode } from "../../config/types.base.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { ChunkMode } from "../../../../src/auto-reply/chunk.js"; +import { chunkMarkdownTextWithMode } from "../../../../src/auto-reply/chunk.js"; +import { createReplyReferencePlanner } from "../../../../src/auto-reply/reply/reply-reference.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../../src/auto-reply/tokens.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import type { MarkdownTableMode } from "../../../../src/config/types.base.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import { markdownToSlackMrkdwnChunks } from "../format.js"; import { sendMessageSlack, type SlackSendIdentity } from "../send.js"; diff --git a/src/slack/monitor/room-context.ts b/extensions/slack/src/monitor/room-context.ts similarity index 90% rename from src/slack/monitor/room-context.ts rename to extensions/slack/src/monitor/room-context.ts index 65359136227..3cdf584566a 100644 --- a/src/slack/monitor/room-context.ts +++ b/extensions/slack/src/monitor/room-context.ts @@ -1,4 +1,4 @@ -import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; +import { buildUntrustedChannelMetadata } from "../../../../src/security/channel-metadata.js"; export function resolveSlackRoomContextHints(params: { isRoomish: boolean; diff --git a/src/slack/monitor/slash-commands.runtime.ts b/extensions/slack/src/monitor/slash-commands.runtime.ts similarity index 71% rename from src/slack/monitor/slash-commands.runtime.ts rename to extensions/slack/src/monitor/slash-commands.runtime.ts index c6225a9d7e5..a87490f43bc 100644 --- a/src/slack/monitor/slash-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-commands.runtime.ts @@ -4,4 +4,4 @@ export { listNativeCommandSpecsForConfig, parseCommandArgs, resolveCommandArgMenu, -} from "../../auto-reply/commands-registry.js"; +} from "../../../../src/auto-reply/commands-registry.js"; diff --git a/extensions/slack/src/monitor/slash-dispatch.runtime.ts b/extensions/slack/src/monitor/slash-dispatch.runtime.ts new file mode 100644 index 00000000000..01e47782467 --- /dev/null +++ b/extensions/slack/src/monitor/slash-dispatch.runtime.ts @@ -0,0 +1,9 @@ +export { resolveChunkMode } from "../../../../src/auto-reply/chunk.js"; +export { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; +export { dispatchReplyWithDispatcher } from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +export { resolveConversationLabel } from "../../../../src/channels/conversation-label.js"; +export { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; +export { recordInboundSessionMetaSafe } from "../../../../src/channels/session-meta.js"; +export { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; +export { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +export { deliverSlackSlashReplies } from "./replies.js"; diff --git a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts new file mode 100644 index 00000000000..20da07b3ec5 --- /dev/null +++ b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts @@ -0,0 +1 @@ +export { listSkillCommandsForAgents } from "../../../../src/auto-reply/skill-commands.js"; diff --git a/src/slack/monitor/slash.test-harness.ts b/extensions/slack/src/monitor/slash.test-harness.ts similarity index 85% rename from src/slack/monitor/slash.test-harness.ts rename to extensions/slack/src/monitor/slash.test-harness.ts index 39dec929b44..4b6f5a4ea27 100644 --- a/src/slack/monitor/slash.test-harness.ts +++ b/extensions/slack/src/monitor/slash.test-harness.ts @@ -12,32 +12,32 @@ const mocks = vi.hoisted(() => ({ resolveStorePathMock: vi.fn(), })); -vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({ +vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ dispatchReplyWithDispatcher: (...args: unknown[]) => mocks.dispatchMock(...args), })); -vi.mock("../../pairing/pairing-store.js", () => ({ +vi.mock("../../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => mocks.readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => mocks.upsertPairingRequestMock(...args), })); -vi.mock("../../routing/resolve-route.js", () => ({ +vi.mock("../../../../src/routing/resolve-route.js", () => ({ resolveAgentRoute: (...args: unknown[]) => mocks.resolveAgentRouteMock(...args), })); -vi.mock("../../auto-reply/reply/inbound-context.js", () => ({ +vi.mock("../../../../src/auto-reply/reply/inbound-context.js", () => ({ finalizeInboundContext: (...args: unknown[]) => mocks.finalizeInboundContextMock(...args), })); -vi.mock("../../channels/conversation-label.js", () => ({ +vi.mock("../../../../src/channels/conversation-label.js", () => ({ resolveConversationLabel: (...args: unknown[]) => mocks.resolveConversationLabelMock(...args), })); -vi.mock("../../channels/reply-prefix.js", () => ({ +vi.mock("../../../../src/channels/reply-prefix.js", () => ({ createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args), })); -vi.mock("../../config/sessions.js", () => ({ +vi.mock("../../../../src/config/sessions.js", () => ({ recordSessionMetaFromInbound: (...args: unknown[]) => mocks.recordSessionMetaFromInboundMock(...args), resolveStorePath: (...args: unknown[]) => mocks.resolveStorePathMock(...args), diff --git a/src/slack/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts similarity index 99% rename from src/slack/monitor/slash.test.ts rename to extensions/slack/src/monitor/slash.test.ts index 527bd2eac17..f4cc507c59e 100644 --- a/src/slack/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -1,7 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js"; -vi.mock("../../auto-reply/commands-registry.js", () => { +vi.mock("../../../../src/auto-reply/commands-registry.js", () => { const usageCommand = { key: "usage", nativeName: "usage" }; const reportCommand = { key: "report", nativeName: "report" }; const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" }; diff --git a/src/slack/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts similarity index 98% rename from src/slack/monitor/slash.ts rename to extensions/slack/src/monitor/slash.ts index f8b030e59ca..adf173a0961 100644 --- a/src/slack/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -2,13 +2,16 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@sla import { type ChatCommandDefinition, type CommandArgs, -} from "../../auto-reply/commands-registry.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; -import { resolveNativeCommandSessionTargets } from "../../channels/native-command-session-targets.js"; -import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; -import { danger, logVerbose } from "../../globals.js"; -import { chunkItems } from "../../utils/chunk-items.js"; +} from "../../../../src/auto-reply/commands-registry.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; +import { resolveNativeCommandSessionTargets } from "../../../../src/channels/native-command-session-targets.js"; +import { + resolveNativeCommandsEnabled, + resolveNativeSkillsEnabled, +} from "../../../../src/config/commands.js"; +import { danger, logVerbose } from "../../../../src/globals.js"; +import { chunkItems } from "../../../../src/utils/chunk-items.js"; import type { ResolvedSlackAccount } from "../accounts.js"; import { truncateSlackText } from "../truncate.js"; import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "./allow-list.js"; diff --git a/src/slack/monitor/thread-resolution.ts b/extensions/slack/src/monitor/thread-resolution.ts similarity index 96% rename from src/slack/monitor/thread-resolution.ts rename to extensions/slack/src/monitor/thread-resolution.ts index a4ae0ac7187..4230d5fc50f 100644 --- a/src/slack/monitor/thread-resolution.ts +++ b/extensions/slack/src/monitor/thread-resolution.ts @@ -1,6 +1,6 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; -import { logVerbose, shouldLogVerbose } from "../../globals.js"; -import { pruneMapToMaxSize } from "../../infra/map-size.js"; +import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; +import { pruneMapToMaxSize } from "../../../../src/infra/map-size.js"; import type { SlackMessageEvent } from "../types.js"; type ThreadTsCacheEntry = { diff --git a/src/slack/monitor/types.ts b/extensions/slack/src/monitor/types.ts similarity index 96% rename from src/slack/monitor/types.ts rename to extensions/slack/src/monitor/types.ts index 7aa27b5a4e1..1239ab771f5 100644 --- a/src/slack/monitor/types.ts +++ b/extensions/slack/src/monitor/types.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig, SlackSlashCommandConfig } from "../../config/config.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { OpenClawConfig, SlackSlashCommandConfig } from "../../../../src/config/config.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import type { SlackFile, SlackMessageEvent } from "../types.js"; export type MonitorSlackOpts = { diff --git a/src/slack/probe.test.ts b/extensions/slack/src/probe.test.ts similarity index 97% rename from src/slack/probe.test.ts rename to extensions/slack/src/probe.test.ts index 501d808d492..608a61864e6 100644 --- a/src/slack/probe.test.ts +++ b/extensions/slack/src/probe.test.ts @@ -8,7 +8,7 @@ vi.mock("./client.js", () => ({ createSlackWebClient: createSlackWebClientMock, })); -vi.mock("../utils/with-timeout.js", () => ({ +vi.mock("../../../src/utils/with-timeout.js", () => ({ withTimeout: withTimeoutMock, })); diff --git a/src/slack/probe.ts b/extensions/slack/src/probe.ts similarity index 89% rename from src/slack/probe.ts rename to extensions/slack/src/probe.ts index 165c5af636b..dba8744a18c 100644 --- a/src/slack/probe.ts +++ b/extensions/slack/src/probe.ts @@ -1,5 +1,5 @@ -import type { BaseProbeResult } from "../channels/plugins/types.js"; -import { withTimeout } from "../utils/with-timeout.js"; +import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; +import { withTimeout } from "../../../src/utils/with-timeout.js"; import { createSlackWebClient } from "./client.js"; export type SlackProbe = BaseProbeResult & { diff --git a/src/slack/resolve-allowlist-common.test.ts b/extensions/slack/src/resolve-allowlist-common.test.ts similarity index 100% rename from src/slack/resolve-allowlist-common.test.ts rename to extensions/slack/src/resolve-allowlist-common.test.ts diff --git a/src/slack/resolve-allowlist-common.ts b/extensions/slack/src/resolve-allowlist-common.ts similarity index 100% rename from src/slack/resolve-allowlist-common.ts rename to extensions/slack/src/resolve-allowlist-common.ts diff --git a/src/slack/resolve-channels.test.ts b/extensions/slack/src/resolve-channels.test.ts similarity index 100% rename from src/slack/resolve-channels.test.ts rename to extensions/slack/src/resolve-channels.test.ts diff --git a/src/slack/resolve-channels.ts b/extensions/slack/src/resolve-channels.ts similarity index 100% rename from src/slack/resolve-channels.ts rename to extensions/slack/src/resolve-channels.ts diff --git a/src/slack/resolve-users.test.ts b/extensions/slack/src/resolve-users.test.ts similarity index 100% rename from src/slack/resolve-users.test.ts rename to extensions/slack/src/resolve-users.test.ts diff --git a/src/slack/resolve-users.ts b/extensions/slack/src/resolve-users.ts similarity index 100% rename from src/slack/resolve-users.ts rename to extensions/slack/src/resolve-users.ts diff --git a/src/slack/scopes.ts b/extensions/slack/src/scopes.ts similarity index 98% rename from src/slack/scopes.ts rename to extensions/slack/src/scopes.ts index 2cea7aaa7ea..e0fe58161f3 100644 --- a/src/slack/scopes.ts +++ b/extensions/slack/src/scopes.ts @@ -1,5 +1,5 @@ import type { WebClient } from "@slack/web-api"; -import { isRecord } from "../utils.js"; +import { isRecord } from "../../../src/utils.js"; import { createSlackWebClient } from "./client.js"; export type SlackScopesResult = { diff --git a/src/slack/send.blocks.test.ts b/extensions/slack/src/send.blocks.test.ts similarity index 100% rename from src/slack/send.blocks.test.ts rename to extensions/slack/src/send.blocks.test.ts diff --git a/src/slack/send.ts b/extensions/slack/src/send.ts similarity index 96% rename from src/slack/send.ts rename to extensions/slack/src/send.ts index 8ce7fd3c3f3..293affe0218 100644 --- a/src/slack/send.ts +++ b/extensions/slack/src/send.ts @@ -3,16 +3,16 @@ import { chunkMarkdownTextWithMode, resolveChunkMode, resolveTextChunkLimit, -} from "../auto-reply/chunk.js"; -import { isSilentReplyText } from "../auto-reply/tokens.js"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { logVerbose } from "../globals.js"; +} from "../../../src/auto-reply/chunk.js"; +import { isSilentReplyText } from "../../../src/auto-reply/tokens.js"; +import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { logVerbose } from "../../../src/globals.js"; import { fetchWithSsrFGuard, withTrustedEnvProxyGuardedFetchMode, -} from "../infra/net/fetch-guard.js"; -import { loadWebMedia } from "../web/media.js"; +} from "../../../src/infra/net/fetch-guard.js"; +import { loadWebMedia } from "../../whatsapp/src/media.js"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; diff --git a/src/slack/send.upload.test.ts b/extensions/slack/src/send.upload.test.ts similarity index 98% rename from src/slack/send.upload.test.ts rename to extensions/slack/src/send.upload.test.ts index 7ff05183b6c..1ee3c76deac 100644 --- a/src/slack/send.upload.test.ts +++ b/extensions/slack/src/send.upload.test.ts @@ -13,7 +13,7 @@ const fetchWithSsrFGuard = vi.fn( }) as const, ); -vi.mock("../infra/net/fetch-guard.js", () => ({ +vi.mock("../../../src/infra/net/fetch-guard.js", () => ({ fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuard(...(args as [params: { url: string; init?: RequestInit }])), withTrustedEnvProxyGuardedFetchMode: (params: Record) => ({ @@ -22,7 +22,7 @@ vi.mock("../infra/net/fetch-guard.js", () => ({ }), })); -vi.mock("../web/media.js", () => ({ +vi.mock("../../whatsapp/src/media.js", () => ({ loadWebMedia: vi.fn(async () => ({ buffer: Buffer.from("fake-image"), contentType: "image/png", diff --git a/src/slack/sent-thread-cache.test.ts b/extensions/slack/src/sent-thread-cache.test.ts similarity index 98% rename from src/slack/sent-thread-cache.test.ts rename to extensions/slack/src/sent-thread-cache.test.ts index 7421a7277e3..1e215af252c 100644 --- a/src/slack/sent-thread-cache.test.ts +++ b/extensions/slack/src/sent-thread-cache.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { importFreshModule } from "../../test/helpers/import-fresh.js"; +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; import { clearSlackThreadParticipationCache, hasSlackThreadParticipation, diff --git a/src/slack/sent-thread-cache.ts b/extensions/slack/src/sent-thread-cache.ts similarity index 96% rename from src/slack/sent-thread-cache.ts rename to extensions/slack/src/sent-thread-cache.ts index b3c2a3c2441..37cf8155472 100644 --- a/src/slack/sent-thread-cache.ts +++ b/extensions/slack/src/sent-thread-cache.ts @@ -1,4 +1,4 @@ -import { resolveGlobalMap } from "../shared/global-singleton.js"; +import { resolveGlobalMap } from "../../../src/shared/global-singleton.js"; /** * In-memory cache of Slack threads the bot has participated in. diff --git a/src/slack/stream-mode.test.ts b/extensions/slack/src/stream-mode.test.ts similarity index 100% rename from src/slack/stream-mode.test.ts rename to extensions/slack/src/stream-mode.test.ts diff --git a/src/slack/stream-mode.ts b/extensions/slack/src/stream-mode.ts similarity index 97% rename from src/slack/stream-mode.ts rename to extensions/slack/src/stream-mode.ts index 44abc91bcb9..819eb4fa722 100644 --- a/src/slack/stream-mode.ts +++ b/extensions/slack/src/stream-mode.ts @@ -4,7 +4,7 @@ import { resolveSlackStreamingMode, type SlackLegacyDraftStreamMode, type StreamingMode, -} from "../config/discord-preview-streaming.js"; +} from "../../../src/config/discord-preview-streaming.js"; export type SlackStreamMode = SlackLegacyDraftStreamMode; export type SlackStreamingMode = StreamingMode; diff --git a/src/slack/streaming.ts b/extensions/slack/src/streaming.ts similarity index 98% rename from src/slack/streaming.ts rename to extensions/slack/src/streaming.ts index 936fba79feb..b6269412c9d 100644 --- a/src/slack/streaming.ts +++ b/extensions/slack/src/streaming.ts @@ -13,7 +13,7 @@ import type { WebClient } from "@slack/web-api"; import type { ChatStreamer } from "@slack/web-api/dist/chat-stream.js"; -import { logVerbose } from "../globals.js"; +import { logVerbose } from "../../../src/globals.js"; // --------------------------------------------------------------------------- // Types diff --git a/src/slack/targets.test.ts b/extensions/slack/src/targets.test.ts similarity index 95% rename from src/slack/targets.test.ts rename to extensions/slack/src/targets.test.ts index 5b56a5bd0da..8ea720e6880 100644 --- a/src/slack/targets.test.ts +++ b/extensions/slack/src/targets.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeSlackMessagingTarget } from "../channels/plugins/normalize/slack.js"; +import { normalizeSlackMessagingTarget } from "../../../src/channels/plugins/normalize/slack.js"; import { parseSlackTarget, resolveSlackChannelId } from "./targets.js"; describe("parseSlackTarget", () => { diff --git a/src/slack/targets.ts b/extensions/slack/src/targets.ts similarity index 97% rename from src/slack/targets.ts rename to extensions/slack/src/targets.ts index e6bc69d8d24..5d80650daff 100644 --- a/src/slack/targets.ts +++ b/extensions/slack/src/targets.ts @@ -6,7 +6,7 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, -} from "../channels/targets.js"; +} from "../../../src/channels/targets.js"; export type SlackTargetKind = MessagingTargetKind; diff --git a/src/slack/threading-tool-context.test.ts b/extensions/slack/src/threading-tool-context.test.ts similarity index 98% rename from src/slack/threading-tool-context.test.ts rename to extensions/slack/src/threading-tool-context.test.ts index 69f4cf0e0dd..793f3a2346f 100644 --- a/src/slack/threading-tool-context.test.ts +++ b/extensions/slack/src/threading-tool-context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; const emptyCfg = {} as OpenClawConfig; diff --git a/src/slack/threading-tool-context.ts b/extensions/slack/src/threading-tool-context.ts similarity index 92% rename from src/slack/threading-tool-context.ts rename to extensions/slack/src/threading-tool-context.ts index 11860f78636..206ce98b42f 100644 --- a/src/slack/threading-tool-context.ts +++ b/extensions/slack/src/threading-tool-context.ts @@ -1,8 +1,8 @@ import type { ChannelThreadingContext, ChannelThreadingToolContext, -} from "../channels/plugins/types.js"; -import type { OpenClawConfig } from "../config/config.js"; +} from "../../../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js"; export function buildSlackThreadingToolContext(params: { diff --git a/src/slack/threading.test.ts b/extensions/slack/src/threading.test.ts similarity index 100% rename from src/slack/threading.test.ts rename to extensions/slack/src/threading.test.ts diff --git a/src/slack/threading.ts b/extensions/slack/src/threading.ts similarity index 96% rename from src/slack/threading.ts rename to extensions/slack/src/threading.ts index 0a72ffa0f3a..ccef2e5e081 100644 --- a/src/slack/threading.ts +++ b/extensions/slack/src/threading.ts @@ -1,4 +1,4 @@ -import type { ReplyToMode } from "../config/types.js"; +import type { ReplyToMode } from "../../../src/config/types.js"; import type { SlackAppMentionEvent, SlackMessageEvent } from "./types.js"; export type SlackThreadContext = { diff --git a/src/slack/token.ts b/extensions/slack/src/token.ts similarity index 89% rename from src/slack/token.ts rename to extensions/slack/src/token.ts index 7a26a845fce..cebda65e335 100644 --- a/src/slack/token.ts +++ b/extensions/slack/src/token.ts @@ -1,4 +1,4 @@ -import { normalizeResolvedSecretInputString } from "../config/types.secrets.js"; +import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; export function normalizeSlackToken(raw?: unknown): string | undefined { return normalizeResolvedSecretInputString({ diff --git a/src/slack/truncate.ts b/extensions/slack/src/truncate.ts similarity index 100% rename from src/slack/truncate.ts rename to extensions/slack/src/truncate.ts diff --git a/src/slack/types.ts b/extensions/slack/src/types.ts similarity index 100% rename from src/slack/types.ts rename to extensions/slack/src/types.ts diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index bc8623b6059..c6148c856a3 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/synology-chat", - "version": "2026.3.13", + "version": "2026.3.14", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 2b4e5fd584d..92054ca01a3 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/src/telegram/account-inspect.test.ts b/extensions/telegram/src/account-inspect.test.ts similarity index 96% rename from src/telegram/account-inspect.test.ts rename to extensions/telegram/src/account-inspect.test.ts index b25bd223667..5e58626ba03 100644 --- a/src/telegram/account-inspect.test.ts +++ b/extensions/telegram/src/account-inspect.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { withEnv } from "../test-utils/env.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { withEnv } from "../../../src/test-utils/env.js"; import { inspectTelegramAccount } from "./account-inspect.js"; describe("inspectTelegramAccount SecretRef resolution", () => { diff --git a/src/telegram/account-inspect.ts b/extensions/telegram/src/account-inspect.ts similarity index 91% rename from src/telegram/account-inspect.ts rename to extensions/telegram/src/account-inspect.ts index 2db9db06e3e..8014df80080 100644 --- a/src/telegram/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -1,14 +1,14 @@ -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { coerceSecretRef, hasConfiguredSecretInput, normalizeSecretInputString, -} from "../config/types.secrets.js"; -import type { TelegramAccountConfig } from "../config/types.telegram.js"; -import { tryReadSecretFileSync } from "../infra/secret-file.js"; -import { resolveAccountWithDefaultFallback } from "../plugin-sdk/account-resolution.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; +} from "../../../src/config/types.secrets.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js"; +import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; +import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk/account-resolution.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js"; import { mergeTelegramAccountConfig, resolveDefaultTelegramAccountId, diff --git a/src/telegram/accounts.test.ts b/extensions/telegram/src/accounts.test.ts similarity index 98% rename from src/telegram/accounts.test.ts rename to extensions/telegram/src/accounts.test.ts index fad5e0a63a5..28af65a5d8a 100644 --- a/src/telegram/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { withEnv } from "../test-utils/env.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { withEnv } from "../../../src/test-utils/env.js"; import { listTelegramAccountIds, resetMissingDefaultWarnFlag, @@ -29,7 +29,7 @@ function resolveAccountWithEnv( return withEnv(env, () => resolveTelegramAccount({ cfg, ...(accountId ? { accountId } : {}) })); } -vi.mock("../logging/subsystem.js", () => ({ +vi.mock("../../../src/logging/subsystem.js", () => ({ createSubsystemLogger: () => { const logger = { warn: warnMock, diff --git a/src/telegram/accounts.ts b/extensions/telegram/src/accounts.ts similarity index 90% rename from src/telegram/accounts.ts rename to extensions/telegram/src/accounts.ts index b8c656d1bfd..71d78590488 100644 --- a/src/telegram/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -1,21 +1,24 @@ import util from "node:util"; -import { createAccountActionGate } from "../channels/plugins/account-action-gate.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js"; -import { isTruthyEnvValue } from "../infra/env.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; +import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig, TelegramActionConfig } from "../../../src/config/types.js"; +import { isTruthyEnvValue } from "../../../src/infra/env.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; import { listConfiguredAccountIds as listConfiguredAccountIdsFromSection, resolveAccountWithDefaultFallback, -} from "../plugin-sdk/account-resolution.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js"; -import { formatSetExplicitDefaultInstruction } from "../routing/default-account-warnings.js"; +} from "../../../src/plugin-sdk/account-resolution.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { + listBoundAccountIds, + resolveDefaultAgentBoundAccountId, +} from "../../../src/routing/bindings.js"; +import { formatSetExplicitDefaultInstruction } from "../../../src/routing/default-account-warnings.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, normalizeOptionalAccountId, -} from "../routing/session-key.js"; +} from "../../../src/routing/session-key.js"; import { resolveTelegramToken } from "./token.js"; const log = createSubsystemLogger("telegram/accounts"); diff --git a/src/channels/telegram/allow-from.test.ts b/extensions/telegram/src/allow-from.test.ts similarity index 100% rename from src/channels/telegram/allow-from.test.ts rename to extensions/telegram/src/allow-from.test.ts diff --git a/src/channels/telegram/allow-from.ts b/extensions/telegram/src/allow-from.ts similarity index 100% rename from src/channels/telegram/allow-from.ts rename to extensions/telegram/src/allow-from.ts diff --git a/src/telegram/allowed-updates.ts b/extensions/telegram/src/allowed-updates.ts similarity index 100% rename from src/telegram/allowed-updates.ts rename to extensions/telegram/src/allowed-updates.ts diff --git a/src/channels/telegram/api.test.ts b/extensions/telegram/src/api-fetch.test.ts similarity index 96% rename from src/channels/telegram/api.test.ts rename to extensions/telegram/src/api-fetch.test.ts index caab59b7ec0..e65499ef25c 100644 --- a/src/channels/telegram/api.test.ts +++ b/extensions/telegram/src/api-fetch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { fetchTelegramChatId } from "./api.js"; +import { fetchTelegramChatId } from "./api-fetch.js"; describe("fetchTelegramChatId", () => { const cases = [ diff --git a/src/channels/telegram/api.ts b/extensions/telegram/src/api-fetch.ts similarity index 100% rename from src/channels/telegram/api.ts rename to extensions/telegram/src/api-fetch.ts diff --git a/src/telegram/api-logging.ts b/extensions/telegram/src/api-logging.ts similarity index 79% rename from src/telegram/api-logging.ts rename to extensions/telegram/src/api-logging.ts index 4534b3f8264..6af9d7ae5a3 100644 --- a/src/telegram/api-logging.ts +++ b/extensions/telegram/src/api-logging.ts @@ -1,7 +1,7 @@ -import { danger } from "../globals.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { danger } from "../../../src/globals.js"; +import { formatErrorMessage } from "../../../src/infra/errors.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; export type TelegramApiLogger = (message: string) => void; diff --git a/src/telegram/approval-buttons.test.ts b/extensions/telegram/src/approval-buttons.test.ts similarity index 100% rename from src/telegram/approval-buttons.test.ts rename to extensions/telegram/src/approval-buttons.test.ts diff --git a/src/telegram/approval-buttons.ts b/extensions/telegram/src/approval-buttons.ts similarity index 93% rename from src/telegram/approval-buttons.ts rename to extensions/telegram/src/approval-buttons.ts index 0439bec58b9..a996ed3adf3 100644 --- a/src/telegram/approval-buttons.ts +++ b/extensions/telegram/src/approval-buttons.ts @@ -1,4 +1,4 @@ -import type { ExecApprovalReplyDecision } from "../infra/exec-approval-reply.js"; +import type { ExecApprovalReplyDecision } from "../../../src/infra/exec-approval-reply.js"; import type { TelegramInlineButtons } from "./button-types.js"; const MAX_CALLBACK_DATA_BYTES = 64; diff --git a/src/telegram/audit-membership-runtime.ts b/extensions/telegram/src/audit-membership-runtime.ts similarity index 95% rename from src/telegram/audit-membership-runtime.ts rename to extensions/telegram/src/audit-membership-runtime.ts index c710fb92aa7..694ad338c5b 100644 --- a/src/telegram/audit-membership-runtime.ts +++ b/extensions/telegram/src/audit-membership-runtime.ts @@ -1,5 +1,5 @@ -import { isRecord } from "../utils.js"; -import { fetchWithTimeout } from "../utils/fetch-timeout.js"; +import { isRecord } from "../../../src/utils.js"; +import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; import type { AuditTelegramGroupMembershipParams, TelegramGroupMembershipAudit, diff --git a/src/telegram/audit.test.ts b/extensions/telegram/src/audit.test.ts similarity index 100% rename from src/telegram/audit.test.ts rename to extensions/telegram/src/audit.test.ts diff --git a/src/telegram/audit.ts b/extensions/telegram/src/audit.ts similarity index 94% rename from src/telegram/audit.ts rename to extensions/telegram/src/audit.ts index 6b667c37581..507f161edca 100644 --- a/src/telegram/audit.ts +++ b/extensions/telegram/src/audit.ts @@ -1,5 +1,5 @@ -import type { TelegramGroupConfig } from "../config/types.js"; -import type { TelegramNetworkConfig } from "../config/types.telegram.js"; +import type { TelegramGroupConfig } from "../../../src/config/types.js"; +import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; export type TelegramGroupMembershipAuditEntry = { chatId: string; diff --git a/src/telegram/bot-access.test.ts b/extensions/telegram/src/bot-access.test.ts similarity index 100% rename from src/telegram/bot-access.test.ts rename to extensions/telegram/src/bot-access.test.ts diff --git a/src/telegram/bot-access.ts b/extensions/telegram/src/bot-access.ts similarity index 93% rename from src/telegram/bot-access.ts rename to extensions/telegram/src/bot-access.ts index 60b3f5582a9..57b242afc3d 100644 --- a/src/telegram/bot-access.ts +++ b/extensions/telegram/src/bot-access.ts @@ -2,9 +2,9 @@ import { firstDefined, isSenderIdAllowed, mergeDmAllowFromSources, -} from "../channels/allow-from.js"; -import type { AllowlistMatch } from "../channels/allowlist-match.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; +} from "../../../src/channels/allow-from.js"; +import type { AllowlistMatch } from "../../../src/channels/allowlist-match.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; export type NormalizedAllowFrom = { entries: string[]; diff --git a/src/telegram/bot-handlers.ts b/extensions/telegram/src/bot-handlers.ts similarity index 97% rename from src/telegram/bot-handlers.ts rename to extensions/telegram/src/bot-handlers.ts index 40eada8f62a..295c4092ec6 100644 --- a/src/telegram/bot-handlers.ts +++ b/extensions/telegram/src/bot-handlers.ts @@ -1,41 +1,41 @@ import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; -import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; +import { resolveAgentDir, resolveDefaultAgentId } from "../../../src/agents/agent-scope.js"; +import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; import { createInboundDebouncer, resolveInboundDebounceMs, -} from "../auto-reply/inbound-debounce.js"; -import { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js"; +} from "../../../src/auto-reply/inbound-debounce.js"; +import { buildCommandsPaginationKeyboard } from "../../../src/auto-reply/reply/commands-info.js"; import { buildModelsProviderData, formatModelsAvailableHeader, -} from "../auto-reply/reply/commands-models.js"; -import { resolveStoredModelOverride } from "../auto-reply/reply/model-selection.js"; -import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; -import { buildCommandsMessagePaginated } from "../auto-reply/status.js"; -import { shouldDebounceTextInbound } from "../channels/inbound-debounce-policy.js"; -import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js"; -import { loadConfig } from "../config/config.js"; -import { writeConfigFile } from "../config/io.js"; +} from "../../../src/auto-reply/reply/commands-models.js"; +import { resolveStoredModelOverride } from "../../../src/auto-reply/reply/model-selection.js"; +import { listSkillCommandsForAgents } from "../../../src/auto-reply/skill-commands.js"; +import { buildCommandsMessagePaginated } from "../../../src/auto-reply/status.js"; +import { shouldDebounceTextInbound } from "../../../src/channels/inbound-debounce-policy.js"; +import { resolveChannelConfigWrites } from "../../../src/channels/plugins/config-writes.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { writeConfigFile } from "../../../src/config/io.js"; import { loadSessionStore, resolveSessionStoreEntry, resolveStorePath, updateSessionStore, -} from "../config/sessions.js"; -import type { DmPolicy } from "../config/types.base.js"; +} from "../../../src/config/sessions.js"; +import type { DmPolicy } from "../../../src/config/types.base.js"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../config/types.js"; -import { danger, logVerbose, warn } from "../globals.js"; -import { enqueueSystemEvent } from "../infra/system-events.js"; -import { MediaFetchError } from "../media/fetch.js"; -import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; -import { resolveAgentRoute } from "../routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../routing/session-key.js"; -import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; +} from "../../../src/config/types.js"; +import { danger, logVerbose, warn } from "../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../src/infra/system-events.js"; +import { MediaFetchError } from "../../../src/media/fetch.js"; +import { readChannelAllowFromStore } from "../../../src/pairing/pairing-store.js"; +import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; +import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; +import { applyModelOverrideToSessionEntry } from "../../../src/sessions/model-overrides.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { isSenderAllowed, diff --git a/src/telegram/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts similarity index 98% rename from src/telegram/bot-message-context.acp-bindings.test.ts rename to extensions/telegram/src/bot-message-context.acp-bindings.test.ts index 1e073366347..1f9adb41a72 100644 --- a/src/telegram/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn()); const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn()); -vi.mock("../acp/persistent-bindings.js", () => ({ +vi.mock("../../../src/acp/persistent-bindings.js", () => ({ ensureConfiguredAcpBindingSession: (...args: unknown[]) => ensureConfiguredAcpBindingSessionMock(...args), resolveConfiguredAcpBindingRecord: (...args: unknown[]) => diff --git a/src/telegram/bot-message-context.audio-transcript.test.ts b/extensions/telegram/src/bot-message-context.audio-transcript.test.ts similarity index 98% rename from src/telegram/bot-message-context.audio-transcript.test.ts rename to extensions/telegram/src/bot-message-context.audio-transcript.test.ts index 1cd0e15df31..a9e60736e70 100644 --- a/src/telegram/bot-message-context.audio-transcript.test.ts +++ b/extensions/telegram/src/bot-message-context.audio-transcript.test.ts @@ -6,7 +6,7 @@ const DEFAULT_MODEL = "anthropic/claude-opus-4-5"; const DEFAULT_WORKSPACE = "/tmp/openclaw"; const DEFAULT_MENTION_PATTERN = "\\bbot\\b"; -vi.mock("../media-understanding/audio-preflight.js", () => ({ +vi.mock("../../../src/media-understanding/audio-preflight.js", () => ({ transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args), })); diff --git a/src/telegram/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts similarity index 89% rename from src/telegram/bot-message-context.body.ts rename to extensions/telegram/src/bot-message-context.body.ts index 56b18f1b944..8290b02169d 100644 --- a/src/telegram/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -2,26 +2,29 @@ import { findModelInCatalog, loadModelCatalog, modelSupportsVision, -} from "../agents/model-catalog.js"; -import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; -import { hasControlCommand } from "../auto-reply/command-detection.js"; +} from "../../../src/agents/model-catalog.js"; +import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; +import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; import { recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "../auto-reply/reply/history.js"; -import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js"; -import type { MsgContext } from "../auto-reply/templating.js"; -import { resolveControlCommandGate } from "../channels/command-gating.js"; -import { formatLocationText, type NormalizedLocation } from "../channels/location.js"; -import { logInboundDrop } from "../channels/logging.js"; -import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; -import type { OpenClawConfig } from "../config/config.js"; +} from "../../../src/auto-reply/reply/history.js"; +import { + buildMentionRegexes, + matchesMentionWithExplicit, +} from "../../../src/auto-reply/reply/mentions.js"; +import type { MsgContext } from "../../../src/auto-reply/templating.js"; +import { resolveControlCommandGate } from "../../../src/channels/command-gating.js"; +import { formatLocationText, type NormalizedLocation } from "../../../src/channels/location.js"; +import { logInboundDrop } from "../../../src/channels/logging.js"; +import { resolveMentionGatingWithBypass } from "../../../src/channels/mention-gating.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../config/types.js"; -import { logVerbose } from "../globals.js"; +} from "../../../src/config/types.js"; +import { logVerbose } from "../../../src/globals.js"; import type { NormalizedAllowFrom } from "./bot-access.js"; import { isSenderAllowed } from "./bot-access.js"; import type { @@ -179,7 +182,8 @@ export async function resolveTelegramInboundBody(params: { if (needsPreflightTranscription) { try { - const { transcribeFirstAudio } = await import("../media-understanding/audio-preflight.js"); + const { transcribeFirstAudio } = + await import("../../../src/media-understanding/audio-preflight.js"); const tempCtx: MsgContext = { MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, MediaTypes: diff --git a/src/telegram/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts similarity index 97% rename from src/telegram/bot-message-context.dm-threads.test.ts rename to extensions/telegram/src/bot-message-context.dm-threads.test.ts index eba4c19c88c..23fb0cdcc19 100644 --- a/src/telegram/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it } from "vitest"; -import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "../../../src/config/config.js"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; describe("buildTelegramMessageContext dm thread sessions", () => { diff --git a/src/telegram/bot-message-context.dm-topic-threadid.test.ts b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts similarity index 98% rename from src/telegram/bot-message-context.dm-topic-threadid.test.ts rename to extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts index ba566898db8..8f8375fd11a 100644 --- a/src/telegram/bot-message-context.dm-topic-threadid.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts @@ -3,7 +3,7 @@ import { buildTelegramMessageContextForTest } from "./bot-message-context.test-h // Mock recordInboundSession to capture updateLastRoute parameter const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined); -vi.mock("../channels/session.js", () => ({ +vi.mock("../../../src/channels/session.js", () => ({ recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), })); diff --git a/src/telegram/bot-message-context.implicit-mention.test.ts b/extensions/telegram/src/bot-message-context.implicit-mention.test.ts similarity index 100% rename from src/telegram/bot-message-context.implicit-mention.test.ts rename to extensions/telegram/src/bot-message-context.implicit-mention.test.ts diff --git a/src/telegram/bot-message-context.named-account-dm.test.ts b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts similarity index 96% rename from src/telegram/bot-message-context.named-account-dm.test.ts rename to extensions/telegram/src/bot-message-context.named-account-dm.test.ts index 50a24b38f8a..a60904514ba 100644 --- a/src/telegram/bot-message-context.named-account-dm.test.ts +++ b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts @@ -1,9 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "../../../src/config/config.js"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined); -vi.mock("../channels/session.js", () => ({ +vi.mock("../../../src/channels/session.js", () => ({ recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), })); diff --git a/src/telegram/bot-message-context.sender-prefix.test.ts b/extensions/telegram/src/bot-message-context.sender-prefix.test.ts similarity index 100% rename from src/telegram/bot-message-context.sender-prefix.test.ts rename to extensions/telegram/src/bot-message-context.sender-prefix.test.ts diff --git a/src/telegram/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts similarity index 90% rename from src/telegram/bot-message-context.session.ts rename to extensions/telegram/src/bot-message-context.session.ts index 6932b315dc7..1a2f54cf22f 100644 --- a/src/telegram/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -1,23 +1,26 @@ -import { normalizeCommandBody } from "../auto-reply/commands-registry.js"; -import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js"; +import { normalizeCommandBody } from "../../../src/auto-reply/commands-registry.js"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../../src/auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, type HistoryEntry, -} from "../auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; -import { toLocationContext } from "../channels/location.js"; -import { recordInboundSession } from "../channels/session.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js"; +} from "../../../src/auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js"; +import { toLocationContext } from "../../../src/channels/location.js"; +import { recordInboundSession } from "../../../src/channels/session.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../src/config/sessions.js"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../config/types.js"; -import { logVerbose, shouldLogVerbose } from "../globals.js"; -import type { ResolvedAgentRoute } from "../routing/resolve-route.js"; -import { resolveInboundLastRouteSessionKey } from "../routing/resolve-route.js"; -import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; +} from "../../../src/config/types.js"; +import { logVerbose, shouldLogVerbose } from "../../../src/globals.js"; +import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js"; +import { resolveInboundLastRouteSessionKey } from "../../../src/routing/resolve-route.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../src/security/dm-policy-shared.js"; import { normalizeAllowFrom } from "./bot-access.js"; import type { TelegramMediaRef, @@ -60,7 +63,7 @@ export async function buildTelegramInboundContextPayload(params: { stickerCacheHit: boolean; effectiveWasMentioned: boolean; commandAuthorized: boolean; - locationData?: import("../channels/location.js").NormalizedLocation; + locationData?: import("../../../src/channels/location.js").NormalizedLocation; options?: TelegramMessageContextOptions; dmAllowFrom?: Array; }): Promise<{ diff --git a/src/telegram/bot-message-context.test-harness.ts b/extensions/telegram/src/bot-message-context.test-harness.ts similarity index 100% rename from src/telegram/bot-message-context.test-harness.ts rename to extensions/telegram/src/bot-message-context.test-harness.ts diff --git a/src/telegram/bot-message-context.thread-binding.test.ts b/extensions/telegram/src/bot-message-context.thread-binding.test.ts similarity index 95% rename from src/telegram/bot-message-context.thread-binding.test.ts rename to extensions/telegram/src/bot-message-context.thread-binding.test.ts index 07a625fa782..e635b6f4a11 100644 --- a/src/telegram/bot-message-context.thread-binding.test.ts +++ b/extensions/telegram/src/bot-message-context.thread-binding.test.ts @@ -9,9 +9,9 @@ const hoisted = vi.hoisted(() => { }; }); -vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) => { +vi.mock("../../../src/infra/outbound/session-binding-service.js", async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, getSessionBindingService: () => ({ diff --git a/src/telegram/bot-message-context.topic-agentid.test.ts b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts similarity index 95% rename from src/telegram/bot-message-context.topic-agentid.test.ts rename to extensions/telegram/src/bot-message-context.topic-agentid.test.ts index d3e24060278..ed55c11b36f 100644 --- a/src/telegram/bot-message-context.topic-agentid.test.ts +++ b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { loadConfig } from "../config/config.js"; +import { loadConfig } from "../../../src/config/config.js"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const { defaultRouteConfig } = vi.hoisted(() => ({ @@ -12,8 +12,8 @@ const { defaultRouteConfig } = vi.hoisted(() => ({ }, })); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: vi.fn(() => defaultRouteConfig), diff --git a/src/telegram/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts similarity index 95% rename from src/telegram/bot-message-context.ts rename to extensions/telegram/src/bot-message-context.ts index 19962121628..03bcd429018 100644 --- a/src/telegram/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -1,17 +1,17 @@ -import { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js"; -import { resolveAckReaction } from "../agents/identity.js"; -import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js"; -import { logInboundDrop } from "../channels/logging.js"; +import { ensureConfiguredAcpRouteReady } from "../../../src/acp/persistent-bindings.route.js"; +import { resolveAckReaction } from "../../../src/agents/identity.js"; +import { shouldAckReaction as shouldAckReactionGate } from "../../../src/channels/ack-reactions.js"; +import { logInboundDrop } from "../../../src/channels/logging.js"; import { createStatusReactionController, type StatusReactionController, -} from "../channels/status-reactions.js"; -import { loadConfig } from "../config/config.js"; -import type { TelegramDirectConfig, TelegramGroupConfig } from "../config/types.js"; -import { logVerbose } from "../globals.js"; -import { recordChannelActivity } from "../infra/channel-activity.js"; -import { buildAgentSessionKey, deriveLastRoutePolicy } from "../routing/resolve-route.js"; -import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js"; +} from "../../../src/channels/status-reactions.js"; +import { loadConfig } from "../../../src/config/config.js"; +import type { TelegramDirectConfig, TelegramGroupConfig } from "../../../src/config/types.js"; +import { logVerbose } from "../../../src/globals.js"; +import { recordChannelActivity } from "../../../src/infra/channel-activity.js"; +import { buildAgentSessionKey, deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js"; +import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js"; import { resolveTelegramInboundBody } from "./bot-message-context.body.js"; diff --git a/src/telegram/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts similarity index 91% rename from src/telegram/bot-message-context.types.ts rename to extensions/telegram/src/bot-message-context.types.ts index 9f140b63907..2853c1a8e34 100644 --- a/src/telegram/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -1,12 +1,12 @@ import type { Bot } from "grammy"; -import type { HistoryEntry } from "../auto-reply/reply/history.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { HistoryEntry } from "../../../src/auto-reply/reply/history.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy, TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../config/types.js"; +} from "../../../src/config/types.js"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; export type TelegramMediaRef = { diff --git a/src/telegram/bot-message-dispatch.sticker-media.test.ts b/extensions/telegram/src/bot-message-dispatch.sticker-media.test.ts similarity index 100% rename from src/telegram/bot-message-dispatch.sticker-media.test.ts rename to extensions/telegram/src/bot-message-dispatch.sticker-media.test.ts diff --git a/src/telegram/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts similarity index 99% rename from src/telegram/bot-message-dispatch.test.ts rename to extensions/telegram/src/bot-message-dispatch.test.ts index 62255706fbd..156d9296ae7 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type { Bot } from "grammy"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { STATE_DIR } from "../config/paths.js"; +import { STATE_DIR } from "../../../src/config/paths.js"; import { createSequencedTestDraftStream, createTestDraftStream, @@ -18,7 +18,7 @@ vi.mock("./draft-stream.js", () => ({ createTelegramDraftStream, })); -vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({ +vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ dispatchReplyWithBufferedBlockDispatcher, })); @@ -30,8 +30,8 @@ vi.mock("./send.js", () => ({ editMessageTelegram, })); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadSessionStore, diff --git a/src/telegram/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts similarity index 95% rename from src/telegram/bot-message-dispatch.ts rename to extensions/telegram/src/bot-message-dispatch.ts index 424f98caefc..a9c0e625508 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -1,29 +1,33 @@ import type { Bot } from "grammy"; -import { resolveAgentDir } from "../agents/agent-scope.js"; +import { resolveAgentDir } from "../../../src/agents/agent-scope.js"; import { findModelInCatalog, loadModelCatalog, modelSupportsVision, -} from "../agents/model-catalog.js"; -import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; -import { resolveChunkMode } from "../auto-reply/chunk.js"; -import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; -import type { ReplyPayload } from "../auto-reply/types.js"; -import { removeAckReactionAfterReply } from "../channels/ack-reactions.js"; -import { logAckFailure, logTypingFailure } from "../channels/logging.js"; -import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; -import { createTypingCallbacks } from "../channels/typing.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +} from "../../../src/agents/model-catalog.js"; +import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; +import { resolveChunkMode } from "../../../src/auto-reply/chunk.js"; +import { clearHistoryEntriesIfEnabled } from "../../../src/auto-reply/reply/history.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import { removeAckReactionAfterReply } from "../../../src/channels/ack-reactions.js"; +import { logAckFailure, logTypingFailure } from "../../../src/channels/logging.js"; +import { createReplyPrefixOptions } from "../../../src/channels/reply-prefix.js"; +import { createTypingCallbacks } from "../../../src/channels/typing.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; import { loadSessionStore, resolveSessionStoreEntry, resolveStorePath, -} from "../config/sessions.js"; -import type { OpenClawConfig, ReplyToMode, TelegramAccountConfig } from "../config/types.js"; -import { danger, logVerbose } from "../globals.js"; -import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; -import type { RuntimeEnv } from "../runtime.js"; +} from "../../../src/config/sessions.js"; +import type { + OpenClawConfig, + ReplyToMode, + TelegramAccountConfig, +} from "../../../src/config/types.js"; +import { danger, logVerbose } from "../../../src/globals.js"; +import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import type { TelegramMessageContext } from "./bot-message-context.js"; import type { TelegramBotOptions } from "./bot.js"; import { deliverReplies } from "./bot/delivery.js"; diff --git a/src/telegram/bot-message.test.ts b/extensions/telegram/src/bot-message.test.ts similarity index 100% rename from src/telegram/bot-message.test.ts rename to extensions/telegram/src/bot-message.test.ts diff --git a/src/telegram/bot-message.ts b/extensions/telegram/src/bot-message.ts similarity index 91% rename from src/telegram/bot-message.ts rename to extensions/telegram/src/bot-message.ts index 3fa58bb9ed8..0a5d44c65db 100644 --- a/src/telegram/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -1,7 +1,7 @@ -import type { ReplyToMode } from "../config/config.js"; -import type { TelegramAccountConfig } from "../config/types.telegram.js"; -import { danger } from "../globals.js"; -import type { RuntimeEnv } from "../runtime.js"; +import type { ReplyToMode } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js"; +import { danger } from "../../../src/globals.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import { buildTelegramMessageContext, type BuildTelegramMessageContextParams, diff --git a/src/telegram/bot-native-command-menu.test.ts b/extensions/telegram/src/bot-native-command-menu.test.ts similarity index 100% rename from src/telegram/bot-native-command-menu.test.ts rename to extensions/telegram/src/bot-native-command-menu.test.ts diff --git a/src/telegram/bot-native-command-menu.ts b/extensions/telegram/src/bot-native-command-menu.ts similarity index 97% rename from src/telegram/bot-native-command-menu.ts rename to extensions/telegram/src/bot-native-command-menu.ts index 6dd8f1ba30a..73fa2d2345a 100644 --- a/src/telegram/bot-native-command-menu.ts +++ b/extensions/telegram/src/bot-native-command-menu.ts @@ -3,13 +3,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Bot } from "grammy"; -import { resolveStateDir } from "../config/paths.js"; +import { resolveStateDir } from "../../../src/config/paths.js"; import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN, -} from "../config/telegram-custom-commands.js"; -import { logVerbose } from "../globals.js"; -import type { RuntimeEnv } from "../runtime.js"; +} from "../../../src/config/telegram-custom-commands.js"; +import { logVerbose } from "../../../src/globals.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; export const TELEGRAM_MAX_COMMANDS = 100; diff --git a/src/telegram/bot-native-commands.group-auth.test.ts b/extensions/telegram/src/bot-native-commands.group-auth.test.ts similarity index 96% rename from src/telegram/bot-native-commands.group-auth.test.ts rename to extensions/telegram/src/bot-native-commands.group-auth.test.ts index cca25aedc2c..efee344b907 100644 --- a/src/telegram/bot-native-commands.group-auth.test.ts +++ b/extensions/telegram/src/bot-native-commands.group-auth.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import type { ChannelGroupPolicy } from "../config/group-policy.js"; -import type { TelegramAccountConfig } from "../config/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; import { createNativeCommandsHarness, createTelegramGroupCommandContext, diff --git a/src/telegram/bot-native-commands.plugin-auth.test.ts b/extensions/telegram/src/bot-native-commands.plugin-auth.test.ts similarity index 86% rename from src/telegram/bot-native-commands.plugin-auth.test.ts rename to extensions/telegram/src/bot-native-commands.plugin-auth.test.ts index d611250bdeb..68268fb047b 100644 --- a/src/telegram/bot-native-commands.plugin-auth.test.ts +++ b/extensions/telegram/src/bot-native-commands.plugin-auth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import type { TelegramAccountConfig } from "../config/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; import { createNativeCommandsHarness, deliverReplies, @@ -11,17 +11,19 @@ import { type GetPluginCommandSpecsMock = { mockReturnValue: ( - value: ReturnType, + value: ReturnType, ) => unknown; }; type MatchPluginCommandMock = { mockReturnValue: ( - value: ReturnType, + value: ReturnType, ) => unknown; }; type ExecutePluginCommandMock = { mockResolvedValue: ( - value: Awaited>, + value: Awaited< + ReturnType + >, ) => unknown; }; diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts similarity index 94% rename from src/telegram/bot-native-commands.session-meta.test.ts rename to extensions/telegram/src/bot-native-commands.session-meta.test.ts index 43b5bb4133f..db3fdc23bba 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { registerTelegramNativeCommands, type RegisterTelegramHandlerParams, @@ -10,11 +10,11 @@ type RegisterTelegramNativeCommandsParams = Parameters[0]; type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< @@ -54,31 +54,31 @@ const sessionBindingMocks = vi.hoisted(() => ({ touch: vi.fn(), })); -vi.mock("../acp/persistent-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/acp/persistent-bindings.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord, ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession, }; }); -vi.mock("../config/sessions.js", () => ({ +vi.mock("../../../src/config/sessions.js", () => ({ recordSessionMetaFromInbound: sessionMocks.recordSessionMetaFromInbound, resolveStorePath: sessionMocks.resolveStorePath, })); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn(async () => []), })); -vi.mock("../auto-reply/reply/inbound-context.js", () => ({ +vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({ finalizeInboundContext: vi.fn((ctx: unknown) => ctx), })); -vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({ +vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, })); -vi.mock("../channels/reply-prefix.js", () => ({ +vi.mock("../../../src/channels/reply-prefix.js", () => ({ createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), })); -vi.mock("../infra/outbound/session-binding-service.js", () => ({ +vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ getSessionBindingService: () => ({ bind: vi.fn(), getCapabilities: vi.fn(), @@ -88,11 +88,11 @@ vi.mock("../infra/outbound/session-binding-service.js", () => ({ unbind: vi.fn(), }), })); -vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) }; }); -vi.mock("../plugins/commands.js", () => ({ +vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: vi.fn(() => []), matchPluginCommand: vi.fn(() => null), executePluginCommand: vi.fn(async () => ({ text: "ok" })), @@ -300,7 +300,7 @@ function createConfiguredAcpTopicBinding(boundSessionKey: string) { status: "active", boundAt: 0, }, - } satisfies import("../acp/persistent-bindings.js").ResolvedConfiguredAcpBinding; + } satisfies import("../../../src/acp/persistent-bindings.js").ResolvedConfiguredAcpBinding; } function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { diff --git a/src/telegram/bot-native-commands.skills-allowlist.test.ts b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts similarity index 93% rename from src/telegram/bot-native-commands.skills-allowlist.test.ts rename to extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts index 40a428064e1..c026392f9f9 100644 --- a/src/telegram/bot-native-commands.skills-allowlist.test.ts +++ b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { writeSkill } from "../agents/skills.e2e-test-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { TelegramAccountConfig } from "../config/types.js"; +import { writeSkill } from "../../../src/agents/skills.e2e-test-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; const pluginCommandMocks = vi.hoisted(() => ({ @@ -16,7 +16,7 @@ const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => ({ delivered: true })), })); -vi.mock("../plugins/commands.js", () => ({ +vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, diff --git a/src/telegram/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts similarity index 88% rename from src/telegram/bot-native-commands.test-helpers.ts rename to extensions/telegram/src/bot-native-commands.test-helpers.ts index eef028c8315..0b4babb180e 100644 --- a/src/telegram/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -1,15 +1,17 @@ import { vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import type { ChannelGroupPolicy } from "../config/group-policy.js"; -import type { TelegramAccountConfig } from "../config/types.js"; -import type { RuntimeEnv } from "../runtime.js"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; type RegisterTelegramNativeCommandsParams = Parameters[0]; -type GetPluginCommandSpecsFn = typeof import("../plugins/commands.js").getPluginCommandSpecs; -type MatchPluginCommandFn = typeof import("../plugins/commands.js").matchPluginCommand; -type ExecutePluginCommandFn = typeof import("../plugins/commands.js").executePluginCommand; +type GetPluginCommandSpecsFn = + typeof import("../../../src/plugins/commands.js").getPluginCommandSpecs; +type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand; +type ExecutePluginCommandFn = + typeof import("../../../src/plugins/commands.js").executePluginCommand; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; type NativeCommandHarness = { @@ -35,7 +37,7 @@ export const getPluginCommandSpecs = pluginCommandMocks.getPluginCommandSpecs; export const matchPluginCommand = pluginCommandMocks.matchPluginCommand; export const executePluginCommand = pluginCommandMocks.executePluginCommand; -vi.mock("../plugins/commands.js", () => ({ +vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, @@ -46,7 +48,7 @@ const deliveryMocks = vi.hoisted(() => ({ })); export const deliverReplies = deliveryMocks.deliverReplies; vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies })); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn(async () => []), })); diff --git a/src/telegram/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts similarity index 94% rename from src/telegram/bot-native-commands.test.ts rename to extensions/telegram/src/bot-native-commands.test.ts index a208649c62b..f6ebfe0dfe8 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -1,10 +1,10 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { STATE_DIR } from "../config/paths.js"; -import { TELEGRAM_COMMAND_NAME_PATTERN } from "../config/telegram-custom-commands.js"; -import type { TelegramAccountConfig } from "../config/types.js"; -import type { RuntimeEnv } from "../runtime.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { STATE_DIR } from "../../../src/config/paths.js"; +import { TELEGRAM_COMMAND_NAME_PATTERN } from "../../../src/config/telegram-custom-commands.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; const { listSkillCommandsForAgents } = vi.hoisted(() => ({ @@ -19,14 +19,14 @@ const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => ({ delivered: true })), })); -vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents, }; }); -vi.mock("../plugins/commands.js", () => ({ +vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, diff --git a/src/telegram/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts similarity index 94% rename from src/telegram/bot-native-commands.ts rename to extensions/telegram/src/bot-native-commands.ts index 2bcbebe63fa..7dd91f6ad63 100644 --- a/src/telegram/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1,8 +1,8 @@ import type { Bot, Context } from "grammy"; -import { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js"; -import { resolveChunkMode } from "../auto-reply/chunk.js"; -import { resolveCommandAuthorization } from "../auto-reply/command-auth.js"; -import type { CommandArgs } from "../auto-reply/commands-registry.js"; +import { ensureConfiguredAcpRouteReady } from "../../../src/acp/persistent-bindings.route.js"; +import { resolveChunkMode } from "../../../src/auto-reply/chunk.js"; +import { resolveCommandAuthorization } from "../../../src/auto-reply/command-auth.js"; +import type { CommandArgs } from "../../../src/auto-reply/commands-registry.js"; import { buildCommandTextFromArgs, findCommandByNativeName, @@ -10,40 +10,40 @@ import { listNativeCommandSpecsForConfig, parseCommandArgs, resolveCommandArgMenu, -} from "../auto-reply/commands-registry.js"; -import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; -import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; -import { resolveNativeCommandSessionTargets } from "../channels/native-command-session-targets.js"; -import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; -import { recordInboundSessionMetaSafe } from "../channels/session-meta.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { ChannelGroupPolicy } from "../config/group-policy.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +} from "../../../src/auto-reply/commands-registry.js"; +import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js"; +import { listSkillCommandsForAgents } from "../../../src/auto-reply/skill-commands.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js"; +import { resolveNativeCommandSessionTargets } from "../../../src/channels/native-command-session-targets.js"; +import { createReplyPrefixOptions } from "../../../src/channels/reply-prefix.js"; +import { recordInboundSessionMetaSafe } from "../../../src/channels/session-meta.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; import { normalizeTelegramCommandName, resolveTelegramCustomCommands, TELEGRAM_COMMAND_NAME_PATTERN, -} from "../config/telegram-custom-commands.js"; +} from "../../../src/config/telegram-custom-commands.js"; import type { ReplyToMode, TelegramAccountConfig, TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../config/types.js"; -import { danger, logVerbose } from "../globals.js"; -import { getChildLogger } from "../logging.js"; -import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; +} from "../../../src/config/types.js"; +import { danger, logVerbose } from "../../../src/globals.js"; +import { getChildLogger } from "../../../src/logging.js"; +import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js"; import { executePluginCommand, getPluginCommandSpecs, matchPluginCommand, -} from "../plugins/commands.js"; -import { resolveAgentRoute } from "../routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; +} from "../../../src/plugins/commands.js"; +import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; +import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; diff --git a/src/telegram/bot-updates.ts b/extensions/telegram/src/bot-updates.ts similarity index 96% rename from src/telegram/bot-updates.ts rename to extensions/telegram/src/bot-updates.ts index 2b1badebed8..3121f1a487e 100644 --- a/src/telegram/bot-updates.ts +++ b/extensions/telegram/src/bot-updates.ts @@ -1,5 +1,5 @@ import type { Message } from "@grammyjs/types"; -import { createDedupeCache } from "../infra/dedupe.js"; +import { createDedupeCache } from "../../../src/infra/dedupe.js"; import type { TelegramContext } from "./bot/types.js"; const MEDIA_GROUP_TIMEOUT_MS = 500; diff --git a/src/telegram/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts similarity index 90% rename from src/telegram/bot.create-telegram-bot.test-harness.ts rename to extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index b0090d62a70..f45cef0d1d7 100644 --- a/src/telegram/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -1,9 +1,9 @@ import { beforeEach, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import type { MsgContext } from "../auto-reply/templating.js"; -import type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; +import type { MsgContext } from "../../../src/auto-reply/templating.js"; +import type { GetReplyOptions, ReplyPayload } from "../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; @@ -20,7 +20,7 @@ export function getLoadWebMediaMock(): AnyMock { return loadWebMedia; } -vi.mock("../web/media.js", () => ({ +vi.mock("../../whatsapp/src/media.js", () => ({ loadWebMedia, })); @@ -31,16 +31,16 @@ const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({ export function getLoadConfigMock(): AnyMock { return loadConfig; } -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig, }; }); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), @@ -68,7 +68,7 @@ export function getUpsertChannelPairingRequestMock(): AnyAsyncMock { return upsertChannelPairingRequest; } -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore, upsertChannelPairingRequest, })); @@ -78,7 +78,7 @@ const skillCommandsHoisted = vi.hoisted(() => ({ })); export const listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents; -vi.mock("../auto-reply/skill-commands.js", () => ({ +vi.mock("../../../src/auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents, })); @@ -87,7 +87,7 @@ const systemEventsHoisted = vi.hoisted(() => ({ })); export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy; -vi.mock("../infra/system-events.js", () => ({ +vi.mock("../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: enqueueSystemEventSpy, })); @@ -201,7 +201,7 @@ export const replySpy: MockFn< return undefined; }); -vi.mock("../auto-reply/reply.js", () => ({ +vi.mock("../../../src/auto-reply/reply.js", () => ({ getReplyFromConfig: replySpy, __replySpy: replySpy, })); diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts similarity index 99% rename from src/telegram/bot.create-telegram-bot.test.ts rename to extensions/telegram/src/bot.create-telegram-bot.test.ts index 378c1eb1065..71b4d489dfc 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { useFrozenTime, useRealTime } from "../test-utils/frozen-time.js"; +import { withEnvAsync } from "../../../src/test-utils/env.js"; +import { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { answerCallbackQuerySpy, botCtorSpy, diff --git a/src/telegram/bot.fetch-abort.test.ts b/extensions/telegram/src/bot.fetch-abort.test.ts similarity index 100% rename from src/telegram/bot.fetch-abort.test.ts rename to extensions/telegram/src/bot.fetch-abort.test.ts diff --git a/src/telegram/bot.helpers.test.ts b/extensions/telegram/src/bot.helpers.test.ts similarity index 100% rename from src/telegram/bot.helpers.test.ts rename to extensions/telegram/src/bot.helpers.test.ts diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts similarity index 100% rename from src/telegram/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts rename to extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts diff --git a/src/telegram/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts similarity index 83% rename from src/telegram/bot.media.e2e-harness.ts rename to extensions/telegram/src/bot.media.e2e-harness.ts index d26eff44fb6..a91362702dd 100644 --- a/src/telegram/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -1,5 +1,5 @@ import { beforeEach, vi, type Mock } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; export const useSpy: Mock = vi.fn(); export const middlewareUseSpy: Mock = vi.fn(); @@ -92,8 +92,8 @@ vi.mock("undici", async (importOriginal) => { }; }); -vi.mock("../media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/media/store.js", async (importOriginal) => { + const actual = await importOriginal(); const mockModule = Object.create(null) as Record; Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); Object.defineProperty(mockModule, "saveMediaBuffer", { @@ -105,8 +105,8 @@ vi.mock("../media/store.js", async (importOriginal) => { return mockModule; }); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => ({ @@ -115,15 +115,15 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, updateLastRoute: vi.fn(async () => undefined), }; }); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn(async () => [] as string[]), upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", @@ -131,7 +131,7 @@ vi.mock("../pairing/pairing-store.js", () => ({ })), })); -vi.mock("../auto-reply/reply.js", () => { +vi.mock("../../../src/auto-reply/reply.js", () => { const replySpy = vi.fn(async (_ctx, opts) => { await opts?.onReplyStart?.(); return undefined; diff --git a/src/telegram/bot.media.stickers-and-fragments.e2e.test.ts b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts similarity index 100% rename from src/telegram/bot.media.stickers-and-fragments.e2e.test.ts rename to extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts diff --git a/src/telegram/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts similarity index 96% rename from src/telegram/bot.media.test-utils.ts rename to extensions/telegram/src/bot.media.test-utils.ts index 94084bad31c..fde76f34e23 100644 --- a/src/telegram/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest"; -import * as ssrf from "../infra/net/ssrf.js"; +import * as ssrf from "../../../src/infra/net/ssrf.js"; import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; type StickerSpy = Mock<(...args: unknown[]) => unknown>; @@ -103,7 +103,7 @@ afterEach(() => { beforeAll(async () => { ({ createTelegramBot: createTelegramBotRef } = await import("./bot.js")); - const replyModule = await import("../auto-reply/reply.js"); + const replyModule = await import("../../../src/auto-reply/reply.js"); replySpyRef = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; }, TELEGRAM_BOT_IMPORT_TIMEOUT_MS); diff --git a/src/telegram/bot.test.ts b/extensions/telegram/src/bot.test.ts similarity index 99% rename from src/telegram/bot.test.ts rename to extensions/telegram/src/bot.test.ts index d8c8bc14ade..f713b98cbe7 100644 --- a/src/telegram/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,13 +1,13 @@ import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, -} from "../auto-reply/commands-registry.js"; -import { loadSessionStore } from "../config/sessions.js"; -import { normalizeTelegramCommandName } from "../config/telegram-custom-commands.js"; +} from "../../../src/auto-reply/commands-registry.js"; +import { loadSessionStore } from "../../../src/config/sessions.js"; +import { normalizeTelegramCommandName } from "../../../src/config/telegram-custom-commands.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; +import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { answerCallbackQuerySpy, commandSpy, diff --git a/src/telegram/bot.ts b/extensions/telegram/src/bot.ts similarity index 94% rename from src/telegram/bot.ts rename to extensions/telegram/src/bot.ts index a1d60e61f71..a817e10cbac 100644 --- a/src/telegram/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -2,31 +2,34 @@ import { sequentialize } from "@grammyjs/runner"; import { apiThrottler } from "@grammyjs/transformer-throttler"; import type { ApiClientOptions } from "grammy"; import { Bot } from "grammy"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; +import { resolveDefaultAgentId } from "../../../src/agents/agent-scope.js"; +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { + DEFAULT_GROUP_HISTORY_LIMIT, + type HistoryEntry, +} from "../../../src/auto-reply/reply/history.js"; import { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingSpawnPolicy, -} from "../channels/thread-bindings-policy.js"; +} from "../../../src/channels/thread-bindings-policy.js"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, -} from "../config/commands.js"; -import type { OpenClawConfig, ReplyToMode } from "../config/config.js"; -import { loadConfig } from "../config/config.js"; +} from "../../../src/config/commands.js"; +import type { OpenClawConfig, ReplyToMode } from "../../../src/config/config.js"; +import { loadConfig } from "../../../src/config/config.js"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, -} from "../config/group-policy.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; -import { formatUncaughtError } from "../infra/errors.js"; -import { getChildLogger } from "../logging.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../runtime.js"; +} from "../../../src/config/group-policy.js"; +import { loadSessionStore, resolveStorePath } from "../../../src/config/sessions.js"; +import { danger, logVerbose, shouldLogVerbose } from "../../../src/globals.js"; +import { formatUncaughtError } from "../../../src/infra/errors.js"; +import { getChildLogger } from "../../../src/logging.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "../../../src/runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; import { registerTelegramHandlers } from "./bot-handlers.js"; import { createTelegramMessageProcessor } from "./bot-message.js"; diff --git a/src/telegram/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts similarity index 95% rename from src/telegram/bot/delivery.replies.ts rename to extensions/telegram/src/bot/delivery.replies.ts index ea744fa8e20..84d66fec12b 100644 --- a/src/telegram/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -1,23 +1,26 @@ import { type Bot, GrammyError, InputFile } from "grammy"; -import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import type { ReplyToMode } from "../../config/config.js"; -import type { MarkdownTableMode } from "../../config/types.base.js"; -import { danger, logVerbose } from "../../globals.js"; -import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; -import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../../src/auto-reply/chunk.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import type { ReplyToMode } from "../../../../src/config/config.js"; +import type { MarkdownTableMode } from "../../../../src/config/types.base.js"; +import { danger, logVerbose } from "../../../../src/globals.js"; +import { fireAndForgetHook } from "../../../../src/hooks/fire-and-forget.js"; +import { + createInternalHookEvent, + triggerInternalHook, +} from "../../../../src/hooks/internal-hooks.js"; import { buildCanonicalSentMessageHookContext, toInternalMessageSentContext, toPluginMessageContext, toPluginMessageSentEvent, -} from "../../hooks/message-hook-mappers.js"; -import { formatErrorMessage } from "../../infra/errors.js"; -import { buildOutboundMediaLoadOptions } from "../../media/load-options.js"; -import { isGifMedia, kindFromMime } from "../../media/mime.js"; -import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import { loadWebMedia } from "../../web/media.js"; +} from "../../../../src/hooks/message-hook-mappers.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import { buildOutboundMediaLoadOptions } from "../../../../src/media/load-options.js"; +import { isGifMedia, kindFromMime } from "../../../../src/media/mime.js"; +import { getGlobalHookRunner } from "../../../../src/plugins/hook-runner-global.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { loadWebMedia } from "../../../whatsapp/src/media.js"; import type { TelegramInlineButtons } from "../button-types.js"; import { splitTelegramCaption } from "../caption.js"; import { diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts similarity index 98% rename from src/telegram/bot/delivery.resolve-media-retry.test.ts rename to extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 05d5c5f8b3e..55fec660a82 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -6,19 +6,19 @@ import type { TelegramContext } from "./types.js"; const saveMediaBuffer = vi.fn(); const fetchRemoteMedia = vi.fn(); -vi.mock("../../media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/media/store.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), }; }); -vi.mock("../../media/fetch.js", () => ({ +vi.mock("../../../../src/media/fetch.js", () => ({ fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), })); -vi.mock("../../globals.js", () => ({ +vi.mock("../../../../src/globals.js", () => ({ danger: (s: string) => s, warn: (s: string) => s, logVerbose: () => {}, diff --git a/src/telegram/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts similarity index 96% rename from src/telegram/bot/delivery.resolve-media.ts rename to extensions/telegram/src/bot/delivery.resolve-media.ts index 1b10583c28b..e42dd11aa1b 100644 --- a/src/telegram/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -1,9 +1,9 @@ import { GrammyError } from "grammy"; -import { logVerbose, warn } from "../../globals.js"; -import { formatErrorMessage } from "../../infra/errors.js"; -import { retryAsync } from "../../infra/retry.js"; -import { fetchRemoteMedia } from "../../media/fetch.js"; -import { saveMediaBuffer } from "../../media/store.js"; +import { logVerbose, warn } from "../../../../src/globals.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import { retryAsync } from "../../../../src/infra/retry.js"; +import { fetchRemoteMedia } from "../../../../src/media/fetch.js"; +import { saveMediaBuffer } from "../../../../src/media/store.js"; import { shouldRetryTelegramIpv4Fallback, type TelegramTransport } from "../fetch.js"; import { cacheSticker, getCachedSticker } from "../sticker-cache.js"; import { resolveTelegramMediaPlaceholder } from "./helpers.js"; diff --git a/src/telegram/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts similarity index 97% rename from src/telegram/bot/delivery.send.ts rename to extensions/telegram/src/bot/delivery.send.ts index 45e81fc36d5..f541495aa76 100644 --- a/src/telegram/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -1,6 +1,6 @@ import { type Bot, GrammyError } from "grammy"; -import { formatErrorMessage } from "../../infra/errors.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import { withTelegramApiErrorLogging } from "../api-logging.js"; import { markdownToTelegramHtml } from "../format.js"; import { buildInlineKeyboard } from "../send.js"; diff --git a/src/telegram/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts similarity index 98% rename from src/telegram/bot/delivery.test.ts rename to extensions/telegram/src/bot/delivery.test.ts index c21e55ccf6c..a1dce34dceb 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -1,6 +1,6 @@ import type { Bot } from "grammy"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import { deliverReplies } from "./delivery.js"; const loadWebMedia = vi.fn(); @@ -24,17 +24,17 @@ type DeliverWithParams = Omit< Partial>; type RuntimeStub = Pick; -vi.mock("../../web/media.js", () => ({ +vi.mock("../../../whatsapp/src/media.js", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMedia(...args), })); -vi.mock("../../plugins/hook-runner-global.js", () => ({ +vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => messageHookRunner, })); -vi.mock("../../hooks/internal-hooks.js", async () => { - const actual = await vi.importActual( - "../../hooks/internal-hooks.js", +vi.mock("../../../../src/hooks/internal-hooks.js", async () => { + const actual = await vi.importActual( + "../../../../src/hooks/internal-hooks.js", ); return { ...actual, diff --git a/src/telegram/bot/delivery.ts b/extensions/telegram/src/bot/delivery.ts similarity index 100% rename from src/telegram/bot/delivery.ts rename to extensions/telegram/src/bot/delivery.ts diff --git a/src/telegram/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts similarity index 100% rename from src/telegram/bot/helpers.test.ts rename to extensions/telegram/src/bot/helpers.test.ts diff --git a/src/telegram/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts similarity index 98% rename from src/telegram/bot/helpers.ts rename to extensions/telegram/src/bot/helpers.ts index 2d1cd9ef7a1..3575da81efb 100644 --- a/src/telegram/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -1,13 +1,13 @@ import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types"; -import { formatLocationText, type NormalizedLocation } from "../../channels/location.js"; -import { resolveTelegramPreviewStreamMode } from "../../config/discord-preview-streaming.js"; +import { formatLocationText, type NormalizedLocation } from "../../../../src/channels/location.js"; +import { resolveTelegramPreviewStreamMode } from "../../../../src/config/discord-preview-streaming.js"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../../config/types.js"; -import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; -import { normalizeAccountId } from "../../routing/session-key.js"; +} from "../../../../src/config/types.js"; +import { readChannelAllowFromStore } from "../../../../src/pairing/pairing-store.js"; +import { normalizeAccountId } from "../../../../src/routing/session-key.js"; import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js"; import type { TelegramStreamMode } from "./types.js"; diff --git a/src/telegram/bot/reply-threading.ts b/extensions/telegram/src/bot/reply-threading.ts similarity index 97% rename from src/telegram/bot/reply-threading.ts rename to extensions/telegram/src/bot/reply-threading.ts index a8ca2c0b27b..cdeeba7151b 100644 --- a/src/telegram/bot/reply-threading.ts +++ b/extensions/telegram/src/bot/reply-threading.ts @@ -1,4 +1,4 @@ -import type { ReplyToMode } from "../../config/config.js"; +import type { ReplyToMode } from "../../../../src/config/config.js"; export type DeliveryProgress = { hasReplied: boolean; diff --git a/src/telegram/bot/types.ts b/extensions/telegram/src/bot/types.ts similarity index 100% rename from src/telegram/bot/types.ts rename to extensions/telegram/src/bot/types.ts diff --git a/src/telegram/button-types.ts b/extensions/telegram/src/button-types.ts similarity index 100% rename from src/telegram/button-types.ts rename to extensions/telegram/src/button-types.ts diff --git a/src/telegram/caption.ts b/extensions/telegram/src/caption.ts similarity index 100% rename from src/telegram/caption.ts rename to extensions/telegram/src/caption.ts diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts new file mode 100644 index 00000000000..29095e7bc7c --- /dev/null +++ b/extensions/telegram/src/channel-actions.ts @@ -0,0 +1,295 @@ +import { + readNumberParam, + readStringArrayParam, + readStringOrNumberParam, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import { handleTelegramAction } from "../../../src/agents/tools/telegram-actions.js"; +import { resolveReactionMessageId } from "../../../src/channels/plugins/actions/reaction-message-id.js"; +import { + createUnionActionGate, + listTokenSourcedAccounts, +} from "../../../src/channels/plugins/actions/shared.js"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "../../../src/channels/plugins/types.js"; +import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; +import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js"; +import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js"; +import { resolveTelegramPollVisibility } from "../../../src/poll-params.js"; +import { + createTelegramActionGate, + listEnabledTelegramAccounts, + resolveTelegramPollActionGateState, +} from "./accounts.js"; +import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js"; + +const providerId = "telegram"; + +function readTelegramSendParams(params: Record) { + const to = readStringParam(params, "to", { required: true }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true }); + const caption = readStringParam(params, "caption", { allowEmpty: true }); + const content = message || caption || ""; + const replyTo = readStringParam(params, "replyTo"); + const threadId = readStringParam(params, "threadId"); + const buttons = params.buttons; + const asVoice = readBooleanParam(params, "asVoice"); + const silent = readBooleanParam(params, "silent"); + const forceDocument = readBooleanParam(params, "forceDocument"); + const quoteText = readStringParam(params, "quoteText"); + return { + to, + content, + mediaUrl: mediaUrl ?? undefined, + replyToMessageId: replyTo ?? undefined, + messageThreadId: threadId ?? undefined, + buttons, + asVoice, + silent, + forceDocument, + quoteText: quoteText ?? undefined, + }; +} + +function readTelegramChatIdParam(params: Record): string | number { + return ( + readStringOrNumberParam(params, "chatId") ?? + readStringOrNumberParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }) + ); +} + +function readTelegramMessageIdParam(params: Record): number { + const messageId = readNumberParam(params, "messageId", { + required: true, + integer: true, + }); + if (typeof messageId !== "number") { + throw new Error("messageId is required."); + } + return messageId; +} + +export const telegramMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); + if (accounts.length === 0) { + return []; + } + // Union of all accounts' action gates (any account enabling an action makes it available) + const gate = createUnionActionGate(accounts, (account) => + createTelegramActionGate({ + cfg, + accountId: account.accountId, + }), + ); + const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) => + gate(key, defaultValue); + const actions = new Set(["send"]); + const pollEnabledForAnyAccount = accounts.some((account) => { + const accountGate = createTelegramActionGate({ + cfg, + accountId: account.accountId, + }); + return resolveTelegramPollActionGateState(accountGate).enabled; + }); + if (pollEnabledForAnyAccount) { + actions.add("poll"); + } + if (isEnabled("reactions")) { + actions.add("react"); + } + if (isEnabled("deleteMessage")) { + actions.add("delete"); + } + if (isEnabled("editMessage")) { + actions.add("edit"); + } + if (isEnabled("sticker", false)) { + actions.add("sticker"); + actions.add("sticker-search"); + } + if (isEnabled("createForumTopic")) { + actions.add("topic-create"); + } + return Array.from(actions); + }, + supportsButtons: ({ cfg }) => { + const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); + if (accounts.length === 0) { + return false; + } + return accounts.some((account) => + isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), + ); + }, + extractToolSend: ({ args }) => { + return extractToolSend(args, "sendMessage"); + }, + handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => { + if (action === "send") { + const sendParams = readTelegramSendParams(params); + return await handleTelegramAction( + { + action: "sendMessage", + ...sendParams, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "react") { + const messageId = resolveReactionMessageId({ args: params, toolContext }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = readBooleanParam(params, "remove"); + return await handleTelegramAction( + { + action: "react", + chatId: readTelegramChatIdParam(params), + messageId, + emoji, + remove, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { required: true }); + const answers = readStringArrayParam(params, "pollOption", { required: true }); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + const durationSeconds = readNumberParam(params, "pollDurationSeconds", { + integer: true, + strict: true, + }); + const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); + const messageThreadId = readNumberParam(params, "threadId", { integer: true }); + const allowMultiselect = readBooleanParam(params, "pollMulti"); + const pollAnonymous = readBooleanParam(params, "pollAnonymous"); + const pollPublic = readBooleanParam(params, "pollPublic"); + const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); + const silent = readBooleanParam(params, "silent"); + return await handleTelegramAction( + { + action: "poll", + to, + question, + answers, + allowMultiselect, + durationHours: durationHours ?? undefined, + durationSeconds: durationSeconds ?? undefined, + replyToMessageId: replyToMessageId ?? undefined, + messageThreadId: messageThreadId ?? undefined, + isAnonymous, + silent, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "delete") { + const chatId = readTelegramChatIdParam(params); + const messageId = readTelegramMessageIdParam(params); + return await handleTelegramAction( + { + action: "deleteMessage", + chatId, + messageId, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "edit") { + const chatId = readTelegramChatIdParam(params); + const messageId = readTelegramMessageIdParam(params); + const message = readStringParam(params, "message", { required: true, allowEmpty: false }); + const buttons = params.buttons; + return await handleTelegramAction( + { + action: "editMessage", + chatId, + messageId, + content: message, + buttons, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "sticker") { + const to = + readStringParam(params, "to") ?? readStringParam(params, "target", { required: true }); + // Accept stickerId (array from shared schema) and use first element as fileId + const stickerIds = readStringArrayParam(params, "stickerId"); + const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true }); + const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); + const messageThreadId = readNumberParam(params, "threadId", { integer: true }); + return await handleTelegramAction( + { + action: "sendSticker", + to, + fileId, + replyToMessageId: replyToMessageId ?? undefined, + messageThreadId: messageThreadId ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "sticker-search") { + const query = readStringParam(params, "query", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleTelegramAction( + { + action: "searchSticker", + query, + limit: limit ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "topic-create") { + const chatId = readTelegramChatIdParam(params); + const name = readStringParam(params, "name", { required: true }); + const iconColor = readNumberParam(params, "iconColor", { integer: true }); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + return await handleTelegramAction( + { + action: "createForumTopic", + chatId, + name, + iconColor: iconColor ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); + }, +}; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 20d012c9dda..b13e33859f9 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -40,8 +40,16 @@ import { type ResolvedTelegramAccount, type TelegramProbe, } from "openclaw/plugin-sdk/telegram"; +import { + type OutboundSendDeps, + resolveOutboundSendDep, +} from "../../../src/infra/outbound/deliver.js"; import { getTelegramRuntime } from "./runtime.js"; +type TelegramSendFn = ReturnType< + typeof getTelegramRuntime +>["channel"]["telegram"]["sendMessageTelegram"]; + const meta = getChatChannelMeta("telegram"); function findTelegramTokenOwnerAccountId(params: { @@ -78,9 +86,6 @@ function formatDuplicateTelegramTokenReason(params: { ); } -type TelegramSendFn = ReturnType< - typeof getTelegramRuntime ->["channel"]["telegram"]["sendMessageTelegram"]; type TelegramSendOptions = NonNullable[2]>; function buildTelegramSendOptions(params: { @@ -111,13 +116,14 @@ async function sendTelegramOutbound(params: { mediaUrl?: string | null; mediaLocalRoots?: readonly string[] | null; accountId?: string | null; - deps?: { sendTelegram?: TelegramSendFn }; + deps?: OutboundSendDeps; replyToId?: string | null; threadId?: string | number | null; silent?: boolean | null; }) { const send = - params.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; + resolveOutboundSendDep(params.deps, "telegram") ?? + getTelegramRuntime().channel.telegram.sendMessageTelegram; return await send( params.to, params.text, @@ -381,7 +387,9 @@ export const telegramPlugin: ChannelPlugin { - const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; + const send = + resolveOutboundSendDep(deps, "telegram") ?? + getTelegramRuntime().channel.telegram.sendMessageTelegram; const result = await sendTelegramPayloadMessages({ send, to, diff --git a/src/telegram/conversation-route.ts b/extensions/telegram/src/conversation-route.ts similarity index 90% rename from src/telegram/conversation-route.ts rename to extensions/telegram/src/conversation-route.ts index 32088b818af..20137468486 100644 --- a/src/telegram/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -1,14 +1,17 @@ -import { resolveConfiguredAcpRoute } from "../acp/persistent-bindings.route.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { logVerbose } from "../globals.js"; -import { getSessionBindingService } from "../infra/outbound/session-binding-service.js"; +import { resolveConfiguredAcpRoute } from "../../../src/acp/persistent-bindings.route.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { logVerbose } from "../../../src/globals.js"; +import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js"; import { buildAgentSessionKey, deriveLastRoutePolicy, pickFirstExistingAgentId, resolveAgentRoute, -} from "../routing/resolve-route.js"; -import { buildAgentMainSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +} from "../../../src/routing/resolve-route.js"; +import { + buildAgentMainSessionKey, + resolveAgentIdFromSessionKey, +} from "../../../src/routing/session-key.js"; import { buildTelegramGroupPeerId, buildTelegramParentPeer, diff --git a/src/telegram/dm-access.ts b/extensions/telegram/src/dm-access.ts similarity index 92% rename from src/telegram/dm-access.ts rename to extensions/telegram/src/dm-access.ts index 26734b69602..db8cc419c6a 100644 --- a/src/telegram/dm-access.ts +++ b/extensions/telegram/src/dm-access.ts @@ -1,9 +1,9 @@ import type { Message } from "@grammyjs/types"; import type { Bot } from "grammy"; -import type { DmPolicy } from "../config/types.js"; -import { logVerbose } from "../globals.js"; -import { issuePairingChallenge } from "../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../pairing/pairing-store.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { logVerbose } from "../../../src/globals.js"; +import { issuePairingChallenge } from "../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../src/pairing/pairing-store.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { resolveSenderAllowMatch, type NormalizedAllowFrom } from "./bot-access.js"; diff --git a/src/telegram/draft-chunking.test.ts b/extensions/telegram/src/draft-chunking.test.ts similarity index 95% rename from src/telegram/draft-chunking.test.ts rename to extensions/telegram/src/draft-chunking.test.ts index cc24f069624..0243715a18d 100644 --- a/src/telegram/draft-chunking.test.ts +++ b/extensions/telegram/src/draft-chunking.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; describe("resolveTelegramDraftStreamingChunking", () => { diff --git a/src/telegram/draft-chunking.ts b/extensions/telegram/src/draft-chunking.ts similarity index 78% rename from src/telegram/draft-chunking.ts rename to extensions/telegram/src/draft-chunking.ts index 3b4d5e30afb..f907faf02f8 100644 --- a/src/telegram/draft-chunking.ts +++ b/extensions/telegram/src/draft-chunking.ts @@ -1,8 +1,8 @@ -import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { getChannelDock } from "../channels/dock.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { getChannelDock } from "../../../src/channels/dock.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200; const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800; diff --git a/src/telegram/draft-stream.test-helpers.ts b/extensions/telegram/src/draft-stream.test-helpers.ts similarity index 100% rename from src/telegram/draft-stream.test-helpers.ts rename to extensions/telegram/src/draft-stream.test-helpers.ts diff --git a/src/telegram/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts similarity index 99% rename from src/telegram/draft-stream.test.ts rename to extensions/telegram/src/draft-stream.test.ts index 7fe7a1713cb..8f10e552406 100644 --- a/src/telegram/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -1,6 +1,6 @@ import type { Bot } from "grammy"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { importFreshModule } from "../../test/helpers/import-fresh.js"; +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; import { __testing, createTelegramDraftStream } from "./draft-stream.js"; type TelegramDraftStreamParams = Parameters[0]; diff --git a/src/telegram/draft-stream.ts b/extensions/telegram/src/draft-stream.ts similarity index 98% rename from src/telegram/draft-stream.ts rename to extensions/telegram/src/draft-stream.ts index afab4680e96..5641b042d30 100644 --- a/src/telegram/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -1,6 +1,6 @@ import type { Bot } from "grammy"; -import { createFinalizableDraftLifecycle } from "../channels/draft-stream-controls.js"; -import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import { createFinalizableDraftLifecycle } from "../../../src/channels/draft-stream-controls.js"; +import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js"; import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js"; diff --git a/src/telegram/exec-approvals-handler.test.ts b/extensions/telegram/src/exec-approvals-handler.test.ts similarity index 98% rename from src/telegram/exec-approvals-handler.test.ts rename to extensions/telegram/src/exec-approvals-handler.test.ts index 91aa3fea217..80ecca833d2 100644 --- a/src/telegram/exec-approvals-handler.test.ts +++ b/extensions/telegram/src/exec-approvals-handler.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; const baseRequest = { diff --git a/src/telegram/exec-approvals-handler.ts b/extensions/telegram/src/exec-approvals-handler.ts similarity index 92% rename from src/telegram/exec-approvals-handler.ts rename to extensions/telegram/src/exec-approvals-handler.ts index 01e3b51bedd..a9d32d0887d 100644 --- a/src/telegram/exec-approvals-handler.ts +++ b/extensions/telegram/src/exec-approvals-handler.ts @@ -1,18 +1,21 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { GatewayClient } from "../gateway/client.js"; -import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js"; -import type { EventFrame } from "../gateway/protocol/index.js"; -import { resolveExecApprovalCommandDisplay } from "../infra/exec-approval-command-display.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { GatewayClient } from "../../../src/gateway/client.js"; +import { createOperatorApprovalsGatewayClient } from "../../../src/gateway/operator-approvals-client.js"; +import type { EventFrame } from "../../../src/gateway/protocol/index.js"; +import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; import { buildExecApprovalPendingReplyPayload, type ExecApprovalPendingReplyParams, -} from "../infra/exec-approval-reply.js"; -import { resolveExecApprovalSessionTarget } from "../infra/exec-approval-session-target.js"; -import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js"; +} from "../../../src/infra/exec-approval-reply.js"; +import { resolveExecApprovalSessionTarget } from "../../../src/infra/exec-approval-session-target.js"; +import type { + ExecApprovalRequest, + ExecApprovalResolved, +} from "../../../src/infra/exec-approvals.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import { normalizeAccountId, parseAgentSessionKey } from "../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { compileSafeRegex, testRegexWithBoundedInput } from "../../../src/security/safe-regex.js"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; import { getTelegramExecApprovalApprovers, diff --git a/src/telegram/exec-approvals.test.ts b/extensions/telegram/src/exec-approvals.test.ts similarity index 98% rename from src/telegram/exec-approvals.test.ts rename to extensions/telegram/src/exec-approvals.test.ts index d85e07f7187..f56279318ea 100644 --- a/src/telegram/exec-approvals.test.ts +++ b/extensions/telegram/src/exec-approvals.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, diff --git a/src/telegram/exec-approvals.ts b/extensions/telegram/src/exec-approvals.ts similarity index 90% rename from src/telegram/exec-approvals.ts rename to extensions/telegram/src/exec-approvals.ts index 1055e1d1676..b1b0eed8d4f 100644 --- a/src/telegram/exec-approvals.ts +++ b/extensions/telegram/src/exec-approvals.ts @@ -1,7 +1,7 @@ -import type { ReplyPayload } from "../auto-reply/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { TelegramExecApprovalConfig } from "../config/types.telegram.js"; -import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramExecApprovalConfig } from "../../../src/config/types.telegram.js"; +import { getExecApprovalReplyMetadata } from "../../../src/infra/exec-approval-reply.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramTargetChatType } from "./targets.js"; diff --git a/src/telegram/fetch.env-proxy-runtime.test.ts b/extensions/telegram/src/fetch.env-proxy-runtime.test.ts similarity index 100% rename from src/telegram/fetch.env-proxy-runtime.test.ts rename to extensions/telegram/src/fetch.env-proxy-runtime.test.ts diff --git a/src/telegram/fetch.test.ts b/extensions/telegram/src/fetch.test.ts similarity index 99% rename from src/telegram/fetch.test.ts rename to extensions/telegram/src/fetch.test.ts index 730bc377309..7681d0c8701 100644 --- a/src/telegram/fetch.test.ts +++ b/extensions/telegram/src/fetch.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveFetch } from "../infra/fetch.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; import { resolveTelegramFetch, resolveTelegramTransport } from "./fetch.js"; const setDefaultResultOrder = vi.hoisted(() => vi.fn()); diff --git a/src/telegram/fetch.ts b/extensions/telegram/src/fetch.ts similarity index 97% rename from src/telegram/fetch.ts rename to extensions/telegram/src/fetch.ts index 6ccdc0395e9..4b234c8d107 100644 --- a/src/telegram/fetch.ts +++ b/extensions/telegram/src/fetch.ts @@ -1,10 +1,10 @@ import * as dns from "node:dns"; import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; -import type { TelegramNetworkConfig } from "../config/types.telegram.js"; -import { resolveFetch } from "../infra/fetch.js"; -import { hasEnvHttpProxyConfigured } from "../infra/net/proxy-env.js"; -import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; +import { hasEnvHttpProxyConfigured } from "../../../src/infra/net/proxy-env.js"; +import type { PinnedDispatcherPolicy } from "../../../src/infra/net/ssrf.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; import { resolveTelegramAutoSelectFamilyDecision, resolveTelegramDnsResultOrderDecision, diff --git a/src/telegram/format.test.ts b/extensions/telegram/src/format.test.ts similarity index 100% rename from src/telegram/format.test.ts rename to extensions/telegram/src/format.test.ts diff --git a/src/telegram/format.ts b/extensions/telegram/src/format.ts similarity index 98% rename from src/telegram/format.ts rename to extensions/telegram/src/format.ts index ed1f6c822f8..1ccd8f8299b 100644 --- a/src/telegram/format.ts +++ b/extensions/telegram/src/format.ts @@ -1,11 +1,11 @@ -import type { MarkdownTableMode } from "../config/types.base.js"; +import type { MarkdownTableMode } from "../../../src/config/types.base.js"; import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan, type MarkdownIR, -} from "../markdown/ir.js"; -import { renderMarkdownWithMarkers } from "../markdown/render.js"; +} from "../../../src/markdown/ir.js"; +import { renderMarkdownWithMarkers } from "../../../src/markdown/render.js"; export type TelegramFormattedChunk = { html: string; diff --git a/src/telegram/format.wrap-md.test.ts b/extensions/telegram/src/format.wrap-md.test.ts similarity index 100% rename from src/telegram/format.wrap-md.test.ts rename to extensions/telegram/src/format.wrap-md.test.ts diff --git a/src/telegram/forum-service-message.ts b/extensions/telegram/src/forum-service-message.ts similarity index 100% rename from src/telegram/forum-service-message.ts rename to extensions/telegram/src/forum-service-message.ts diff --git a/src/telegram/group-access.base-access.test.ts b/extensions/telegram/src/group-access.base-access.test.ts similarity index 100% rename from src/telegram/group-access.base-access.test.ts rename to extensions/telegram/src/group-access.base-access.test.ts diff --git a/src/telegram/group-access.group-policy.test.ts b/extensions/telegram/src/group-access.group-policy.test.ts similarity index 91% rename from src/telegram/group-access.group-policy.test.ts rename to extensions/telegram/src/group-access.group-policy.test.ts index 07e05780536..8b93c52d160 100644 --- a/src/telegram/group-access.group-policy.test.ts +++ b/extensions/telegram/src/group-access.group-policy.test.ts @@ -1,5 +1,5 @@ import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../test-utils/runtime-group-policy-contract.js"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../src/test-utils/runtime-group-policy-contract.js"; import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js"; describe("resolveTelegramRuntimeGroupPolicy", () => { diff --git a/src/telegram/group-access.policy-access.test.ts b/extensions/telegram/src/group-access.policy-access.test.ts similarity index 97% rename from src/telegram/group-access.policy-access.test.ts rename to extensions/telegram/src/group-access.policy-access.test.ts index d32863318d2..812dda9af49 100644 --- a/src/telegram/group-access.policy-access.test.ts +++ b/extensions/telegram/src/group-access.policy-access.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import type { TelegramAccountConfig } from "../config/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; import { evaluateTelegramGroupPolicyAccess } from "./group-access.js"; /** diff --git a/src/telegram/group-access.ts b/extensions/telegram/src/group-access.ts similarity index 95% rename from src/telegram/group-access.ts rename to extensions/telegram/src/group-access.ts index e97251c950a..b5c30979dbb 100644 --- a/src/telegram/group-access.ts +++ b/extensions/telegram/src/group-access.ts @@ -1,13 +1,13 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { ChannelGroupPolicy } from "../config/group-policy.js"; -import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; +import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js"; import type { TelegramAccountConfig, TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../config/types.js"; -import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; +} from "../../../src/config/types.js"; +import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js"; import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js"; import { firstDefined } from "./bot-access.js"; diff --git a/src/telegram/group-config-helpers.ts b/extensions/telegram/src/group-config-helpers.ts similarity index 95% rename from src/telegram/group-config-helpers.ts rename to extensions/telegram/src/group-config-helpers.ts index 523f1df57e0..5a60d116dd3 100644 --- a/src/telegram/group-config-helpers.ts +++ b/extensions/telegram/src/group-config-helpers.ts @@ -2,7 +2,7 @@ import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../config/types.js"; +} from "../../../src/config/types.js"; import { firstDefined } from "./bot-access.js"; export function resolveTelegramGroupPromptSettings(params: { diff --git a/src/telegram/group-migration.test.ts b/extensions/telegram/src/group-migration.test.ts similarity index 100% rename from src/telegram/group-migration.test.ts rename to extensions/telegram/src/group-migration.test.ts diff --git a/src/telegram/group-migration.ts b/extensions/telegram/src/group-migration.ts similarity index 91% rename from src/telegram/group-migration.ts rename to extensions/telegram/src/group-migration.ts index 921e34d5a9b..0609fcf4b5a 100644 --- a/src/telegram/group-migration.ts +++ b/extensions/telegram/src/group-migration.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { TelegramGroupConfig } from "../config/types.telegram.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramGroupConfig } from "../../../src/config/types.telegram.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; type TelegramGroups = Record; diff --git a/src/telegram/inline-buttons.test.ts b/extensions/telegram/src/inline-buttons.test.ts similarity index 100% rename from src/telegram/inline-buttons.test.ts rename to extensions/telegram/src/inline-buttons.test.ts diff --git a/src/telegram/inline-buttons.ts b/extensions/telegram/src/inline-buttons.ts similarity index 93% rename from src/telegram/inline-buttons.ts rename to extensions/telegram/src/inline-buttons.ts index 1137d61d1cd..ead8068feba 100644 --- a/src/telegram/inline-buttons.ts +++ b/extensions/telegram/src/inline-buttons.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { TelegramInlineButtonsScope } from "../config/types.telegram.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramInlineButtonsScope } from "../../../src/config/types.telegram.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist"; diff --git a/src/telegram/lane-delivery-state.ts b/extensions/telegram/src/lane-delivery-state.ts similarity index 100% rename from src/telegram/lane-delivery-state.ts rename to extensions/telegram/src/lane-delivery-state.ts diff --git a/src/telegram/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts similarity index 99% rename from src/telegram/lane-delivery-text-deliverer.ts rename to extensions/telegram/src/lane-delivery-text-deliverer.ts index 000087cc692..08875329649 100644 --- a/src/telegram/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -1,4 +1,4 @@ -import type { ReplyPayload } from "../auto-reply/types.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramDraftStream } from "./draft-stream.js"; import { diff --git a/src/telegram/lane-delivery.test.ts b/extensions/telegram/src/lane-delivery.test.ts similarity index 99% rename from src/telegram/lane-delivery.test.ts rename to extensions/telegram/src/lane-delivery.test.ts index 4bec98f66f2..aba9974eff5 100644 --- a/src/telegram/lane-delivery.test.ts +++ b/extensions/telegram/src/lane-delivery.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { ReplyPayload } from "../auto-reply/types.js"; +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"; diff --git a/src/telegram/lane-delivery.ts b/extensions/telegram/src/lane-delivery.ts similarity index 100% rename from src/telegram/lane-delivery.ts rename to extensions/telegram/src/lane-delivery.ts diff --git a/src/telegram/model-buttons.test.ts b/extensions/telegram/src/model-buttons.test.ts similarity index 100% rename from src/telegram/model-buttons.test.ts rename to extensions/telegram/src/model-buttons.test.ts diff --git a/src/telegram/model-buttons.ts b/extensions/telegram/src/model-buttons.ts similarity index 100% rename from src/telegram/model-buttons.ts rename to extensions/telegram/src/model-buttons.ts diff --git a/src/telegram/monitor.test.ts b/extensions/telegram/src/monitor.test.ts similarity index 98% rename from src/telegram/monitor.test.ts rename to extensions/telegram/src/monitor.test.ts index 83b39fa5c78..c4a898c5a6d 100644 --- a/src/telegram/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -209,8 +209,8 @@ async function monitorWithAutoAbort( }); } -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig, @@ -254,12 +254,12 @@ vi.mock("@grammyjs/runner", () => ({ run: runSpy, })); -vi.mock("../infra/backoff.js", () => ({ +vi.mock("../../../src/infra/backoff.js", () => ({ computeBackoff, sleepWithAbort, })); -vi.mock("../infra/unhandled-rejections.js", () => ({ +vi.mock("../../../src/infra/unhandled-rejections.js", () => ({ registerUnhandledRejectionHandler: registerUnhandledRejectionHandlerMock, })); @@ -272,7 +272,7 @@ vi.mock("./update-offset-store.js", () => ({ writeTelegramUpdateOffset: vi.fn(async () => undefined), })); -vi.mock("../auto-reply/reply.js", () => ({ +vi.mock("../../../src/auto-reply/reply.js", () => ({ getReplyFromConfig: async (ctx: { Body?: string }) => ({ text: `echo:${ctx.Body}`, }), diff --git a/src/telegram/monitor.ts b/extensions/telegram/src/monitor.ts similarity index 92% rename from src/telegram/monitor.ts rename to extensions/telegram/src/monitor.ts index f7704f62dea..8620fb01c2b 100644 --- a/src/telegram/monitor.ts +++ b/extensions/telegram/src/monitor.ts @@ -1,11 +1,11 @@ import type { RunOptions } from "@grammyjs/runner"; -import { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig } from "../config/config.js"; -import { waitForAbortSignal } from "../infra/abort-signal.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { resolveAgentMaxConcurrent } from "../../../src/config/agent-limits.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { waitForAbortSignal } from "../../../src/infra/abort-signal.js"; +import { formatErrorMessage } from "../../../src/infra/errors.js"; +import { registerUnhandledRejectionHandler } from "../../../src/infra/unhandled-rejections.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; diff --git a/src/telegram/network-config.test.ts b/extensions/telegram/src/network-config.test.ts similarity index 97% rename from src/telegram/network-config.test.ts rename to extensions/telegram/src/network-config.test.ts index 70de5f46826..2b9428c1773 100644 --- a/src/telegram/network-config.test.ts +++ b/extensions/telegram/src/network-config.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { TelegramNetworkConfig } from "../config/types.telegram.js"; +import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; import { resetTelegramNetworkConfigStateForTests, resolveTelegramAutoSelectFamilyDecision, @@ -7,11 +7,11 @@ import { } from "./network-config.js"; // Mock isWSL2Sync at the top level -vi.mock("../infra/wsl.js", () => ({ +vi.mock("../../../src/infra/wsl.js", () => ({ isWSL2Sync: vi.fn(() => false), })); -import { isWSL2Sync } from "../infra/wsl.js"; +import { isWSL2Sync } from "../../../src/infra/wsl.js"; describe("resolveTelegramAutoSelectFamilyDecision", () => { afterEach(() => { diff --git a/src/telegram/network-config.ts b/extensions/telegram/src/network-config.ts similarity index 94% rename from src/telegram/network-config.ts rename to extensions/telegram/src/network-config.ts index 6bf20567cb7..81156ce67ac 100644 --- a/src/telegram/network-config.ts +++ b/extensions/telegram/src/network-config.ts @@ -1,7 +1,7 @@ import process from "node:process"; -import type { TelegramNetworkConfig } from "../config/types.telegram.js"; -import { isTruthyEnvValue } from "../infra/env.js"; -import { isWSL2Sync } from "../infra/wsl.js"; +import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; +import { isTruthyEnvValue } from "../../../src/infra/env.js"; +import { isWSL2Sync } from "../../../src/infra/wsl.js"; export const TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV = "OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY"; diff --git a/src/telegram/network-errors.test.ts b/extensions/telegram/src/network-errors.test.ts similarity index 100% rename from src/telegram/network-errors.test.ts rename to extensions/telegram/src/network-errors.test.ts diff --git a/src/telegram/network-errors.ts b/extensions/telegram/src/network-errors.ts similarity index 99% rename from src/telegram/network-errors.ts rename to extensions/telegram/src/network-errors.ts index 08e5d2dc2c0..59753f9d8c1 100644 --- a/src/telegram/network-errors.ts +++ b/extensions/telegram/src/network-errors.ts @@ -3,7 +3,7 @@ import { extractErrorCode, formatErrorMessage, readErrorName, -} from "../infra/errors.js"; +} from "../../../src/infra/errors.js"; const TELEGRAM_NETWORK_ORIGIN = Symbol("openclaw.telegram.network-origin"); diff --git a/extensions/telegram/src/normalize.ts b/extensions/telegram/src/normalize.ts new file mode 100644 index 00000000000..e819d78af10 --- /dev/null +++ b/extensions/telegram/src/normalize.ts @@ -0,0 +1,44 @@ +import { normalizeTelegramLookupTarget, parseTelegramTarget } from "./targets.js"; + +const TELEGRAM_PREFIX_RE = /^(telegram|tg):/i; + +function normalizeTelegramTargetBody(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + + const prefixStripped = trimmed.replace(TELEGRAM_PREFIX_RE, "").trim(); + if (!prefixStripped) { + return undefined; + } + + const parsed = parseTelegramTarget(trimmed); + const normalizedChatId = normalizeTelegramLookupTarget(parsed.chatId); + if (!normalizedChatId) { + return undefined; + } + + const keepLegacyGroupPrefix = /^group:/i.test(prefixStripped); + const hasTopicSuffix = /:topic:\d+$/i.test(prefixStripped); + const chatSegment = keepLegacyGroupPrefix ? `group:${normalizedChatId}` : normalizedChatId; + if (parsed.messageThreadId == null) { + return chatSegment; + } + const threadSuffix = hasTopicSuffix + ? `:topic:${parsed.messageThreadId}` + : `:${parsed.messageThreadId}`; + return `${chatSegment}${threadSuffix}`; +} + +export function normalizeTelegramMessagingTarget(raw: string): string | undefined { + const normalizedBody = normalizeTelegramTargetBody(raw); + if (!normalizedBody) { + return undefined; + } + return `telegram:${normalizedBody}`.toLowerCase(); +} + +export function looksLikeTelegramTargetId(raw: string): boolean { + return normalizeTelegramTargetBody(raw) !== undefined; +} diff --git a/extensions/telegram/src/onboarding.ts b/extensions/telegram/src/onboarding.ts new file mode 100644 index 00000000000..c555b748d2d --- /dev/null +++ b/extensions/telegram/src/onboarding.ts @@ -0,0 +1,256 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; +import { + applySingleTokenPromptResult, + patchChannelConfigForAccount, + promptSingleChannelSecretInput, + promptResolvedAllowFrom, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "./accounts.js"; +import { fetchTelegramChatId } from "./api-fetch.js"; + +const channel = "telegram" as const; + +async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Open Telegram and chat with @BotFather", + "2) Run /newbot (or /mybots)", + "3) Copy the token (looks like 123456:ABC...)", + "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", + ].join("\n"), + "Telegram bot token", + ); +} + +async function noteTelegramUserIdHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, + "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", + "3) Third-party: DM @userinfobot or @getidsbot", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", + ].join("\n"), + "Telegram user id", + ); +} + +export function normalizeTelegramAllowFromInput(raw: string): string { + return raw + .trim() + .replace(/^(telegram|tg):/i, "") + .trim(); +} + +export function parseTelegramAllowFromId(raw: string): string | null { + const stripped = normalizeTelegramAllowFromInput(raw); + return /^\d+$/.test(stripped) ? stripped : null; +} + +async function promptTelegramAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId: string; + tokenOverride?: string; +}): Promise { + const { cfg, prompter, accountId } = params; + const resolved = resolveTelegramAccount({ cfg, accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + await noteTelegramUserIdHelp(prompter); + + const token = params.tokenOverride?.trim() || resolved.token; + if (!token) { + await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram"); + } + const unique = await promptResolvedAllowFrom({ + prompter, + existing: existingAllowFrom, + token, + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + label: "Telegram allowlist", + parseInputs: splitOnboardingEntries, + parseId: parseTelegramAllowFromId, + invalidWithoutTokenNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + resolveEntries: async ({ token: tokenValue, entries }) => { + const results = await Promise.all( + entries.map(async (entry) => { + const numericId = parseTelegramAllowFromId(entry); + if (numericId) { + return { input: entry, resolved: true, id: numericId }; + } + const stripped = normalizeTelegramAllowFromInput(entry); + if (!stripped) { + return { input: entry, resolved: false, id: null }; + } + const username = stripped.startsWith("@") ? stripped : `@${stripped}`; + const id = await fetchTelegramChatId({ token: tokenValue, chatId: username }); + return { input: entry, resolved: Boolean(id), id }; + }), + ); + return results; + }, + }); + + return patchChannelConfigForAccount({ + cfg, + channel: "telegram", + accountId, + patch: { dmPolicy: "allowlist", allowFrom: unique }, + }); +} + +async function promptTelegramAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), + }); + return promptTelegramAllowFrom({ + cfg: params.cfg, + prompter: params.prompter, + accountId, + }); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Telegram", + channel, + policyKey: "channels.telegram.dmPolicy", + allowFromKey: "channels.telegram.allowFrom", + getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel: "telegram", + dmPolicy: policy, + }), + promptAllowFrom: promptTelegramAllowFromForAccount, +}; + +export const telegramOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const configured = listTelegramAccountIds(cfg).some((accountId) => { + const account = inspectTelegramAccount({ cfg, accountId }); + return account.configured; + }); + return { + channel, + configured, + statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`], + selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly", + quickstartScore: configured ? 1 : 10, + }; + }, + configure: async ({ + cfg, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom, + }) => { + const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg); + const telegramAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Telegram", + accountOverride: accountOverrides.telegram, + shouldPromptAccountIds, + listAccountIds: listTelegramAccountIds, + defaultAccountId: defaultTelegramAccountId, + }); + + let next = cfg; + const resolvedAccount = resolveTelegramAccount({ + cfg: next, + accountId: telegramAccountId, + }); + const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken); + const hasConfigToken = + hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim()); + const accountConfigured = Boolean(resolvedAccount.token) || hasConfigToken; + const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID; + const canUseEnv = + allowEnv && !hasConfigToken && Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim()); + + if (!accountConfigured) { + await noteTelegramTokenHelp(prompter); + } + + const tokenResult = await promptSingleChannelSecretInput({ + cfg: next, + prompter, + providerHint: "telegram", + credentialLabel: "Telegram bot token", + secretInputMode: options?.secretInputMode, + accountConfigured, + canUseEnv, + hasConfigToken, + envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", + keepPrompt: "Telegram token already configured. Keep it?", + inputPrompt: "Enter Telegram bot token", + preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined, + }); + + let resolvedTokenForAllowFrom: string | undefined; + if (tokenResult.action === "use-env") { + next = applySingleTokenPromptResult({ + cfg: next, + channel: "telegram", + accountId: telegramAccountId, + tokenPatchKey: "botToken", + tokenResult: { useEnv: true, token: null }, + }); + resolvedTokenForAllowFrom = process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined; + } else if (tokenResult.action === "set") { + next = applySingleTokenPromptResult({ + cfg: next, + channel: "telegram", + accountId: telegramAccountId, + tokenPatchKey: "botToken", + tokenResult: { useEnv: false, token: tokenResult.value }, + }); + resolvedTokenForAllowFrom = tokenResult.resolvedValue; + } + + if (forceAllowFrom) { + next = await promptTelegramAllowFrom({ + cfg: next, + prompter, + accountId: telegramAccountId, + tokenOverride: resolvedTokenForAllowFrom, + }); + } + + return { cfg: next, accountId: telegramAccountId }; + }, + dmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts new file mode 100644 index 00000000000..52700ba61dc --- /dev/null +++ b/extensions/telegram/src/outbound-adapter.ts @@ -0,0 +1,163 @@ +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import { + resolvePayloadMediaUrls, + sendPayloadMediaSequence, +} from "../../../src/channels/plugins/outbound/direct-text-media.js"; +import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; +import { + resolveOutboundSendDep, + type OutboundSendDeps, +} from "../../../src/infra/outbound/deliver.js"; +import type { TelegramInlineButtons } from "./button-types.js"; +import { markdownToTelegramHtmlChunks } from "./format.js"; +import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; +import { sendMessageTelegram } from "./send.js"; + +type TelegramSendFn = typeof sendMessageTelegram; +type TelegramSendOpts = Parameters[2]; + +function resolveTelegramSendContext(params: { + cfg: NonNullable["cfg"]; + deps?: OutboundSendDeps; + accountId?: string | null; + replyToId?: string | null; + threadId?: string | number | null; +}): { + send: TelegramSendFn; + baseOpts: { + cfg: NonNullable["cfg"]; + verbose: false; + textMode: "html"; + messageThreadId?: number; + replyToMessageId?: number; + accountId?: string; + }; +} { + const send = + resolveOutboundSendDep(params.deps, "telegram") ?? sendMessageTelegram; + return { + send, + baseOpts: { + verbose: false, + textMode: "html", + cfg: params.cfg, + messageThreadId: parseTelegramThreadId(params.threadId), + replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), + accountId: params.accountId ?? undefined, + }, + }; +} + +export async function sendTelegramPayloadMessages(params: { + send: TelegramSendFn; + to: string; + payload: ReplyPayload; + baseOpts: Omit, "buttons" | "mediaUrl" | "quoteText">; +}): Promise>> { + const telegramData = params.payload.channelData?.telegram as + | { buttons?: TelegramInlineButtons; quoteText?: string } + | undefined; + const quoteText = + typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; + const text = params.payload.text ?? ""; + const mediaUrls = resolvePayloadMediaUrls(params.payload); + const payloadOpts = { + ...params.baseOpts, + quoteText, + }; + + if (mediaUrls.length === 0) { + return await params.send(params.to, text, { + ...payloadOpts, + buttons: telegramData?.buttons, + }); + } + + // Telegram allows reply_markup on media; attach buttons only to the first send. + const finalResult = await sendPayloadMediaSequence({ + text, + mediaUrls, + send: async ({ text, mediaUrl, isFirst }) => + await params.send(params.to, text, { + ...payloadOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }), + }); + return finalResult ?? { messageId: "unknown", chatId: params.to }; +} + +export const telegramOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: markdownToTelegramHtmlChunks, + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { + const { send, baseOpts } = resolveTelegramSendContext({ + cfg, + deps, + accountId, + replyToId, + threadId, + }); + const result = await send(to, text, { + ...baseOpts, + }); + return { channel: "telegram", ...result }; + }, + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + forceDocument, + }) => { + const { send, baseOpts } = resolveTelegramSendContext({ + cfg, + deps, + accountId, + replyToId, + threadId, + }); + const result = await send(to, text, { + ...baseOpts, + mediaUrl, + mediaLocalRoots, + forceDocument: forceDocument ?? false, + }); + return { channel: "telegram", ...result }; + }, + sendPayload: async ({ + cfg, + to, + payload, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + }) => { + const { send, baseOpts } = resolveTelegramSendContext({ + cfg, + deps, + accountId, + replyToId, + threadId, + }); + const result = await sendTelegramPayloadMessages({ + send, + to, + payload, + baseOpts: { + ...baseOpts, + mediaLocalRoots, + }, + }); + return { channel: "telegram", ...result }; + }, +}; diff --git a/src/telegram/outbound-params.ts b/extensions/telegram/src/outbound-params.ts similarity index 100% rename from src/telegram/outbound-params.ts rename to extensions/telegram/src/outbound-params.ts diff --git a/src/telegram/polling-session.ts b/extensions/telegram/src/polling-session.ts similarity index 97% rename from src/telegram/polling-session.ts rename to extensions/telegram/src/polling-session.ts index 3a78747e41f..5506ce4e434 100644 --- a/src/telegram/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -1,7 +1,7 @@ import { type RunOptions, run } from "@grammyjs/runner"; -import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; +import { computeBackoff, sleepWithAbort } from "../../../src/infra/backoff.js"; +import { formatErrorMessage } from "../../../src/infra/errors.js"; +import { formatDurationPrecise } from "../../../src/infra/format-time/format-duration.ts"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { createTelegramBot } from "./bot.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; diff --git a/src/telegram/probe.test.ts b/extensions/telegram/src/probe.test.ts similarity index 99% rename from src/telegram/probe.test.ts rename to extensions/telegram/src/probe.test.ts index 7006d14a2f7..23a2051cfa0 100644 --- a/src/telegram/probe.test.ts +++ b/extensions/telegram/src/probe.test.ts @@ -1,5 +1,5 @@ import { afterEach, type Mock, describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js"; import { probeTelegram, resetTelegramProbeFetcherCacheForTests } from "./probe.js"; const resolveTelegramFetch = vi.hoisted(() => vi.fn()); diff --git a/src/telegram/probe.ts b/extensions/telegram/src/probe.ts similarity index 96% rename from src/telegram/probe.ts rename to extensions/telegram/src/probe.ts index 8311506e455..8a12161470a 100644 --- a/src/telegram/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -1,6 +1,6 @@ -import type { BaseProbeResult } from "../channels/plugins/types.js"; -import type { TelegramNetworkConfig } from "../config/types.telegram.js"; -import { fetchWithTimeout } from "../utils/fetch-timeout.js"; +import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; +import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; +import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; diff --git a/src/telegram/proxy.test.ts b/extensions/telegram/src/proxy.test.ts similarity index 100% rename from src/telegram/proxy.test.ts rename to extensions/telegram/src/proxy.test.ts diff --git a/extensions/telegram/src/proxy.ts b/extensions/telegram/src/proxy.ts new file mode 100644 index 00000000000..d74710c9cbd --- /dev/null +++ b/extensions/telegram/src/proxy.ts @@ -0,0 +1 @@ +export { getProxyUrlFromFetch, makeProxyFetch } from "../../../src/infra/net/proxy-fetch.js"; diff --git a/src/telegram/reaction-level.test.ts b/extensions/telegram/src/reaction-level.test.ts similarity index 98% rename from src/telegram/reaction-level.test.ts rename to extensions/telegram/src/reaction-level.test.ts index 6cc8e2dd39d..612a8eec424 100644 --- a/src/telegram/reaction-level.test.ts +++ b/extensions/telegram/src/reaction-level.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveTelegramReactionLevel } from "./reaction-level.js"; type ReactionResolution = ReturnType; diff --git a/src/telegram/reaction-level.ts b/extensions/telegram/src/reaction-level.ts similarity index 86% rename from src/telegram/reaction-level.ts rename to extensions/telegram/src/reaction-level.ts index 98873a05180..4597ce0602e 100644 --- a/src/telegram/reaction-level.ts +++ b/extensions/telegram/src/reaction-level.ts @@ -1,9 +1,9 @@ -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveReactionLevel, type ReactionLevel, type ResolvedReactionLevel as BaseResolvedReactionLevel, -} from "../utils/reaction-level.js"; +} from "../../../src/utils/reaction-level.js"; import { resolveTelegramAccount } from "./accounts.js"; export type TelegramReactionLevel = ReactionLevel; diff --git a/src/telegram/reasoning-lane-coordinator.test.ts b/extensions/telegram/src/reasoning-lane-coordinator.test.ts similarity index 100% rename from src/telegram/reasoning-lane-coordinator.test.ts rename to extensions/telegram/src/reasoning-lane-coordinator.test.ts diff --git a/src/telegram/reasoning-lane-coordinator.ts b/extensions/telegram/src/reasoning-lane-coordinator.ts similarity index 90% rename from src/telegram/reasoning-lane-coordinator.ts rename to extensions/telegram/src/reasoning-lane-coordinator.ts index a0207a39c72..4bc0da94dfe 100644 --- a/src/telegram/reasoning-lane-coordinator.ts +++ b/extensions/telegram/src/reasoning-lane-coordinator.ts @@ -1,7 +1,7 @@ -import { formatReasoningMessage } from "../agents/pi-embedded-utils.js"; -import type { ReplyPayload } from "../auto-reply/types.js"; -import { findCodeRegions, isInsideCode } from "../shared/text/code-regions.js"; -import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js"; +import { formatReasoningMessage } from "../../../src/agents/pi-embedded-utils.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import { findCodeRegions, isInsideCode } from "../../../src/shared/text/code-regions.js"; +import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js"; const REASONING_MESSAGE_PREFIX = "Reasoning:\n"; const REASONING_TAG_PREFIXES = [ diff --git a/src/telegram/send.proxy.test.ts b/extensions/telegram/src/send.proxy.test.ts similarity index 96% rename from src/telegram/send.proxy.test.ts rename to extensions/telegram/src/send.proxy.test.ts index 8e16078a67c..6c17b33fe38 100644 --- a/src/telegram/send.proxy.test.ts +++ b/extensions/telegram/src/send.proxy.test.ts @@ -21,8 +21,8 @@ const { resolveTelegramFetch } = vi.hoisted(() => ({ resolveTelegramFetch: vi.fn(), })); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig, diff --git a/src/telegram/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts similarity index 88% rename from src/telegram/send.test-harness.ts rename to extensions/telegram/src/send.test-harness.ts index b8092034a95..6d53a3d20e7 100644 --- a/src/telegram/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -1,5 +1,5 @@ import { beforeEach, vi } from "vitest"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; const { botApi, botCtorSpy } = vi.hoisted(() => ({ botApi: { @@ -40,7 +40,7 @@ type TelegramSendTestMocks = { maybePersistResolvedTelegramTarget: MockFn; }; -vi.mock("../web/media.js", () => ({ +vi.mock("../../whatsapp/src/media.js", () => ({ loadWebMedia, })); @@ -60,8 +60,8 @@ vi.mock("grammy", () => ({ InputFile: class {}, })); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig, diff --git a/src/telegram/send.test.ts b/extensions/telegram/src/send.test.ts similarity index 96% rename from src/telegram/send.test.ts rename to extensions/telegram/src/send.test.ts index f2875af1dc0..8dc4aff0c2d 100644 --- a/src/telegram/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -1,6 +1,6 @@ import type { Bot } from "grammy"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { importFreshModule } from "../../test/helpers/import-fresh.js"; +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; import { getTelegramSendTestMocks, importTelegramSendModule, @@ -877,6 +877,87 @@ describe("sendMessageTelegram", () => { expect(res.messageId).toBe("9"); }); + it.each([ + { + name: "images", + buffer: Buffer.from("fake-image"), + contentType: "image/png", + fileName: "photo.png", + mediaUrl: "https://example.com/photo.png", + }, + { + name: "GIFs", + buffer: Buffer.from("GIF89a"), + contentType: "image/gif", + fileName: "fun.gif", + mediaUrl: "https://example.com/fun.gif", + }, + ])("sends $name as documents when forceDocument is true", async (testCase) => { + const chatId = "123"; + const sendAnimation = vi.fn(); + const sendDocument = vi.fn().mockResolvedValue({ + message_id: 10, + chat: { id: chatId }, + }); + const sendPhoto = vi.fn(); + const api = { sendAnimation, sendDocument, sendPhoto } as unknown as { + sendAnimation: typeof sendAnimation; + sendDocument: typeof sendDocument; + sendPhoto: typeof sendPhoto; + }; + + mockLoadedMedia({ + buffer: testCase.buffer, + contentType: testCase.contentType, + fileName: testCase.fileName, + }); + + const res = await sendMessageTelegram(chatId, "caption", { + token: "tok", + api, + mediaUrl: testCase.mediaUrl, + forceDocument: true, + }); + + expect(sendDocument, testCase.name).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: "caption", + parse_mode: "HTML", + disable_content_type_detection: true, + }); + expect(sendPhoto, testCase.name).not.toHaveBeenCalled(); + expect(sendAnimation, testCase.name).not.toHaveBeenCalled(); + expect(res.messageId).toBe("10"); + }); + + it("keeps regular document sends on the default Telegram params", async () => { + const chatId = "123"; + const sendDocument = vi.fn().mockResolvedValue({ + message_id: 11, + chat: { id: chatId }, + }); + const api = { sendDocument } as unknown as { + sendDocument: typeof sendDocument; + }; + + mockLoadedMedia({ + buffer: Buffer.from("%PDF-1.7"), + contentType: "application/pdf", + fileName: "report.pdf", + }); + + const res = await sendMessageTelegram(chatId, "caption", { + token: "tok", + api, + mediaUrl: "https://example.com/report.pdf", + }); + + expect(sendDocument).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: "caption", + parse_mode: "HTML", + }); + expect(res.messageId).toBe("11"); + }); + it("routes audio media to sendAudio/sendVoice based on voice compatibility", async () => { const cases: Array<{ name: string; diff --git a/src/telegram/send.ts b/extensions/telegram/src/send.ts similarity index 96% rename from src/telegram/send.ts rename to extensions/telegram/src/send.ts index 5261887779f..e7d2c48e9fc 100644 --- a/src/telegram/send.ts +++ b/extensions/telegram/src/send.ts @@ -5,21 +5,21 @@ import type { ReactionTypeEmoji, } from "@grammyjs/types"; import { type ApiClientOptions, Bot, HttpError, InputFile } from "grammy"; -import { loadConfig } from "../config/config.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { logVerbose } from "../globals.js"; -import { recordChannelActivity } from "../infra/channel-activity.js"; -import { isDiagnosticFlagEnabled } from "../infra/diagnostic-flags.js"; -import { formatErrorMessage, formatUncaughtError } from "../infra/errors.js"; -import { createTelegramRetryRunner } from "../infra/retry-policy.js"; -import type { RetryConfig } from "../infra/retry.js"; -import { redactSensitiveText } from "../logging/redact.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import type { MediaKind } from "../media/constants.js"; -import { buildOutboundMediaLoadOptions } from "../media/load-options.js"; -import { isGifMedia, kindFromMime } from "../media/mime.js"; -import { normalizePollInput, type PollInput } from "../polls.js"; -import { loadWebMedia } from "../web/media.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { logVerbose } from "../../../src/globals.js"; +import { recordChannelActivity } from "../../../src/infra/channel-activity.js"; +import { isDiagnosticFlagEnabled } from "../../../src/infra/diagnostic-flags.js"; +import { formatErrorMessage, formatUncaughtError } from "../../../src/infra/errors.js"; +import { createTelegramRetryRunner } from "../../../src/infra/retry-policy.js"; +import type { RetryConfig } from "../../../src/infra/retry.js"; +import { redactSensitiveText } from "../../../src/logging/redact.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import type { MediaKind } from "../../../src/media/constants.js"; +import { buildOutboundMediaLoadOptions } from "../../../src/media/load-options.js"; +import { isGifMedia, kindFromMime } from "../../../src/media/mime.js"; +import { normalizePollInput, type PollInput } from "../../../src/polls.js"; +import { loadWebMedia } from "../../whatsapp/src/media.js"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js"; @@ -71,6 +71,8 @@ type TelegramSendOpts = { messageThreadId?: number; /** Inline keyboard buttons (reply markup). */ buttons?: TelegramInlineButtons; + /** Send image as document to avoid Telegram compression. Defaults to false. */ + forceDocument?: boolean; }; type TelegramSendResult = { @@ -763,6 +765,7 @@ export async function sendMessageTelegram( buildOutboundMediaLoadOptions({ maxBytes: mediaMaxBytes, mediaLocalRoots: opts.mediaLocalRoots, + optimizeImages: opts.forceDocument ? false : undefined, }), ); const kind = kindFromMime(media.contentType ?? undefined); @@ -815,7 +818,7 @@ export async function sendMessageTelegram( ); const mediaSender = (() => { - if (isGif) { + if (isGif && !opts.forceDocument) { return { label: "animation", sender: (effectiveParams: Record | undefined) => @@ -826,7 +829,7 @@ export async function sendMessageTelegram( ) as Promise, }; } - if (kind === "image") { + if (kind === "image" && !opts.forceDocument) { return { label: "photo", sender: (effectiveParams: Record | undefined) => @@ -893,7 +896,11 @@ export async function sendMessageTelegram( api.sendDocument( chatId, file, - effectiveParams as Parameters[2], + // Only force Telegram to keep the uploaded media type when callers explicitly + // opt into document delivery for image/GIF uploads. + (opts.forceDocument + ? { ...effectiveParams, disable_content_type_detection: true } + : effectiveParams) as Parameters[2], ) as Promise, }; })(); diff --git a/src/telegram/sendchataction-401-backoff.test.ts b/extensions/telegram/src/sendchataction-401-backoff.test.ts similarity index 96% rename from src/telegram/sendchataction-401-backoff.test.ts rename to extensions/telegram/src/sendchataction-401-backoff.test.ts index 4fbaaaaf9e5..302a3b19c4d 100644 --- a/src/telegram/sendchataction-401-backoff.test.ts +++ b/extensions/telegram/src/sendchataction-401-backoff.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js"; // Mock the backoff sleep to avoid real delays in tests -vi.mock("../infra/backoff.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/infra/backoff.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, sleepWithAbort: vi.fn().mockResolvedValue(undefined), diff --git a/src/telegram/sendchataction-401-backoff.ts b/extensions/telegram/src/sendchataction-401-backoff.ts similarity index 99% rename from src/telegram/sendchataction-401-backoff.ts rename to extensions/telegram/src/sendchataction-401-backoff.ts index f87915961c0..72ac8690403 100644 --- a/src/telegram/sendchataction-401-backoff.ts +++ b/extensions/telegram/src/sendchataction-401-backoff.ts @@ -1,4 +1,4 @@ -import { computeBackoff, sleepWithAbort, type BackoffPolicy } from "../infra/backoff.js"; +import { computeBackoff, sleepWithAbort, type BackoffPolicy } from "../../../src/infra/backoff.js"; export type TelegramSendChatActionLogger = (message: string) => void; diff --git a/src/telegram/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts similarity index 95% rename from src/telegram/sent-message-cache.ts rename to extensions/telegram/src/sent-message-cache.ts index 974510669e7..49a6ab4c3d9 100644 --- a/src/telegram/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -1,4 +1,4 @@ -import { resolveGlobalMap } from "../shared/global-singleton.js"; +import { resolveGlobalMap } from "../../../src/shared/global-singleton.js"; /** * In-memory cache of sent message IDs per chat. diff --git a/src/telegram/sequential-key.test.ts b/extensions/telegram/src/sequential-key.test.ts similarity index 100% rename from src/telegram/sequential-key.test.ts rename to extensions/telegram/src/sequential-key.test.ts diff --git a/src/telegram/sequential-key.ts b/extensions/telegram/src/sequential-key.ts similarity index 95% rename from src/telegram/sequential-key.ts rename to extensions/telegram/src/sequential-key.ts index 3e787055e0d..7bf22f5e8e1 100644 --- a/src/telegram/sequential-key.ts +++ b/extensions/telegram/src/sequential-key.ts @@ -1,5 +1,5 @@ import { type Message, type UserFromGetMe } from "@grammyjs/types"; -import { isAbortRequestText } from "../auto-reply/reply/abort.js"; +import { isAbortRequestText } from "../../../src/auto-reply/reply/abort.js"; import { resolveTelegramForumThreadId } from "./bot/helpers.js"; export type TelegramSequentialKeyContext = { diff --git a/extensions/telegram/src/status-issues.ts b/extensions/telegram/src/status-issues.ts new file mode 100644 index 00000000000..b970f533dd0 --- /dev/null +++ b/extensions/telegram/src/status-issues.ts @@ -0,0 +1,148 @@ +import { + appendMatchMetadata, + asString, + isRecord, + resolveEnabledConfiguredAccountId, +} from "../../../src/channels/plugins/status-issues/shared.js"; +import type { + ChannelAccountSnapshot, + ChannelStatusIssue, +} from "../../../src/channels/plugins/types.js"; + +type TelegramAccountStatus = { + accountId?: unknown; + enabled?: unknown; + configured?: unknown; + allowUnmentionedGroups?: unknown; + audit?: unknown; +}; + +type TelegramGroupMembershipAuditSummary = { + unresolvedGroups?: number; + hasWildcardUnmentionedGroups?: boolean; + groups?: Array<{ + chatId: string; + ok?: boolean; + status?: string | null; + error?: string | null; + matchKey?: string; + matchSource?: string; + }>; +}; + +function readTelegramAccountStatus(value: ChannelAccountSnapshot): TelegramAccountStatus | null { + if (!isRecord(value)) { + return null; + } + return { + accountId: value.accountId, + enabled: value.enabled, + configured: value.configured, + allowUnmentionedGroups: value.allowUnmentionedGroups, + audit: value.audit, + }; +} + +function readTelegramGroupMembershipAuditSummary( + value: unknown, +): TelegramGroupMembershipAuditSummary { + if (!isRecord(value)) { + return {}; + } + const unresolvedGroups = + typeof value.unresolvedGroups === "number" && Number.isFinite(value.unresolvedGroups) + ? value.unresolvedGroups + : undefined; + const hasWildcardUnmentionedGroups = + typeof value.hasWildcardUnmentionedGroups === "boolean" + ? value.hasWildcardUnmentionedGroups + : undefined; + const groupsRaw = value.groups; + const groups = Array.isArray(groupsRaw) + ? (groupsRaw + .map((entry) => { + if (!isRecord(entry)) { + return null; + } + const chatId = asString(entry.chatId); + if (!chatId) { + return null; + } + const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; + const status = asString(entry.status) ?? null; + const error = asString(entry.error) ?? null; + const matchKey = asString(entry.matchKey) ?? undefined; + const matchSource = asString(entry.matchSource) ?? undefined; + return { chatId, ok, status, error, matchKey, matchSource }; + }) + .filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"]) + : undefined; + return { unresolvedGroups, hasWildcardUnmentionedGroups, groups }; +} + +export function collectTelegramStatusIssues( + accounts: ChannelAccountSnapshot[], +): ChannelStatusIssue[] { + const issues: ChannelStatusIssue[] = []; + for (const entry of accounts) { + const account = readTelegramAccountStatus(entry); + if (!account) { + continue; + } + const accountId = resolveEnabledConfiguredAccountId(account); + if (!accountId) { + continue; + } + + if (account.allowUnmentionedGroups === true) { + issues.push({ + channel: "telegram", + accountId, + kind: "config", + message: + "Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.", + fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).", + }); + } + + const audit = readTelegramGroupMembershipAuditSummary(account.audit); + if (audit.hasWildcardUnmentionedGroups === true) { + issues.push({ + channel: "telegram", + accountId, + kind: "config", + message: + 'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.', + fix: "Add explicit numeric group ids under channels.telegram.groups (or per-account groups) to enable probing.", + }); + } + if (audit.unresolvedGroups && audit.unresolvedGroups > 0) { + issues.push({ + channel: "telegram", + accountId, + kind: "config", + message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`, + fix: "Use numeric chat IDs (e.g. -100...) as keys in channels.telegram.groups for requireMention=false groups.", + }); + } + for (const group of audit.groups ?? []) { + if (group.ok === true) { + continue; + } + const status = group.status ? ` status=${group.status}` : ""; + const err = group.error ? `: ${group.error}` : ""; + const baseMessage = `Group ${group.chatId} not reachable by bot.${status}${err}`; + issues.push({ + channel: "telegram", + accountId, + kind: "runtime", + message: appendMatchMetadata(baseMessage, { + matchKey: group.matchKey, + matchSource: group.matchSource, + }), + fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.", + }); + } + } + return issues; +} diff --git a/src/telegram/status-reaction-variants.test.ts b/extensions/telegram/src/status-reaction-variants.test.ts similarity index 98% rename from src/telegram/status-reaction-variants.test.ts rename to extensions/telegram/src/status-reaction-variants.test.ts index 53d13e60ca8..123334fcaad 100644 --- a/src/telegram/status-reaction-variants.test.ts +++ b/extensions/telegram/src/status-reaction-variants.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_EMOJIS } from "../channels/status-reactions.js"; +import { DEFAULT_EMOJIS } from "../../../src/channels/status-reactions.js"; import { buildTelegramStatusReactionVariants, extractTelegramAllowedEmojiReactions, diff --git a/src/telegram/status-reaction-variants.ts b/extensions/telegram/src/status-reaction-variants.ts similarity index 98% rename from src/telegram/status-reaction-variants.ts rename to extensions/telegram/src/status-reaction-variants.ts index 9ce3d033eb0..6c5c80e9fd8 100644 --- a/src/telegram/status-reaction-variants.ts +++ b/extensions/telegram/src/status-reaction-variants.ts @@ -1,4 +1,7 @@ -import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "../channels/status-reactions.js"; +import { + DEFAULT_EMOJIS, + type StatusReactionEmojis, +} from "../../../src/channels/status-reactions.js"; type StatusReactionEmojiKey = keyof Required; diff --git a/src/telegram/sticker-cache.test.ts b/extensions/telegram/src/sticker-cache.test.ts similarity index 97% rename from src/telegram/sticker-cache.test.ts rename to extensions/telegram/src/sticker-cache.test.ts index 0c9ac280406..219ce421e62 100644 --- a/src/telegram/sticker-cache.test.ts +++ b/extensions/telegram/src/sticker-cache.test.ts @@ -10,8 +10,8 @@ import { } from "./sticker-cache.js"; // Mock the state directory to use a temp location -vi.mock("../config/paths.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/paths.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, STATE_DIR: "/tmp/openclaw-test-sticker-cache", diff --git a/src/telegram/sticker-cache.ts b/extensions/telegram/src/sticker-cache.ts similarity index 88% rename from src/telegram/sticker-cache.ts rename to extensions/telegram/src/sticker-cache.ts index be8966b1eb5..e6cdfbd9015 100644 --- a/src/telegram/sticker-cache.ts +++ b/extensions/telegram/src/sticker-cache.ts @@ -1,19 +1,22 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { resolveApiKeyForProvider } from "../agents/model-auth.js"; -import type { ModelCatalogEntry } from "../agents/model-catalog.js"; +import { resolveApiKeyForProvider } from "../../../src/agents/model-auth.js"; +import type { ModelCatalogEntry } from "../../../src/agents/model-catalog.js"; import { findModelInCatalog, loadModelCatalog, modelSupportsVision, -} from "../agents/model-catalog.js"; -import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { STATE_DIR } from "../config/paths.js"; -import { logVerbose } from "../globals.js"; -import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; -import { AUTO_IMAGE_KEY_PROVIDERS, DEFAULT_IMAGE_MODELS } from "../media-understanding/defaults.js"; -import { resolveAutoImageModel } from "../media-understanding/runner.js"; +} from "../../../src/agents/model-catalog.js"; +import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { STATE_DIR } from "../../../src/config/paths.js"; +import { logVerbose } from "../../../src/globals.js"; +import { loadJsonFile, saveJsonFile } from "../../../src/infra/json-file.js"; +import { + AUTO_IMAGE_KEY_PROVIDERS, + DEFAULT_IMAGE_MODELS, +} from "../../../src/media-understanding/defaults.js"; +import { resolveAutoImageModel } from "../../../src/media-understanding/runner.js"; const CACHE_FILE = path.join(STATE_DIR, "telegram", "sticker-cache.json"); const CACHE_VERSION = 1; @@ -144,11 +147,11 @@ export function getCacheStats(): { count: number; oldestAt?: string; newestAt?: const STICKER_DESCRIPTION_PROMPT = "Describe this sticker image in 1-2 sentences. Focus on what the sticker depicts (character, object, action, emotion). Be concise and objective."; let imageRuntimePromise: Promise< - typeof import("../media-understanding/providers/image-runtime.js") + typeof import("../../../src/media-understanding/providers/image-runtime.js") > | null = null; function loadImageRuntime() { - imageRuntimePromise ??= import("../media-understanding/providers/image-runtime.js"); + imageRuntimePromise ??= import("../../../src/media-understanding/providers/image-runtime.js"); return imageRuntimePromise; } diff --git a/src/telegram/target-writeback.test.ts b/extensions/telegram/src/target-writeback.test.ts similarity index 92% rename from src/telegram/target-writeback.test.ts rename to extensions/telegram/src/target-writeback.test.ts index b32d5b33e2f..bb8b2129924 100644 --- a/src/telegram/target-writeback.test.ts +++ b/extensions/telegram/src/target-writeback.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; const readConfigFileSnapshotForWrite = vi.fn(); const writeConfigFile = vi.fn(); @@ -7,8 +7,8 @@ const loadCronStore = vi.fn(); const resolveCronStorePath = vi.fn(); const saveCronStore = vi.fn(); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, readConfigFileSnapshotForWrite, @@ -16,8 +16,8 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("../cron/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/cron/store.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadCronStore, diff --git a/src/telegram/target-writeback.ts b/extensions/telegram/src/target-writeback.ts similarity index 96% rename from src/telegram/target-writeback.ts rename to extensions/telegram/src/target-writeback.ts index e8c4d52b2cb..6423215ffa2 100644 --- a/src/telegram/target-writeback.ts +++ b/extensions/telegram/src/target-writeback.ts @@ -1,7 +1,7 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { readConfigFileSnapshotForWrite, writeConfigFile } from "../config/config.js"; -import { loadCronStore, resolveCronStorePath, saveCronStore } from "../cron/store.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { readConfigFileSnapshotForWrite, writeConfigFile } from "../../../src/config/config.js"; +import { loadCronStore, resolveCronStorePath, saveCronStore } from "../../../src/cron/store.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; import { normalizeTelegramChatId, normalizeTelegramLookupTarget, diff --git a/src/telegram/targets.test.ts b/extensions/telegram/src/targets.test.ts similarity index 100% rename from src/telegram/targets.test.ts rename to extensions/telegram/src/targets.test.ts diff --git a/src/telegram/targets.ts b/extensions/telegram/src/targets.ts similarity index 100% rename from src/telegram/targets.ts rename to extensions/telegram/src/targets.ts diff --git a/src/telegram/thread-bindings.test.ts b/extensions/telegram/src/thread-bindings.test.ts similarity index 96% rename from src/telegram/thread-bindings.test.ts rename to extensions/telegram/src/thread-bindings.test.ts index fc32ace254b..3b05f50ac9b 100644 --- a/src/telegram/thread-bindings.test.ts +++ b/extensions/telegram/src/thread-bindings.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { importFreshModule } from "../../test/helpers/import-fresh.js"; -import { resolveStateDir } from "../config/paths.js"; -import { getSessionBindingService } from "../infra/outbound/session-binding-service.js"; +import { resolveStateDir } from "../../../src/config/paths.js"; +import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js"; +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; import { __testing, createTelegramThreadBindingManager, diff --git a/src/telegram/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts similarity index 97% rename from src/telegram/thread-bindings.ts rename to extensions/telegram/src/thread-bindings.ts index ea2fd11ac1e..831e46d952f 100644 --- a/src/telegram/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -1,19 +1,19 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveThreadBindingConversationIdFromBindingId } from "../channels/thread-binding-id.js"; -import { formatThreadBindingDurationLabel } from "../channels/thread-bindings-messages.js"; -import { resolveStateDir } from "../config/paths.js"; -import { logVerbose } from "../globals.js"; -import { writeJsonAtomic } from "../infra/json-files.js"; +import { resolveThreadBindingConversationIdFromBindingId } from "../../../src/channels/thread-binding-id.js"; +import { formatThreadBindingDurationLabel } from "../../../src/channels/thread-bindings-messages.js"; +import { resolveStateDir } from "../../../src/config/paths.js"; +import { logVerbose } from "../../../src/globals.js"; +import { writeJsonAtomic } from "../../../src/infra/json-files.js"; import { registerSessionBindingAdapter, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, -} from "../infra/outbound/session-binding-service.js"; -import { normalizeAccountId } from "../routing/session-key.js"; -import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +} from "../../../src/infra/outbound/session-binding-service.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js"; const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000; const DEFAULT_THREAD_BINDING_MAX_AGE_MS = 0; diff --git a/src/telegram/token.test.ts b/extensions/telegram/src/token.test.ts similarity index 97% rename from src/telegram/token.test.ts rename to extensions/telegram/src/token.test.ts index 17e412cf584..c81e5d57b2c 100644 --- a/src/telegram/token.test.ts +++ b/extensions/telegram/src/token.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { withStateDirEnv } from "../../../src/test-helpers/state-dir-env.js"; import { resolveTelegramToken } from "./token.js"; import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js"; diff --git a/src/telegram/token.ts b/extensions/telegram/src/token.ts similarity index 85% rename from src/telegram/token.ts rename to extensions/telegram/src/token.ts index 3615c703582..827b4899e21 100644 --- a/src/telegram/token.ts +++ b/extensions/telegram/src/token.ts @@ -1,9 +1,9 @@ -import type { BaseTokenResolution } from "../channels/plugins/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { normalizeResolvedSecretInputString } from "../config/types.secrets.js"; -import type { TelegramAccountConfig } from "../config/types.telegram.js"; -import { tryReadSecretFileSync } from "../infra/secret-file.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { BaseTokenResolution } from "../../../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js"; +import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; diff --git a/src/telegram/update-offset-store.test.ts b/extensions/telegram/src/update-offset-store.test.ts similarity index 98% rename from src/telegram/update-offset-store.test.ts rename to extensions/telegram/src/update-offset-store.test.ts index 8c00c3a151d..517944f6972 100644 --- a/src/telegram/update-offset-store.test.ts +++ b/extensions/telegram/src/update-offset-store.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; +import { withStateDirEnv } from "../../../src/test-helpers/state-dir-env.js"; import { deleteTelegramUpdateOffset, readTelegramUpdateOffset, diff --git a/src/telegram/update-offset-store.ts b/extensions/telegram/src/update-offset-store.ts similarity index 96% rename from src/telegram/update-offset-store.ts rename to extensions/telegram/src/update-offset-store.ts index 8a511788c66..55b4e96ae23 100644 --- a/src/telegram/update-offset-store.ts +++ b/extensions/telegram/src/update-offset-store.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { resolveStateDir } from "../config/paths.js"; -import { writeJsonAtomic } from "../infra/json-files.js"; +import { resolveStateDir } from "../../../src/config/paths.js"; +import { writeJsonAtomic } from "../../../src/infra/json-files.js"; const STORE_VERSION = 2; diff --git a/src/telegram/voice.test.ts b/extensions/telegram/src/voice.test.ts similarity index 100% rename from src/telegram/voice.test.ts rename to extensions/telegram/src/voice.test.ts diff --git a/src/telegram/voice.ts b/extensions/telegram/src/voice.ts similarity index 92% rename from src/telegram/voice.ts rename to extensions/telegram/src/voice.ts index 67d8bc56e2f..865bd82d72e 100644 --- a/src/telegram/voice.ts +++ b/extensions/telegram/src/voice.ts @@ -1,4 +1,4 @@ -import { isTelegramVoiceCompatibleAudio } from "../media/audio.js"; +import { isTelegramVoiceCompatibleAudio } from "../../../src/media/audio.js"; export function resolveTelegramVoiceDecision(opts: { wantsVoice: boolean; diff --git a/src/telegram/webhook.test.ts b/extensions/telegram/src/webhook.test.ts similarity index 100% rename from src/telegram/webhook.test.ts rename to extensions/telegram/src/webhook.test.ts diff --git a/src/telegram/webhook.ts b/extensions/telegram/src/webhook.ts similarity index 95% rename from src/telegram/webhook.ts rename to extensions/telegram/src/webhook.ts index c049089a2ad..39458ae036a 100644 --- a/src/telegram/webhook.ts +++ b/extensions/telegram/src/webhook.ts @@ -1,19 +1,19 @@ import { timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import { InputFile, webhookCallback } from "grammy"; -import type { OpenClawConfig } from "../config/config.js"; -import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import { readJsonBodyWithLimit } from "../infra/http-body.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { isDiagnosticsEnabled } from "../../../src/infra/diagnostic-events.js"; +import { formatErrorMessage } from "../../../src/infra/errors.js"; +import { readJsonBodyWithLimit } from "../../../src/infra/http-body.js"; import { logWebhookError, logWebhookProcessed, logWebhookReceived, startDiagnosticHeartbeat, stopDiagnosticHeartbeat, -} from "../logging/diagnostic.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { defaultRuntime } from "../runtime.js"; +} from "../../../src/logging/diagnostic.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { defaultRuntime } from "../../../src/runtime.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { createTelegramBot } from "./bot.js"; diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index e5f9c1e9ed5..40ec9aeedde 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 123b391c2ce..cc887a99055 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 5213b5c7b74..bc730150b5e 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/vllm/package.json b/extensions/vllm/package.json index 3ef665a6bf2..bb293610355 100644 --- a/extensions/vllm/package.json +++ b/extensions/vllm/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/vllm-provider", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw vLLM provider plugin", "type": "module", diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 25b90b3db54..d9d27a97e87 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 75c500db1f9..3c65532f9c9 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 383edd4612d..ec73a1b0613 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/src/web/accounts.test.ts b/extensions/whatsapp/src/accounts.test.ts similarity index 100% rename from src/web/accounts.test.ts rename to extensions/whatsapp/src/accounts.test.ts diff --git a/src/web/accounts.ts b/extensions/whatsapp/src/accounts.ts similarity index 91% rename from src/web/accounts.ts rename to extensions/whatsapp/src/accounts.ts index 3370d4c9d80..a225b09dfb8 100644 --- a/src/web/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -1,12 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveOAuthDir } from "../config/paths.js"; -import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveUserPath } from "../utils.js"; +import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveOAuthDir } from "../../../src/config/paths.js"; +import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../../../src/config/types.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { resolveUserPath } from "../../../src/utils.js"; import { hasWebCredsSync } from "./auth-store.js"; export type ResolvedWhatsAppAccount = { diff --git a/src/web/accounts.whatsapp-auth.test.ts b/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts similarity index 96% rename from src/web/accounts.whatsapp-auth.test.ts rename to extensions/whatsapp/src/accounts.whatsapp-auth.test.ts index 89dac3977cc..349bccc65e5 100644 --- a/src/web/accounts.whatsapp-auth.test.ts +++ b/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { captureEnv } from "../test-utils/env.js"; +import { captureEnv } from "../../../src/test-utils/env.js"; import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js"; describe("hasAnyWhatsAppAuth", () => { diff --git a/src/web/active-listener.ts b/extensions/whatsapp/src/active-listener.ts similarity index 92% rename from src/web/active-listener.ts rename to extensions/whatsapp/src/active-listener.ts index 2c852899617..fc8f11fe20e 100644 --- a/src/web/active-listener.ts +++ b/extensions/whatsapp/src/active-listener.ts @@ -1,6 +1,6 @@ -import { formatCliCommand } from "../cli/command-format.js"; -import type { PollInput } from "../polls.js"; -import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { PollInput } from "../../../src/polls.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; export type ActiveWebSendOptions = { gifPlayback?: boolean; diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts new file mode 100644 index 00000000000..a1ac87a3976 --- /dev/null +++ b/extensions/whatsapp/src/agent-tools-login.ts @@ -0,0 +1,72 @@ +import { Type } from "@sinclair/typebox"; +import type { ChannelAgentTool } from "../../../src/channels/plugins/types.js"; + +export function createWhatsAppLoginTool(): ChannelAgentTool { + return { + label: "WhatsApp Login", + name: "whatsapp_login", + ownerOnly: true, + description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", + // NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)] + // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. + parameters: Type.Object({ + action: Type.Unsafe<"start" | "wait">({ + type: "string", + enum: ["start", "wait"], + }), + timeoutMs: Type.Optional(Type.Number()), + force: Type.Optional(Type.Boolean()), + }), + execute: async (_toolCallId, args) => { + const { startWebLoginWithQr, waitForWebLogin } = await import("./login-qr.js"); + const action = (args as { action?: string })?.action ?? "start"; + if (action === "wait") { + const result = await waitForWebLogin({ + timeoutMs: + typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" + ? (args as { timeoutMs?: number }).timeoutMs + : undefined, + }); + return { + content: [{ type: "text", text: result.message }], + details: { connected: result.connected }, + }; + } + + const result = await startWebLoginWithQr({ + timeoutMs: + typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" + ? (args as { timeoutMs?: number }).timeoutMs + : undefined, + force: + typeof (args as { force?: unknown }).force === "boolean" + ? (args as { force?: boolean }).force + : false, + }); + + if (!result.qrDataUrl) { + return { + content: [ + { + type: "text", + text: result.message, + }, + ], + details: { qr: false }, + }; + } + + const text = [ + result.message, + "", + "Open WhatsApp → Linked Devices and scan:", + "", + `![whatsapp-qr](${result.qrDataUrl})`, + ].join("\n"); + return { + content: [{ type: "text", text }], + details: { qr: true }, + }; + }, + }; +} diff --git a/src/web/auth-store.ts b/extensions/whatsapp/src/auth-store.ts similarity index 91% rename from src/web/auth-store.ts rename to extensions/whatsapp/src/auth-store.ts index b17df5e322f..636c114676f 100644 --- a/src/web/auth-store.ts +++ b/extensions/whatsapp/src/auth-store.ts @@ -1,14 +1,14 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { formatCliCommand } from "../cli/command-format.js"; -import { resolveOAuthDir } from "../config/paths.js"; -import { info, success } from "../globals.js"; -import { getChildLogger } from "../logging.js"; -import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import type { WebChannel } from "../utils.js"; -import { jidToE164, resolveUserPath } from "../utils.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import { resolveOAuthDir } from "../../../src/config/paths.js"; +import { info, success } from "../../../src/globals.js"; +import { getChildLogger } from "../../../src/logging.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js"; +import type { WebChannel } from "../../../src/utils.js"; +import { jidToE164, resolveUserPath } from "../../../src/utils.js"; export function resolveDefaultWebAuthDir(): string { return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID); diff --git a/src/web/auto-reply.broadcast-groups.combined.test.ts b/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts similarity index 98% rename from src/web/auto-reply.broadcast-groups.combined.test.ts rename to extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts index 40b2f90b22d..3cc4421f594 100644 --- a/src/web/auto-reply.broadcast-groups.combined.test.ts +++ b/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts @@ -1,6 +1,6 @@ import "./test-helpers.js"; import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { monitorWebChannelWithCapture, sendWebDirectInboundAndCollectSessionKeys, diff --git a/src/web/auto-reply.broadcast-groups.test-harness.ts b/extensions/whatsapp/src/auto-reply.broadcast-groups.test-harness.ts similarity index 100% rename from src/web/auto-reply.broadcast-groups.test-harness.ts rename to extensions/whatsapp/src/auto-reply.broadcast-groups.test-harness.ts diff --git a/src/web/auto-reply.impl.ts b/extensions/whatsapp/src/auto-reply.impl.ts similarity index 63% rename from src/web/auto-reply.impl.ts rename to extensions/whatsapp/src/auto-reply.impl.ts index c53a13e3219..57feff1ab4d 100644 --- a/src/web/auto-reply.impl.ts +++ b/extensions/whatsapp/src/auto-reply.impl.ts @@ -1,5 +1,5 @@ -export { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../auto-reply/heartbeat.js"; -export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +export { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../../../src/auto-reply/heartbeat.js"; +export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js"; export { DEFAULT_WEB_MEDIA_BYTES } from "./auto-reply/constants.js"; export { resolveHeartbeatRecipients, runWebHeartbeatOnce } from "./auto-reply/heartbeat-runner.js"; diff --git a/src/web/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts similarity index 96% rename from src/web/auto-reply.test-harness.ts rename to extensions/whatsapp/src/auto-reply.test-harness.ts index 0e7b0c7e3a7..dfbcf447fa9 100644 --- a/src/web/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -3,9 +3,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import * as ssrf from "../infra/net/ssrf.js"; -import { resetLogger, setLoggerOverride } from "../logging.js"; +import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; +import * as ssrf from "../../../src/infra/net/ssrf.js"; +import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js"; import { resetBaileysMocks as _resetBaileysMocks, @@ -29,7 +29,7 @@ type MockWebListener = { export const TEST_NET_IP = "203.0.113.10"; -vi.mock("../agents/pi-embedded.js", () => ({ +vi.mock("../../../src/agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), diff --git a/src/web/auto-reply.ts b/extensions/whatsapp/src/auto-reply.ts similarity index 100% rename from src/web/auto-reply.ts rename to extensions/whatsapp/src/auto-reply.ts diff --git a/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts similarity index 100% rename from src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts rename to extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts diff --git a/src/web/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts similarity index 98% rename from src/web/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts rename to extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 97e77f25f3d..dd324f47351 100644 --- a/src/web/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -2,10 +2,10 @@ import "./test-helpers.js"; import crypto from "node:crypto"; import fs from "node:fs/promises"; import { beforeAll, describe, expect, it, vi } from "vitest"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { setLoggerOverride } from "../logging.js"; -import { withEnvAsync } from "../test-utils/env.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { setLoggerOverride } from "../../../src/logging.js"; +import { withEnvAsync } from "../../../src/test-utils/env.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { createMockWebListener, createWebListenerFactoryCapture, diff --git a/src/web/auto-reply.web-auto-reply.last-route.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts similarity index 98% rename from src/web/auto-reply.web-auto-reply.last-route.test.ts rename to extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts index a810b2ece29..a370876f514 100644 --- a/src/web/auto-reply.web-auto-reply.last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts @@ -1,7 +1,7 @@ import "./test-helpers.js"; import fs from "node:fs/promises"; import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js"; import { buildMentionConfig } from "./auto-reply/mentions.js"; import { createEchoTracker } from "./auto-reply/monitor/echo.js"; diff --git a/src/web/auto-reply/constants.ts b/extensions/whatsapp/src/auto-reply/constants.ts similarity index 100% rename from src/web/auto-reply/constants.ts rename to extensions/whatsapp/src/auto-reply/constants.ts diff --git a/src/web/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts similarity index 95% rename from src/web/auto-reply/deliver-reply.test.ts rename to extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index 6a2810d182a..2a28a636fff 100644 --- a/src/web/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { logVerbose } from "../../globals.js"; -import { sleep } from "../../utils.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { sleep } from "../../../../src/utils.js"; import { loadWebMedia } from "../media.js"; import { deliverWebReply } from "./deliver-reply.js"; import type { WebInboundMsg } from "./types.js"; -vi.mock("../../globals.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/globals.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, shouldLogVerbose: vi.fn(() => true), @@ -18,8 +18,8 @@ vi.mock("../media.js", () => ({ loadWebMedia: vi.fn(), })); -vi.mock("../../utils.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/utils.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, sleep: vi.fn(async () => {}), diff --git a/src/web/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts similarity index 93% rename from src/web/auto-reply/deliver-reply.ts rename to extensions/whatsapp/src/auto-reply/deliver-reply.ts index 7866fea0c8a..6fb4ce39143 100644 --- a/src/web/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -1,10 +1,10 @@ -import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import type { MarkdownTableMode } from "../../config/types.base.js"; -import { logVerbose, shouldLogVerbose } from "../../globals.js"; -import { convertMarkdownTables } from "../../markdown/tables.js"; -import { markdownToWhatsApp } from "../../markdown/whatsapp.js"; -import { sleep } from "../../utils.js"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../../src/auto-reply/chunk.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import type { MarkdownTableMode } from "../../../../src/config/types.base.js"; +import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; +import { convertMarkdownTables } from "../../../../src/markdown/tables.js"; +import { markdownToWhatsApp } from "../../../../src/markdown/whatsapp.js"; +import { sleep } from "../../../../src/utils.js"; import { loadWebMedia } from "../media.js"; import { newConnectionId } from "../reconnect.js"; import { formatError } from "../session.js"; diff --git a/src/web/auto-reply/heartbeat-runner.test.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts similarity index 89% rename from src/web/auto-reply/heartbeat-runner.test.ts rename to extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts index 87d8d8a7ca9..a0022abaa8c 100644 --- a/src/web/auto-reply/heartbeat-runner.test.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { getReplyFromConfig } from "../../auto-reply/reply.js"; -import { HEARTBEAT_TOKEN } from "../../auto-reply/tokens.js"; -import { redactIdentifier } from "../../logging/redact-identifier.js"; -import type { sendMessageWhatsApp } from "../outbound.js"; +import type { getReplyFromConfig } from "../../../../src/auto-reply/reply.js"; +import { HEARTBEAT_TOKEN } from "../../../../src/auto-reply/tokens.js"; +import { redactIdentifier } from "../../../../src/logging/redact-identifier.js"; +import type { sendMessageWhatsApp } from "../send.js"; const state = vi.hoisted(() => ({ visibility: { showAlerts: true, showOk: true, useIndicator: false }, @@ -22,34 +22,34 @@ const state = vi.hoisted(() => ({ heartbeatWarnLogs: [] as string[], })); -vi.mock("../../agents/current-time.js", () => ({ +vi.mock("../../../../src/agents/current-time.js", () => ({ appendCronStyleCurrentTimeLine: (body: string) => `${body}\nCurrent time: 2026-02-15T00:00:00Z (mock)`, })); // Perf: this module otherwise pulls a large dependency graph that we don't need // for these unit tests. -vi.mock("../../auto-reply/reply.js", () => ({ +vi.mock("../../../../src/auto-reply/reply.js", () => ({ getReplyFromConfig: vi.fn(async () => undefined), })); -vi.mock("../../channels/plugins/whatsapp-heartbeat.js", () => ({ +vi.mock("../../../../src/channels/plugins/whatsapp-heartbeat.js", () => ({ resolveWhatsAppHeartbeatRecipients: () => [], })); -vi.mock("../../config/config.js", () => ({ +vi.mock("../../../../src/config/config.js", () => ({ loadConfig: () => ({ agents: { defaults: {} }, session: {} }), })); -vi.mock("../../routing/session-key.js", () => ({ +vi.mock("../../../../src/routing/session-key.js", () => ({ normalizeMainKey: () => null, })); -vi.mock("../../infra/heartbeat-visibility.js", () => ({ +vi.mock("../../../../src/infra/heartbeat-visibility.js", () => ({ resolveHeartbeatVisibility: () => state.visibility, })); -vi.mock("../../config/sessions.js", () => ({ +vi.mock("../../../../src/config/sessions.js", () => ({ loadSessionStore: () => state.store, resolveSessionKey: () => "k", resolveStorePath: () => "/tmp/store.json", @@ -62,12 +62,12 @@ vi.mock("./session-snapshot.js", () => ({ getSessionSnapshot: () => state.snapshot, })); -vi.mock("../../infra/heartbeat-events.js", () => ({ +vi.mock("../../../../src/infra/heartbeat-events.js", () => ({ emitHeartbeatEvent: (event: unknown) => state.events.push(event), resolveIndicatorType: (status: string) => `indicator:${status}`, })); -vi.mock("../../logging.js", () => ({ +vi.mock("../../../../src/logging.js", () => ({ getChildLogger: () => ({ info: (...args: unknown[]) => state.loggerInfoCalls.push(args), warn: (...args: unknown[]) => state.loggerWarnCalls.push(args), @@ -85,7 +85,7 @@ vi.mock("../reconnect.js", () => ({ newConnectionId: () => "run-1", })); -vi.mock("../outbound.js", () => ({ +vi.mock("../send.js", () => ({ sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })), })); diff --git a/src/web/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts similarity index 89% rename from src/web/auto-reply/heartbeat-runner.ts rename to extensions/whatsapp/src/auto-reply/heartbeat-runner.ts index e393339a781..0b423a3f116 100644 --- a/src/web/auto-reply/heartbeat-runner.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts @@ -1,27 +1,30 @@ -import { appendCronStyleCurrentTimeLine } from "../../agents/current-time.js"; -import { resolveHeartbeatReplyPayload } from "../../auto-reply/heartbeat-reply-payload.js"; +import { appendCronStyleCurrentTimeLine } from "../../../../src/agents/current-time.js"; +import { resolveHeartbeatReplyPayload } from "../../../../src/auto-reply/heartbeat-reply-payload.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, resolveHeartbeatPrompt, stripHeartbeatToken, -} from "../../auto-reply/heartbeat.js"; -import { getReplyFromConfig } from "../../auto-reply/reply.js"; -import { HEARTBEAT_TOKEN } from "../../auto-reply/tokens.js"; -import { resolveWhatsAppHeartbeatRecipients } from "../../channels/plugins/whatsapp-heartbeat.js"; -import { loadConfig } from "../../config/config.js"; +} from "../../../../src/auto-reply/heartbeat.js"; +import { getReplyFromConfig } from "../../../../src/auto-reply/reply.js"; +import { HEARTBEAT_TOKEN } from "../../../../src/auto-reply/tokens.js"; +import { resolveWhatsAppHeartbeatRecipients } from "../../../../src/channels/plugins/whatsapp-heartbeat.js"; +import { loadConfig } from "../../../../src/config/config.js"; import { loadSessionStore, resolveSessionKey, resolveStorePath, updateSessionStore, -} from "../../config/sessions.js"; -import { emitHeartbeatEvent, resolveIndicatorType } from "../../infra/heartbeat-events.js"; -import { resolveHeartbeatVisibility } from "../../infra/heartbeat-visibility.js"; -import { getChildLogger } from "../../logging.js"; -import { redactIdentifier } from "../../logging/redact-identifier.js"; -import { normalizeMainKey } from "../../routing/session-key.js"; -import { sendMessageWhatsApp } from "../outbound.js"; +} from "../../../../src/config/sessions.js"; +import { + emitHeartbeatEvent, + resolveIndicatorType, +} from "../../../../src/infra/heartbeat-events.js"; +import { resolveHeartbeatVisibility } from "../../../../src/infra/heartbeat-visibility.js"; +import { getChildLogger } from "../../../../src/logging.js"; +import { redactIdentifier } from "../../../../src/logging/redact-identifier.js"; +import { normalizeMainKey } from "../../../../src/routing/session-key.js"; import { newConnectionId } from "../reconnect.js"; +import { sendMessageWhatsApp } from "../send.js"; import { formatError } from "../session.js"; import { whatsappHeartbeatLog } from "./loggers.js"; import { getSessionSnapshot } from "./session-snapshot.js"; diff --git a/src/web/auto-reply/loggers.ts b/extensions/whatsapp/src/auto-reply/loggers.ts similarity index 78% rename from src/web/auto-reply/loggers.ts rename to extensions/whatsapp/src/auto-reply/loggers.ts index b5272289325..71575671b2e 100644 --- a/src/web/auto-reply/loggers.ts +++ b/extensions/whatsapp/src/auto-reply/loggers.ts @@ -1,4 +1,4 @@ -import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; export const whatsappLog = createSubsystemLogger("gateway/channels/whatsapp"); export const whatsappInboundLog = whatsappLog.child("inbound"); diff --git a/src/web/auto-reply/mentions.ts b/extensions/whatsapp/src/auto-reply/mentions.ts similarity index 95% rename from src/web/auto-reply/mentions.ts rename to extensions/whatsapp/src/auto-reply/mentions.ts index f595bd2f0a2..3891810c617 100644 --- a/src/web/auto-reply/mentions.ts +++ b/extensions/whatsapp/src/auto-reply/mentions.ts @@ -1,6 +1,9 @@ -import { buildMentionRegexes, normalizeMentionText } from "../../auto-reply/reply/mentions.js"; -import type { loadConfig } from "../../config/config.js"; -import { isSelfChatMode, jidToE164, normalizeE164 } from "../../utils.js"; +import { + buildMentionRegexes, + normalizeMentionText, +} from "../../../../src/auto-reply/reply/mentions.js"; +import type { loadConfig } from "../../../../src/config/config.js"; +import { isSelfChatMode, jidToE164, normalizeE164 } from "../../../../src/utils.js"; import type { WebInboundMsg } from "./types.js"; export type MentionConfig = { diff --git a/src/web/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts similarity index 92% rename from src/web/auto-reply/monitor.ts rename to extensions/whatsapp/src/auto-reply/monitor.ts index a9ef2f4b229..1222c69b71a 100644 --- a/src/web/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -1,18 +1,18 @@ -import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { resolveInboundDebounceMs } from "../../auto-reply/inbound-debounce.js"; -import { getReplyFromConfig } from "../../auto-reply/reply.js"; -import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js"; -import { formatCliCommand } from "../../cli/command-format.js"; -import { waitForever } from "../../cli/wait.js"; -import { loadConfig } from "../../config/config.js"; -import { createConnectedChannelStatusPatch } from "../../gateway/channel-status-patches.js"; -import { logVerbose } from "../../globals.js"; -import { formatDurationPrecise } from "../../infra/format-time/format-duration.ts"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js"; -import { getChildLogger } from "../../logging.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; +import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js"; +import { resolveInboundDebounceMs } from "../../../../src/auto-reply/inbound-debounce.js"; +import { getReplyFromConfig } from "../../../../src/auto-reply/reply.js"; +import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js"; +import { formatCliCommand } from "../../../../src/cli/command-format.js"; +import { waitForever } from "../../../../src/cli/wait.js"; +import { loadConfig } from "../../../../src/config/config.js"; +import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { formatDurationPrecise } from "../../../../src/infra/format-time/format-duration.ts"; +import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; +import { registerUnhandledRejectionHandler } from "../../../../src/infra/unhandled-rejections.js"; +import { getChildLogger } from "../../../../src/logging.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +import { defaultRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "../accounts.js"; import { setActiveWebListener } from "../active-listener.js"; import { monitorWebInbox } from "../inbound.js"; diff --git a/src/web/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts similarity index 88% rename from src/web/auto-reply/monitor/ack-reaction.ts rename to extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index 2ac7c56d2a4..c5a5d149ab7 100644 --- a/src/web/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -1,7 +1,7 @@ -import { shouldAckReactionForWhatsApp } from "../../../channels/ack-reactions.js"; -import type { loadConfig } from "../../../config/config.js"; -import { logVerbose } from "../../../globals.js"; -import { sendReactionWhatsApp } from "../../outbound.js"; +import { shouldAckReactionForWhatsApp } from "../../../../../src/channels/ack-reactions.js"; +import type { loadConfig } from "../../../../../src/config/config.js"; +import { logVerbose } from "../../../../../src/globals.js"; +import { sendReactionWhatsApp } from "../../send.js"; import { formatError } from "../../session.js"; import type { WebInboundMsg } from "../types.js"; import { resolveGroupActivationFor } from "./group-activation.js"; diff --git a/src/web/auto-reply/monitor/broadcast.ts b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts similarity index 91% rename from src/web/auto-reply/monitor/broadcast.ts rename to extensions/whatsapp/src/auto-reply/monitor/broadcast.ts index 1dc51bef179..b00ba7aff9b 100644 --- a/src/web/auto-reply/monitor/broadcast.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts @@ -1,11 +1,14 @@ -import type { loadConfig } from "../../../config/config.js"; -import type { resolveAgentRoute } from "../../../routing/resolve-route.js"; -import { buildAgentSessionKey, deriveLastRoutePolicy } from "../../../routing/resolve-route.js"; +import type { loadConfig } from "../../../../../src/config/config.js"; +import type { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; +import { + buildAgentSessionKey, + deriveLastRoutePolicy, +} from "../../../../../src/routing/resolve-route.js"; import { buildAgentMainSessionKey, DEFAULT_MAIN_KEY, normalizeAgentId, -} from "../../../routing/session-key.js"; +} from "../../../../../src/routing/session-key.js"; import { formatError } from "../../session.js"; import { whatsappInboundLog } from "../loggers.js"; import type { WebInboundMsg } from "../types.js"; diff --git a/src/web/auto-reply/monitor/commands.ts b/extensions/whatsapp/src/auto-reply/monitor/commands.ts similarity index 100% rename from src/web/auto-reply/monitor/commands.ts rename to extensions/whatsapp/src/auto-reply/monitor/commands.ts diff --git a/src/web/auto-reply/monitor/echo.ts b/extensions/whatsapp/src/auto-reply/monitor/echo.ts similarity index 100% rename from src/web/auto-reply/monitor/echo.ts rename to extensions/whatsapp/src/auto-reply/monitor/echo.ts diff --git a/src/web/auto-reply/monitor/group-activation.ts b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts similarity index 86% rename from src/web/auto-reply/monitor/group-activation.ts rename to extensions/whatsapp/src/auto-reply/monitor/group-activation.ts index 01f96e94528..60b15f5b3c6 100644 --- a/src/web/auto-reply/monitor/group-activation.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts @@ -1,14 +1,14 @@ -import { normalizeGroupActivation } from "../../../auto-reply/group-activation.js"; -import type { loadConfig } from "../../../config/config.js"; +import { normalizeGroupActivation } from "../../../../../src/auto-reply/group-activation.js"; +import type { loadConfig } from "../../../../../src/config/config.js"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, -} from "../../../config/group-policy.js"; +} from "../../../../../src/config/group-policy.js"; import { loadSessionStore, resolveGroupSessionKey, resolveStorePath, -} from "../../../config/sessions.js"; +} from "../../../../../src/config/sessions.js"; export function resolveGroupPolicyFor(cfg: ReturnType, conversationId: string) { const groupId = resolveGroupSessionKey({ diff --git a/src/web/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts similarity index 91% rename from src/web/auto-reply/monitor/group-gating.ts rename to extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index d1867ed24b0..418d5ebee83 100644 --- a/src/web/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -1,9 +1,9 @@ -import { hasControlCommand } from "../../../auto-reply/command-detection.js"; -import { parseActivationCommand } from "../../../auto-reply/group-activation.js"; -import { recordPendingHistoryEntryIfEnabled } from "../../../auto-reply/reply/history.js"; -import { resolveMentionGating } from "../../../channels/mention-gating.js"; -import type { loadConfig } from "../../../config/config.js"; -import { normalizeE164 } from "../../../utils.js"; +import { hasControlCommand } from "../../../../../src/auto-reply/command-detection.js"; +import { parseActivationCommand } from "../../../../../src/auto-reply/group-activation.js"; +import { recordPendingHistoryEntryIfEnabled } from "../../../../../src/auto-reply/reply/history.js"; +import { resolveMentionGating } from "../../../../../src/channels/mention-gating.js"; +import type { loadConfig } from "../../../../../src/config/config.js"; +import { normalizeE164 } from "../../../../../src/utils.js"; import type { MentionConfig } from "../mentions.js"; import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js"; import type { WebInboundMsg } from "../types.js"; diff --git a/src/web/auto-reply/monitor/group-members.test.ts b/extensions/whatsapp/src/auto-reply/monitor/group-members.test.ts similarity index 100% rename from src/web/auto-reply/monitor/group-members.test.ts rename to extensions/whatsapp/src/auto-reply/monitor/group-members.test.ts diff --git a/src/web/auto-reply/monitor/group-members.ts b/extensions/whatsapp/src/auto-reply/monitor/group-members.ts similarity index 96% rename from src/web/auto-reply/monitor/group-members.ts rename to extensions/whatsapp/src/auto-reply/monitor/group-members.ts index 5564c4b87cf..fc2d541bcf5 100644 --- a/src/web/auto-reply/monitor/group-members.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-members.ts @@ -1,4 +1,4 @@ -import { normalizeE164 } from "../../../utils.js"; +import { normalizeE164 } from "../../../../../src/utils.js"; function appendNormalizedUnique(entries: Iterable, seen: Set, ordered: string[]) { for (const entry of entries) { diff --git a/src/web/auto-reply/monitor/last-route.ts b/extensions/whatsapp/src/auto-reply/monitor/last-route.ts similarity index 85% rename from src/web/auto-reply/monitor/last-route.ts rename to extensions/whatsapp/src/auto-reply/monitor/last-route.ts index 2943537e1cf..9fbe17d104d 100644 --- a/src/web/auto-reply/monitor/last-route.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/last-route.ts @@ -1,6 +1,6 @@ -import type { MsgContext } from "../../../auto-reply/templating.js"; -import type { loadConfig } from "../../../config/config.js"; -import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; +import type { MsgContext } from "../../../../../src/auto-reply/templating.js"; +import type { loadConfig } from "../../../../../src/config/config.js"; +import { resolveStorePath, updateLastRoute } from "../../../../../src/config/sessions.js"; import { formatError } from "../../session.js"; export function trackBackgroundTask( diff --git a/src/web/auto-reply/monitor/message-line.ts b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts similarity index 85% rename from src/web/auto-reply/monitor/message-line.ts rename to extensions/whatsapp/src/auto-reply/monitor/message-line.ts index ba99766aedf..299d5868bf8 100644 --- a/src/web/auto-reply/monitor/message-line.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts @@ -1,6 +1,9 @@ -import { resolveMessagePrefix } from "../../../agents/identity.js"; -import { formatInboundEnvelope, type EnvelopeFormatOptions } from "../../../auto-reply/envelope.js"; -import type { loadConfig } from "../../../config/config.js"; +import { resolveMessagePrefix } from "../../../../../src/agents/identity.js"; +import { + formatInboundEnvelope, + type EnvelopeFormatOptions, +} from "../../../../../src/auto-reply/envelope.js"; +import type { loadConfig } from "../../../../../src/config/config.js"; import type { WebInboundMsg } from "../types.js"; export function formatReplyContext(msg: WebInboundMsg) { diff --git a/src/web/auto-reply/monitor/on-message.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts similarity index 89% rename from src/web/auto-reply/monitor/on-message.ts rename to extensions/whatsapp/src/auto-reply/monitor/on-message.ts index 947a56603e8..caa519f5cf0 100644 --- a/src/web/auto-reply/monitor/on-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts @@ -1,10 +1,10 @@ -import type { getReplyFromConfig } from "../../../auto-reply/reply.js"; -import type { MsgContext } from "../../../auto-reply/templating.js"; -import { loadConfig } from "../../../config/config.js"; -import { logVerbose } from "../../../globals.js"; -import { resolveAgentRoute } from "../../../routing/resolve-route.js"; -import { buildGroupHistoryKey } from "../../../routing/session-key.js"; -import { normalizeE164 } from "../../../utils.js"; +import type { getReplyFromConfig } from "../../../../../src/auto-reply/reply.js"; +import type { MsgContext } from "../../../../../src/auto-reply/templating.js"; +import { loadConfig } from "../../../../../src/config/config.js"; +import { logVerbose } from "../../../../../src/globals.js"; +import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; +import { buildGroupHistoryKey } from "../../../../../src/routing/session-key.js"; +import { normalizeE164 } from "../../../../../src/utils.js"; import type { MentionConfig } from "../mentions.js"; import type { WebInboundMsg } from "../types.js"; import { maybeBroadcastMessage } from "./broadcast.js"; @@ -26,7 +26,7 @@ export function createWebOnMessageHandler(params: { echoTracker: EchoTracker; backgroundTasks: Set>; replyResolver: typeof getReplyFromConfig; - replyLogger: ReturnType<(typeof import("../../../logging.js"))["getChildLogger"]>; + replyLogger: ReturnType<(typeof import("../../../../../src/logging.js"))["getChildLogger"]>; baseMentionConfig: MentionConfig; account: { authDir?: string; accountId?: string }; }) { diff --git a/src/web/auto-reply/monitor/peer.ts b/extensions/whatsapp/src/auto-reply/monitor/peer.ts similarity index 84% rename from src/web/auto-reply/monitor/peer.ts rename to extensions/whatsapp/src/auto-reply/monitor/peer.ts index b41555ffa26..7795ac7c4d1 100644 --- a/src/web/auto-reply/monitor/peer.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/peer.ts @@ -1,4 +1,4 @@ -import { jidToE164, normalizeE164 } from "../../../utils.js"; +import { jidToE164, normalizeE164 } from "../../../../../src/utils.js"; import type { WebInboundMsg } from "../types.js"; export function resolvePeerId(msg: WebInboundMsg) { diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts similarity index 95% rename from src/web/auto-reply/monitor/process-message.inbound-contract.test.ts rename to extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts index 1a02f2d5f93..85b784d03a8 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; +import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js"; let capturedCtx: unknown; let capturedDispatchParams: unknown; @@ -72,7 +72,7 @@ function createWhatsAppDirectStreamingArgs(params?: { channels: { whatsapp: { blockStreaming: true } }, messages: {}, session: { store: sessionStorePath }, - } as unknown as ReturnType, + } as unknown as ReturnType, msg: { id: "msg1", from: "+1555", @@ -83,7 +83,7 @@ function createWhatsAppDirectStreamingArgs(params?: { }); } -vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ +vi.mock("../../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ // oxlint-disable-next-line typescript/no-explicit-any dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: any) => { capturedDispatchParams = params; @@ -222,7 +222,7 @@ describe("web processMessage inbound contract", () => { }, messages: {}, session: { store: sessionStorePath }, - } as unknown as ReturnType); + } as unknown as ReturnType); expect(getDispatcherResponsePrefix()).toBe("[Mainbot]"); }); @@ -231,7 +231,7 @@ describe("web processMessage inbound contract", () => { await processSelfDirectMessage({ messages: {}, session: { store: sessionStorePath }, - } as unknown as ReturnType); + } as unknown as ReturnType); expect(getDispatcherResponsePrefix()).toBeUndefined(); }); @@ -258,7 +258,7 @@ describe("web processMessage inbound contract", () => { cfg: { messages: {}, session: { store: sessionStorePath }, - } as unknown as ReturnType, + } as unknown as ReturnType, msg: { id: "g1", from: "123@g.us", @@ -378,7 +378,7 @@ describe("web processMessage inbound contract", () => { }, messages: {}, session: { store: sessionStorePath, dmScope: "main" }, - } as unknown as ReturnType, + } as unknown as ReturnType, msg: { id: params.messageId, from: params.from, diff --git a/src/web/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts similarity index 90% rename from src/web/auto-reply/monitor/process-message.ts rename to extensions/whatsapp/src/auto-reply/monitor/process-message.ts index b9e7993779e..094e4570bdb 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -1,34 +1,34 @@ -import { resolveIdentityNamePrefix } from "../../../agents/identity.js"; -import { resolveChunkMode, resolveTextChunkLimit } from "../../../auto-reply/chunk.js"; -import { shouldComputeCommandAuthorized } from "../../../auto-reply/command-detection.js"; -import { formatInboundEnvelope } from "../../../auto-reply/envelope.js"; -import type { getReplyFromConfig } from "../../../auto-reply/reply.js"; +import { resolveIdentityNamePrefix } from "../../../../../src/agents/identity.js"; +import { resolveChunkMode, resolveTextChunkLimit } from "../../../../../src/auto-reply/chunk.js"; +import { shouldComputeCommandAuthorized } from "../../../../../src/auto-reply/command-detection.js"; +import { formatInboundEnvelope } from "../../../../../src/auto-reply/envelope.js"; +import type { getReplyFromConfig } from "../../../../../src/auto-reply/reply.js"; import { buildHistoryContextFromEntries, type HistoryEntry, -} from "../../../auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { toLocationContext } from "../../../channels/location.js"; -import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js"; -import { resolveInboundSessionEnvelopeContext } from "../../../channels/session-envelope.js"; -import type { loadConfig } from "../../../config/config.js"; -import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js"; -import { recordSessionMetaFromInbound } from "../../../config/sessions.js"; -import { logVerbose, shouldLogVerbose } from "../../../globals.js"; -import type { getChildLogger } from "../../../logging.js"; -import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; +} from "../../../../../src/auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../../../../src/auto-reply/reply/provider-dispatcher.js"; +import type { ReplyPayload } from "../../../../../src/auto-reply/types.js"; +import { toLocationContext } from "../../../../../src/channels/location.js"; +import { createReplyPrefixOptions } from "../../../../../src/channels/reply-prefix.js"; +import { resolveInboundSessionEnvelopeContext } from "../../../../../src/channels/session-envelope.js"; +import type { loadConfig } from "../../../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../../../src/config/markdown-tables.js"; +import { recordSessionMetaFromInbound } from "../../../../../src/config/sessions.js"; +import { logVerbose, shouldLogVerbose } from "../../../../../src/globals.js"; +import type { getChildLogger } from "../../../../../src/logging.js"; +import { getAgentScopedMediaLocalRoots } from "../../../../../src/media/local-roots.js"; import { resolveInboundLastRouteSessionKey, type resolveAgentRoute, -} from "../../../routing/resolve-route.js"; +} from "../../../../../src/routing/resolve-route.js"; import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, resolveDmGroupAccessWithCommandGate, -} from "../../../security/dm-policy-shared.js"; -import { jidToE164, normalizeE164 } from "../../../utils.js"; +} from "../../../../../src/security/dm-policy-shared.js"; +import { jidToE164, normalizeE164 } from "../../../../../src/utils.js"; import { resolveWhatsAppAccount } from "../../accounts.js"; import { newConnectionId } from "../../reconnect.js"; import { formatError } from "../../session.js"; diff --git a/src/web/auto-reply/session-snapshot.ts b/extensions/whatsapp/src/auto-reply/session-snapshot.ts similarity index 90% rename from src/web/auto-reply/session-snapshot.ts rename to extensions/whatsapp/src/auto-reply/session-snapshot.ts index 12a5619e639..53b7e3ae615 100644 --- a/src/web/auto-reply/session-snapshot.ts +++ b/extensions/whatsapp/src/auto-reply/session-snapshot.ts @@ -1,4 +1,4 @@ -import type { loadConfig } from "../../config/config.js"; +import type { loadConfig } from "../../../../src/config/config.js"; import { evaluateSessionFreshness, loadSessionStore, @@ -8,8 +8,8 @@ import { resolveSessionResetType, resolveSessionKey, resolveStorePath, -} from "../../config/sessions.js"; -import { normalizeMainKey } from "../../routing/session-key.js"; +} from "../../../../src/config/sessions.js"; +import { normalizeMainKey } from "../../../../src/routing/session-key.js"; export function getSessionSnapshot( cfg: ReturnType, diff --git a/src/web/auto-reply/types.ts b/extensions/whatsapp/src/auto-reply/types.ts similarity index 100% rename from src/web/auto-reply/types.ts rename to extensions/whatsapp/src/auto-reply/types.ts diff --git a/src/web/auto-reply/util.ts b/extensions/whatsapp/src/auto-reply/util.ts similarity index 100% rename from src/web/auto-reply/util.ts rename to extensions/whatsapp/src/auto-reply/util.ts diff --git a/src/web/auto-reply/web-auto-reply-monitor.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts similarity index 97% rename from src/web/auto-reply/web-auto-reply-monitor.test.ts rename to extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts index 925d430de9c..412648b3180 100644 --- a/src/web/auto-reply/web-auto-reply-monitor.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; import { buildMentionConfig } from "./mentions.js"; import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js"; import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js"; @@ -33,10 +33,10 @@ const makeConfig = (overrides: Record) => }, session: { store: sessionStorePath }, ...overrides, - }) as unknown as ReturnType; + }) as unknown as ReturnType; function runGroupGating(params: { - cfg: ReturnType; + cfg: ReturnType; msg: Record; conversationId?: string; agentId?: string; diff --git a/src/web/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts similarity index 98% rename from src/web/auto-reply/web-auto-reply-utils.test.ts rename to extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index bb7f27f3a93..0107fa126d7 100644 --- a/src/web/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { saveSessionStore } from "../../config/sessions.js"; -import { withTempDir } from "../../test-utils/temp-dir.js"; +import { saveSessionStore } from "../../../../src/config/sessions.js"; +import { withTempDir } from "../../../../src/test-utils/temp-dir.js"; import { debugMention, isBotMentionedFromTargets, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 5be1ba412b0..28de41a9fea 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -6,24 +6,18 @@ import { import { applyAccountNameToChannelSection, buildChannelConfigSchema, - collectWhatsAppStatusIssues, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, getChatChannelMeta, - listWhatsAppAccountIds, listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, - looksLikeWhatsAppTargetId, migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeE164, formatWhatsAppConfigAllowFromEntries, - normalizeWhatsAppMessagingTarget, readStringParam, - resolveDefaultWhatsAppAccountId, resolveWhatsAppOutboundTarget, - resolveWhatsAppAccount, resolveWhatsAppConfigAllowFrom, resolveWhatsAppConfigDefaultTo, resolveWhatsAppGroupRequireMention, @@ -31,13 +25,21 @@ import { resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripPatterns, - whatsappOnboardingAdapter, WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, - type ResolvedWhatsAppAccount, } from "openclaw/plugin-sdk/whatsapp"; +// WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; +import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; +import { whatsappOnboardingAdapter } from "./onboarding.js"; import { getWhatsAppRuntime } from "./runtime.js"; +import { collectWhatsAppStatusIssues } from "./status-issues.js"; const meta = getChatChannelMeta("whatsapp"); diff --git a/src/web/inbound.media.test.ts b/extensions/whatsapp/src/inbound.media.test.ts similarity index 95% rename from src/web/inbound.media.test.ts rename to extensions/whatsapp/src/inbound.media.test.ts index 82cc0fb83d0..7ed52cace45 100644 --- a/src/web/inbound.media.test.ts +++ b/extensions/whatsapp/src/inbound.media.test.ts @@ -8,8 +8,8 @@ const readAllowFromStoreMock = vi.fn().mockResolvedValue([]); const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true }); const saveMediaBufferSpy = vi.fn(); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: vi.fn().mockReturnValue({ @@ -26,7 +26,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("../pairing/pairing-store.js", () => { +vi.mock("../../../src/pairing/pairing-store.js", () => { return { readChannelAllowFromStore(...args: unknown[]) { return readAllowFromStoreMock(...args); @@ -37,8 +37,8 @@ vi.mock("../pairing/pairing-store.js", () => { }; }); -vi.mock("../media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/media/store.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, saveMediaBuffer: vi.fn(async (...args: Parameters) => { diff --git a/src/web/inbound.test.ts b/extensions/whatsapp/src/inbound.test.ts similarity index 100% rename from src/web/inbound.test.ts rename to extensions/whatsapp/src/inbound.test.ts diff --git a/src/web/inbound.ts b/extensions/whatsapp/src/inbound.ts similarity index 100% rename from src/web/inbound.ts rename to extensions/whatsapp/src/inbound.ts diff --git a/src/web/inbound/access-control.group-policy.test.ts b/extensions/whatsapp/src/inbound/access-control.group-policy.test.ts similarity index 91% rename from src/web/inbound/access-control.group-policy.test.ts rename to extensions/whatsapp/src/inbound/access-control.group-policy.test.ts index 9b546f7a423..0a508f9739b 100644 --- a/src/web/inbound/access-control.group-policy.test.ts +++ b/extensions/whatsapp/src/inbound/access-control.group-policy.test.ts @@ -1,5 +1,5 @@ import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; import { __testing } from "./access-control.js"; describe("resolveWhatsAppRuntimeGroupPolicy", () => { diff --git a/src/web/inbound/access-control.test-harness.ts b/extensions/whatsapp/src/inbound/access-control.test-harness.ts similarity index 85% rename from src/web/inbound/access-control.test-harness.ts rename to extensions/whatsapp/src/inbound/access-control.test-harness.ts index 23213ceefcd..a8bf7a9df19 100644 --- a/src/web/inbound/access-control.test-harness.ts +++ b/extensions/whatsapp/src/inbound/access-control.test-harness.ts @@ -33,15 +33,15 @@ export function setupAccessControlTestHarness(): void { }); } -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => config, }; }); -vi.mock("../../pairing/pairing-store.js", () => ({ +vi.mock("../../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), })); diff --git a/src/web/inbound/access-control.test.ts b/extensions/whatsapp/src/inbound/access-control.test.ts similarity index 100% rename from src/web/inbound/access-control.test.ts rename to extensions/whatsapp/src/inbound/access-control.test.ts diff --git a/src/web/inbound/access-control.ts b/extensions/whatsapp/src/inbound/access-control.ts similarity index 94% rename from src/web/inbound/access-control.ts rename to extensions/whatsapp/src/inbound/access-control.ts index a01e27fb6e0..ee81e119392 100644 --- a/src/web/inbound/access-control.ts +++ b/extensions/whatsapp/src/inbound/access-control.ts @@ -1,17 +1,17 @@ -import { loadConfig } from "../../config/config.js"; +import { loadConfig } from "../../../../src/config/config.js"; import { resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../../config/runtime-group-policy.js"; -import { logVerbose } from "../../globals.js"; -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; +} from "../../../../src/config/runtime-group-policy.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, -} from "../../security/dm-policy-shared.js"; -import { isSelfChatMode, normalizeE164 } from "../../utils.js"; +} from "../../../../src/security/dm-policy-shared.js"; +import { isSelfChatMode, normalizeE164 } from "../../../../src/utils.js"; import { resolveWhatsAppAccount } from "../accounts.js"; export type InboundAccessControlResult = { diff --git a/src/web/inbound/dedupe.ts b/extensions/whatsapp/src/inbound/dedupe.ts similarity index 85% rename from src/web/inbound/dedupe.ts rename to extensions/whatsapp/src/inbound/dedupe.ts index def359ec949..9d20a25b8c4 100644 --- a/src/web/inbound/dedupe.ts +++ b/extensions/whatsapp/src/inbound/dedupe.ts @@ -1,4 +1,4 @@ -import { createDedupeCache } from "../../infra/dedupe.js"; +import { createDedupeCache } from "../../../../src/infra/dedupe.js"; const RECENT_WEB_MESSAGE_TTL_MS = 20 * 60_000; const RECENT_WEB_MESSAGE_MAX = 5000; diff --git a/src/web/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts similarity index 98% rename from src/web/inbound/extract.ts rename to extensions/whatsapp/src/inbound/extract.ts index 2cd9b8eb38c..a34937c9793 100644 --- a/src/web/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -4,9 +4,9 @@ import { getContentType, normalizeMessageContent, } from "@whiskeysockets/baileys"; -import { formatLocationText, type NormalizedLocation } from "../../channels/location.js"; -import { logVerbose } from "../../globals.js"; -import { jidToE164 } from "../../utils.js"; +import { formatLocationText, type NormalizedLocation } from "../../../../src/channels/location.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { jidToE164 } from "../../../../src/utils.js"; import { parseVcard } from "../vcard.js"; function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined { diff --git a/src/web/inbound/media.node.test.ts b/extensions/whatsapp/src/inbound/media.node.test.ts similarity index 100% rename from src/web/inbound/media.node.test.ts rename to extensions/whatsapp/src/inbound/media.node.test.ts diff --git a/src/web/inbound/media.ts b/extensions/whatsapp/src/inbound/media.ts similarity index 97% rename from src/web/inbound/media.ts rename to extensions/whatsapp/src/inbound/media.ts index d6f7d534671..9f2fe70698a 100644 --- a/src/web/inbound/media.ts +++ b/extensions/whatsapp/src/inbound/media.ts @@ -1,6 +1,6 @@ import type { proto, WAMessage } from "@whiskeysockets/baileys"; import { downloadMediaMessage, normalizeMessageContent } from "@whiskeysockets/baileys"; -import { logVerbose } from "../../globals.js"; +import { logVerbose } from "../../../../src/globals.js"; import type { createWaSocket } from "../session.js"; function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined { diff --git a/src/web/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts similarity index 96% rename from src/web/inbound/monitor.ts rename to extensions/whatsapp/src/inbound/monitor.ts index 6dc2ce5f521..4f2d5541b6a 100644 --- a/src/web/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -1,13 +1,13 @@ import type { AnyMessageContent, proto, WAMessage } from "@whiskeysockets/baileys"; import { DisconnectReason, isJidGroup } from "@whiskeysockets/baileys"; -import { createInboundDebouncer } from "../../auto-reply/inbound-debounce.js"; -import { formatLocationText } from "../../channels/location.js"; -import { logVerbose, shouldLogVerbose } from "../../globals.js"; -import { recordChannelActivity } from "../../infra/channel-activity.js"; -import { getChildLogger } from "../../logging/logger.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { saveMediaBuffer } from "../../media/store.js"; -import { jidToE164, resolveJidToE164 } from "../../utils.js"; +import { createInboundDebouncer } from "../../../../src/auto-reply/inbound-debounce.js"; +import { formatLocationText } from "../../../../src/channels/location.js"; +import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; +import { recordChannelActivity } from "../../../../src/infra/channel-activity.js"; +import { getChildLogger } from "../../../../src/logging/logger.js"; +import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; +import { saveMediaBuffer } from "../../../../src/media/store.js"; +import { jidToE164, resolveJidToE164 } from "../../../../src/utils.js"; import { createWaSocket, getStatusCode, waitForWaConnection } from "../session.js"; import { checkInboundAccessControl } from "./access-control.js"; import { isRecentInboundMessage } from "./dedupe.js"; diff --git a/src/web/inbound/send-api.test.ts b/extensions/whatsapp/src/inbound/send-api.test.ts similarity index 98% rename from src/web/inbound/send-api.test.ts rename to extensions/whatsapp/src/inbound/send-api.test.ts index daa44a3c69f..e7bfcdce360 100644 --- a/src/web/inbound/send-api.test.ts +++ b/extensions/whatsapp/src/inbound/send-api.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const recordChannelActivity = vi.fn(); -vi.mock("../../infra/channel-activity.js", () => ({ +vi.mock("../../../../src/infra/channel-activity.js", () => ({ recordChannelActivity: (...args: unknown[]) => recordChannelActivity(...args), })); diff --git a/src/web/inbound/send-api.ts b/extensions/whatsapp/src/inbound/send-api.ts similarity index 96% rename from src/web/inbound/send-api.ts rename to extensions/whatsapp/src/inbound/send-api.ts index f0e5ea764fa..a5619383415 100644 --- a/src/web/inbound/send-api.ts +++ b/extensions/whatsapp/src/inbound/send-api.ts @@ -1,6 +1,6 @@ import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys"; -import { recordChannelActivity } from "../../infra/channel-activity.js"; -import { toWhatsappJid } from "../../utils.js"; +import { recordChannelActivity } from "../../../../src/infra/channel-activity.js"; +import { toWhatsappJid } from "../../../../src/utils.js"; import type { ActiveWebSendOptions } from "../active-listener.js"; function recordWhatsAppOutbound(accountId: string) { diff --git a/src/web/inbound/types.ts b/extensions/whatsapp/src/inbound/types.ts similarity index 93% rename from src/web/inbound/types.ts rename to extensions/whatsapp/src/inbound/types.ts index c9b49e945b5..c9c97810bad 100644 --- a/src/web/inbound/types.ts +++ b/extensions/whatsapp/src/inbound/types.ts @@ -1,5 +1,5 @@ import type { AnyMessageContent } from "@whiskeysockets/baileys"; -import type { NormalizedLocation } from "../../channels/location.js"; +import type { NormalizedLocation } from "../../../../src/channels/location.js"; export type WebListenerCloseReason = { status?: number; diff --git a/src/web/login-qr.test.ts b/extensions/whatsapp/src/login-qr.test.ts similarity index 100% rename from src/web/login-qr.test.ts rename to extensions/whatsapp/src/login-qr.test.ts diff --git a/src/web/login-qr.ts b/extensions/whatsapp/src/login-qr.ts similarity index 97% rename from src/web/login-qr.ts rename to extensions/whatsapp/src/login-qr.ts index f913bf4d04b..a54e3fe56b2 100644 --- a/src/web/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import { DisconnectReason } from "@whiskeysockets/baileys"; -import { loadConfig } from "../config/config.js"; -import { danger, info, success } from "../globals.js"; -import { logInfo } from "../logger.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { danger, info, success } from "../../../src/globals.js"; +import { logInfo } from "../../../src/logger.js"; +import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js"; import { resolveWhatsAppAccount } from "./accounts.js"; import { renderQrPngBase64 } from "./qr-image.js"; import { diff --git a/src/web/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts similarity index 98% rename from src/web/login.coverage.test.ts rename to extensions/whatsapp/src/login.coverage.test.ts index 8b3673006eb..6306228693a 100644 --- a/src/web/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -14,7 +14,7 @@ function resolveTestAuthDir() { const authDir = resolveTestAuthDir(); -vi.mock("../config/config.js", () => ({ +vi.mock("../../../src/config/config.js", () => ({ loadConfig: () => ({ channels: { diff --git a/src/web/login.test.ts b/extensions/whatsapp/src/login.test.ts similarity index 93% rename from src/web/login.test.ts rename to extensions/whatsapp/src/login.test.ts index 545c47af9a6..96a9cff2c10 100644 --- a/src/web/login.test.ts +++ b/extensions/whatsapp/src/login.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../logging.js"; +import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; import { renderQrPngBase64 } from "./qr-image.js"; vi.mock("./session.js", () => { @@ -61,7 +61,7 @@ describe("renderQrPngBase64", () => { }); it("avoids dynamic require of qrcode-terminal vendor modules", async () => { - const sourcePath = resolve(process.cwd(), "src/web/qr-image.ts"); + const sourcePath = resolve(process.cwd(), "extensions/whatsapp/src/qr-image.ts"); const source = await readFile(sourcePath, "utf-8"); expect(source).not.toContain("createRequire("); expect(source).not.toContain('require("qrcode-terminal/vendor/QRCode")'); diff --git a/src/web/login.ts b/extensions/whatsapp/src/login.ts similarity index 88% rename from src/web/login.ts rename to extensions/whatsapp/src/login.ts index b336f8ebe4f..3eae0732c5d 100644 --- a/src/web/login.ts +++ b/extensions/whatsapp/src/login.ts @@ -1,9 +1,9 @@ import { DisconnectReason } from "@whiskeysockets/baileys"; -import { formatCliCommand } from "../cli/command-format.js"; -import { loadConfig } from "../config/config.js"; -import { danger, info, success } from "../globals.js"; -import { logInfo } from "../logger.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { danger, info, success } from "../../../src/globals.js"; +import { logInfo } from "../../../src/logger.js"; +import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js"; import { resolveWhatsAppAccount } from "./accounts.js"; import { createWaSocket, formatError, logoutWeb, waitForWaConnection } from "./session.js"; diff --git a/src/web/logout.test.ts b/extensions/whatsapp/src/logout.test.ts similarity index 100% rename from src/web/logout.test.ts rename to extensions/whatsapp/src/logout.test.ts diff --git a/src/web/media.test.ts b/extensions/whatsapp/src/media.test.ts similarity index 96% rename from src/web/media.test.ts rename to extensions/whatsapp/src/media.test.ts index 27a7d6ccb19..e21d58b4bb7 100644 --- a/src/web/media.test.ts +++ b/extensions/whatsapp/src/media.test.ts @@ -3,12 +3,12 @@ import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { resolveStateDir } from "../config/paths.js"; -import { sendVoiceMessageDiscord } from "../discord/send.js"; -import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -import { optimizeImageToPng } from "../media/image-ops.js"; -import { mockPinnedHostnameResolution } from "../test-helpers/ssrf.js"; -import { captureEnv } from "../test-utils/env.js"; +import { resolveStateDir } from "../../../src/config/paths.js"; +import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; +import { optimizeImageToPng } from "../../../src/media/image-ops.js"; +import { mockPinnedHostnameResolution } from "../../../src/test-helpers/ssrf.js"; +import { captureEnv } from "../../../src/test-utils/env.js"; +import { sendVoiceMessageDiscord } from "../../discord/src/send.js"; import { LocalMediaAccessError, loadWebMedia, @@ -18,9 +18,10 @@ import { const convertHeicToJpegMock = vi.fn(); -vi.mock("../media/image-ops.js", async () => { - const actual = - await vi.importActual("../media/image-ops.js"); +vi.mock("../../../src/media/image-ops.js", async () => { + const actual = await vi.importActual( + "../../../src/media/image-ops.js", + ); return { ...actual, convertHeicToJpeg: (...args: unknown[]) => convertHeicToJpegMock(...args), diff --git a/src/web/media.ts b/extensions/whatsapp/src/media.ts similarity index 95% rename from src/web/media.ts rename to extensions/whatsapp/src/media.ts index 200a2b03379..2b297ef8907 100644 --- a/src/web/media.ts +++ b/extensions/whatsapp/src/media.ts @@ -1,20 +1,20 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { logVerbose, shouldLogVerbose } from "../globals.js"; -import { SafeOpenError, readLocalFileSafely } from "../infra/fs-safe.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { type MediaKind, maxBytesForKind } from "../media/constants.js"; -import { fetchRemoteMedia } from "../media/fetch.js"; +import { logVerbose, shouldLogVerbose } from "../../../src/globals.js"; +import { SafeOpenError, readLocalFileSafely } from "../../../src/infra/fs-safe.js"; +import type { SsrFPolicy } from "../../../src/infra/net/ssrf.js"; +import { type MediaKind, maxBytesForKind } from "../../../src/media/constants.js"; +import { fetchRemoteMedia } from "../../../src/media/fetch.js"; import { convertHeicToJpeg, hasAlphaChannel, optimizeImageToPng, resizeToJpeg, -} from "../media/image-ops.js"; -import { getDefaultMediaLocalRoots } from "../media/local-roots.js"; -import { detectMime, extensionForMime, kindFromMime } from "../media/mime.js"; -import { resolveUserPath } from "../utils.js"; +} from "../../../src/media/image-ops.js"; +import { getDefaultMediaLocalRoots } from "../../../src/media/local-roots.js"; +import { detectMime, extensionForMime, kindFromMime } from "../../../src/media/mime.js"; +import { resolveUserPath } from "../../../src/utils.js"; export type WebMediaResult = { buffer: Buffer; diff --git a/src/web/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts b/extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts similarity index 100% rename from src/web/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts rename to extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts diff --git a/src/web/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts b/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts similarity index 100% rename from src/web/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts rename to extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts diff --git a/src/web/monitor-inbox.captures-media-path-image-messages.test.ts b/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts similarity index 99% rename from src/web/monitor-inbox.captures-media-path-image-messages.test.ts rename to extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts index 0913fb34103..d9d9593c49b 100644 --- a/src/web/monitor-inbox.captures-media-path-image-messages.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import "./monitor-inbox.test-harness.js"; import { describe, expect, it, vi } from "vitest"; -import { setLoggerOverride } from "../logging.js"; +import { setLoggerOverride } from "../../../src/logging.js"; import { monitorWebInbox } from "./inbound.js"; import { DEFAULT_ACCOUNT_ID, diff --git a/src/web/monitor-inbox.streams-inbound-messages.test.ts b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts similarity index 100% rename from src/web/monitor-inbox.streams-inbound-messages.test.ts rename to extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts diff --git a/src/web/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts similarity index 85% rename from src/web/monitor-inbox.test-harness.ts rename to extensions/whatsapp/src/monitor-inbox.test-harness.ts index a4e9f62f92b..43bc731c459 100644 --- a/src/web/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -3,7 +3,7 @@ import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../logging.js"; +import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // oxlint-disable-next-line typescript/no-explicit-any @@ -81,24 +81,28 @@ function getPairingStoreMocks() { const sock: MockSock = createMockSock(); -vi.mock("../media/store.js", () => ({ - saveMediaBuffer: vi.fn().mockResolvedValue({ - id: "mid", - path: "/tmp/mid", - size: 1, - contentType: "image/jpeg", - }), -})); +vi.mock("../../../src/media/store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveMediaBuffer: vi.fn().mockResolvedValue({ + id: "mid", + path: "/tmp/mid", + size: 1, + contentType: "image/jpeg", + }), + }; +}); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => mockLoadConfig(), }; }); -vi.mock("../pairing/pairing-store.js", () => getPairingStoreMocks()); +vi.mock("../../../src/pairing/pairing-store.js", () => getPairingStoreMocks()); vi.mock("./session.js", () => ({ createWaSocket: vi.fn().mockResolvedValue(sock), diff --git a/extensions/whatsapp/src/normalize.ts b/extensions/whatsapp/src/normalize.ts new file mode 100644 index 00000000000..319dabe25bd --- /dev/null +++ b/extensions/whatsapp/src/normalize.ts @@ -0,0 +1,28 @@ +import { + looksLikeHandleOrPhoneTarget, + trimMessagingTarget, +} from "../../../src/channels/plugins/normalize/shared.js"; +import { normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js"; + +export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined { + const trimmed = trimMessagingTarget(raw); + if (!trimmed) { + return undefined; + } + return normalizeWhatsAppTarget(trimmed) ?? undefined; +} + +export function normalizeWhatsAppAllowFromEntries(allowFrom: Array): string[] { + return allowFrom + .map((entry) => String(entry).trim()) + .filter((entry): entry is string => Boolean(entry)) + .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) + .filter((entry): entry is string => Boolean(entry)); +} + +export function looksLikeWhatsAppTargetId(raw: string): boolean { + return looksLikeHandleOrPhoneTarget({ + raw, + prefixPattern: /^whatsapp:/i, + }); +} diff --git a/src/channels/plugins/onboarding/whatsapp.test.ts b/extensions/whatsapp/src/onboarding.test.ts similarity index 94% rename from src/channels/plugins/onboarding/whatsapp.test.ts rename to extensions/whatsapp/src/onboarding.test.ts index 369499bf0fb..b046928cf15 100644 --- a/src/channels/plugins/onboarding/whatsapp.test.ts +++ b/extensions/whatsapp/src/onboarding.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; -import type { RuntimeEnv } from "../../../runtime.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import { whatsappOnboardingAdapter } from "./whatsapp.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { whatsappOnboardingAdapter } from "./onboarding.js"; const loginWebMock = vi.hoisted(() => vi.fn(async () => {})); const pathExistsMock = vi.hoisted(() => vi.fn(async () => false)); @@ -14,19 +14,20 @@ const resolveWhatsAppAuthDirMock = vi.hoisted(() => })), ); -vi.mock("../../../channel-web.js", () => ({ +vi.mock("../../../src/channel-web.js", () => ({ loginWeb: loginWebMock, })); -vi.mock("../../../utils.js", async () => { - const actual = await vi.importActual("../../../utils.js"); +vi.mock("../../../src/utils.js", async () => { + const actual = + await vi.importActual("../../../src/utils.js"); return { ...actual, pathExists: pathExistsMock, }; }); -vi.mock("../../../web/accounts.js", () => ({ +vi.mock("./accounts.js", () => ({ listWhatsAppAccountIds: listWhatsAppAccountIdsMock, resolveDefaultWhatsAppAccountId: resolveDefaultWhatsAppAccountIdMock, resolveWhatsAppAuthDir: resolveWhatsAppAuthDirMock, diff --git a/extensions/whatsapp/src/onboarding.ts b/extensions/whatsapp/src/onboarding.ts new file mode 100644 index 00000000000..e68fc42a5c3 --- /dev/null +++ b/extensions/whatsapp/src/onboarding.ts @@ -0,0 +1,354 @@ +import path from "node:path"; +import { loginWeb } from "../../../src/channel-web.js"; +import type { ChannelOnboardingAdapter } from "../../../src/channels/plugins/onboarding-types.js"; +import { + normalizeAllowFromEntries, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { mergeWhatsAppConfig } from "../../../src/config/merge-config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { normalizeE164, pathExists } from "../../../src/utils.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAuthDir, +} from "./accounts.js"; + +const channel = "whatsapp" as const; + +function setWhatsAppDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return mergeWhatsAppConfig(cfg, { dmPolicy }); +} + +function setWhatsAppAllowFrom(cfg: OpenClawConfig, allowFrom?: string[]): OpenClawConfig { + return mergeWhatsAppConfig(cfg, { allowFrom }, { unsetOnUndefined: ["allowFrom"] }); +} + +function setWhatsAppSelfChatMode(cfg: OpenClawConfig, selfChatMode: boolean): OpenClawConfig { + return mergeWhatsAppConfig(cfg, { selfChatMode }); +} + +async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise { + const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); + const credsPath = path.join(authDir, "creds.json"); + return await pathExists(credsPath); +} + +async function promptWhatsAppOwnerAllowFrom(params: { + prompter: WizardPrompter; + existingAllowFrom: string[]; +}): Promise<{ normalized: string; allowFrom: string[] }> { + const { prompter, existingAllowFrom } = params; + + await prompter.note( + "We need the sender/owner number so OpenClaw can allowlist you.", + "WhatsApp number", + ); + const entry = await prompter.text({ + message: "Your personal WhatsApp number (the phone you will message from)", + placeholder: "+15555550123", + initialValue: existingAllowFrom[0], + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const normalized = normalizeE164(raw); + if (!normalized) { + return `Invalid number: ${raw}`; + } + return undefined; + }, + }); + + const normalized = normalizeE164(String(entry).trim()); + if (!normalized) { + throw new Error("Invalid WhatsApp owner number (expected E.164 after validation)."); + } + const allowFrom = normalizeAllowFromEntries( + [...existingAllowFrom.filter((item) => item !== "*"), normalized], + normalizeE164, + ); + return { normalized, allowFrom }; +} + +async function applyWhatsAppOwnerAllowlist(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + existingAllowFrom: string[]; + title: string; + messageLines: string[]; +}): Promise { + const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ + prompter: params.prompter, + existingAllowFrom: params.existingAllowFrom, + }); + let next = setWhatsAppSelfChatMode(params.cfg, true); + next = setWhatsAppDmPolicy(next, "allowlist"); + next = setWhatsAppAllowFrom(next, allowFrom); + await params.prompter.note( + [...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"), + params.title, + ); + return next; +} + +function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { + const parts = splitOnboardingEntries(raw); + if (parts.length === 0) { + return { entries: [] }; + } + const entries: string[] = []; + for (const part of parts) { + if (part === "*") { + entries.push("*"); + continue; + } + const normalized = normalizeE164(part); + if (!normalized) { + return { entries: [], invalidEntry: part }; + } + entries.push(normalized); + } + return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; +} + +async function promptWhatsAppAllowFrom( + cfg: OpenClawConfig, + _runtime: RuntimeEnv, + prompter: WizardPrompter, + options?: { forceAllowlist?: boolean }, +): Promise { + const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing"; + const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? []; + const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; + + if (options?.forceAllowlist) { + return await applyWhatsAppOwnerAllowlist({ + cfg, + prompter, + existingAllowFrom, + title: "WhatsApp allowlist", + messageLines: ["Allowlist mode enabled."], + }); + } + + await prompter.note( + [ + "WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.", + "- pairing (default): unknown senders get a pairing code; owner approves", + "- allowlist: unknown senders are blocked", + '- open: public inbound DMs (requires allowFrom to include "*")', + "- disabled: ignore WhatsApp DMs", + "", + `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, + ].join("\n"), + "WhatsApp DM access", + ); + + const phoneMode = await prompter.select({ + message: "WhatsApp phone setup", + options: [ + { value: "personal", label: "This is my personal phone number" }, + { value: "separate", label: "Separate phone just for OpenClaw" }, + ], + }); + + if (phoneMode === "personal") { + return await applyWhatsAppOwnerAllowlist({ + cfg, + prompter, + existingAllowFrom, + title: "WhatsApp personal phone", + messageLines: [ + "Personal phone mode enabled.", + "- dmPolicy set to allowlist (pairing skipped)", + ], + }); + } + + const policy = (await prompter.select({ + message: "WhatsApp DM policy", + options: [ + { value: "pairing", label: "Pairing (recommended)" }, + { value: "allowlist", label: "Allowlist only (block unknown senders)" }, + { value: "open", label: "Open (public inbound DMs)" }, + { value: "disabled", label: "Disabled (ignore WhatsApp DMs)" }, + ], + })) as DmPolicy; + + let next = setWhatsAppSelfChatMode(cfg, false); + next = setWhatsAppDmPolicy(next, policy); + if (policy === "open") { + const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); + next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]); + return next; + } + if (policy === "disabled") { + return next; + } + + const allowOptions = + existingAllowFrom.length > 0 + ? ([ + { value: "keep", label: "Keep current allowFrom" }, + { + value: "unset", + label: "Unset allowFrom (use pairing approvals only)", + }, + { value: "list", label: "Set allowFrom to specific numbers" }, + ] as const) + : ([ + { value: "unset", label: "Unset allowFrom (default)" }, + { value: "list", label: "Set allowFrom to specific numbers" }, + ] as const); + + const mode = await prompter.select({ + message: "WhatsApp allowFrom (optional pre-allowlist)", + options: allowOptions.map((opt) => ({ + value: opt.value, + label: opt.label, + })), + }); + + if (mode === "keep") { + // Keep allowFrom as-is. + } else if (mode === "unset") { + next = setWhatsAppAllowFrom(next, undefined); + } else { + const allowRaw = await prompter.text({ + message: "Allowed sender numbers (comma-separated, E.164)", + placeholder: "+15555550123, +447700900123", + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const parsed = parseWhatsAppAllowFromEntries(raw); + if (parsed.entries.length === 0 && !parsed.invalidEntry) { + return "Required"; + } + if (parsed.invalidEntry) { + return `Invalid number: ${parsed.invalidEntry}`; + } + return undefined; + }, + }); + + const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); + next = setWhatsAppAllowFrom(next, parsed.entries); + } + + return next; +} + +export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg, accountOverrides }) => { + const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg); + const accountId = resolveOnboardingAccountId({ + accountId: accountOverrides.whatsapp, + defaultAccountId, + }); + const linked = await detectWhatsAppLinked(cfg, accountId); + const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId; + return { + channel, + configured: linked, + statusLines: [`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`], + selectionHint: linked ? "linked" : "not linked", + quickstartScore: linked ? 5 : 4, + }; + }, + configure: async ({ + cfg, + runtime, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom, + }) => { + const accountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "WhatsApp", + accountOverride: accountOverrides.whatsapp, + shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), + listAccountIds: listWhatsAppAccountIds, + defaultAccountId: resolveDefaultWhatsAppAccountId(cfg), + }); + + let next = cfg; + if (accountId !== DEFAULT_ACCOUNT_ID) { + next = { + ...next, + channels: { + ...next.channels, + whatsapp: { + ...next.channels?.whatsapp, + accounts: { + ...next.channels?.whatsapp?.accounts, + [accountId]: { + ...next.channels?.whatsapp?.accounts?.[accountId], + enabled: next.channels?.whatsapp?.accounts?.[accountId]?.enabled ?? true, + }, + }, + }, + }, + }; + } + + const linked = await detectWhatsAppLinked(next, accountId); + const { authDir } = resolveWhatsAppAuthDir({ + cfg: next, + accountId, + }); + + if (!linked) { + await prompter.note( + [ + "Scan the QR with WhatsApp on your phone.", + `Credentials are stored under ${authDir}/ for future runs.`, + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, + ].join("\n"), + "WhatsApp linking", + ); + } + const wantsLink = await prompter.confirm({ + message: linked ? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?", + initialValue: !linked, + }); + if (wantsLink) { + try { + await loginWeb(false, undefined, runtime, accountId); + } catch (err) { + runtime.error(`WhatsApp login failed: ${String(err)}`); + await prompter.note(`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, "WhatsApp help"); + } + } else if (!linked) { + await prompter.note( + `Run \`${formatCliCommand("openclaw channels login")}\` later to link WhatsApp.`, + "WhatsApp", + ); + } + + next = await promptWhatsAppAllowFrom(next, runtime, prompter, { + forceAllowlist: forceAllowFrom, + }); + + return { cfg: next, accountId }; + }, + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, +}; diff --git a/src/channels/plugins/outbound/whatsapp.poll.test.ts b/extensions/whatsapp/src/outbound-adapter.poll.test.ts similarity index 50% rename from src/channels/plugins/outbound/whatsapp.poll.test.ts rename to extensions/whatsapp/src/outbound-adapter.poll.test.ts index 6474322264a..46c9696cc98 100644 --- a/src/channels/plugins/outbound/whatsapp.poll.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.poll.test.ts @@ -1,35 +1,41 @@ import { describe, expect, it, vi } from "vitest"; -import { - createWhatsAppPollFixture, - expectWhatsAppPollSent, -} from "../../../test-helpers/whatsapp-outbound.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; const hoisted = vi.hoisted(() => ({ sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })), })); -vi.mock("../../../globals.js", () => ({ +vi.mock("../../../src/globals.js", () => ({ shouldLogVerbose: () => false, })); -vi.mock("../../../web/outbound.js", () => ({ +vi.mock("./send.js", () => ({ sendPollWhatsApp: hoisted.sendPollWhatsApp, })); -import { whatsappOutbound } from "./whatsapp.js"; +import { whatsappOutbound } from "./outbound-adapter.js"; describe("whatsappOutbound sendPoll", () => { it("threads cfg through poll send options", async () => { - const { cfg, poll, to, accountId } = createWhatsAppPollFixture(); + const cfg = { marker: "resolved-cfg" } as OpenClawConfig; + const poll = { + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + }; const result = await whatsappOutbound.sendPoll!({ cfg, - to, + to: "+1555", poll, - accountId, + accountId: "work", }); - expectWhatsAppPollSent(hoisted.sendPollWhatsApp, { cfg, poll, to, accountId }); + expect(hoisted.sendPollWhatsApp).toHaveBeenCalledWith("+1555", poll, { + verbose: false, + accountId: "work", + cfg, + }); expect(result).toEqual({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" }); }); }); diff --git a/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts similarity index 94% rename from src/channels/plugins/outbound/whatsapp.sendpayload.test.ts rename to extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts index 943c8a8ba9b..81f30ea1c71 100644 --- a/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; import { installSendPayloadContractSuite, primeSendMock, -} from "../../../test-utils/send-payload-contract.js"; -import { whatsappOutbound } from "./whatsapp.js"; +} from "../../../src/test-utils/send-payload-contract.js"; +import { whatsappOutbound } from "./outbound-adapter.js"; function createHarness(params: { payload: ReplyPayload; diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts new file mode 100644 index 00000000000..12497df9d6b --- /dev/null +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -0,0 +1,76 @@ +import { chunkText } from "../../../src/auto-reply/chunk.js"; +import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js"; +import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; +import { shouldLogVerbose } from "../../../src/globals.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; +import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js"; +import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js"; + +function trimLeadingWhitespace(text: string | undefined): string { + return text?.trimStart() ?? ""; +} + +export const whatsappOutbound: ChannelOutboundAdapter = { + deliveryMode: "gateway", + chunker: chunkText, + chunkerMode: "text", + textChunkLimit: 4000, + pollMaxOptions: 12, + resolveTarget: ({ to, allowFrom, mode }) => + resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), + sendPayload: async (ctx) => { + const text = trimLeadingWhitespace(ctx.payload.text); + const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0; + if (!text && !hasMedia) { + return { channel: "whatsapp", messageId: "" }; + } + return await sendTextMediaPayload({ + channel: "whatsapp", + ctx: { + ...ctx, + payload: { + ...ctx.payload, + text, + }, + }, + adapter: whatsappOutbound, + }); + }, + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { + const normalizedText = trimLeadingWhitespace(text); + if (!normalizedText) { + return { channel: "whatsapp", messageId: "" }; + } + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? + (await import("./send.js")).sendMessageWhatsApp; + const result = await send(to, normalizedText, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + gifPlayback, + }); + return { channel: "whatsapp", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => { + const normalizedText = trimLeadingWhitespace(text); + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? + (await import("./send.js")).sendMessageWhatsApp; + const result = await send(to, normalizedText, { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + gifPlayback, + }); + return { channel: "whatsapp", ...result }; + }, + sendPoll: async ({ cfg, to, poll, accountId }) => + await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId: accountId ?? undefined, + cfg, + }), +}; diff --git a/src/web/qr-image.ts b/extensions/whatsapp/src/qr-image.ts similarity index 95% rename from src/web/qr-image.ts rename to extensions/whatsapp/src/qr-image.ts index 0def0d5ac72..d4d8b9c7b2f 100644 --- a/src/web/qr-image.ts +++ b/extensions/whatsapp/src/qr-image.ts @@ -1,6 +1,6 @@ import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js"; import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"; -import { encodePngRgba, fillPixel } from "../media/png-encode.js"; +import { encodePngRgba, fillPixel } from "../../../src/media/png-encode.js"; type QRCodeConstructor = new ( typeNumber: number, diff --git a/src/web/reconnect.test.ts b/extensions/whatsapp/src/reconnect.test.ts similarity index 95% rename from src/web/reconnect.test.ts rename to extensions/whatsapp/src/reconnect.test.ts index 6166a509e57..019ca176b43 100644 --- a/src/web/reconnect.test.ts +++ b/extensions/whatsapp/src/reconnect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { computeBackoff, DEFAULT_HEARTBEAT_SECONDS, diff --git a/src/web/reconnect.ts b/extensions/whatsapp/src/reconnect.ts similarity index 83% rename from src/web/reconnect.ts rename to extensions/whatsapp/src/reconnect.ts index eec6f4689e3..d99ddf98ad6 100644 --- a/src/web/reconnect.ts +++ b/extensions/whatsapp/src/reconnect.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; -import type { OpenClawConfig } from "../config/config.js"; -import type { BackoffPolicy } from "../infra/backoff.js"; -import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; -import { clamp } from "../utils.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { BackoffPolicy } from "../../../src/infra/backoff.js"; +import { computeBackoff, sleepWithAbort } from "../../../src/infra/backoff.js"; +import { clamp } from "../../../src/utils.js"; export type ReconnectPolicy = BackoffPolicy & { maxAttempts: number; diff --git a/src/web/outbound.test.ts b/extensions/whatsapp/src/send.test.ts similarity index 96% rename from src/web/outbound.test.ts rename to extensions/whatsapp/src/send.test.ts index 506d7816630..f45ca9d0d29 100644 --- a/src/web/outbound.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -3,9 +3,9 @@ import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { resetLogger, setLoggerOverride } from "../logging.js"; -import { redactIdentifier } from "../logging/redact-identifier.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; +import { redactIdentifier } from "../../../src/logging/redact-identifier.js"; import { setActiveWebListener } from "./active-listener.js"; const loadWebMediaMock = vi.fn(); @@ -13,7 +13,7 @@ vi.mock("./media.js", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), })); -import { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } from "./outbound.js"; +import { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } from "./send.js"; describe("web outbound", () => { const sendComposingTo = vi.fn(async () => {}); diff --git a/src/web/outbound.ts b/extensions/whatsapp/src/send.ts similarity index 90% rename from src/web/outbound.ts rename to extensions/whatsapp/src/send.ts index 1fcaa807c37..4ac9c03faf4 100644 --- a/src/web/outbound.ts +++ b/extensions/whatsapp/src/send.ts @@ -1,13 +1,13 @@ -import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { generateSecureUuid } from "../infra/secure-random.js"; -import { getChildLogger } from "../logging/logger.js"; -import { redactIdentifier } from "../logging/redact-identifier.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { convertMarkdownTables } from "../markdown/tables.js"; -import { markdownToWhatsApp } from "../markdown/whatsapp.js"; -import { normalizePollInput, type PollInput } from "../polls.js"; -import { toWhatsappJid } from "../utils.js"; +import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { generateSecureUuid } from "../../../src/infra/secure-random.js"; +import { getChildLogger } from "../../../src/logging/logger.js"; +import { redactIdentifier } from "../../../src/logging/redact-identifier.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import { convertMarkdownTables } from "../../../src/markdown/tables.js"; +import { markdownToWhatsApp } from "../../../src/markdown/whatsapp.js"; +import { normalizePollInput, type PollInput } from "../../../src/polls.js"; +import { toWhatsappJid } from "../../../src/utils.js"; import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "./accounts.js"; import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; import { loadWebMedia } from "./media.js"; diff --git a/src/web/session.test.ts b/extensions/whatsapp/src/session.test.ts similarity index 98% rename from src/web/session.test.ts rename to extensions/whatsapp/src/session.test.ts index 0bf8fefc040..177c8c8e5e6 100644 --- a/src/web/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import fsSync from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../logging.js"; +import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js"; const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } = diff --git a/src/web/session.ts b/extensions/whatsapp/src/session.ts similarity index 96% rename from src/web/session.ts rename to extensions/whatsapp/src/session.ts index 9dc8c6e47ba..db48b49c874 100644 --- a/src/web/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -8,11 +8,11 @@ import { useMultiFileAuthState, } from "@whiskeysockets/baileys"; import qrcode from "qrcode-terminal"; -import { formatCliCommand } from "../cli/command-format.js"; -import { danger, success } from "../globals.js"; -import { getChildLogger, toPinoLikeLogger } from "../logging.js"; -import { ensureDir, resolveUserPath } from "../utils.js"; -import { VERSION } from "../version.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import { danger, success } from "../../../src/globals.js"; +import { getChildLogger, toPinoLikeLogger } from "../../../src/logging.js"; +import { ensureDir, resolveUserPath } from "../../../src/utils.js"; +import { VERSION } from "../../../src/version.js"; import { maybeRestoreCredsFromBackup, readCredsJsonRaw, diff --git a/src/channels/plugins/status-issues/whatsapp.test.ts b/extensions/whatsapp/src/status-issues.test.ts similarity index 95% rename from src/channels/plugins/status-issues/whatsapp.test.ts rename to extensions/whatsapp/src/status-issues.test.ts index 77a4e6ecf59..cc346547932 100644 --- a/src/channels/plugins/status-issues/whatsapp.test.ts +++ b/extensions/whatsapp/src/status-issues.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { collectWhatsAppStatusIssues } from "./whatsapp.js"; +import { collectWhatsAppStatusIssues } from "./status-issues.js"; describe("collectWhatsAppStatusIssues", () => { it("reports unlinked enabled accounts", () => { diff --git a/extensions/whatsapp/src/status-issues.ts b/extensions/whatsapp/src/status-issues.ts new file mode 100644 index 00000000000..bddd6dd7d9d --- /dev/null +++ b/extensions/whatsapp/src/status-issues.ts @@ -0,0 +1,73 @@ +import { + asString, + collectIssuesForEnabledAccounts, + isRecord, +} from "../../../src/channels/plugins/status-issues/shared.js"; +import type { + ChannelAccountSnapshot, + ChannelStatusIssue, +} from "../../../src/channels/plugins/types.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; + +type WhatsAppAccountStatus = { + accountId?: unknown; + enabled?: unknown; + linked?: unknown; + connected?: unknown; + running?: unknown; + reconnectAttempts?: unknown; + lastError?: unknown; +}; + +function readWhatsAppAccountStatus(value: ChannelAccountSnapshot): WhatsAppAccountStatus | null { + if (!isRecord(value)) { + return null; + } + return { + accountId: value.accountId, + enabled: value.enabled, + linked: value.linked, + connected: value.connected, + running: value.running, + reconnectAttempts: value.reconnectAttempts, + lastError: value.lastError, + }; +} + +export function collectWhatsAppStatusIssues( + accounts: ChannelAccountSnapshot[], +): ChannelStatusIssue[] { + return collectIssuesForEnabledAccounts({ + accounts, + readAccount: readWhatsAppAccountStatus, + collectIssues: ({ account, accountId, issues }) => { + const linked = account.linked === true; + const running = account.running === true; + const connected = account.connected === true; + const reconnectAttempts = + typeof account.reconnectAttempts === "number" ? account.reconnectAttempts : null; + const lastError = asString(account.lastError); + + if (!linked) { + issues.push({ + channel: "whatsapp", + accountId, + kind: "auth", + message: "Not linked (no WhatsApp Web session).", + fix: `Run: ${formatCliCommand("openclaw channels login")} (scan QR on the gateway host).`, + }); + return; + } + + if (running && !connected) { + issues.push({ + channel: "whatsapp", + accountId, + kind: "runtime", + message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`, + fix: `Run: ${formatCliCommand("openclaw doctor")} (or restart the gateway). If it persists, relink via channels login and check logs.`, + }); + } + }, + }); +} diff --git a/src/web/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts similarity index 89% rename from src/web/test-helpers.ts rename to extensions/whatsapp/src/test-helpers.ts index 3e8964b507d..b3289164463 100644 --- a/src/web/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -1,6 +1,6 @@ import { vi } from "vitest"; -import type { MockBaileysSocket } from "../../test/mocks/baileys.js"; -import { createMockBaileys } from "../../test/mocks/baileys.js"; +import type { MockBaileysSocket } from "../../../test/mocks/baileys.js"; +import { createMockBaileys } from "../../../test/mocks/baileys.js"; // Use globalThis to store the mock config so it survives vi.mock hoisting const CONFIG_KEY = Symbol.for("openclaw:testConfigMock"); @@ -30,8 +30,8 @@ export function resetLoadConfigMock() { (globalThis as Record)[CONFIG_KEY] = () => DEFAULT_CONFIG; } -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => { @@ -51,7 +51,7 @@ vi.mock("../../config/config.js", async (importOriginal) => { // `../../config/config.js` is correct for modules under `src/web/auto-reply/*`. // For typing in this file (which lives in `src/web/*`), refer to the same module // via the local relative path. - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, loadConfig: () => { @@ -64,8 +64,8 @@ vi.mock("../../config/config.js", async (importOriginal) => { }; }); -vi.mock("../media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/media/store.js", async (importOriginal) => { + const actual = await importOriginal(); const mockModule = Object.create(null) as Record; Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); Object.defineProperty(mockModule, "saveMediaBuffer", { diff --git a/src/web/vcard.ts b/extensions/whatsapp/src/vcard.ts similarity index 100% rename from src/web/vcard.ts rename to extensions/whatsapp/src/vcard.ts diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 154f69b9867..6c3b72b8fbb 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 3880b66abf8..a72aabbb29e 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index 09dfdbb1ff3..9731672126c 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.14 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.13 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 82e796cf676..e7c12c9b4b2 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration", "type": "module", "dependencies": { diff --git a/knip.config.ts b/knip.config.ts index e4daabd7e95..6a76a8238b7 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -9,8 +9,8 @@ const rootEntries = [ "src/channels/plugins/actions/discord.ts!", "src/channels/plugins/actions/signal.ts!", "src/channels/plugins/actions/telegram.ts!", - "src/telegram/audit.ts!", - "src/telegram/token.ts!", + "extensions/telegram/src/audit.ts!", + "extensions/telegram/src/token.ts!", "src/line/accounts.ts!", "src/line/send.ts!", "src/line/template-messages.ts!", @@ -69,8 +69,8 @@ const config = { "src/gateway/live-tool-probe-utils.ts", "src/gateway/server.auth.shared.ts", "src/shared/text/assistant-visible-text.ts", - "src/telegram/bot/reply-threading.ts", - "src/telegram/draft-chunking.ts", + "extensions/telegram/src/bot/reply-threading.ts", + "extensions/telegram/src/draft-chunking.ts", "extensions/msteams/src/conversation-store-memory.ts", "extensions/msteams/src/polls-store-memory.ts", "extensions/voice-call/src/providers/index.ts", diff --git a/package.json b/package.json index cea0293676e..6cde8d84431 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.3.13", + "version": "2026.3.14", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", @@ -226,7 +226,7 @@ "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", - "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", + "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", @@ -353,10 +353,10 @@ "@larksuiteoapi/node-sdk": "^1.59.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.57.1", - "@mariozechner/pi-ai": "0.57.1", - "@mariozechner/pi-coding-agent": "0.57.1", - "@mariozechner/pi-tui": "0.57.1", + "@mariozechner/pi-agent-core": "0.58.0", + "@mariozechner/pi-ai": "0.58.0", + "@mariozechner/pi-coding-agent": "0.58.0", + "@mariozechner/pi-tui": "0.58.0", "@modelcontextprotocol/sdk": "1.27.1", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.48", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9477cdd9b2..6460473fe84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,17 +59,17 @@ importers: specifier: 1.2.0-beta.3 version: 1.2.0-beta.3 '@mariozechner/pi-agent-core': - specifier: 0.57.1 - version: 0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + specifier: 0.58.0 + version: 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': - specifier: 0.57.1 - version: 0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + specifier: 0.58.0 + version: 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': - specifier: 0.57.1 - version: 0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + specifier: 0.58.0 + version: 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-tui': - specifier: 0.57.1 - version: 0.57.1 + specifier: 0.58.0 + version: 0.58.0 '@modelcontextprotocol/sdk': specifier: 1.27.1 version: 1.27.1(zod@4.3.6) @@ -347,10 +347,9 @@ importers: google-auth-library: specifier: ^10.6.1 version: 10.6.1 - devDependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.3.11' + version: 2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/imessage: {} @@ -380,8 +379,8 @@ importers: extensions/matrix: dependencies: '@mariozechner/pi-agent-core': - specifier: 0.57.1 - version: 0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + specifier: 0.58.0 + version: 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@matrix-org/matrix-sdk-crypto-nodejs': specifier: ^0.4.0 version: 0.4.0 @@ -408,10 +407,10 @@ importers: version: 4.3.6 extensions/memory-core: - devDependencies: + dependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.3.11' + version: 2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -1705,22 +1704,22 @@ packages: resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} hasBin: true - '@mariozechner/pi-agent-core@0.57.1': - resolution: {integrity: sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==} + '@mariozechner/pi-agent-core@0.58.0': + resolution: {integrity: sha512-zhkwx3Wdo27snVfnJWi7l+wyU4XlazkeunTtz4e500GC+ufGOp4C3aIf0XiO5ZOtTE/0lvUiG2bWULR/i4lgUQ==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.57.1': - resolution: {integrity: sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==} + '@mariozechner/pi-ai@0.58.0': + resolution: {integrity: sha512-3TrkJ9QcBYFPo4NxYluhd+JQ4M+98RaEkNPMrLFU4wK4GMFVtsL3kp1YJ/oj7X0eqKuuDKbHj6MdoMZeT2TCvA==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.57.1': - resolution: {integrity: sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==} + '@mariozechner/pi-coding-agent@0.58.0': + resolution: {integrity: sha512-aCoqIMfcFWwuZrLC4MC1EnHwUrqo+ppamXlNYk5+nANH8U+51AP8OUqOUqT9NSHO9ZdItheU9wCqt7wPf5Ah8A==} engines: {node: '>=20.6.0'} hasBin: true - '@mariozechner/pi-tui@0.57.1': - resolution: {integrity: sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==} + '@mariozechner/pi-tui@0.58.0': + resolution: {integrity: sha512-luRbQlk0ZCbYGCtCrKTqQX0ECKNYPj7OSlxKMXEY0B3bA6s4f/Xj0aLPiKlhsIynC2dPQmijA44ZDfrWFniWwA==} engines: {node: '>=20.0.0'} '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': @@ -3107,10 +3106,6 @@ packages: resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.17': - resolution: {integrity: sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==} - engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.19': resolution: {integrity: sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==} engines: {node: '>=18.0.0'} @@ -5536,6 +5531,17 @@ packages: zod: optional: true + openclaw@2026.3.13: + resolution: {integrity: sha512-/juSUb070Xz8K8CnShjaZQr7CVtRaW4FbR93lgr1hLepcRSbyz2PQR+V4w5giVWkea61opXWPA6Vb8dybaztFg==} + engines: {node: '>=22.16.0'} + hasBin: true + peerDependencies: + '@napi-rs/canvas': ^0.1.89 + node-llama-cpp: 3.16.2 + peerDependenciesMeta: + node-llama-cpp: + optional: true + opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} @@ -6953,7 +6959,7 @@ snapshots: '@smithy/util-endpoints': 3.3.3 '@smithy/util-middleware': 4.2.12 '@smithy/util-retry': 4.2.12 - '@smithy/util-stream': 4.5.17 + '@smithy/util-stream': 4.5.19 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -8494,9 +8500,9 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-agent-core@0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -8506,7 +8512,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-ai@0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.1004.0 @@ -8530,12 +8536,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-coding-agent@0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.57.1(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.57.1 + '@mariozechner/pi-agent-core': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.58.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cli-highlight: 2.1.11 @@ -8562,7 +8568,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.57.1': + '@mariozechner/pi-tui@0.58.0': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -10116,17 +10122,6 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/util-stream@4.5.17': - dependencies: - '@smithy/fetch-http-handler': 5.3.15 - '@smithy/node-http-handler': 4.4.16 - '@smithy/types': 4.13.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - '@smithy/util-stream@4.5.19': dependencies: '@smithy/fetch-http-handler': 5.3.15 @@ -12822,6 +12817,83 @@ snapshots: ws: 8.19.0 zod: 4.3.6 + openclaw@2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)): + dependencies: + '@agentclientprotocol/sdk': 0.16.1(zod@4.3.6) + '@aws-sdk/client-bedrock': 3.1009.0 + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1) + '@clack/prompts': 1.1.0 + '@discordjs/voice': 0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1) + '@grammyjs/runner': 2.0.3(grammy@1.41.1) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1) + '@homebridge/ciao': 1.3.5 + '@larksuiteoapi/node-sdk': 1.59.0 + '@line/bot-sdk': 10.6.0 + '@lydell/node-pty': 1.2.0-beta.3 + '@mariozechner/pi-agent-core': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.58.0 + '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) + '@mozilla/readability': 0.6.0 + '@napi-rs/canvas': 0.1.95 + '@sinclair/typebox': 0.34.48 + '@slack/bolt': 4.6.0(@types/express@5.0.6) + '@slack/web-api': 7.15.0 + '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) + ajv: 8.18.0 + chalk: 5.6.2 + chokidar: 5.0.0 + cli-highlight: 2.1.11 + commander: 14.0.3 + croner: 10.0.1 + discord-api-types: 0.38.42 + dotenv: 17.3.1 + express: 5.2.1 + file-type: 21.3.2 + grammy: 1.41.1 + hono: 4.12.7 + https-proxy-agent: 8.0.0 + ipaddr.js: 2.3.0 + jiti: 2.6.1 + json5: 2.2.3 + jszip: 3.10.1 + linkedom: 0.18.12 + long: 5.3.2 + markdown-it: 14.1.1 + node-edge-tts: 1.2.10 + opusscript: 0.1.1 + osc-progress: 0.3.0 + pdfjs-dist: 5.5.207 + playwright-core: 1.58.2 + qrcode-terminal: 0.12.0 + sharp: 0.34.5 + sqlite-vec: 0.1.7-alpha.2 + tar: 7.5.11 + tslog: 4.10.2 + undici: 7.24.1 + ws: 8.19.0 + yaml: 2.8.2 + zod: 4.3.6 + optionalDependencies: + node-llama-cpp: 3.16.2(typescript@5.9.3) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@discordjs/opus' + - '@types/express' + - audio-decode + - aws-crt + - bufferutil + - canvas + - debug + - encoding + - ffmpeg-static + - jimp + - link-preview-js + - node-opus + - supports-color + - utf-8-validate + opus-decoder@0.7.11: dependencies: '@wasm-audio-decoders/common': 9.0.7 diff --git a/scripts/check-ingress-agent-owner-context.mjs b/scripts/check-ingress-agent-owner-context.mjs index 20b99536e1d..da9da112c6b 100644 --- a/scripts/check-ingress-agent-owner-context.mjs +++ b/scripts/check-ingress-agent-owner-context.mjs @@ -5,9 +5,9 @@ import ts from "typescript"; import { runCallsiteGuard } from "./lib/callsite-guard.mjs"; import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs"; -const sourceRoots = ["src/gateway", "src/discord/voice"]; +const sourceRoots = ["src/gateway", "extensions/discord/src/voice"]; const enforcedFiles = new Set([ - "src/discord/voice/manager.ts", + "extensions/discord/src/voice/manager.ts", "src/gateway/openai-http.ts", "src/gateway/openresponses-http.ts", "src/gateway/server-methods/agent.ts", diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index ecd8a2f64f8..788585b8c54 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -4,18 +4,7 @@ import ts from "typescript"; import { runCallsiteGuard } from "./lib/callsite-guard.mjs"; import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs"; -const sourceRoots = [ - "src/telegram", - "src/discord", - "src/slack", - "src/signal", - "src/imessage", - "src/web", - "src/channels", - "src/routing", - "src/line", - "extensions", -]; +const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"]; // Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime // code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers. @@ -54,14 +43,14 @@ const allowedRawFetchCallsites = new Set([ "extensions/voice-call/src/providers/telnyx.ts:61", "extensions/voice-call/src/providers/tts-openai.ts:111", "extensions/voice-call/src/providers/twilio/api.ts:23", - "src/channels/telegram/api.ts:8", - "src/discord/send.outbound.ts:347", - "src/discord/voice-message.ts:264", - "src/discord/voice-message.ts:308", - "src/slack/monitor/media.ts:64", - "src/slack/monitor/media.ts:68", - "src/slack/monitor/media.ts:82", - "src/slack/monitor/media.ts:108", + "extensions/telegram/src/api-fetch.ts:8", + "extensions/discord/src/send.outbound.ts:363", + "extensions/discord/src/voice-message.ts:268", + "extensions/discord/src/voice-message.ts:312", + "extensions/slack/src/monitor/media.ts:55", + "extensions/slack/src/monitor/media.ts:59", + "extensions/slack/src/monitor/media.ts:73", + "extensions/slack/src/monitor/media.ts:99", ]); function isRawFetchCall(expression) { diff --git a/scripts/dev/test-device-pair-telegram.ts b/scripts/dev/test-device-pair-telegram.ts index e33a060ecd4..e39e0a378cd 100644 --- a/scripts/dev/test-device-pair-telegram.ts +++ b/scripts/dev/test-device-pair-telegram.ts @@ -1,7 +1,7 @@ +import { sendMessageTelegram } from "../../extensions/telegram/src/send.js"; import { loadConfig } from "../../src/config/config.js"; import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js"; import { loadOpenClawPlugins } from "../../src/plugins/loader.js"; -import { sendMessageTelegram } from "../../src/telegram/send.js"; const args = process.argv.slice(2); const getArg = (flag: string, short?: string) => { diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index 120a8290bc2..a3e3f96bb56 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -10,6 +10,8 @@ HOST_PORT="18427" HOST_PORT_EXPLICIT=0 HOST_IP="" LATEST_VERSION="" +INSTALL_VERSION="" +TARGET_PACKAGE_SPEC="" JSON_OUTPUT=0 KEEP_SERVER=0 @@ -17,6 +19,7 @@ MAIN_TGZ_DIR="$(mktemp -d)" MAIN_TGZ_PATH="" SERVER_PID="" RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-linux.XXXXXX)" +BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock" TIMEOUT_SNAPSHOT_S=180 TIMEOUT_BOOTSTRAP_S=600 @@ -40,6 +43,14 @@ say() { printf '==> %s\n' "$*" } +artifact_label() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + printf 'target package tgz' + return + fi + printf 'current main tgz' +} + warn() { printf 'warn: %s\n' "$*" >&2 } @@ -71,6 +82,10 @@ Options: --host-port Host HTTP port for current-main tgz. Default: 18427 --host-ip Override Parallels host IP. --latest-version Override npm latest version lookup. + --install-version Pin site-installer version/dist-tag for the baseline lane. + --target-package-spec + Install this npm package tarball instead of packing current main. + Example: openclaw@2026.3.13-beta.1 --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. -h, --help Show help. @@ -112,6 +127,14 @@ while [[ $# -gt 0 ]]; do LATEST_VERSION="$2" shift 2 ;; + --install-version) + INSTALL_VERSION="$2" + shift 2 + ;; + --target-package-spec) + TARGET_PACKAGE_SPEC="$2" + shift 2 + ;; --keep-server) KEEP_SERVER=1 shift @@ -260,23 +283,64 @@ else: PY } +acquire_build_lock() { + local owner_pid="" + while ! mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do + if [[ -f "$BUILD_LOCK_DIR/pid" ]]; then + owner_pid="$(cat "$BUILD_LOCK_DIR/pid" 2>/dev/null || true)" + if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" >/dev/null 2>&1; then + warn "Removing stale Parallels build lock" + rm -rf "$BUILD_LOCK_DIR" + continue + fi + fi + sleep 1 + done + printf '%s\n' "$$" >"$BUILD_LOCK_DIR/pid" +} + +release_build_lock() { + if [[ -d "$BUILD_LOCK_DIR" ]]; then + rm -rf "$BUILD_LOCK_DIR" + fi +} + ensure_current_build() { local head build_commit + acquire_build_lock head="$(git rev-parse HEAD)" build_commit="$(current_build_commit)" if [[ "$build_commit" == "$head" ]]; then + release_build_lock return fi say "Build dist for current head" pnpm build build_commit="$(current_build_commit)" + release_build_lock [[ "$build_commit" == "$head" ]] || die "dist/build-info.json still does not match HEAD after build" } +extract_package_version_from_tgz() { + tar -xOf "$1" package/package.json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])' +} + pack_main_tgz() { + local short_head pkg + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + say "Pack target package tgz: $TARGET_PACKAGE_SPEC" + pkg="$( + npm pack "$TARGET_PACKAGE_SPEC" --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ + | python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])' + )" + MAIN_TGZ_PATH="$MAIN_TGZ_DIR/$(basename "$pkg")" + TARGET_EXPECT_VERSION="$(extract_package_version_from_tgz "$MAIN_TGZ_PATH")" + say "Packed $MAIN_TGZ_PATH" + say "Target package version: $TARGET_EXPECT_VERSION" + return + fi say "Pack current main tgz" ensure_current_build - local short_head pkg short_head="$(git rev-parse --short HEAD)" pkg="$( npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ @@ -288,6 +352,14 @@ pack_main_tgz() { tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json } +verify_target_version() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + verify_version_contains "$TARGET_EXPECT_VERSION" + return + fi + verify_version_contains "$(git rev-parse --short=7 HEAD)" +} + start_server() { local host_ip="$1" local artifact probe_url attempt @@ -295,7 +367,7 @@ start_server() { attempt=0 while :; do attempt=$((attempt + 1)) - say "Serve current main tgz on $host_ip:$HOST_PORT" + say "Serve $(artifact_label) on $host_ip:$HOST_PORT" ( cd "$MAIN_TGZ_DIR" exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0 @@ -318,8 +390,12 @@ start_server() { } install_latest_release() { + local version_args=() + if [[ -n "$INSTALL_VERSION" ]]; then + version_args=(--version "$INSTALL_VERSION") + fi guest_exec curl -fsSL "$INSTALL_URL" -o /tmp/openclaw-install.sh - guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh --no-onboard + guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh "${version_args[@]}" --no-onboard guest_exec openclaw --version } @@ -452,6 +528,8 @@ summary = { "snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"], "mode": os.environ["SUMMARY_MODE"], "latestVersion": os.environ["SUMMARY_LATEST_VERSION"], + "installVersion": os.environ["SUMMARY_INSTALL_VERSION"], + "targetPackageSpec": os.environ["SUMMARY_TARGET_PACKAGE_SPEC"], "currentHead": os.environ["SUMMARY_CURRENT_HEAD"], "runDir": os.environ["SUMMARY_RUN_DIR"], "daemon": os.environ["SUMMARY_DAEMON_STATUS"], @@ -483,7 +561,7 @@ run_fresh_main_lane() { phase_run "fresh.install-latest-bootstrap" "$TIMEOUT_INSTALL_S" install_latest_release phase_run "fresh.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz" FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")" - phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$(git rev-parse --short=7 HEAD)" + phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard FRESH_GATEWAY_STATUS="skipped-no-detached-linux-gateway" phase_run "fresh.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn @@ -500,7 +578,7 @@ run_upgrade_lane() { phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$LATEST_VERSION" phase_run "upgrade.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-upgrade.tgz" UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-main)")" - phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$(git rev-parse --short=7 HEAD)" + phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard UPGRADE_GATEWAY_STATUS="skipped-no-detached-linux-gateway" phase_run "upgrade.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn @@ -556,6 +634,8 @@ SUMMARY_JSON_PATH="$( SUMMARY_SNAPSHOT_ID="$SNAPSHOT_ID" \ SUMMARY_MODE="$MODE" \ SUMMARY_LATEST_VERSION="$LATEST_VERSION" \ + SUMMARY_INSTALL_VERSION="$INSTALL_VERSION" \ + SUMMARY_TARGET_PACKAGE_SPEC="$TARGET_PACKAGE_SPEC" \ SUMMARY_CURRENT_HEAD="$(git rev-parse --short HEAD)" \ SUMMARY_RUN_DIR="$RUN_DIR" \ SUMMARY_DAEMON_STATUS="$DAEMON_STATUS" \ @@ -575,6 +655,12 @@ if [[ "$JSON_OUTPUT" -eq 1 ]]; then cat "$SUMMARY_JSON_PATH" else printf '\nSummary:\n' + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + printf ' target-package: %s\n' "$TARGET_PACKAGE_SPEC" + fi + if [[ -n "$INSTALL_VERSION" ]]; then + printf ' baseline-install-version: %s\n' "$INSTALL_VERSION" + fi printf ' daemon: %s\n' "$DAEMON_STATUS" printf ' fresh-main: %s (%s)\n' "$FRESH_MAIN_STATUS" "$FRESH_MAIN_VERSION" printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION" diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh index c85f3d237ec..0b790346358 100644 --- a/scripts/e2e/parallels-macos-smoke.sh +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -12,6 +12,8 @@ HOST_PORT="18425" HOST_PORT_EXPLICIT=0 HOST_IP="" LATEST_VERSION="" +INSTALL_VERSION="" +TARGET_PACKAGE_SPEC="" KEEP_SERVER=0 CHECK_LATEST_REF=1 JSON_OUTPUT=0 @@ -24,6 +26,7 @@ MAIN_TGZ_DIR="$(mktemp -d)" MAIN_TGZ_PATH="" SERVER_PID="" RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-smoke.XXXXXX)" +BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock" TIMEOUT_INSTALL_S=900 TIMEOUT_VERIFY_S=60 @@ -45,6 +48,14 @@ say() { printf '==> %s\n' "$*" } +artifact_label() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + printf 'target package tgz' + return + fi + printf 'current main tgz' +} + warn() { printf 'warn: %s\n' "$*" >&2 } @@ -80,8 +91,8 @@ Options: --snapshot-hint Snapshot name substring/fuzzy match. Default: "macOS 26.3.1 fresh" --mode - fresh = fresh snapshot -> current main tgz -> onboard smoke - upgrade = fresh snapshot -> latest release -> current main tgz -> onboard smoke + fresh = fresh snapshot -> target package/current main tgz -> onboard smoke + upgrade = fresh snapshot -> latest release -> target package/current main tgz -> onboard smoke both = run both lanes --openai-api-key-env Host env var name for OpenAI API key. Default: OPENAI_API_KEY @@ -89,6 +100,10 @@ Options: --host-port Host HTTP port for current-main tgz. Default: 18425 --host-ip Override Parallels host IP. --latest-version Override npm latest version lookup. + --install-version Pin site-installer version/dist-tag for the baseline lane. + --target-package-spec + Install this npm package tarball instead of packing current main. + Example: openclaw@2026.3.13-beta.1 --skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane. --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. @@ -131,6 +146,14 @@ while [[ $# -gt 0 ]]; do LATEST_VERSION="$2" shift 2 ;; + --install-version) + INSTALL_VERSION="$2" + shift 2 + ;; + --target-package-spec) + TARGET_PACKAGE_SPEC="$2" + shift 2 + ;; --skip-latest-ref-check) CHECK_LATEST_REF=0 shift @@ -342,12 +365,16 @@ resolve_latest_version() { } install_latest_release() { - local install_url_q + local install_url_q version_arg_q install_url_q="$(shell_quote "$INSTALL_URL")" + version_arg_q="" + if [[ -n "$INSTALL_VERSION" ]]; then + version_arg_q=" --version $(shell_quote "$INSTALL_VERSION")" + fi guest_current_user_sh "$(cat </dev/null; do + if [[ -f "$BUILD_LOCK_DIR/pid" ]]; then + owner_pid="$(cat "$BUILD_LOCK_DIR/pid" 2>/dev/null || true)" + if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" >/dev/null 2>&1; then + warn "Removing stale Parallels build lock" + rm -rf "$BUILD_LOCK_DIR" + continue + fi + fi + sleep 1 + done + printf '%s\n' "$$" >"$BUILD_LOCK_DIR/pid" +} + +release_build_lock() { + if [[ -d "$BUILD_LOCK_DIR" ]]; then + rm -rf "$BUILD_LOCK_DIR" + fi +} + ensure_current_build() { local head build_commit + acquire_build_lock head="$(git rev-parse HEAD)" build_commit="$(current_build_commit)" if [[ "$build_commit" == "$head" ]]; then + release_build_lock return fi say "Build dist for current head" pnpm build build_commit="$(current_build_commit)" + release_build_lock [[ "$build_commit" == "$head" ]] || die "dist/build-info.json still does not match HEAD after build" } start_server() { local host_ip="$1" - say "Serve current main tgz on $host_ip:$HOST_PORT" + say "Serve $(artifact_label) on $host_ip:$HOST_PORT" ( cd "$MAIN_TGZ_DIR" exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0 @@ -465,6 +541,14 @@ verify_gateway() { guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" gateway status --deep --require-rpc } +show_gateway_status_compat() { + if guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" gateway status --help | grep -Fq -- "--require-rpc"; then + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" gateway status --deep --require-rpc + return + fi + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" gateway status --deep +} + verify_turn() { guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" agent --agent main --message ping --json } @@ -553,6 +637,8 @@ summary = { "snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"], "mode": os.environ["SUMMARY_MODE"], "latestVersion": os.environ["SUMMARY_LATEST_VERSION"], + "installVersion": os.environ["SUMMARY_INSTALL_VERSION"], + "targetPackageSpec": os.environ["SUMMARY_TARGET_PACKAGE_SPEC"], "currentHead": os.environ["SUMMARY_CURRENT_HEAD"], "runDir": os.environ["SUMMARY_RUN_DIR"], "freshMain": { @@ -587,7 +673,7 @@ capture_latest_ref_failure() { fi warn "Latest release ref-mode onboard failed pre-upgrade" set +e - guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" gateway status --deep --require-rpc || true + show_gateway_status_compat || true set -e return 1 } @@ -598,7 +684,7 @@ run_fresh_main_lane() { phase_run "fresh.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id" phase_run "fresh.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz" FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")" - phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$(git rev-parse --short=7 HEAD)" + phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version phase_run "fresh.verify-bundle-permissions" "$TIMEOUT_PERMISSION_S" verify_bundle_permissions phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard phase_run "fresh.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway @@ -625,7 +711,7 @@ run_upgrade_lane() { fi phase_run "upgrade.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-upgrade.tgz" UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-main)")" - phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$(git rev-parse --short=7 HEAD)" + phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version phase_run "upgrade.verify-bundle-permissions" "$TIMEOUT_PERMISSION_S" verify_bundle_permissions phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard phase_run "upgrade.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway @@ -687,6 +773,8 @@ SUMMARY_JSON_PATH="$( SUMMARY_SNAPSHOT_ID="$SNAPSHOT_ID" \ SUMMARY_MODE="$MODE" \ SUMMARY_LATEST_VERSION="$LATEST_VERSION" \ + SUMMARY_INSTALL_VERSION="$INSTALL_VERSION" \ + SUMMARY_TARGET_PACKAGE_SPEC="$TARGET_PACKAGE_SPEC" \ SUMMARY_CURRENT_HEAD="$(git rev-parse --short HEAD)" \ SUMMARY_RUN_DIR="$RUN_DIR" \ SUMMARY_FRESH_MAIN_STATUS="$FRESH_MAIN_STATUS" \ @@ -706,6 +794,12 @@ if [[ "$JSON_OUTPUT" -eq 1 ]]; then cat "$SUMMARY_JSON_PATH" else printf '\nSummary:\n' + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + printf ' target-package: %s\n' "$TARGET_PACKAGE_SPEC" + fi + if [[ -n "$INSTALL_VERSION" ]]; then + printf ' baseline-install-version: %s\n' "$INSTALL_VERSION" + fi printf ' fresh-main: %s (%s)\n' "$FRESH_MAIN_STATUS" "$FRESH_MAIN_VERSION" printf ' latest->main precheck: %s (%s)\n' "$UPGRADE_PRECHECK_STATUS" "$LATEST_INSTALLED_VERSION" printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION" diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index 548d3d033aa..cd144511f49 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -10,6 +10,8 @@ HOST_PORT="18426" HOST_PORT_EXPLICIT=0 HOST_IP="" LATEST_VERSION="" +INSTALL_VERSION="" +TARGET_PACKAGE_SPEC="" JSON_OUTPUT=0 KEEP_SERVER=0 CHECK_LATEST_REF=1 @@ -20,6 +22,7 @@ MINGIT_ZIP_PATH="" MINGIT_ZIP_NAME="" SERVER_PID="" RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-windows.XXXXXX)" +BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock" TIMEOUT_SNAPSHOT_S=240 TIMEOUT_INSTALL_S=1200 @@ -43,6 +46,14 @@ say() { printf '==> %s\n' "$*" } +artifact_label() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + printf 'target package tgz' + return + fi + printf 'current main tgz' +} + warn() { printf 'warn: %s\n' "$*" >&2 } @@ -76,6 +87,10 @@ Options: --host-port Host HTTP port for current-main tgz. Default: 18426 --host-ip Override Parallels host IP. --latest-version Override npm latest version lookup. + --install-version Pin site-installer version/dist-tag for the baseline lane. + --target-package-spec + Install this npm package tarball instead of packing current main. + Example: openclaw@2026.3.13-beta.1 --skip-latest-ref-check Skip latest-release ref-mode precheck. --keep-server Leave temp host HTTP server running. --json Print machine-readable JSON summary. @@ -118,6 +133,14 @@ while [[ $# -gt 0 ]]; do LATEST_VERSION="$2" shift 2 ;; + --install-version) + INSTALL_VERSION="$2" + shift 2 + ;; + --target-package-spec) + TARGET_PACKAGE_SPEC="$2" + shift 2 + ;; --skip-latest-ref-check) CHECK_LATEST_REF=0 shift @@ -420,6 +443,8 @@ summary = { "snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"], "mode": os.environ["SUMMARY_MODE"], "latestVersion": os.environ["SUMMARY_LATEST_VERSION"], + "installVersion": os.environ["SUMMARY_INSTALL_VERSION"], + "targetPackageSpec": os.environ["SUMMARY_TARGET_PACKAGE_SPEC"], "currentHead": os.environ["SUMMARY_CURRENT_HEAD"], "runDir": os.environ["SUMMARY_RUN_DIR"], "freshMain": { @@ -509,16 +534,41 @@ else: PY } +acquire_build_lock() { + local owner_pid="" + while ! mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do + if [[ -f "$BUILD_LOCK_DIR/pid" ]]; then + owner_pid="$(cat "$BUILD_LOCK_DIR/pid" 2>/dev/null || true)" + if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" >/dev/null 2>&1; then + warn "Removing stale Parallels build lock" + rm -rf "$BUILD_LOCK_DIR" + continue + fi + fi + sleep 1 + done + printf '%s\n' "$$" >"$BUILD_LOCK_DIR/pid" +} + +release_build_lock() { + if [[ -d "$BUILD_LOCK_DIR" ]]; then + rm -rf "$BUILD_LOCK_DIR" + fi +} + ensure_current_build() { local head build_commit + acquire_build_lock head="$(git rev-parse HEAD)" build_commit="$(current_build_commit)" if [[ "$build_commit" == "$head" ]]; then + release_build_lock return fi say "Build dist for current head" pnpm build build_commit="$(current_build_commit)" + release_build_lock [[ "$build_commit" == "$head" ]] || die "dist/build-info.json still does not match HEAD after build" } @@ -530,6 +580,7 @@ ensure_guest_git() { return fi guest_exec cmd.exe /d /s /c "if exist \"%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\" rmdir /s /q \"%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\"" + guest_exec cmd.exe /d /s /c "if not exist \"%LOCALAPPDATA%\\OpenClaw\\deps\" mkdir \"%LOCALAPPDATA%\\OpenClaw\\deps\"" guest_exec cmd.exe /d /s /c "mkdir \"%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\"" guest_exec cmd.exe /d /s /c "curl.exe -fsSL \"$mingit_url\" -o \"%TEMP%\\$MINGIT_ZIP_NAME\"" guest_exec cmd.exe /d /s /c "tar.exe -xf \"%TEMP%\\$MINGIT_ZIP_NAME\" -C \"%LOCALAPPDATA%\\OpenClaw\\deps\\portable-git\"" @@ -537,9 +588,30 @@ ensure_guest_git() { } pack_main_tgz() { + local mingit_name mingit_url short_head pkg + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + say "Pack target package tgz: $TARGET_PACKAGE_SPEC" + mapfile -t mingit_meta < <(resolve_mingit_download) + mingit_name="${mingit_meta[0]}" + mingit_url="${mingit_meta[1]}" + MINGIT_ZIP_NAME="$mingit_name" + MINGIT_ZIP_PATH="$MAIN_TGZ_DIR/$mingit_name" + if [[ ! -f "$MINGIT_ZIP_PATH" ]]; then + say "Download $MINGIT_ZIP_NAME" + curl -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH" + fi + pkg="$( + npm pack "$TARGET_PACKAGE_SPEC" --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ + | python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])' + )" + MAIN_TGZ_PATH="$MAIN_TGZ_DIR/$(basename "$pkg")" + TARGET_EXPECT_VERSION="$(tar -xOf "$MAIN_TGZ_PATH" package/package.json | python3 -c "import json, sys; print(json.load(sys.stdin)['version'])")" + say "Packed $MAIN_TGZ_PATH" + say "Target package version: $TARGET_EXPECT_VERSION" + return + fi say "Pack current main tgz" ensure_current_build - local mingit_name mingit_url mapfile -t mingit_meta < <(resolve_mingit_download) mingit_name="${mingit_meta[0]}" mingit_url="${mingit_meta[1]}" @@ -549,7 +621,6 @@ pack_main_tgz() { say "Download $MINGIT_ZIP_NAME" curl -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH" fi - local short_head pkg short_head="$(git rev-parse --short HEAD)" pkg="$( npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ @@ -561,6 +632,14 @@ pack_main_tgz() { tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json } +verify_target_version() { + if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then + verify_version_contains "$TARGET_EXPECT_VERSION" + return + fi + verify_version_contains "$(git rev-parse --short=7 HEAD)" +} + start_server() { local host_ip="$1" local artifact probe_url attempt @@ -568,7 +647,7 @@ start_server() { attempt=0 while :; do attempt=$((attempt + 1)) - say "Serve current main tgz on $host_ip:$HOST_PORT" + say "Serve $(artifact_label) on $host_ip:$HOST_PORT" ( cd "$MAIN_TGZ_DIR" exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0 @@ -591,12 +670,16 @@ start_server() { } install_latest_release() { - local install_url_q + local install_url_q version_flag_q install_url_q="$(ps_single_quote "$INSTALL_URL")" + version_flag_q="" + if [[ -n "$INSTALL_VERSION" ]]; then + version_flag_q="-Tag '$(ps_single_quote "$INSTALL_VERSION")' " + fi guest_powershell "$(cat <main precheck: %s (%s)\n' "$UPGRADE_PRECHECK_STATUS" "$LATEST_INSTALLED_VERSION" printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION" diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 021ff1f905e..17d41da6dad 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -42,8 +42,8 @@ const unitIsolatedFilesRaw = [ "src/commands/agent.test.ts", "src/media/store.test.ts", "src/media/store.header-ext.test.ts", - "src/web/media.test.ts", - "src/web/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts", + "extensions/whatsapp/src/media.test.ts", + "extensions/whatsapp/src/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts", "src/browser/server.covers-additional-endpoint-branches.test.ts", "src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts", "src/browser/server.agent-contract-snapshot-endpoints.test.ts", @@ -80,15 +80,15 @@ const unitIsolatedFilesRaw = [ "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts", "src/auto-reply/reply.triggers.group-intro-prompts.test.ts", "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts", - "src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts", + "extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts", // Setup-heavy bot bootstrap suite. - "src/telegram/bot.create-telegram-bot.test.ts", + "extensions/telegram/src/bot.create-telegram-bot.test.ts", // Medium-heavy bot behavior suite; move off unit-fast critical path. - "src/telegram/bot.test.ts", + "extensions/telegram/src/bot.test.ts", // Slack slash registration tests are setup-heavy and can bottleneck unit-fast. - "src/slack/monitor/slash.test.ts", + "extensions/slack/src/monitor/slash.test.ts", // Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage. - "src/imessage/monitor.shutdown.unhandled-rejection.test.ts", + "extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts", // Mutates process.cwd() and mocks core module loaders; isolate from the shared fast lane. "src/infra/git-commit.test.ts", ]; @@ -303,7 +303,13 @@ const passthroughRequiresSingleRun = passthroughOptionArgs.some((arg) => { const [flag] = arg.split("=", 1); return SINGLE_RUN_ONLY_FLAGS.has(flag); }); -const channelPrefixes = ["src/telegram/", "src/discord/", "src/web/", "src/browser/", "src/line/"]; +const channelPrefixes = [ + "extensions/telegram/", + "extensions/discord/", + "extensions/whatsapp/", + "src/browser/", + "src/line/", +]; const baseConfigPrefixes = ["src/agents/", "src/auto-reply/", "src/commands/", "test/", "ui/"]; const normalizeRepoPath = (value) => value.split(path.sep).join("/"); const walkTestFiles = (rootDir) => { diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index 7053feb19a8..beb5db5481b 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -// `tsc` emits declarations under `dist/plugin-sdk/plugin-sdk/*` because the source lives -// at `src/plugin-sdk/*` and `rootDir` is `src/`. +// `tsc` emits declarations under `dist/plugin-sdk/src/plugin-sdk/*` because the source lives +// at `src/plugin-sdk/*` and `rootDir` is `.` (repo root, to support cross-src/extensions refs). // // Our package export map points subpath `types` at `dist/plugin-sdk/.d.ts`, so we // generate stable entry d.ts files that re-export the real declarations. @@ -56,5 +56,5 @@ for (const entry of entrypoints) { const out = path.join(process.cwd(), `dist/plugin-sdk/${entry}.d.ts`); fs.mkdirSync(path.dirname(out), { recursive: true }); // NodeNext: reference the runtime specifier with `.js`, TS will map it to `.d.ts`. - fs.writeFileSync(out, `export * from "./plugin-sdk/${entry}.js";\n`, "utf8"); + fs.writeFileSync(out, `export * from "./src/plugin-sdk/${entry}.js";\n`, "utf8"); } diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 1ddd1d9ceef..38e3530f011 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -364,6 +364,23 @@ describe("failover-error", () => { expect(isTimeoutError(err)).toBe(true); }); + it("classifies abort-wrapped RESOURCE_EXHAUSTED as rate_limit", () => { + const err = Object.assign(new Error("request aborted"), { + name: "AbortError", + cause: { + error: { + code: 429, + message: GEMINI_RESOURCE_EXHAUSTED_MESSAGE, + status: "RESOURCE_EXHAUSTED", + }, + }, + }); + + expect(resolveFailoverReasonFromError(err)).toBe("rate_limit"); + expect(coerceToFailoverError(err)?.reason).toBe("rate_limit"); + expect(coerceToFailoverError(err)?.status).toBe(429); + }); + it("coerces failover-worthy errors into FailoverError with metadata", () => { const err = coerceToFailoverError("credit balance too low", { provider: "anthropic", diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 8c49df40acb..dd482310a2b 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -68,7 +68,30 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine } } -function getStatusCode(err: unknown): number | undefined { +function findErrorProperty( + err: unknown, + reader: (candidate: unknown) => T | undefined, + seen: Set = new Set(), +): T | undefined { + const direct = reader(err); + if (direct !== undefined) { + return direct; + } + if (!err || typeof err !== "object") { + return undefined; + } + if (seen.has(err)) { + return undefined; + } + seen.add(err); + const candidate = err as { error?: unknown; cause?: unknown }; + return ( + findErrorProperty(candidate.error, reader, seen) ?? + findErrorProperty(candidate.cause, reader, seen) + ); +} + +function readDirectStatusCode(err: unknown): number | undefined { if (!err || typeof err !== "object") { return undefined; } @@ -84,38 +107,87 @@ function getStatusCode(err: unknown): number | undefined { return undefined; } -function getErrorCode(err: unknown): string | undefined { +function getStatusCode(err: unknown): number | undefined { + return findErrorProperty(err, readDirectStatusCode); +} + +function readDirectErrorCode(err: unknown): string | undefined { if (!err || typeof err !== "object") { return undefined; } - const candidate = (err as { code?: unknown }).code; - if (typeof candidate !== "string") { + const directCode = (err as { code?: unknown }).code; + if (typeof directCode === "string") { + const trimmed = directCode.trim(); + return trimmed ? trimmed : undefined; + } + const status = (err as { status?: unknown }).status; + if (typeof status !== "string" || /^\d+$/.test(status)) { return undefined; } - const trimmed = candidate.trim(); + const trimmed = status.trim(); return trimmed ? trimmed : undefined; } -function getErrorMessage(err: unknown): string { +function getErrorCode(err: unknown): string | undefined { + return findErrorProperty(err, readDirectErrorCode); +} + +function readDirectErrorMessage(err: unknown): string | undefined { if (err instanceof Error) { - return err.message; + return err.message || undefined; } if (typeof err === "string") { - return err; + return err || undefined; } if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") { return String(err); } if (typeof err === "symbol") { - return err.description ?? ""; + return err.description ?? undefined; } if (err && typeof err === "object") { const message = (err as { message?: unknown }).message; if (typeof message === "string") { - return message; + return message || undefined; } } - return ""; + return undefined; +} + +function getErrorMessage(err: unknown): string { + return findErrorProperty(err, readDirectErrorMessage) ?? ""; +} + +function getErrorCause(err: unknown): unknown { + if (!err || typeof err !== "object" || !("cause" in err)) { + return undefined; + } + return (err as { cause?: unknown }).cause; +} + +/** Classify rate-limit / overloaded from symbolic error codes like RESOURCE_EXHAUSTED. */ +function classifyFailoverReasonFromSymbolicCode(raw: string | undefined): FailoverReason | null { + const normalized = raw?.trim().toUpperCase(); + if (!normalized) { + return null; + } + switch (normalized) { + case "RESOURCE_EXHAUSTED": + case "RATE_LIMIT": + case "RATE_LIMITED": + case "RATE_LIMIT_EXCEEDED": + case "TOO_MANY_REQUESTS": + case "THROTTLED": + case "THROTTLING": + case "THROTTLINGEXCEPTION": + case "THROTTLING_EXCEPTION": + return "rate_limit"; + case "OVERLOADED": + case "OVERLOADED_ERROR": + return "overloaded"; + default: + return null; + } } function hasTimeoutHint(err: unknown): boolean { @@ -160,6 +232,12 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n return statusReason; } + // Check symbolic error codes (e.g. RESOURCE_EXHAUSTED from Google APIs) + const symbolicCodeReason = classifyFailoverReasonFromSymbolicCode(getErrorCode(err)); + if (symbolicCodeReason) { + return symbolicCodeReason; + } + const code = (getErrorCode(err) ?? "").toUpperCase(); if ( [ @@ -178,6 +256,16 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n ) { return "timeout"; } + // Walk into error cause chain *before* timeout heuristics so that a specific + // cause (e.g. RESOURCE_EXHAUSTED wrapped in AbortError) overrides a parent + // message-based "timeout" guess from isTimeoutError. + const cause = getErrorCause(err); + if (cause && cause !== err) { + const causeReason = resolveFailoverReasonFromError(cause); + if (causeReason) { + return causeReason; + } + } if (isTimeoutError(err)) { return "timeout"; } diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 3969416cd38..e80c3e3edd4 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -331,6 +331,77 @@ describe("runWithModelFallback – probe logic", () => { }); }); + it("keeps walking remaining fallbacks after an abort-wrapped RESOURCE_EXHAUSTED probe failure", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "google/gemini-3-flash-preview", + fallbacks: ["anthropic/claude-haiku-3-5", "deepseek/deepseek-chat"], + }, + }, + }, + } as Partial); + + mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => { + if (provider === "google") { + return ["google-profile-1"]; + } + if (provider === "anthropic") { + return ["anthropic-profile-1"]; + } + if (provider === "deepseek") { + return ["deepseek-profile-1"]; + } + return []; + }); + mockedIsProfileInCooldown.mockImplementation((_store, profileId: string) => + profileId.startsWith("google"), + ); + mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 1000); + mockedResolveProfilesUnavailableReason.mockReturnValue("rate_limit"); + + // Simulate Google Vertex abort-wrapped RESOURCE_EXHAUSTED (the shape that was + // previously swallowed by shouldRethrowAbort before the fallback loop could continue) + const primaryAbort = Object.assign(new Error("request aborted"), { + name: "AbortError", + cause: { + error: { + code: 429, + message: "Resource has been exhausted (e.g. check quota).", + status: "RESOURCE_EXHAUSTED", + }, + }, + }); + const run = vi + .fn() + .mockRejectedValueOnce(primaryAbort) + .mockRejectedValueOnce( + Object.assign(new Error("fallback still rate limited"), { status: 429 }), + ) + .mockRejectedValueOnce( + Object.assign(new Error("final fallback still rate limited"), { status: 429 }), + ); + + await expect( + runWithModelFallback({ + cfg, + provider: "google", + model: "gemini-3-flash-preview", + run, + }), + ).rejects.toThrow(/All models failed \(3\)/); + + // All three candidates must be attempted — the abort must not short-circuit + expect(run).toHaveBeenCalledTimes(3); + + expect(run).toHaveBeenNthCalledWith(1, "google", "gemini-3-flash-preview", { + allowTransientCooldownProbe: true, + }); + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5"); + expect(run).toHaveBeenNthCalledWith(3, "deepseek", "deepseek-chat"); + }); + it("throttles probe when called within 30s interval", async () => { const cfg = makeCfg(); // Cooldown just about to expire (within probe margin) diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index d14ede7658b..5fd6e533a1a 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -140,10 +140,16 @@ async function runFallbackCandidate(params: { result, }; } catch (err) { - if (shouldRethrowAbort(err)) { + // Normalize abort-wrapped rate-limit errors (e.g. Google Vertex RESOURCE_EXHAUSTED) + // so they become FailoverErrors and continue the fallback loop instead of aborting. + const normalizedFailover = coerceToFailoverError(err, { + provider: params.provider, + model: params.model, + }); + if (shouldRethrowAbort(err) && !normalizedFailover) { throw err; } - return { ok: false, error: err }; + return { ok: false, error: normalizedFailover ?? err }; } } diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 8c490e113d4..63678333bed 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -7,6 +7,9 @@ import { estimateTokens, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { resolveSignalReactionLevel } from "../../../extensions/signal/src/reaction-level.js"; +import { resolveTelegramInlineButtonsScope } from "../../../extensions/telegram/src/inline-buttons.js"; +import { resolveTelegramReactionLevel } from "../../../extensions/telegram/src/reaction-level.js"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; @@ -23,9 +26,6 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; -import { resolveSignalReactionLevel } from "../../signal/reaction-level.js"; -import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js"; -import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index 3e3d4a83461..53e73e6246d 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -209,9 +209,36 @@ vi.mock("../defaults.js", () => ({ DEFAULT_PROVIDER: "anthropic", })); +type MockFailoverErrorDescription = { + message: string; + reason: string | undefined; + status: number | undefined; + code: string | undefined; +}; + +type MockCoerceToFailoverError = ( + err: unknown, + params?: { provider?: string; model?: string; profileId?: string }, +) => unknown; +type MockDescribeFailoverError = (err: unknown) => MockFailoverErrorDescription; +type MockResolveFailoverStatus = (reason: string) => number | undefined; + +export const mockedCoerceToFailoverError = vi.fn(); +export const mockedDescribeFailoverError = vi.fn( + (err: unknown): MockFailoverErrorDescription => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + }), +); +export const mockedResolveFailoverStatus = vi.fn(); + vi.mock("../failover-error.js", () => ({ FailoverError: class extends Error {}, - resolveFailoverStatus: vi.fn(), + coerceToFailoverError: mockedCoerceToFailoverError, + describeFailoverError: mockedDescribeFailoverError, + resolveFailoverStatus: mockedResolveFailoverStatus, })); vi.mock("./lanes.js", () => ({ diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index b9f7707c0b6..d18123a4ae2 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -9,7 +9,12 @@ import { mockOverflowRetrySuccess, queueOverflowAttemptWithOversizedToolOutput, } from "./run.overflow-compaction.fixture.js"; -import { mockedGlobalHookRunner } from "./run.overflow-compaction.mocks.shared.js"; +import { + mockedCoerceToFailoverError, + mockedDescribeFailoverError, + mockedGlobalHookRunner, + mockedResolveFailoverStatus, +} from "./run.overflow-compaction.mocks.shared.js"; import { mockedContextEngine, mockedCompactDirect, @@ -25,6 +30,9 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { vi.clearAllMocks(); mockedRunEmbeddedAttempt.mockReset(); mockedCompactDirect.mockReset(); + mockedCoerceToFailoverError.mockReset(); + mockedDescribeFailoverError.mockReset(); + mockedResolveFailoverStatus.mockReset(); mockedSessionLikelyHasOversizedToolResults.mockReset(); mockedTruncateOversizedToolResultsInSession.mockReset(); mockedGlobalHookRunner.runBeforeAgentStart.mockReset(); @@ -36,6 +44,13 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { compacted: false, reason: "nothing to compact", }); + mockedCoerceToFailoverError.mockReturnValue(null); + mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + })); mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false); mockedTruncateOversizedToolResultsInSession.mockResolvedValue({ truncated: false, @@ -255,4 +270,57 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(result.meta.error?.kind).toBe("retry_limit"); expect(result.payloads?.[0]?.isError).toBe(true); }); + + it("normalizes abort-wrapped prompt errors before handing off to model fallback", async () => { + const promptError = Object.assign(new Error("request aborted"), { + name: "AbortError", + cause: { + error: { + code: 429, + message: "Resource has been exhausted (e.g. check quota).", + status: "RESOURCE_EXHAUSTED", + }, + }, + }); + const normalized = Object.assign(new Error("Resource has been exhausted (e.g. check quota)."), { + name: "FailoverError", + reason: "rate_limit", + status: 429, + }); + + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError })); + mockedCoerceToFailoverError.mockReturnValueOnce(normalized); + mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ + message: err instanceof Error ? err.message : String(err), + reason: err === normalized ? "rate_limit" : undefined, + status: err === normalized ? 429 : undefined, + code: undefined, + })); + mockedResolveFailoverStatus.mockReturnValueOnce(429); + + await expect( + runEmbeddedPiAgent({ + ...overflowBaseRunParams, + config: { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.2"], + }, + }, + }, + }, + }), + ).rejects.toBe(normalized); + + expect(mockedCoerceToFailoverError).toHaveBeenCalledWith( + promptError, + expect.objectContaining({ + provider: "anthropic", + model: "test-model", + profileId: "test-profile", + }), + ); + expect(mockedResolveFailoverStatus).toHaveBeenCalledWith("rate_limit"); + }); }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 1839a9df1bb..4ca6c0ea226 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -28,7 +28,12 @@ import { resolveContextWindowInfo, } from "../context-window-guard.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; -import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; +import { + coerceToFailoverError, + describeFailoverError, + FailoverError, + resolveFailoverStatus, +} from "../failover-error.js"; import { applyLocalNoAuthHeaderOverride, ensureAuthProfileStore, @@ -1217,7 +1222,17 @@ export async function runEmbeddedPiAgent( } if (promptError && !aborted) { - const errorText = describeUnknownError(promptError); + // Normalize wrapped errors (e.g. abort-wrapped RESOURCE_EXHAUSTED) into + // FailoverError so rate-limit classification works even for nested shapes. + const normalizedPromptFailover = coerceToFailoverError(promptError, { + provider: activeErrorContext.provider, + model: activeErrorContext.model, + profileId: lastProfileId, + }); + const promptErrorDetails = normalizedPromptFailover + ? describeFailoverError(normalizedPromptFailover) + : describeFailoverError(promptError); + const errorText = promptErrorDetails.message || describeUnknownError(promptError); if (await maybeRefreshCopilotForAuthError(errorText, copilotAuthRetry)) { authRetryPending = true; continue; @@ -1281,14 +1296,16 @@ export async function runEmbeddedPiAgent( }, }; } - const promptFailoverReason = classifyFailoverReason(errorText); + const promptFailoverReason = + promptErrorDetails.reason ?? classifyFailoverReason(errorText); const promptProfileFailureReason = resolveAuthProfileFailureReason(promptFailoverReason); await maybeMarkAuthProfileFailure({ profileId: lastProfileId, reason: promptProfileFailureReason, }); - const promptFailoverFailure = isFailoverErrorMessage(errorText); + const promptFailoverFailure = + promptFailoverReason !== null || isFailoverErrorMessage(errorText); // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. const failedPromptProfileId = lastProfileId; const logPromptFailoverDecision = createFailoverDecisionLogger({ @@ -1330,13 +1347,16 @@ export async function runEmbeddedPiAgent( const status = resolveFailoverStatus(promptFailoverReason ?? "unknown"); logPromptFailoverDecision("fallback_model", { status }); await maybeBackoffBeforeOverloadFailover(promptFailoverReason); - throw new FailoverError(errorText, { - reason: promptFailoverReason ?? "unknown", - provider, - model: modelId, - profileId: lastProfileId, - status, - }); + throw ( + normalizedPromptFailover ?? + new FailoverError(errorText, { + reason: promptFailoverReason ?? "unknown", + provider, + model: modelId, + profileId: lastProfileId, + status: resolveFailoverStatus(promptFailoverReason ?? "unknown"), + }) + ); } if (promptFailoverFailure || promptFailoverReason) { logPromptFailoverDecision("surface_error"); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 274ef0ef865..7361f8b9e00 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -7,6 +7,9 @@ import { DefaultResourceLoader, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { resolveSignalReactionLevel } from "../../../../extensions/signal/src/reaction-level.js"; +import { resolveTelegramInlineButtonsScope } from "../../../../extensions/telegram/src/inline-buttons.js"; +import { resolveTelegramReactionLevel } from "../../../../extensions/telegram/src/reaction-level.js"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../../config/config.js"; @@ -24,9 +27,6 @@ import type { } from "../../../plugins/types.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; -import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; -import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; -import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; import { buildTtsSystemPromptHint } from "../../../tts/tts.js"; import { resolveUserPath } from "../../../utils.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; diff --git a/src/agents/pi-embedded-runner/run/images.ts b/src/agents/pi-embedded-runner/run/images.ts index caf78f739ba..a1899bb99af 100644 --- a/src/agents/pi-embedded-runner/run/images.ts +++ b/src/agents/pi-embedded-runner/run/images.ts @@ -1,8 +1,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ImageContent } from "@mariozechner/pi-ai"; +import { loadWebMedia } from "../../../../extensions/whatsapp/src/media.js"; import { resolveUserPath } from "../../../utils.js"; -import { loadWebMedia } from "../../../web/media.js"; import type { ImageSanitizationLimits } from "../../image-sanitization.js"; import { createSandboxBridgeReadFile, diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index 0623101c2d7..ed705842ada 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -160,9 +160,8 @@ describe("createOpenClawCodingTools", () => { it("mentions Chrome extension relay in browser tool description", () => { const browser = createBrowserTool(); expect(browser.description).toMatch(/Chrome extension/i); - expect(browser.description).toMatch(/browserSession="agent"/i); - expect(browser.description).toMatch(/browserSession="user"/i); - expect(browser.description).toMatch(/profile="chrome"/i); + expect(browser.description).toMatch(/profile="user"/i); + expect(browser.description).toMatch(/profile="chrome-relay"/i); }); it("keeps browser tool schema properties after normalization", () => { const browser = defaultTools.find((tool) => tool.name === "browser"); @@ -174,7 +173,6 @@ describe("createOpenClawCodingTools", () => { }; expect(parameters.properties?.action).toBeDefined(); expect(parameters.properties?.target).toBeDefined(); - expect(parameters.properties?.browserSession).toBeDefined(); expect(parameters.properties?.targetUrl).toBeDefined(); expect(parameters.properties?.request).toBeDefined(); expect(parameters.required ?? []).toContain("action"); diff --git a/src/agents/tool-display-common.ts b/src/agents/tool-display-common.ts index a7564c98052..f5d231fd898 100644 --- a/src/agents/tool-display-common.ts +++ b/src/agents/tool-display-common.ts @@ -1081,9 +1081,10 @@ export function resolveExecDetail(args: unknown): string | undefined { const displaySummary = cwd ? `${summary} (in ${cwd})` : summary; - // Append the raw command when the summary differs meaningfully from the command itself. + // Keep the raw command inline so chat surfaces do not break "Exec:" onto a + // separate paragraph/code block. if (compact && compact !== displaySummary && compact !== summary) { - return `${displaySummary}\n\n\`${compact}\``; + return `${displaySummary} · \`${compact}\``; } return displaySummary; diff --git a/src/agents/tool-display.test.ts b/src/agents/tool-display.test.ts index b41db4d0552..19ef7652ffb 100644 --- a/src/agents/tool-display.test.ts +++ b/src/agents/tool-display.test.ts @@ -112,9 +112,7 @@ describe("tool display details", () => { }), ); - expect(detail).toBe( - "install dependencies (in ~/my-project)\n\n`cd ~/my-project && npm install`", - ); + expect(detail).toBe("install dependencies (in ~/my-project), `cd ~/my-project && npm install`"); }); it("moves cd path to context suffix with multiple stages and raw command", () => { @@ -126,7 +124,7 @@ describe("tool display details", () => { ); expect(detail).toBe( - "install dependencies → run tests (in ~/my-project)\n\n`cd ~/my-project && npm install && npm test`", + "install dependencies → run tests (in ~/my-project), `cd ~/my-project && npm install && npm test`", ); }); @@ -138,7 +136,7 @@ describe("tool display details", () => { }), ); - expect(detail).toBe("check git status (in /tmp)\n\n`pushd /tmp && git status`"); + expect(detail).toBe("check git status (in /tmp), `pushd /tmp && git status`"); }); it("clears inferred cwd when popd is stripped from preamble", () => { @@ -149,7 +147,7 @@ describe("tool display details", () => { }), ); - expect(detail).toBe("install dependencies\n\n`pushd /tmp && popd && npm install`"); + expect(detail).toBe("install dependencies, `pushd /tmp && popd && npm install`"); }); it("moves cd path to context suffix with || separator", () => { @@ -173,7 +171,7 @@ describe("tool display details", () => { }), ); - expect(detail).toBe("install dependencies (in /app)\n\n`cd /tmp && npm install`"); + expect(detail).toBe("install dependencies (in /app), `cd /tmp && npm install`"); }); it("summarizes all stages and appends raw command", () => { @@ -185,7 +183,7 @@ describe("tool display details", () => { ); expect(detail).toBe( - "fetch git changes → rebase git branch\n\n`git fetch && git rebase origin/main`", + "fetch git changes → rebase git branch, `git fetch && git rebase origin/main`", ); }); diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index 52551b166a3..a4b6cb456af 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -74,7 +74,7 @@ function formatConsoleToolResult(result: { } function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean { - if (profile !== "chrome") { + if (profile !== "chrome-relay" && profile !== "chrome") { return false; } const msg = String(err); @@ -340,7 +340,7 @@ export async function executeActAction(params: { ); } throw new Error( - `Chrome tab not found (stale targetId?). Run action=tabs profile="chrome" and use one of the returned targetIds.`, + `Chrome tab not found (stale targetId?). Run action=tabs profile="chrome-relay" and use one of the returned targetIds.`, { cause: err }, ); } diff --git a/src/agents/tools/browser-tool.schema.ts b/src/agents/tools/browser-tool.schema.ts index 3c1a46af3f0..aef51f6359d 100644 --- a/src/agents/tools/browser-tool.schema.ts +++ b/src/agents/tools/browser-tool.schema.ts @@ -35,7 +35,6 @@ const BROWSER_TOOL_ACTIONS = [ ] as const; const BROWSER_TARGETS = ["sandbox", "host", "node"] as const; -const BROWSER_SESSION_CHOICES = ["agent", "user"] as const; const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const; const BROWSER_SNAPSHOT_MODES = ["efficient"] as const; @@ -89,7 +88,6 @@ const BrowserActSchema = Type.Object({ export const BrowserToolSchema = Type.Object({ action: stringEnum(BROWSER_TOOL_ACTIONS), target: optionalStringEnum(BROWSER_TARGETS), - browserSession: optionalStringEnum(BROWSER_SESSION_CHOICES), node: Type.Optional(Type.String()), profile: Type.Optional(Type.String()), targetUrl: Type.Optional(Type.String()), diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index 5f35077fa98..adaaea78221 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -187,7 +187,6 @@ async function runSnapshotToolCall(params: { refs?: "aria" | "dom"; maxChars?: number; profile?: string; - browserSession?: "agent" | "user"; }) { const tool = createBrowserTool(); await tool.execute?.("call-1", { action: "snapshot", ...params }); @@ -288,58 +287,56 @@ describe("browser tool snapshot maxChars", () => { expect(opts?.mode).toBeUndefined(); }); - it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => { - const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); - await tool.execute?.("call-1", { action: "snapshot", profile: "chrome", snapshotFormat: "ai" }); - - expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( - undefined, - expect.objectContaining({ - profile: "chrome", - }), - ); - }); - - it('uses the isolated openclaw profile for browserSession="agent"', async () => { - await runSnapshotToolCall({ browserSession: "agent", snapshotFormat: "ai" }); - - expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( - undefined, - expect.objectContaining({ - profile: "openclaw", - }), - ); - }); - - it('uses the host user browser for browserSession="user"', async () => { + it("defaults to host when using profile=chrome-relay (even in sandboxed sessions)", async () => { setResolvedBrowserProfiles({ - openclaw: { cdpPort: 18800, color: "#FF4500" }, - chrome: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" }, + "chrome-relay": { + driver: "extension", + cdpUrl: "http://127.0.0.1:18792", + color: "#0066CC", + }, }); const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); await tool.execute?.("call-1", { action: "snapshot", - browserSession: "user", + profile: "chrome-relay", snapshotFormat: "ai", }); expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( undefined, expect.objectContaining({ - profile: "chrome", + profile: "chrome-relay", }), ); }); - it('uses a sole existing-session profile for browserSession="user"', async () => { + it("defaults to host when using profile=user (even in sandboxed sessions)", async () => { + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); + await tool.execute?.("call-1", { + action: "snapshot", + profile: "user", + snapshotFormat: "ai", + }); + + expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + profile: "user", + }), + ); + }); + + it("defaults to host for custom existing-session profiles too", async () => { setResolvedBrowserProfiles({ - openclaw: { cdpPort: 18800, color: "#FF4500" }, "chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" }, }); const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); await tool.execute?.("call-1", { action: "snapshot", - browserSession: "user", + profile: "chrome-live", snapshotFormat: "ai", }); @@ -351,47 +348,30 @@ describe("browser tool snapshot maxChars", () => { ); }); - it('fails when browserSession="user" is ambiguous', async () => { + it('rejects profile="user" with target="sandbox"', async () => { setResolvedBrowserProfiles({ - openclaw: { cdpPort: 18800, color: "#FF4500" }, - personal: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, - work: { driver: "existing-session", attachOnly: true, color: "#0066CC" }, - }); - const tool = createBrowserTool(); - - await expect( - tool.execute?.("call-1", { - action: "snapshot", - browserSession: "user", - snapshotFormat: "ai", - }), - ).rejects.toThrow(/Multiple user-browser profiles are configured/); - }); - - it('rejects browserSession="user" with target="sandbox"', async () => { - setResolvedBrowserProfiles({ - chrome: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" }, + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, }); const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); await expect( tool.execute?.("call-1", { action: "snapshot", - browserSession: "user", + profile: "user", target: "sandbox", snapshotFormat: "ai", }), - ).rejects.toThrow(/cannot use the sandbox browser/); + ).rejects.toThrow(/profile="user" cannot use the sandbox browser/i); }); it("lets the server choose snapshot format when the user does not request one", async () => { const tool = createBrowserTool(); - await tool.execute?.("call-1", { action: "snapshot", profile: "chrome" }); + await tool.execute?.("call-1", { action: "snapshot", profile: "chrome-relay" }); expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( undefined, expect.objectContaining({ - profile: "chrome", + profile: "chrome-relay", }), ); const opts = browserClientMocks.browserSnapshot.mock.calls.at(-1)?.[1] as @@ -458,14 +438,21 @@ describe("browser tool snapshot maxChars", () => { expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled(); }); - it("keeps chrome profile on host when node proxy is available", async () => { + it("keeps chrome-relay profile on host when node proxy is available", async () => { mockSingleBrowserProxyNode(); + setResolvedBrowserProfiles({ + "chrome-relay": { + driver: "extension", + cdpUrl: "http://127.0.0.1:18792", + color: "#0066CC", + }, + }); const tool = createBrowserTool(); - await tool.execute?.("call-1", { action: "status", profile: "chrome" }); + await tool.execute?.("call-1", { action: "status", profile: "chrome-relay" }); expect(browserClientMocks.browserStatus).toHaveBeenCalledWith( undefined, - expect.objectContaining({ profile: "chrome" }), + expect.objectContaining({ profile: "chrome-relay" }), ); expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled(); }); @@ -758,7 +745,7 @@ describe("browser tool external content wrapping", () => { describe("browser tool act stale target recovery", () => { registerBrowserToolAfterEachReset(); - it("retries safe chrome act once without targetId when exactly one tab remains", async () => { + it("retries safe chrome-relay act once without targetId when exactly one tab remains", async () => { browserActionsMocks.browserAct .mockRejectedValueOnce(new Error("404: tab not found")) .mockResolvedValueOnce({ ok: true }); @@ -767,7 +754,7 @@ describe("browser tool act stale target recovery", () => { const tool = createBrowserTool(); const result = await tool.execute?.("call-1", { action: "act", - profile: "chrome", + profile: "chrome-relay", request: { kind: "hover", targetId: "stale-tab", @@ -780,18 +767,18 @@ describe("browser tool act stale target recovery", () => { 1, undefined, expect.objectContaining({ targetId: "stale-tab", kind: "hover", ref: "btn-1" }), - expect.objectContaining({ profile: "chrome" }), + expect.objectContaining({ profile: "chrome-relay" }), ); expect(browserActionsMocks.browserAct).toHaveBeenNthCalledWith( 2, undefined, expect.not.objectContaining({ targetId: expect.anything() }), - expect.objectContaining({ profile: "chrome" }), + expect.objectContaining({ profile: "chrome-relay" }), ); expect(result?.details).toMatchObject({ ok: true }); }); - it("does not retry mutating chrome act requests without targetId", async () => { + it("does not retry mutating chrome-relay act requests without targetId", async () => { browserActionsMocks.browserAct.mockRejectedValueOnce(new Error("404: tab not found")); browserClientMocks.browserTabs.mockResolvedValueOnce([{ targetId: "only-tab" }]); @@ -799,14 +786,14 @@ describe("browser tool act stale target recovery", () => { await expect( tool.execute?.("call-1", { action: "act", - profile: "chrome", + profile: "chrome-relay", request: { kind: "click", targetId: "stale-tab", ref: "btn-1", }, }), - ).rejects.toThrow(/Run action=tabs profile="chrome"/i); + ).rejects.toThrow(/Run action=tabs profile="chrome-relay"/i); expect(browserActionsMocks.browserAct).toHaveBeenCalledTimes(1); }); diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 96f82389303..8cb57435100 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -17,7 +17,6 @@ import { browserStop, } from "../../browser/client.js"; import { resolveBrowserConfig, resolveProfile } from "../../browser/config.js"; -import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME } from "../../browser/constants.js"; import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "../../browser/paths.js"; import { getBrowserProfileCapabilities } from "../../browser/profile-capabilities.js"; import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "../../browser/proxy-files.js"; @@ -280,58 +279,22 @@ function resolveBrowserBaseUrl(params: { return undefined; } -function listUserBrowserProfiles() { +function shouldPreferHostForProfile(profileName: string | undefined) { + if (!profileName) { + return false; + } const cfg = loadConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); - return Object.keys(resolved.profiles ?? {}) - .map((name) => resolveProfile(resolved, name)) - .filter((profile): profile is NonNullable => Boolean(profile)) - .filter((profile) => { - const capabilities = getBrowserProfileCapabilities(profile); - return capabilities.requiresRelay || capabilities.usesChromeMcp; - }); + const profile = resolveProfile(resolved, profileName); + if (!profile) { + return false; + } + const capabilities = getBrowserProfileCapabilities(profile); + return capabilities.requiresRelay || capabilities.usesChromeMcp; } -function resolveBrowserToolProfile(params: { - profile?: string; - browserSession?: "agent" | "user"; -}): string | undefined { - if (params.profile) { - return params.profile; - } - if (!params.browserSession) { - return undefined; - } - if (params.browserSession === "agent") { - return DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME; - } - - const userProfiles = listUserBrowserProfiles(); - const defaultUserProfile = userProfiles.find( - (profile) => profile.name !== DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, - ); - if (defaultUserProfile?.name === "chrome") { - return defaultUserProfile.name; - } - const chromeRelay = userProfiles.find((profile) => profile.name === "chrome"); - if (chromeRelay) { - return chromeRelay.name; - } - if (userProfiles.length === 1) { - return userProfiles[0]?.name; - } - const chromeLive = userProfiles.find((profile) => profile.name === "chrome-live"); - if (chromeLive) { - return chromeLive.name; - } - if (userProfiles.length === 0) { - throw new Error( - 'No user-browser profile is configured. Use profile="chrome" for the extension relay or create an existing-session profile first.', - ); - } - throw new Error( - `Multiple user-browser profiles are configured (${userProfiles.map((profile) => profile.name).join(", ")}). Pass profile="".`, - ); +function isHostOnlyProfileName(profileName: string | undefined) { + return profileName === "user" || profileName === "chrome-relay"; } export function createBrowserTool(opts?: { @@ -347,12 +310,12 @@ export function createBrowserTool(opts?: { name: "browser", description: [ "Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", - 'Browser choice: use browserSession="agent" by default for the isolated OpenClaw browser. Use browserSession="user" only when logged-in browser state matters and the user is present to click/approve browser attach prompts.', - 'browserSession="user" means the real local user browser on the host, not sandbox/node browsers. If user presence is unclear, ask first.', - 'profile remains the explicit override. Use profile="chrome" for Chrome extension relay takeover (existing Chrome tabs). Use profile="openclaw" for the isolated OpenClaw-managed browser.', - 'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS use browserSession="user" and prefer profile="chrome" (do not ask which profile unless ambiguous).', + "Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).", + 'For the logged-in user browser on the local host, prefer profile="user". Use it only when existing logins/cookies matter and the user is present to click/approve any browser attach prompt.', + 'Use profile="chrome-relay" only for the Chrome extension / Browser Relay / toolbar-button attach-tab flow, or when the user explicitly asks for the extension relay.', + 'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS prefer profile="chrome-relay". Otherwise prefer profile="user" over the extension relay for user-browser work.', 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', - "User-browser flows need user interaction: Chrome extension relay needs the user to click the OpenClaw Browser Relay toolbar icon on the tab (badge ON); existing-session may require approving a browser attach prompt.", + 'User-browser flows need user interaction: profile="user" may require approving a browser attach prompt; profile="chrome-relay" needs the user to click the OpenClaw Browser Relay toolbar icon on the tab (badge ON). If user presence is unclear, ask first.', "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).", 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', "Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.", @@ -363,36 +326,25 @@ export function createBrowserTool(opts?: { execute: async (_toolCallId, args) => { const params = args as Record; const action = readStringParam(params, "action", { required: true }); - const browserSession = readStringParam(params, "browserSession") as - | "agent" - | "user" - | undefined; - const profile = resolveBrowserToolProfile({ - profile: readStringParam(params, "profile"), - browserSession, - }); + const profile = readStringParam(params, "profile"); const requestedNode = readStringParam(params, "node"); let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined; if (requestedNode && target && target !== "node") { throw new Error('node is only supported with target="node".'); } - if (browserSession === "user") { + if (isHostOnlyProfileName(profile)) { if (requestedNode || target === "node") { - throw new Error('browserSession="user" only supports the local host browser.'); + throw new Error(`profile="${profile}" only supports the local host browser.`); } if (target === "sandbox") { throw new Error( - 'browserSession="user" cannot use the sandbox browser; use target="host" or omit target.', + `profile="${profile}" cannot use the sandbox browser; use target="host" or omit target.`, ); } } - if (!target && !requestedNode && browserSession === "user") { - target = "host"; - } - - if (!target && !requestedNode && profile === "chrome") { - // Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node. + if (!target && !requestedNode && shouldPreferHostForProfile(profile)) { + // Local host user-browser profiles should not silently bind to sandbox/node browsers. target = "host"; } diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 14df6901024..2976dee3924 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -230,11 +230,22 @@ JOB SCHEMA (for add action): "name": "string (optional)", "schedule": { ... }, // Required: when to run "payload": { ... }, // Required: what to execute - "delivery": { ... }, // Optional: announce summary or webhook POST - "sessionTarget": "main" | "isolated", // Required + "delivery": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST + "sessionTarget": "main" | "isolated" | "current" | "session:", // Optional, defaults based on context "enabled": true | false // Optional, default true } +SESSION TARGET OPTIONS: +- "main": Run in the main session (requires payload.kind="systemEvent") +- "isolated": Run in an ephemeral isolated session (requires payload.kind="agentTurn") +- "current": Bind to the current session where the cron is created (resolved at creation time) +- "session:": Run in a persistent named session (e.g., "session:project-alpha-daily") + +DEFAULT BEHAVIOR (unchanged for backward compatibility): +- payload.kind="systemEvent" → defaults to "main" +- payload.kind="agentTurn" → defaults to "isolated" +To use current session binding, explicitly set sessionTarget="current". + SCHEDULE TYPES (schedule.kind): - "at": One-shot at absolute time { "kind": "at", "at": "" } @@ -260,9 +271,9 @@ DELIVERY (top-level): CRITICAL CONSTRAINTS: - sessionTarget="main" REQUIRES payload.kind="systemEvent" -- sessionTarget="isolated" REQUIRES payload.kind="agentTurn" +- sessionTarget="isolated" | "current" | "session:xxx" REQUIRES payload.kind="agentTurn" - For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL. -Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event. +Default: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding. WAKE MODES (for wake action): - "next-heartbeat" (default): Wake on next heartbeat @@ -346,7 +357,10 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con if (!params.job || typeof params.job !== "object") { throw new Error("job required"); } - const job = normalizeCronJobCreate(params.job) ?? params.job; + const job = + normalizeCronJobCreate(params.job, { + sessionContext: { sessionKey: opts?.agentSessionKey }, + }) ?? params.job; if (job && typeof job === "object") { const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index ba0ba300985..6e08c87a276 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -1,6 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; -import { getPresence } from "../../discord/monitor/presence-cache.js"; +import { getPresence } from "../../../extensions/discord/src/monitor/presence-cache.js"; import { addRoleDiscord, createChannelDiscord, @@ -20,7 +19,8 @@ import { setChannelPermissionDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../discord/send.js"; +} from "../../../extensions/discord/src/send.js"; +import type { DiscordActionConfig } from "../../config/config.js"; import { type ActionGate, jsonResult, diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 7349e65a3e6..c38f2d7066f 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -1,7 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readDiscordComponentSpec } from "../../discord/components.js"; +import { readDiscordComponentSpec } from "../../../extensions/discord/src/components.js"; import { createThreadDiscord, deleteMessageDiscord, @@ -23,9 +21,14 @@ import { sendStickerDiscord, sendVoiceMessageDiscord, unpinMessageDiscord, -} from "../../discord/send.js"; -import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js"; -import { resolveDiscordChannelId } from "../../discord/targets.js"; +} from "../../../extensions/discord/src/send.js"; +import type { + DiscordSendComponents, + DiscordSendEmbeds, +} from "../../../extensions/discord/src/send.shared.js"; +import { resolveDiscordChannelId } from "../../../extensions/discord/src/targets.js"; +import type { DiscordActionConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/config.js"; import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { withNormalizedTimestamp } from "../date-time.js"; diff --git a/src/agents/tools/discord-actions-moderation.authz.test.ts b/src/agents/tools/discord-actions-moderation.authz.test.ts index 606a3178dd6..d6b3651ca88 100644 --- a/src/agents/tools/discord-actions-moderation.authz.test.ts +++ b/src/agents/tools/discord-actions-moderation.authz.test.ts @@ -13,7 +13,7 @@ const discordSendMocks = vi.hoisted(() => ({ const { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord, hasAnyGuildPermissionDiscord } = discordSendMocks; -vi.mock("../../discord/send.js", () => ({ +vi.mock("../../../extensions/discord/src/send.js", () => ({ ...discordSendMocks, })); diff --git a/src/agents/tools/discord-actions-moderation.ts b/src/agents/tools/discord-actions-moderation.ts index c2dd5ebc142..68db19d1d7f 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/src/agents/tools/discord-actions-moderation.ts @@ -1,11 +1,11 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; import { banMemberDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, timeoutMemberDiscord, -} from "../../discord/send.js"; +} from "../../../extensions/discord/src/send.js"; +import type { DiscordActionConfig } from "../../config/config.js"; import { type ActionGate, jsonResult, readStringParam } from "./common.js"; import { isDiscordModerationAction, diff --git a/src/agents/tools/discord-actions-presence.test.ts b/src/agents/tools/discord-actions-presence.test.ts index d1476f9b9b3..dc8080666c6 100644 --- a/src/agents/tools/discord-actions-presence.test.ts +++ b/src/agents/tools/discord-actions-presence.test.ts @@ -1,7 +1,10 @@ import type { GatewayPlugin } from "@buape/carbon/gateway"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearGateways, + registerGateway, +} from "../../../extensions/discord/src/monitor/gateway-registry.js"; import type { DiscordActionConfig } from "../../config/config.js"; -import { clearGateways, registerGateway } from "../../discord/monitor/gateway-registry.js"; import type { ActionGate } from "./common.js"; import { handleDiscordPresenceAction } from "./discord-actions-presence.js"; diff --git a/src/agents/tools/discord-actions-presence.ts b/src/agents/tools/discord-actions-presence.ts index 90639aa64e4..46f476bafec 100644 --- a/src/agents/tools/discord-actions-presence.ts +++ b/src/agents/tools/discord-actions-presence.ts @@ -1,7 +1,7 @@ import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { getGateway } from "../../../extensions/discord/src/monitor/gateway-registry.js"; import type { DiscordActionConfig } from "../../config/config.js"; -import { getGateway } from "../../discord/monitor/gateway-registry.js"; import { type ActionGate, jsonResult, readStringParam } from "./common.js"; const ACTIVITY_TYPE_MAP: Record = { diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index 95f6c7ec4f2..ab2d71caf23 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -67,7 +67,7 @@ const { timeoutMemberDiscord, } = discordSendMocks; -vi.mock("../../discord/send.js", () => ({ +vi.mock("../../../extensions/discord/src/send.js", () => ({ ...discordSendMocks, })); diff --git a/src/agents/tools/discord-actions.ts b/src/agents/tools/discord-actions.ts index d4533517c8a..9b1c57bb240 100644 --- a/src/agents/tools/discord-actions.ts +++ b/src/agents/tools/discord-actions.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { createDiscordActionGate } from "../../../extensions/discord/src/accounts.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { createDiscordActionGate } from "../../discord/accounts.js"; import { readStringParam } from "./common.js"; import { handleDiscordGuildAction } from "./discord-actions-guild.js"; import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index c1e9537d8c5..4a50263cada 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -1,8 +1,8 @@ import { type Context, complete } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; +import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveUserPath } from "../../utils.js"; -import { loadWebMedia } from "../../web/media.js"; import { isMinimaxVlmModel, isMinimaxVlmProvider, minimaxUnderstandImage } from "../minimax-vlm.js"; import { coerceImageAssistantText, diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index 177bf296275..8ad943a4b91 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -1,6 +1,6 @@ import { type Api, type Model } from "@mariozechner/pi-ai"; +import { getDefaultLocalRoots } from "../../../extensions/whatsapp/src/media.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { getDefaultLocalRoots } from "../../web/media.js"; import type { ImageModelConfig } from "./image-tool.helpers.js"; import { getApiKeyForModel, normalizeWorkspaceDir, requireApiKey } from "./tool-runtime.helpers.js"; diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 96b2702f065..63963ab5f38 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -201,6 +201,11 @@ function buildSendSchema(options: { ), bestEffort: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()), + forceDocument: Type.Optional( + Type.Boolean({ + description: "Send image/GIF as document to avoid Telegram compression (Telegram only).", + }), + ), buttons: Type.Optional( Type.Array( Type.Array( diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index 381fc53c4b9..a9c9539d61d 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -131,7 +131,7 @@ async function stubPdfToolInfra( modelFound?: boolean; }, ) { - const webMedia = await import("../../web/media.js"); + const webMedia = await import("../../../extensions/whatsapp/src/media.js"); const loadSpy = vi.spyOn(webMedia, "loadWebMediaRaw").mockResolvedValue(FAKE_PDF_MEDIA as never); const modelDiscovery = await import("../pi-model-discovery.js"); diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index c03dbe24f84..8f229dd7b10 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -1,9 +1,9 @@ import { type Context, complete } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; +import { loadWebMediaRaw } from "../../../extensions/whatsapp/src/media.js"; import type { OpenClawConfig } from "../../config/config.js"; import { extractPdfContent, type PdfExtractedContent } from "../../media/pdf-extract.js"; import { resolveUserPath } from "../../utils.js"; -import { loadWebMediaRaw } from "../../web/media.js"; import { coerceImageModelConfig, type ImageModelConfig, diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index 8a57602f58e..bf28c2bed01 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -17,7 +17,7 @@ const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({})); const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); -vi.mock("../../slack/actions.js", () => ({ +vi.mock("../../../extensions/slack/src/actions.js", () => ({ deleteSlackMessage: (...args: Parameters) => deleteSlackMessage(...args), downloadSlackFile: (...args: Parameters) => downloadSlackFile(...args), diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 1cb233f06a7..5ed58d5960f 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -1,6 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveSlackAccount } from "../../slack/accounts.js"; +import { resolveSlackAccount } from "../../../extensions/slack/src/accounts.js"; import { deleteSlackMessage, downloadSlackFile, @@ -16,10 +15,11 @@ import { removeSlackReaction, sendSlackMessage, unpinSlackMessage, -} from "../../slack/actions.js"; -import { parseSlackBlocksInput } from "../../slack/blocks-input.js"; -import { recordSlackThreadParticipation } from "../../slack/sent-thread-cache.js"; -import { parseSlackTarget, resolveSlackChannelId } from "../../slack/targets.js"; +} from "../../../extensions/slack/src/actions.js"; +import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-input.js"; +import { recordSlackThreadParticipation } from "../../../extensions/slack/src/sent-thread-cache.js"; +import { parseSlackTarget, resolveSlackChannelId } from "../../../extensions/slack/src/targets.js"; +import type { OpenClawConfig } from "../../config/config.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { createActionGate, diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index e15b4bd2e17..5963a64b667 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -30,7 +30,7 @@ const createForumTopicTelegram = vi.fn(async () => ({ })); let envSnapshot: ReturnType; -vi.mock("../../telegram/send.js", () => ({ +vi.mock("../../../extensions/telegram/src/send.js", () => ({ reactMessageTelegram: (...args: Parameters) => reactMessageTelegram(...args), sendMessageTelegram: (...args: Parameters) => diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 143d154e633..6c8d4f84204 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,17 +1,17 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; -import { resolvePollMaxSelections } from "../../polls.js"; import { createTelegramActionGate, resolveTelegramPollActionGateState, -} from "../../telegram/accounts.js"; -import type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js"; +} from "../../../extensions/telegram/src/accounts.js"; +import type { + TelegramButtonStyle, + TelegramInlineButtons, +} from "../../../extensions/telegram/src/button-types.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../telegram/inline-buttons.js"; -import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; +} from "../../../extensions/telegram/src/inline-buttons.js"; +import { resolveTelegramReactionLevel } from "../../../extensions/telegram/src/reaction-level.js"; import { createForumTopicTelegram, deleteMessageTelegram, @@ -20,9 +20,12 @@ import { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../telegram/send.js"; -import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js"; -import { resolveTelegramToken } from "../../telegram/token.js"; +} from "../../../extensions/telegram/src/send.js"; +import { getCacheStats, searchStickers } from "../../../extensions/telegram/src/sticker-cache.js"; +import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +import { resolvePollMaxSelections } from "../../polls.js"; import { jsonResult, readNumberParam, @@ -249,6 +252,7 @@ export async function handleTelegramAction( quoteText: quoteText ?? undefined, asVoice: readBooleanParam(params, "asVoice"), silent: readBooleanParam(params, "silent"), + forceDocument: readBooleanParam(params, "forceDocument") ?? false, }); return jsonResult({ ok: true, diff --git a/src/agents/tools/whatsapp-actions.test.ts b/src/agents/tools/whatsapp-actions.test.ts index bb0941dbb42..1fc195ffd1e 100644 --- a/src/agents/tools/whatsapp-actions.test.ts +++ b/src/agents/tools/whatsapp-actions.test.ts @@ -8,7 +8,7 @@ const { sendReactionWhatsApp, sendPollWhatsApp } = vi.hoisted(() => ({ sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "jid-1" })), })); -vi.mock("../../web/outbound.js", () => ({ +vi.mock("../../../extensions/whatsapp/src/send.js", () => ({ sendReactionWhatsApp, sendPollWhatsApp, })); diff --git a/src/agents/tools/whatsapp-actions.ts b/src/agents/tools/whatsapp-actions.ts index b2da3820797..92332d1b3c5 100644 --- a/src/agents/tools/whatsapp-actions.ts +++ b/src/agents/tools/whatsapp-actions.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { sendReactionWhatsApp } from "../../../extensions/whatsapp/src/send.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { sendReactionWhatsApp } from "../../web/outbound.js"; import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js"; import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js"; diff --git a/src/agents/tools/whatsapp-target-auth.ts b/src/agents/tools/whatsapp-target-auth.ts index b6f4da57ccf..569a930d1a5 100644 --- a/src/agents/tools/whatsapp-target-auth.ts +++ b/src/agents/tools/whatsapp-target-auth.ts @@ -1,5 +1,5 @@ +import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; import { ToolAuthorizationError } from "./common.js"; diff --git a/src/auto-reply/reply.heartbeat-typing.test.ts b/src/auto-reply/reply.heartbeat-typing.test.ts index f677885a701..3bfc5f635b3 100644 --- a/src/auto-reply/reply.heartbeat-typing.test.ts +++ b/src/auto-reply/reply.heartbeat-typing.test.ts @@ -14,7 +14,7 @@ const webMocks = vi.hoisted(() => ({ readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), })); -vi.mock("../web/session.js", () => webMocks); +vi.mock("../../extensions/whatsapp/src/session.js", () => webMocks); import { getReplyFromConfig } from "./reply.js"; diff --git a/src/auto-reply/reply.raw-body.test.ts b/src/auto-reply/reply.raw-body.test.ts index 306d62eb88a..aeb9adc8378 100644 --- a/src/auto-reply/reply.raw-body.test.ts +++ b/src/auto-reply/reply.raw-body.test.ts @@ -14,7 +14,7 @@ vi.mock("../agents/model-catalog.js", () => ({ loadModelCatalog: agentMocks.loadModelCatalog, })); -vi.mock("../web/session.js", () => ({ +vi.mock("../../extensions/whatsapp/src/session.js", () => ({ webAuthExists: agentMocks.webAuthExists, getWebAuthAgeMs: agentMocks.getWebAuthAgeMs, readWebSelfId: agentMocks.readWebSelfId, diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index db8dd5b1fae..9e0390bc887 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -101,7 +101,7 @@ export function getWebSessionMocks(): AnyMocks { return webSessionMocks; } -vi.mock("../web/session.js", () => webSessionMocks); +vi.mock("../../extensions/whatsapp/src/session.js", () => webSessionMocks); export const MAIN_SESSION_KEY = "agent:main:main"; diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 7447419fd1e..904ae965fa7 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -105,7 +105,7 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal }); // Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. -vi.mock("../../discord/monitor/gateway-plugin.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({ createDiscordGatewayPlugin: () => ({}), })); diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index fcecb0b31f3..83d263b828c 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -1,3 +1,11 @@ +import { resolveDiscordAccount } from "../../../extensions/discord/src/accounts.js"; +import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; +import { resolveIMessageAccount } from "../../../extensions/imessage/src/accounts.js"; +import { resolveSignalAccount } from "../../../extensions/signal/src/accounts.js"; +import { resolveSlackAccount } from "../../../extensions/slack/src/accounts.js"; +import { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js"; +import { resolveTelegramAccount } from "../../../extensions/telegram/src/accounts.js"; +import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js"; import { getChannelDock } from "../../channels/dock.js"; import { resolveExplicitConfigWriteTarget } from "../../channels/plugins/config-writes.js"; import { listPairingChannels } from "../../channels/plugins/pairing.js"; @@ -9,9 +17,6 @@ import { validateConfigObjectWithPlugins, writeConfigFile, } from "../../config/config.js"; -import { resolveDiscordAccount } from "../../discord/accounts.js"; -import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js"; -import { resolveIMessageAccount } from "../../imessage/accounts.js"; import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; import { addChannelAllowFromStoreEntry, @@ -24,11 +29,6 @@ import { normalizeOptionalAccountId, } from "../../routing/session-key.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; -import { resolveSignalAccount } from "../../signal/accounts.js"; -import { resolveSlackAccount } from "../../slack/accounts.js"; -import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js"; -import { resolveTelegramAccount } from "../../telegram/accounts.js"; -import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { rejectUnauthorizedCommand, requireCommandFlagEnabled } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; import { resolveConfigWriteDeniedText } from "./config-write-authorization.js"; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 5b0caec9c8f..ad1fde9eb0b 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,9 +1,9 @@ -import { callGateway } from "../../gateway/call.js"; -import { logVerbose } from "../../globals.js"; import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, -} from "../../telegram/exec-approvals.js"; +} from "../../../extensions/telegram/src/exec-approvals.js"; +import { callGateway } from "../../gateway/call.js"; +import { logVerbose } from "../../globals.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 17c25a6bfe0..afe56688256 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -1,3 +1,10 @@ +import { + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + type ProviderInfo, +} from "../../../extensions/telegram/src/model-buttons.js"; import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; @@ -10,13 +17,6 @@ import { } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { - buildModelsKeyboard, - buildProviderKeyboard, - calculateTotalPages, - getModelsPageSize, - type ProviderInfo, -} from "../../telegram/model-buttons.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index baf5addc60e..92812abaae6 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -19,8 +19,11 @@ const hoisted = vi.hoisted(() => { }; }); -vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../extensions/discord/src/monitor/thread-bindings.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../extensions/discord/src/monitor/thread-bindings.js") + >(); return { ...actual, getThreadBindingManager: hoisted.getThreadBindingManagerMock, @@ -29,8 +32,9 @@ vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => { }; }); -vi.mock("../../telegram/thread-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../extensions/telegram/src/thread-bindings.js", async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, setTelegramThreadBindingIdleTimeoutBySessionKey: diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index c4d0c88e432..b04d5112345 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -1,6 +1,3 @@ -import { resolveFastModeState } from "../../agents/fast-mode.js"; -import { parseDurationMs } from "../../cli/parse-duration.js"; -import { isRestartEnabled } from "../../config/commands.js"; import { formatThreadBindingDurationLabel, getThreadBindingManager, @@ -10,16 +7,19 @@ import { resolveThreadBindingMaxAgeMs, setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, -} from "../../discord/monitor/thread-bindings.js"; +} from "../../../extensions/discord/src/monitor/thread-bindings.js"; +import { + setTelegramThreadBindingIdleTimeoutBySessionKey, + setTelegramThreadBindingMaxAgeBySessionKey, +} from "../../../extensions/telegram/src/thread-bindings.js"; +import { resolveFastModeState } from "../../agents/fast-mode.js"; +import { parseDurationMs } from "../../cli/parse-duration.js"; +import { isRestartEnabled } from "../../config/commands.js"; import { logVerbose } from "../../globals.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js"; import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js"; -import { - setTelegramThreadBindingIdleTimeoutBySessionKey, - setTelegramThreadBindingMaxAgeBySessionKey, -} from "../../telegram/thread-bindings.js"; import { formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import { parseActivationCommand } from "../group-activation.js"; import { parseSendPolicyCommand } from "../send-policy.js"; diff --git a/src/auto-reply/reply/commands-subagents.test-mocks.ts b/src/auto-reply/reply/commands-subagents.test-mocks.ts index da70d449b6f..99c34fbf35c 100644 --- a/src/auto-reply/reply/commands-subagents.test-mocks.ts +++ b/src/auto-reply/reply/commands-subagents.test-mocks.ts @@ -10,7 +10,7 @@ export function installSubagentsCommandCoreMocks() { }); // Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. - vi.mock("../../discord/monitor/gateway-plugin.js", () => ({ + vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({ createDiscordGatewayPlugin: () => ({}), })); } diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index bb923b52e46..1c7db7e13cd 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -1,3 +1,4 @@ +import { parseDiscordTarget } from "../../../../extensions/discord/src/targets.js"; import { resolveStoredSubagentCapabilities } from "../../../agents/subagent-capabilities.js"; import type { ResolvedSubagentController } from "../../../agents/subagent-control.js"; import { @@ -16,7 +17,6 @@ import type { loadSessionStore as loadSessionStoreFn, resolveStorePath as resolveStorePathFn, } from "../../../config/sessions.js"; -import { parseDiscordTarget } from "../../../discord/targets.js"; import { callGateway } from "../../../gateway/call.js"; import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts"; import { parseAgentSessionKey } from "../../../routing/session-key.js"; diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index e05b7044edb..bb66d8b8d7f 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -1,3 +1,4 @@ +import { buildBrowseProvidersButton } from "../../../extensions/telegram/src/model-buttons.js"; import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles.js"; import { type ModelAliasIndex, @@ -8,7 +9,6 @@ import { } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { buildBrowseProvidersButton } from "../../telegram/model-buttons.js"; import { shortenHomePath } from "../../utils.js"; import { resolveSelectedAndActiveModel } from "../model-runtime.js"; import type { ReplyPayload } from "../types.js"; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 87e77785bbb..666964eb865 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -41,6 +41,12 @@ const acpMocks = vi.hoisted(() => ({ const sessionBindingMocks = vi.hoisted(() => ({ listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []), })); +const sessionStoreMocks = vi.hoisted(() => ({ + currentEntry: undefined as Record | undefined, + loadSessionStore: vi.fn(() => ({})), + resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"), + resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })), +})); const ttsMocks = vi.hoisted(() => { const state = { synthesizeFinalAudio: false, @@ -77,9 +83,16 @@ vi.mock("./route-reply.js", () => ({ isRoutableChannel: (channel: string | undefined) => Boolean( channel && - ["telegram", "slack", "discord", "signal", "imessage", "whatsapp", "feishu"].includes( - channel, - ), + [ + "telegram", + "slack", + "discord", + "signal", + "imessage", + "whatsapp", + "feishu", + "mattermost", + ].includes(channel), ), routeReply: mocks.routeReply, })); @@ -100,6 +113,15 @@ vi.mock("../../logging/diagnostic.js", () => ({ logMessageProcessed: diagnosticMocks.logMessageProcessed, logSessionStateChange: diagnosticMocks.logSessionStateChange, })); +vi.mock("../../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionStore: sessionStoreMocks.loadSessionStore, + resolveStorePath: sessionStoreMocks.resolveStorePath, + resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry, + }; +}); vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookMocks.runner, @@ -228,6 +250,10 @@ describe("dispatchReplyFromConfig", () => { acpMocks.requireAcpRuntimeBackend.mockReset(); sessionBindingMocks.listBySession.mockReset(); sessionBindingMocks.listBySession.mockReturnValue([]); + sessionStoreMocks.currentEntry = undefined; + sessionStoreMocks.loadSessionStore.mockClear(); + sessionStoreMocks.resolveStorePath.mockClear(); + sessionStoreMocks.resolveSessionStoreEntry.mockClear(); ttsMocks.state.synthesizeFinalAudio = false; ttsMocks.maybeApplyTtsToPayload.mockClear(); ttsMocks.normalizeTtsAutoMode.mockClear(); @@ -293,6 +319,88 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("falls back to thread-scoped session key when current ctx has no MessageThreadId", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + sessionStoreMocks.currentEntry = { + deliveryContext: { + channel: "mattermost", + to: "channel:CHAN1", + accountId: "default", + }, + origin: { + threadId: "stale-origin-root", + }, + lastThreadId: "stale-origin-root", + }; + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "webchat", + Surface: "webchat", + SessionKey: "agent:main:mattermost:channel:CHAN1:thread:post-root", + AccountId: "default", + MessageThreadId: undefined, + OriginatingChannel: "mattermost", + OriginatingTo: "channel:CHAN1", + ExplicitDeliverRoute: true, + }); + + const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(mocks.routeReply).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "mattermost", + to: "channel:CHAN1", + threadId: "post-root", + }), + ); + }); + + it("does not resurrect a cleared route thread from origin metadata", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + // Simulate the real store: lastThreadId and deliveryContext.threadId may be normalised from + // origin.threadId on read, but a non-thread session key must still route to channel root. + sessionStoreMocks.currentEntry = { + deliveryContext: { + channel: "mattermost", + to: "channel:CHAN1", + accountId: "default", + threadId: "stale-root", + }, + lastThreadId: "stale-root", + origin: { + threadId: "stale-root", + }, + }; + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "webchat", + Surface: "webchat", + SessionKey: "agent:main:mattermost:channel:CHAN1", + AccountId: "default", + MessageThreadId: undefined, + OriginatingChannel: "mattermost", + OriginatingTo: "channel:CHAN1", + ExplicitDeliverRoute: true, + }); + + const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + const routeCall = mocks.routeReply.mock.calls[0]?.[0] as + | { channel?: string; to?: string; threadId?: string | number } + | undefined; + expect(routeCall).toMatchObject({ + channel: "mattermost", + to: "channel:CHAN1", + }); + expect(routeCall?.threadId).toBeUndefined(); + }); + it("forces suppressTyping when routing to a different originating channel", async () => { setNoAbort(); const cfg = emptyConfig; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 5b250b03362..5b679fa59e5 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1,12 +1,13 @@ +import { shouldSuppressLocalDiscordExecApprovalPrompt } from "../../../extensions/discord/src/exec-approvals.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionStore, + parseSessionThreadInfo, resolveSessionStoreEntry, resolveStorePath, type SessionEntry, } from "../../config/sessions.js"; -import { shouldSuppressLocalDiscordExecApprovalPrompt } from "../../discord/exec-approvals.js"; import { logVerbose } from "../../globals.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; @@ -172,6 +173,12 @@ export async function dispatchReplyFromConfig(params: { const sessionStoreEntry = resolveSessionStoreLookup(ctx, cfg); const acpDispatchSessionKey = sessionStoreEntry.sessionKey ?? sessionKey; + // Restore route thread context only from the active turn or the thread-scoped session key. + // Do not read thread ids from the normalised session store here: `origin.threadId` can be + // folded back into lastThreadId/deliveryContext during store normalisation and resurrect a + // stale route after thread delivery was intentionally cleared. + const routeThreadId = + ctx.MessageThreadId ?? parseSessionThreadInfo(acpDispatchSessionKey).threadId; const inboundAudio = isInboundAudioContext(ctx); const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto); const hookRunner = getGlobalHookRunner(); @@ -260,7 +267,7 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, - threadId: ctx.MessageThreadId, + threadId: routeThreadId, cfg, abortSignal, mirror, @@ -289,7 +296,7 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, - threadId: ctx.MessageThreadId, + threadId: routeThreadId, cfg, isGroup, groupId, @@ -519,7 +526,7 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, - threadId: ctx.MessageThreadId, + threadId: routeThreadId, cfg, isGroup, groupId, @@ -571,7 +578,7 @@ export async function dispatchReplyFromConfig(params: { to: originatingTo, sessionKey: ctx.SessionKey, accountId: ctx.AccountId, - threadId: ctx.MessageThreadId, + threadId: routeThreadId, cfg, isGroup, groupId, diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 5a20d4ba950..d0f38c745c7 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -1,10 +1,10 @@ +import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ReplyToMode } from "../../config/types.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; -import { parseTelegramTarget } from "../../telegram/targets.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { extractReplyToTag } from "./reply-tags.js"; diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 62f91097223..b0818f62512 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; import { discordOutbound } from "../../channels/plugins/outbound/discord.js"; import { imessageOutbound } from "../../channels/plugins/outbound/imessage.js"; import { signalOutbound } from "../../channels/plugins/outbound/signal.js"; @@ -24,28 +25,32 @@ 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" })), deliverOutboundPayloads: vi.fn(), })); -vi.mock("../../discord/send.js", () => ({ +vi.mock("../../../extensions/discord/src/send.js", () => ({ sendMessageDiscord: mocks.sendMessageDiscord, })); -vi.mock("../../imessage/send.js", () => ({ +vi.mock("../../../extensions/imessage/src/send.js", () => ({ sendMessageIMessage: mocks.sendMessageIMessage, })); -vi.mock("../../signal/send.js", () => ({ +vi.mock("../../../extensions/signal/src/send.js", () => ({ sendMessageSignal: mocks.sendMessageSignal, })); -vi.mock("../../slack/send.js", () => ({ +vi.mock("../../../extensions/slack/src/send.js", () => ({ sendMessageSlack: mocks.sendMessageSlack, })); -vi.mock("../../telegram/send.js", () => ({ +vi.mock("../../../extensions/telegram/src/send.js", () => ({ sendMessageTelegram: mocks.sendMessageTelegram, })); -vi.mock("../../web/outbound.js", () => ({ +vi.mock("../../../extensions/whatsapp/src/send.js", () => ({ sendMessageWhatsApp: mocks.sendMessageWhatsApp, sendPollWhatsApp: mocks.sendMessageWhatsApp, })); +vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({ + sendMessageMattermost: mocks.sendMessageMattermost, +})); vi.mock("../../infra/outbound/deliver.js", async () => { const actual = await vi.importActual( "../../infra/outbound/deliver.js", @@ -335,6 +340,33 @@ describe("routeReply", () => { ); }); + it("uses threadId as replyToId for Mattermost when replyToId is missing", async () => { + mocks.deliverOutboundPayloads.mockResolvedValue([]); + await routeReply({ + payload: { text: "hi" }, + channel: "mattermost", + to: "channel:CHAN1", + threadId: "post-root", + cfg: { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + } as unknown as OpenClawConfig, + }); + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "mattermost", + to: "channel:CHAN1", + replyToId: "post-root", + threadId: "post-root", + }), + ); + }); + it("sends multiple mediaUrls (caption only on first)", async () => { mocks.sendMessageSlack.mockClear(); await routeReply({ @@ -501,4 +533,9 @@ const defaultRegistry = createTestRegistry([ }), source: "test", }, + { + pluginId: "mattermost", + plugin: mattermostPlugin, + source: "test", + }, ]); diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 8b3319698b2..a2d2dcc2f1f 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -7,13 +7,13 @@ * across multiple providers. */ +import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-input.js"; +import { isSlackInteractiveRepliesEnabled } from "../../../extensions/slack/src/interactive-replies.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; -import { parseSlackBlocksInput } from "../../slack/blocks-input.js"; -import { isSlackInteractiveRepliesEnabled } from "../../slack/interactive-replies.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -149,7 +149,9 @@ export async function routeReply(params: RouteReplyParams): Promise { expect(openclaw?.driver).toBe("openclaw"); expect(openclaw?.cdpPort).toBe(18800); expect(openclaw?.cdpUrl).toBe("http://127.0.0.1:18800"); - const chrome = resolveProfile(resolved, "chrome"); - expect(chrome?.driver).toBe("extension"); - expect(chrome?.cdpPort).toBe(18792); - expect(chrome?.cdpUrl).toBe("http://127.0.0.1:18792"); + const user = resolveProfile(resolved, "user"); + expect(user?.driver).toBe("existing-session"); + expect(user?.cdpPort).toBe(0); + expect(user?.cdpUrl).toBe(""); + const chromeRelay = resolveProfile(resolved, "chrome-relay"); + expect(chromeRelay?.driver).toBe("extension"); + expect(chromeRelay?.cdpPort).toBe(18792); + expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:18792"); expect(resolved.remoteCdpTimeoutMs).toBe(1500); expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000); }); @@ -34,10 +38,10 @@ describe("browser config", () => { withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => { const resolved = resolveBrowserConfig(undefined); expect(resolved.controlPort).toBe(19003); - const chrome = resolveProfile(resolved, "chrome"); - expect(chrome?.driver).toBe("extension"); - expect(chrome?.cdpPort).toBe(19004); - expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19004"); + const chromeRelay = resolveProfile(resolved, "chrome-relay"); + expect(chromeRelay?.driver).toBe("extension"); + expect(chromeRelay?.cdpPort).toBe(19004); + expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:19004"); const openclaw = resolveProfile(resolved, "openclaw"); expect(openclaw?.cdpPort).toBe(19012); @@ -49,10 +53,10 @@ describe("browser config", () => { withEnv({ OPENCLAW_GATEWAY_PORT: undefined }, () => { const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } }); expect(resolved.controlPort).toBe(19013); - const chrome = resolveProfile(resolved, "chrome"); - expect(chrome?.driver).toBe("extension"); - expect(chrome?.cdpPort).toBe(19014); - expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19014"); + const chromeRelay = resolveProfile(resolved, "chrome-relay"); + expect(chromeRelay?.driver).toBe("extension"); + expect(chromeRelay?.cdpPort).toBe(19014); + expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:19014"); const openclaw = resolveProfile(resolved, "openclaw"); expect(openclaw?.cdpPort).toBe(19022); @@ -205,13 +209,13 @@ describe("browser config", () => { ); }); - it("does not add the built-in chrome extension profile if the derived relay port is already used", () => { + it("does not add the built-in chrome-relay profile if the derived relay port is already used", () => { const resolved = resolveBrowserConfig({ profiles: { openclaw: { cdpPort: 18792, color: "#FF4500" }, }, }); - expect(resolveProfile(resolved, "chrome")).toBe(null); + expect(resolveProfile(resolved, "chrome-relay")).toBe(null); expect(resolved.defaultProfile).toBe("openclaw"); }); @@ -313,7 +317,7 @@ describe("browser config", () => { const managed = resolveProfile(resolved, "openclaw")!; expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false); - const extension = resolveProfile(resolved, "chrome")!; + const extension = resolveProfile(resolved, "chrome-relay")!; expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false); const work = resolveProfile(resolved, "work")!; @@ -354,17 +358,17 @@ describe("browser config", () => { it("explicit defaultProfile config overrides defaults in headless mode", () => { const resolved = resolveBrowserConfig({ headless: true, - defaultProfile: "chrome", + defaultProfile: "chrome-relay", }); - expect(resolved.defaultProfile).toBe("chrome"); + expect(resolved.defaultProfile).toBe("chrome-relay"); }); it("explicit defaultProfile config overrides defaults in noSandbox mode", () => { const resolved = resolveBrowserConfig({ noSandbox: true, - defaultProfile: "chrome", + defaultProfile: "chrome-relay", }); - expect(resolved.defaultProfile).toBe("chrome"); + expect(resolved.defaultProfile).toBe("chrome-relay"); }); it("allows custom profile as default even in headless mode", () => { diff --git a/src/browser/config.ts b/src/browser/config.ts index 898980de681..8bcd51d0a68 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -180,17 +180,35 @@ function ensureDefaultProfile( } /** - * Ensure a built-in "chrome" profile exists for the Chrome extension relay. + * Ensure a built-in "user" profile exists for Chrome's existing-session attach flow. + */ +function ensureDefaultUserBrowserProfile( + profiles: Record, +): Record { + const result = { ...profiles }; + if (result.user) { + return result; + } + result.user = { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }; + return result; +} + +/** + * Ensure a built-in "chrome-relay" profile exists for the Chrome extension relay. * * Note: this is an OpenClaw browser profile (routing config), not a Chrome user profile. * It points at the local relay CDP endpoint (controlPort + 1). */ -function ensureDefaultChromeExtensionProfile( +function ensureDefaultChromeRelayProfile( profiles: Record, controlPort: number, ): Record { const result = { ...profiles }; - if (result.chrome) { + if (result["chrome-relay"]) { return result; } const relayPort = controlPort + 1; @@ -202,7 +220,7 @@ function ensureDefaultChromeExtensionProfile( if (getUsedPorts(result).has(relayPort)) { return result; } - result.chrome = { + result["chrome-relay"] = { driver: "extension", cdpUrl: `http://127.0.0.1:${relayPort}`, color: "#00AA00", @@ -268,13 +286,15 @@ export function resolveBrowserConfig( const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:"; const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined; - const profiles = ensureDefaultChromeExtensionProfile( - ensureDefaultProfile( - cfg?.profiles, - defaultColor, - legacyCdpPort, - cdpPortRangeStart, - legacyCdpUrl, + const profiles = ensureDefaultChromeRelayProfile( + ensureDefaultUserBrowserProfile( + ensureDefaultProfile( + cfg?.profiles, + defaultColor, + legacyCdpPort, + cdpPortRangeStart, + legacyCdpUrl, + ), ), controlPort, ); @@ -286,7 +306,7 @@ export function resolveBrowserConfig( ? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME : profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] ? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME - : "chrome"); + : "user"); const extraArgs = Array.isArray(cfg?.extraArgs) ? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0) diff --git a/src/browser/pw-tools-core.interactions.batch.test.ts b/src/browser/pw-tools-core.interactions.batch.test.ts index fbd2de4cbc6..2801ebe8190 100644 --- a/src/browser/pw-tools-core.interactions.batch.test.ts +++ b/src/browser/pw-tools-core.interactions.batch.test.ts @@ -82,23 +82,4 @@ describe("batchViaPlaywright", () => { targetId: "tab-1", }); }); - - it("propagates nested batch failures to the parent batch result", async () => { - const result = await batchViaPlaywright({ - cdpUrl: "http://127.0.0.1:9222", - targetId: "tab-1", - actions: [ - { - kind: "batch", - actions: [{ kind: "evaluate", fn: "() => 1" }], - }, - ], - }); - - expect(result).toEqual({ - results: [ - { ok: false, error: "act:evaluate is disabled by config (browser.evaluateEnabled=false)" }, - ], - }); - }); }); diff --git a/src/browser/pw-tools-core.interactions.ts b/src/browser/pw-tools-core.interactions.ts index da0efa0c145..01abc5338f0 100644 --- a/src/browser/pw-tools-core.interactions.ts +++ b/src/browser/pw-tools-core.interactions.ts @@ -20,6 +20,7 @@ type TargetOpts = { cdpUrl: string; targetId?: string; }; + const MAX_CLICK_DELAY_MS = 5_000; const MAX_WAIT_TIME_MS = 30_000; const MAX_BATCH_ACTIONS = 100; @@ -98,7 +99,7 @@ export async function clickViaPlaywright(opts: { const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS); if (delayMs > 0) { await locator.hover({ timeout }); - await new Promise((r) => setTimeout(r, delayMs)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); } if (opts.doubleClick) { await locator.dblclick({ @@ -844,8 +845,7 @@ async function executeSingleAction( }); break; case "batch": - // Nested batches: delegate recursively - const nestedResult = await batchViaPlaywright({ + await batchViaPlaywright({ cdpUrl, targetId: effectiveTargetId, actions: action.actions, @@ -853,10 +853,6 @@ async function executeSingleAction( evaluateEnabled, depth: depth + 1, }); - const nestedFailure = nestedResult.results.find((result) => !result.ok); - if (nestedFailure) { - throw new Error(nestedFailure.error ?? "Nested batch action failed"); - } break; default: throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`); diff --git a/src/browser/routes/agent.snapshot.plan.test.ts b/src/browser/routes/agent.snapshot.plan.test.ts index 493fbcdfbad..71870aa1a6d 100644 --- a/src/browser/routes/agent.snapshot.plan.test.ts +++ b/src/browser/routes/agent.snapshot.plan.test.ts @@ -3,9 +3,9 @@ import { resolveBrowserConfig, resolveProfile } from "../config.js"; import { resolveSnapshotPlan } from "./agent.snapshot.plan.js"; describe("resolveSnapshotPlan", () => { - it("defaults chrome extension relay snapshots to aria when format is omitted", () => { + it("defaults chrome-relay snapshots to aria when format is omitted", () => { const resolved = resolveBrowserConfig({}); - const profile = resolveProfile(resolved, "chrome"); + const profile = resolveProfile(resolved, "chrome-relay"); expect(profile).toBeTruthy(); const plan = resolveSnapshotPlan({ diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index 13c5f82e31d..ceaafc46d41 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -25,9 +25,9 @@ function makeBrowserState(): BrowserServerState { headless: true, noSandbox: false, attachOnly: false, - defaultProfile: "chrome", + defaultProfile: "chrome-relay", profiles: { - chrome: { + "chrome-relay": { driver: "extension", cdpUrl: "http://127.0.0.1:18792", cdpPort: 18792, diff --git a/src/browser/server-lifecycle.test.ts b/src/browser/server-lifecycle.test.ts index e2395f99f04..5ef331f1784 100644 --- a/src/browser/server-lifecycle.test.ts +++ b/src/browser/server-lifecycle.test.ts @@ -43,7 +43,7 @@ describe("ensureExtensionRelayForProfiles", () => { it("starts relay only for extension profiles", async () => { resolveProfileMock.mockImplementation((_resolved: unknown, name: string) => { - if (name === "chrome") { + if (name === "chrome-relay") { return { driver: "extension", cdpUrl: "http://127.0.0.1:18888" }; } return { driver: "openclaw", cdpUrl: "http://127.0.0.1:18889" }; @@ -53,7 +53,7 @@ describe("ensureExtensionRelayForProfiles", () => { await ensureExtensionRelayForProfiles({ resolved: { profiles: { - chrome: {}, + "chrome-relay": {}, openclaw: {}, }, } as never, @@ -72,12 +72,12 @@ describe("ensureExtensionRelayForProfiles", () => { const onWarn = vi.fn(); await ensureExtensionRelayForProfiles({ - resolved: { profiles: { chrome: {} } } as never, + resolved: { profiles: { "chrome-relay": {} } } as never, onWarn, }); expect(onWarn).toHaveBeenCalledWith( - 'Chrome extension relay init failed for profile "chrome": Error: boom', + 'Chrome extension relay init failed for profile "chrome-relay": Error: boom', ); }); }); @@ -91,10 +91,10 @@ describe("stopKnownBrowserProfiles", () => { }); it("stops all known profiles and ignores per-profile failures", async () => { - listKnownProfileNamesMock.mockReturnValue(["openclaw", "chrome"]); + listKnownProfileNamesMock.mockReturnValue(["openclaw", "chrome-relay"]); const stopMap: Record> = { openclaw: vi.fn(async () => {}), - chrome: vi.fn(async () => { + "chrome-relay": vi.fn(async () => { throw new Error("profile stop failed"); }), }; @@ -112,7 +112,7 @@ describe("stopKnownBrowserProfiles", () => { }); expect(stopMap.openclaw).toHaveBeenCalledTimes(1); - expect(stopMap.chrome).toHaveBeenCalledTimes(1); + expect(stopMap["chrome-relay"]).toHaveBeenCalledTimes(1); expect(onWarn).not.toHaveBeenCalled(); }); diff --git a/src/channel-web.ts b/src/channel-web.ts index bd0590412c7..99e36ef67bc 100644 --- a/src/channel-web.ts +++ b/src/channel-web.ts @@ -9,17 +9,17 @@ export { runWebHeartbeatOnce, type WebChannelStatus, type WebMonitorTuning, -} from "./web/auto-reply.js"; +} from "../extensions/whatsapp/src/auto-reply.js"; export { extractMediaPlaceholder, extractText, monitorWebInbox, type WebInboundMessage, type WebListenerCloseReason, -} from "./web/inbound.js"; -export { loginWeb } from "./web/login.js"; -export { loadWebMedia, optimizeImageToJpeg } from "./web/media.js"; -export { sendMessageWhatsApp } from "./web/outbound.js"; +} from "../extensions/whatsapp/src/inbound.js"; +export { loginWeb } from "../extensions/whatsapp/src/login.js"; +export { loadWebMedia, optimizeImageToJpeg } from "../extensions/whatsapp/src/media.js"; +export { sendMessageWhatsApp } from "../extensions/whatsapp/src/send.js"; export { createWaSocket, formatError, @@ -30,4 +30,4 @@ export { WA_WEB_AUTH_DIR, waitForWaConnection, webAuthExists, -} from "./web/session.js"; +} from "../extensions/whatsapp/src/session.js"; diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 52965790beb..e080d513c16 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -1,8 +1,13 @@ +import { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +import { resolveSignalAccount } from "../../extensions/signal/src/accounts.js"; +import { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +import { resolveSlackReplyToMode } from "../../extensions/slack/src/accounts.js"; +import { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; +import { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, } from "../config/group-policy.js"; -import { inspectDiscordAccount } from "../discord/account-inspect.js"; import { formatAllowFromLowercase, formatNormalizedAllowFromEntries, @@ -19,11 +24,6 @@ import { } from "../plugin-sdk/channel-config-helpers.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { normalizeAccountId } from "../routing/session-key.js"; -import { resolveSignalAccount } from "../signal/accounts.js"; -import { inspectSlackAccount } from "../slack/account-inspect.js"; -import { resolveSlackReplyToMode } from "../slack/accounts.js"; -import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; -import { inspectTelegramAccount } from "../telegram/account-inspect.js"; import { normalizeE164 } from "../utils.js"; import { resolveDiscordGroupRequireMention, diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index a6e1e89fc2e..055d660524f 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -15,7 +15,7 @@ vi.mock("../../../agents/tools/telegram-actions.js", () => ({ handleTelegramAction, })); -vi.mock("../../../signal/send-reactions.js", () => ({ +vi.mock("../../../../extensions/signal/src/send-reactions.js", () => ({ sendReactionSignal, removeReactionSignal, })); diff --git a/src/channels/plugins/actions/discord.ts b/src/channels/plugins/actions/discord.ts index 04293056607..6b8689effb3 100644 --- a/src/channels/plugins/actions/discord.ts +++ b/src/channels/plugins/actions/discord.ts @@ -1,134 +1,2 @@ -import type { DiscordActionConfig } from "../../../config/types.discord.js"; -import { createDiscordActionGate, listEnabledDiscordAccounts } from "../../../discord/accounts.js"; -import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; -import { handleDiscordMessageAction } from "./discord/handle-action.js"; -import { createUnionActionGate, listTokenSourcedAccounts } from "./shared.js"; - -export const discordMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)); - if (accounts.length === 0) { - return []; - } - // Union of all accounts' action gates (any account enabling an action makes it available) - const gate = createUnionActionGate(accounts, (account) => - createDiscordActionGate({ - cfg, - accountId: account.accountId, - }), - ); - const isEnabled = (key: keyof DiscordActionConfig, defaultValue = true) => - gate(key, defaultValue); - const actions = new Set(["send"]); - if (isEnabled("polls")) { - actions.add("poll"); - } - if (isEnabled("reactions")) { - actions.add("react"); - actions.add("reactions"); - } - if (isEnabled("messages")) { - actions.add("read"); - actions.add("edit"); - actions.add("delete"); - } - if (isEnabled("pins")) { - actions.add("pin"); - actions.add("unpin"); - actions.add("list-pins"); - } - if (isEnabled("permissions")) { - actions.add("permissions"); - } - if (isEnabled("threads")) { - actions.add("thread-create"); - actions.add("thread-list"); - actions.add("thread-reply"); - } - if (isEnabled("search")) { - actions.add("search"); - } - if (isEnabled("stickers")) { - actions.add("sticker"); - } - if (isEnabled("memberInfo")) { - actions.add("member-info"); - } - if (isEnabled("roleInfo")) { - actions.add("role-info"); - } - if (isEnabled("reactions")) { - actions.add("emoji-list"); - } - if (isEnabled("emojiUploads")) { - actions.add("emoji-upload"); - } - if (isEnabled("stickerUploads")) { - actions.add("sticker-upload"); - } - if (isEnabled("roles", false)) { - actions.add("role-add"); - actions.add("role-remove"); - } - if (isEnabled("channelInfo")) { - actions.add("channel-info"); - actions.add("channel-list"); - } - if (isEnabled("channels")) { - actions.add("channel-create"); - actions.add("channel-edit"); - actions.add("channel-delete"); - actions.add("channel-move"); - actions.add("category-create"); - actions.add("category-edit"); - actions.add("category-delete"); - } - if (isEnabled("voiceStatus")) { - actions.add("voice-status"); - } - if (isEnabled("events")) { - actions.add("event-list"); - actions.add("event-create"); - } - if (isEnabled("moderation", false)) { - actions.add("timeout"); - actions.add("kick"); - actions.add("ban"); - } - if (isEnabled("presence", false)) { - actions.add("set-presence"); - } - return Array.from(actions); - }, - extractToolSend: ({ args }) => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action === "sendMessage") { - const to = typeof args.to === "string" ? args.to : undefined; - return to ? { to } : null; - } - if (action === "threadReply") { - const channelId = typeof args.channelId === "string" ? args.channelId.trim() : ""; - return channelId ? { to: `channel:${channelId}` } : null; - } - return null; - }, - handleAction: async ({ - action, - params, - cfg, - accountId, - requesterSenderId, - toolContext, - mediaLocalRoots, - }) => { - return await handleDiscordMessageAction({ - action, - params, - cfg, - accountId, - requesterSenderId, - toolContext, - mediaLocalRoots, - }); - }, -}; +// Shim: re-exports from extension +export * from "../../../../extensions/discord/src/channel-actions.js"; diff --git a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts index 18c3bfd01e3..3ba353b1f6e 100644 --- a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts +++ b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts @@ -1,451 +1 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { - parseAvailableTags, - readNumberParam, - readStringArrayParam, - readStringParam, -} from "../../../../agents/tools/common.js"; -import { - isDiscordModerationAction, - readDiscordModerationCommand, -} from "../../../../agents/tools/discord-actions-moderation-shared.js"; -import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js"; -import type { ChannelMessageActionContext } from "../../types.js"; - -type Ctx = Pick< - ChannelMessageActionContext, - "action" | "params" | "cfg" | "accountId" | "requesterSenderId" ->; - -export async function tryHandleDiscordMessageActionGuildAdmin(params: { - ctx: Ctx; - resolveChannelId: () => string; - readParentIdParam: (params: Record) => string | null | undefined; -}): Promise | undefined> { - const { ctx, resolveChannelId, readParentIdParam } = params; - const { action, params: actionParams, cfg } = ctx; - const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId"); - - if (action === "member-info") { - const userId = readStringParam(actionParams, "userId", { required: true }); - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - return await handleDiscordAction( - { action: "memberInfo", accountId: accountId ?? undefined, guildId, userId }, - cfg, - ); - } - - if (action === "role-info") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - return await handleDiscordAction( - { action: "roleInfo", accountId: accountId ?? undefined, guildId }, - cfg, - ); - } - - if (action === "emoji-list") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - return await handleDiscordAction( - { action: "emojiList", accountId: accountId ?? undefined, guildId }, - cfg, - ); - } - - if (action === "emoji-upload") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const name = readStringParam(actionParams, "emojiName", { required: true }); - const mediaUrl = readStringParam(actionParams, "media", { - required: true, - trim: false, - }); - const roleIds = readStringArrayParam(actionParams, "roleIds"); - return await handleDiscordAction( - { - action: "emojiUpload", - accountId: accountId ?? undefined, - guildId, - name, - mediaUrl, - roleIds, - }, - cfg, - ); - } - - if (action === "sticker-upload") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const name = readStringParam(actionParams, "stickerName", { - required: true, - }); - const description = readStringParam(actionParams, "stickerDesc", { - required: true, - }); - const tags = readStringParam(actionParams, "stickerTags", { - required: true, - }); - const mediaUrl = readStringParam(actionParams, "media", { - required: true, - trim: false, - }); - return await handleDiscordAction( - { - action: "stickerUpload", - accountId: accountId ?? undefined, - guildId, - name, - description, - tags, - mediaUrl, - }, - cfg, - ); - } - - if (action === "role-add" || action === "role-remove") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const userId = readStringParam(actionParams, "userId", { required: true }); - const roleId = readStringParam(actionParams, "roleId", { required: true }); - return await handleDiscordAction( - { - action: action === "role-add" ? "roleAdd" : "roleRemove", - accountId: accountId ?? undefined, - guildId, - userId, - roleId, - }, - cfg, - ); - } - - if (action === "channel-info") { - const channelId = readStringParam(actionParams, "channelId", { - required: true, - }); - return await handleDiscordAction( - { action: "channelInfo", accountId: accountId ?? undefined, channelId }, - cfg, - ); - } - - if (action === "channel-list") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - return await handleDiscordAction( - { action: "channelList", accountId: accountId ?? undefined, guildId }, - cfg, - ); - } - - if (action === "channel-create") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const name = readStringParam(actionParams, "name", { required: true }); - const type = readNumberParam(actionParams, "type", { integer: true }); - const parentId = readParentIdParam(actionParams); - const topic = readStringParam(actionParams, "topic"); - const position = readNumberParam(actionParams, "position", { - integer: true, - }); - const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; - return await handleDiscordAction( - { - action: "channelCreate", - accountId: accountId ?? undefined, - guildId, - name, - type: type ?? undefined, - parentId: parentId ?? undefined, - topic: topic ?? undefined, - position: position ?? undefined, - nsfw, - }, - cfg, - ); - } - - if (action === "channel-edit") { - const channelId = readStringParam(actionParams, "channelId", { - required: true, - }); - const name = readStringParam(actionParams, "name"); - const topic = readStringParam(actionParams, "topic"); - const position = readNumberParam(actionParams, "position", { - integer: true, - }); - const parentId = readParentIdParam(actionParams); - const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; - const rateLimitPerUser = readNumberParam(actionParams, "rateLimitPerUser", { - integer: true, - }); - const archived = typeof actionParams.archived === "boolean" ? actionParams.archived : undefined; - const locked = typeof actionParams.locked === "boolean" ? actionParams.locked : undefined; - const autoArchiveDuration = readNumberParam(actionParams, "autoArchiveDuration", { - integer: true, - }); - const availableTags = parseAvailableTags(actionParams.availableTags); - return await handleDiscordAction( - { - action: "channelEdit", - accountId: accountId ?? undefined, - channelId, - name: name ?? undefined, - topic: topic ?? undefined, - position: position ?? undefined, - parentId: parentId === undefined ? undefined : parentId, - nsfw, - rateLimitPerUser: rateLimitPerUser ?? undefined, - archived, - locked, - autoArchiveDuration: autoArchiveDuration ?? undefined, - availableTags, - }, - cfg, - ); - } - - if (action === "channel-delete") { - const channelId = readStringParam(actionParams, "channelId", { - required: true, - }); - return await handleDiscordAction( - { action: "channelDelete", accountId: accountId ?? undefined, channelId }, - cfg, - ); - } - - if (action === "channel-move") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const channelId = readStringParam(actionParams, "channelId", { - required: true, - }); - const parentId = readParentIdParam(actionParams); - const position = readNumberParam(actionParams, "position", { - integer: true, - }); - return await handleDiscordAction( - { - action: "channelMove", - accountId: accountId ?? undefined, - guildId, - channelId, - parentId: parentId === undefined ? undefined : parentId, - position: position ?? undefined, - }, - cfg, - ); - } - - if (action === "category-create") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const name = readStringParam(actionParams, "name", { required: true }); - const position = readNumberParam(actionParams, "position", { - integer: true, - }); - return await handleDiscordAction( - { - action: "categoryCreate", - accountId: accountId ?? undefined, - guildId, - name, - position: position ?? undefined, - }, - cfg, - ); - } - - if (action === "category-edit") { - const categoryId = readStringParam(actionParams, "categoryId", { - required: true, - }); - const name = readStringParam(actionParams, "name"); - const position = readNumberParam(actionParams, "position", { - integer: true, - }); - return await handleDiscordAction( - { - action: "categoryEdit", - accountId: accountId ?? undefined, - categoryId, - name: name ?? undefined, - position: position ?? undefined, - }, - cfg, - ); - } - - if (action === "category-delete") { - const categoryId = readStringParam(actionParams, "categoryId", { - required: true, - }); - return await handleDiscordAction( - { action: "categoryDelete", accountId: accountId ?? undefined, categoryId }, - cfg, - ); - } - - if (action === "voice-status") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const userId = readStringParam(actionParams, "userId", { required: true }); - return await handleDiscordAction( - { action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId }, - cfg, - ); - } - - if (action === "event-list") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - return await handleDiscordAction( - { action: "eventList", accountId: accountId ?? undefined, guildId }, - cfg, - ); - } - - if (action === "event-create") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const name = readStringParam(actionParams, "eventName", { required: true }); - const startTime = readStringParam(actionParams, "startTime", { - required: true, - }); - const endTime = readStringParam(actionParams, "endTime"); - const description = readStringParam(actionParams, "desc"); - const channelId = readStringParam(actionParams, "channelId"); - const location = readStringParam(actionParams, "location"); - const entityType = readStringParam(actionParams, "eventType"); - return await handleDiscordAction( - { - action: "eventCreate", - accountId: accountId ?? undefined, - guildId, - name, - startTime, - endTime, - description, - channelId, - location, - entityType, - }, - cfg, - ); - } - - if (isDiscordModerationAction(action)) { - const moderation = readDiscordModerationCommand(action, { - ...actionParams, - durationMinutes: readNumberParam(actionParams, "durationMin", { integer: true }), - deleteMessageDays: readNumberParam(actionParams, "deleteDays", { - integer: true, - }), - }); - const senderUserId = ctx.requesterSenderId?.trim() || undefined; - return await handleDiscordAction( - { - action: moderation.action, - accountId: accountId ?? undefined, - guildId: moderation.guildId, - userId: moderation.userId, - durationMinutes: moderation.durationMinutes, - until: moderation.until, - reason: moderation.reason, - deleteMessageDays: moderation.deleteMessageDays, - senderUserId, - }, - cfg, - ); - } - - // Some actions are conceptually "admin", but still act on a resolved channel. - if (action === "thread-list") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const channelId = readStringParam(actionParams, "channelId"); - const includeArchived = - typeof actionParams.includeArchived === "boolean" ? actionParams.includeArchived : undefined; - const before = readStringParam(actionParams, "before"); - const limit = readNumberParam(actionParams, "limit", { integer: true }); - return await handleDiscordAction( - { - action: "threadList", - accountId: accountId ?? undefined, - guildId, - channelId, - includeArchived, - before, - limit, - }, - cfg, - ); - } - - if (action === "thread-reply") { - const content = readStringParam(actionParams, "message", { - required: true, - }); - const mediaUrl = readStringParam(actionParams, "media", { trim: false }); - const replyTo = readStringParam(actionParams, "replyTo"); - - // `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`. - // Prefer `threadId` when present to avoid accidentally replying in the parent channel. - const threadId = readStringParam(actionParams, "threadId"); - const channelId = threadId ?? resolveChannelId(); - - return await handleDiscordAction( - { - action: "threadReply", - accountId: accountId ?? undefined, - channelId, - content, - mediaUrl: mediaUrl ?? undefined, - replyTo: replyTo ?? undefined, - }, - cfg, - ); - } - - if (action === "search") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); - const query = readStringParam(actionParams, "query", { required: true }); - return await handleDiscordAction( - { - action: "searchMessages", - accountId: accountId ?? undefined, - guildId, - content: query, - channelId: readStringParam(actionParams, "channelId"), - channelIds: readStringArrayParam(actionParams, "channelIds"), - authorId: readStringParam(actionParams, "authorId"), - authorIds: readStringArrayParam(actionParams, "authorIds"), - limit: readNumberParam(actionParams, "limit", { integer: true }), - }, - cfg, - ); - } - - return undefined; -} +export * from "../../../../../extensions/discord/src/actions/handle-action.guild-admin.js"; diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 5b11246210a..4bd957ec624 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -1,295 +1 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { - readNumberParam, - readStringArrayParam, - readStringParam, -} from "../../../../agents/tools/common.js"; -import { readDiscordParentIdParam } from "../../../../agents/tools/discord-actions-shared.js"; -import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js"; -import { resolveDiscordChannelId } from "../../../../discord/targets.js"; -import { readBooleanParam } from "../../../../plugin-sdk/boolean-param.js"; -import type { ChannelMessageActionContext } from "../../types.js"; -import { resolveReactionMessageId } from "../reaction-message-id.js"; -import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; - -const providerId = "discord"; - -export async function handleDiscordMessageAction( - ctx: Pick< - ChannelMessageActionContext, - | "action" - | "params" - | "cfg" - | "accountId" - | "requesterSenderId" - | "toolContext" - | "mediaLocalRoots" - >, -): Promise> { - const { action, params, cfg } = ctx; - const accountId = ctx.accountId ?? readStringParam(params, "accountId"); - const actionOptions = { - mediaLocalRoots: ctx.mediaLocalRoots, - } as const; - - const resolveChannelId = () => - resolveDiscordChannelId( - readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }), - ); - - if (action === "send") { - const to = readStringParam(params, "to", { required: true }); - const asVoice = readBooleanParam(params, "asVoice") === true; - const rawComponents = params.components; - const hasComponents = - Boolean(rawComponents) && - (typeof rawComponents === "function" || typeof rawComponents === "object"); - const components = hasComponents ? rawComponents : undefined; - const content = readStringParam(params, "message", { - required: !asVoice && !hasComponents, - allowEmpty: true, - }); - // Support media, path, and filePath for media URL - const mediaUrl = - readStringParam(params, "media", { trim: false }) ?? - readStringParam(params, "path", { trim: false }) ?? - readStringParam(params, "filePath", { trim: false }); - const filename = readStringParam(params, "filename"); - const replyTo = readStringParam(params, "replyTo"); - const rawEmbeds = params.embeds; - const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; - const silent = readBooleanParam(params, "silent") === true; - const sessionKey = readStringParam(params, "__sessionKey"); - const agentId = readStringParam(params, "__agentId"); - return await handleDiscordAction( - { - action: "sendMessage", - accountId: accountId ?? undefined, - to, - content, - mediaUrl: mediaUrl ?? undefined, - filename: filename ?? undefined, - replyTo: replyTo ?? undefined, - components, - embeds, - asVoice, - silent, - __sessionKey: sessionKey ?? undefined, - __agentId: agentId ?? undefined, - }, - cfg, - actionOptions, - ); - } - - if (action === "poll") { - const to = readStringParam(params, "to", { required: true }); - const question = readStringParam(params, "pollQuestion", { - required: true, - }); - const answers = readStringArrayParam(params, "pollOption", { required: true }); - const allowMultiselect = readBooleanParam(params, "pollMulti"); - const durationHours = readNumberParam(params, "pollDurationHours", { - integer: true, - strict: true, - }); - return await handleDiscordAction( - { - action: "poll", - accountId: accountId ?? undefined, - to, - question, - answers, - allowMultiselect, - durationHours: durationHours ?? undefined, - content: readStringParam(params, "message"), - }, - cfg, - actionOptions, - ); - } - - if (action === "react") { - const messageIdRaw = resolveReactionMessageId({ args: params, toolContext: ctx.toolContext }); - const messageId = messageIdRaw != null ? String(messageIdRaw).trim() : ""; - if (!messageId) { - throw new Error( - "messageId required. Provide messageId explicitly or react to the current inbound message.", - ); - } - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = readBooleanParam(params, "remove"); - return await handleDiscordAction( - { - action: "react", - accountId: accountId ?? undefined, - channelId: resolveChannelId(), - messageId, - emoji, - remove, - }, - cfg, - actionOptions, - ); - } - - if (action === "reactions") { - const messageId = readStringParam(params, "messageId", { required: true }); - const limit = readNumberParam(params, "limit", { integer: true }); - return await handleDiscordAction( - { - action: "reactions", - accountId: accountId ?? undefined, - channelId: resolveChannelId(), - messageId, - limit, - }, - cfg, - actionOptions, - ); - } - - if (action === "read") { - const limit = readNumberParam(params, "limit", { integer: true }); - return await handleDiscordAction( - { - action: "readMessages", - accountId: accountId ?? undefined, - channelId: resolveChannelId(), - limit, - before: readStringParam(params, "before"), - after: readStringParam(params, "after"), - around: readStringParam(params, "around"), - }, - cfg, - actionOptions, - ); - } - - if (action === "edit") { - const messageId = readStringParam(params, "messageId", { required: true }); - const content = readStringParam(params, "message", { required: true }); - return await handleDiscordAction( - { - action: "editMessage", - accountId: accountId ?? undefined, - channelId: resolveChannelId(), - messageId, - content, - }, - cfg, - actionOptions, - ); - } - - if (action === "delete") { - const messageId = readStringParam(params, "messageId", { required: true }); - return await handleDiscordAction( - { - action: "deleteMessage", - accountId: accountId ?? undefined, - channelId: resolveChannelId(), - messageId, - }, - cfg, - actionOptions, - ); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const messageId = - action === "list-pins" ? undefined : readStringParam(params, "messageId", { required: true }); - return await handleDiscordAction( - { - action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", - accountId: accountId ?? undefined, - channelId: resolveChannelId(), - messageId, - }, - cfg, - actionOptions, - ); - } - - if (action === "permissions") { - return await handleDiscordAction( - { - action: "permissions", - accountId: accountId ?? undefined, - channelId: resolveChannelId(), - }, - cfg, - actionOptions, - ); - } - - if (action === "thread-create") { - const name = readStringParam(params, "threadName", { required: true }); - const messageId = readStringParam(params, "messageId"); - const content = readStringParam(params, "message"); - const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { - integer: true, - }); - const appliedTags = readStringArrayParam(params, "appliedTags"); - return await handleDiscordAction( - { - action: "threadCreate", - accountId: accountId ?? undefined, - channelId: resolveChannelId(), - name, - messageId, - content, - autoArchiveMinutes, - appliedTags: appliedTags ?? undefined, - }, - cfg, - actionOptions, - ); - } - - if (action === "sticker") { - const stickerIds = - readStringArrayParam(params, "stickerId", { - required: true, - label: "sticker-id", - }) ?? []; - return await handleDiscordAction( - { - action: "sticker", - accountId: accountId ?? undefined, - to: readStringParam(params, "to", { required: true }), - stickerIds, - content: readStringParam(params, "message"), - }, - cfg, - actionOptions, - ); - } - - if (action === "set-presence") { - return await handleDiscordAction( - { - action: "setPresence", - accountId: accountId ?? undefined, - status: readStringParam(params, "status"), - activityType: readStringParam(params, "activityType"), - activityName: readStringParam(params, "activityName"), - activityUrl: readStringParam(params, "activityUrl"), - activityState: readStringParam(params, "activityState"), - }, - cfg, - actionOptions, - ); - } - - const adminResult = await tryHandleDiscordMessageActionGuildAdmin({ - ctx, - resolveChannelId, - readParentIdParam: readDiscordParentIdParam, - }); - if (adminResult !== undefined) { - return adminResult; - } - - throw new Error(`Action ${String(action)} is not supported for provider ${providerId}.`); -} +export * from "../../../../../extensions/discord/src/actions/handle-action.js"; diff --git a/src/channels/plugins/actions/signal.ts b/src/channels/plugins/actions/signal.ts index c93421489fd..b75a20ae2ec 100644 --- a/src/channels/plugins/actions/signal.ts +++ b/src/channels/plugins/actions/signal.ts @@ -1,7 +1,13 @@ +import { + listEnabledSignalAccounts, + resolveSignalAccount, +} from "../../../../extensions/signal/src/accounts.js"; +import { resolveSignalReactionLevel } from "../../../../extensions/signal/src/reaction-level.js"; +import { + sendReactionSignal, + removeReactionSignal, +} from "../../../../extensions/signal/src/send-reactions.js"; import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js"; -import { listEnabledSignalAccounts, resolveSignalAccount } from "../../../signal/accounts.js"; -import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; -import { sendReactionSignal, removeReactionSignal } from "../../../signal/send-reactions.js"; import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; import { resolveReactionMessageId } from "./reaction-message-id.js"; diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 6e55349698b..57a690d2208 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -1,287 +1 @@ -import { - readNumberParam, - readStringArrayParam, - readStringOrNumberParam, - readStringParam, -} from "../../../agents/tools/common.js"; -import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js"; -import type { TelegramActionConfig } from "../../../config/types.telegram.js"; -import { readBooleanParam } from "../../../plugin-sdk/boolean-param.js"; -import { extractToolSend } from "../../../plugin-sdk/tool-send.js"; -import { resolveTelegramPollVisibility } from "../../../poll-params.js"; -import { - createTelegramActionGate, - listEnabledTelegramAccounts, - resolveTelegramPollActionGateState, -} from "../../../telegram/accounts.js"; -import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js"; -import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; -import { resolveReactionMessageId } from "./reaction-message-id.js"; -import { createUnionActionGate, listTokenSourcedAccounts } from "./shared.js"; - -const providerId = "telegram"; - -function readTelegramSendParams(params: Record) { - const to = readStringParam(params, "to", { required: true }); - const mediaUrl = readStringParam(params, "media", { trim: false }); - const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true }); - const caption = readStringParam(params, "caption", { allowEmpty: true }); - const content = message || caption || ""; - const replyTo = readStringParam(params, "replyTo"); - const threadId = readStringParam(params, "threadId"); - const buttons = params.buttons; - const asVoice = readBooleanParam(params, "asVoice"); - const silent = readBooleanParam(params, "silent"); - const quoteText = readStringParam(params, "quoteText"); - return { - to, - content, - mediaUrl: mediaUrl ?? undefined, - replyToMessageId: replyTo ?? undefined, - messageThreadId: threadId ?? undefined, - buttons, - asVoice, - silent, - quoteText: quoteText ?? undefined, - }; -} - -function readTelegramChatIdParam(params: Record): string | number { - return ( - readStringOrNumberParam(params, "chatId") ?? - readStringOrNumberParam(params, "channelId") ?? - readStringParam(params, "to", { required: true }) - ); -} - -function readTelegramMessageIdParam(params: Record): number { - const messageId = readNumberParam(params, "messageId", { - required: true, - integer: true, - }); - if (typeof messageId !== "number") { - throw new Error("messageId is required."); - } - return messageId; -} - -export const telegramMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); - if (accounts.length === 0) { - return []; - } - // Union of all accounts' action gates (any account enabling an action makes it available) - const gate = createUnionActionGate(accounts, (account) => - createTelegramActionGate({ - cfg, - accountId: account.accountId, - }), - ); - const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) => - gate(key, defaultValue); - const actions = new Set(["send"]); - const pollEnabledForAnyAccount = accounts.some((account) => { - const accountGate = createTelegramActionGate({ - cfg, - accountId: account.accountId, - }); - return resolveTelegramPollActionGateState(accountGate).enabled; - }); - if (pollEnabledForAnyAccount) { - actions.add("poll"); - } - if (isEnabled("reactions")) { - actions.add("react"); - } - if (isEnabled("deleteMessage")) { - actions.add("delete"); - } - if (isEnabled("editMessage")) { - actions.add("edit"); - } - if (isEnabled("sticker", false)) { - actions.add("sticker"); - actions.add("sticker-search"); - } - if (isEnabled("createForumTopic")) { - actions.add("topic-create"); - } - return Array.from(actions); - }, - supportsButtons: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); - if (accounts.length === 0) { - return false; - } - return accounts.some((account) => - isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), - ); - }, - extractToolSend: ({ args }) => { - return extractToolSend(args, "sendMessage"); - }, - handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => { - if (action === "send") { - const sendParams = readTelegramSendParams(params); - return await handleTelegramAction( - { - action: "sendMessage", - ...sendParams, - accountId: accountId ?? undefined, - }, - cfg, - { mediaLocalRoots }, - ); - } - - if (action === "react") { - const messageId = resolveReactionMessageId({ args: params, toolContext }); - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = readBooleanParam(params, "remove"); - return await handleTelegramAction( - { - action: "react", - chatId: readTelegramChatIdParam(params), - messageId, - emoji, - remove, - accountId: accountId ?? undefined, - }, - cfg, - { mediaLocalRoots }, - ); - } - - if (action === "poll") { - const to = readStringParam(params, "to", { required: true }); - const question = readStringParam(params, "pollQuestion", { required: true }); - const answers = readStringArrayParam(params, "pollOption", { required: true }); - const durationHours = readNumberParam(params, "pollDurationHours", { - integer: true, - strict: true, - }); - const durationSeconds = readNumberParam(params, "pollDurationSeconds", { - integer: true, - strict: true, - }); - const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); - const messageThreadId = readNumberParam(params, "threadId", { integer: true }); - const allowMultiselect = readBooleanParam(params, "pollMulti"); - const pollAnonymous = readBooleanParam(params, "pollAnonymous"); - const pollPublic = readBooleanParam(params, "pollPublic"); - const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); - const silent = readBooleanParam(params, "silent"); - return await handleTelegramAction( - { - action: "poll", - to, - question, - answers, - allowMultiselect, - durationHours: durationHours ?? undefined, - durationSeconds: durationSeconds ?? undefined, - replyToMessageId: replyToMessageId ?? undefined, - messageThreadId: messageThreadId ?? undefined, - isAnonymous, - silent, - accountId: accountId ?? undefined, - }, - cfg, - { mediaLocalRoots }, - ); - } - - if (action === "delete") { - const chatId = readTelegramChatIdParam(params); - const messageId = readTelegramMessageIdParam(params); - return await handleTelegramAction( - { - action: "deleteMessage", - chatId, - messageId, - accountId: accountId ?? undefined, - }, - cfg, - { mediaLocalRoots }, - ); - } - - if (action === "edit") { - const chatId = readTelegramChatIdParam(params); - const messageId = readTelegramMessageIdParam(params); - const message = readStringParam(params, "message", { required: true, allowEmpty: false }); - const buttons = params.buttons; - return await handleTelegramAction( - { - action: "editMessage", - chatId, - messageId, - content: message, - buttons, - accountId: accountId ?? undefined, - }, - cfg, - { mediaLocalRoots }, - ); - } - - if (action === "sticker") { - const to = - readStringParam(params, "to") ?? readStringParam(params, "target", { required: true }); - // Accept stickerId (array from shared schema) and use first element as fileId - const stickerIds = readStringArrayParam(params, "stickerId"); - const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true }); - const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); - const messageThreadId = readNumberParam(params, "threadId", { integer: true }); - return await handleTelegramAction( - { - action: "sendSticker", - to, - fileId, - replyToMessageId: replyToMessageId ?? undefined, - messageThreadId: messageThreadId ?? undefined, - accountId: accountId ?? undefined, - }, - cfg, - { mediaLocalRoots }, - ); - } - - if (action === "sticker-search") { - const query = readStringParam(params, "query", { required: true }); - const limit = readNumberParam(params, "limit", { integer: true }); - return await handleTelegramAction( - { - action: "searchSticker", - query, - limit: limit ?? undefined, - accountId: accountId ?? undefined, - }, - cfg, - { mediaLocalRoots }, - ); - } - - if (action === "topic-create") { - const chatId = readTelegramChatIdParam(params); - const name = readStringParam(params, "name", { required: true }); - const iconColor = readNumberParam(params, "iconColor", { integer: true }); - const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); - return await handleTelegramAction( - { - action: "createForumTopic", - chatId, - name, - iconColor: iconColor ?? undefined, - iconCustomEmojiId: iconCustomEmojiId ?? undefined, - accountId: accountId ?? undefined, - }, - cfg, - { mediaLocalRoots }, - ); - } - - throw new Error(`Action ${action} is not supported for provider ${providerId}.`); - }, -}; +export * from "../../../../extensions/telegram/src/channel-actions.js"; diff --git a/src/channels/plugins/agent-tools/whatsapp-login.ts b/src/channels/plugins/agent-tools/whatsapp-login.ts index bba63808410..741b40a6fc9 100644 --- a/src/channels/plugins/agent-tools/whatsapp-login.ts +++ b/src/channels/plugins/agent-tools/whatsapp-login.ts @@ -1,72 +1,2 @@ -import { Type } from "@sinclair/typebox"; -import type { ChannelAgentTool } from "../types.js"; - -export function createWhatsAppLoginTool(): ChannelAgentTool { - return { - label: "WhatsApp Login", - name: "whatsapp_login", - ownerOnly: true, - description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", - // NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)] - // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. - parameters: Type.Object({ - action: Type.Unsafe<"start" | "wait">({ - type: "string", - enum: ["start", "wait"], - }), - timeoutMs: Type.Optional(Type.Number()), - force: Type.Optional(Type.Boolean()), - }), - execute: async (_toolCallId, args) => { - const { startWebLoginWithQr, waitForWebLogin } = await import("../../../web/login-qr.js"); - const action = (args as { action?: string })?.action ?? "start"; - if (action === "wait") { - const result = await waitForWebLogin({ - timeoutMs: - typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" - ? (args as { timeoutMs?: number }).timeoutMs - : undefined, - }); - return { - content: [{ type: "text", text: result.message }], - details: { connected: result.connected }, - }; - } - - const result = await startWebLoginWithQr({ - timeoutMs: - typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" - ? (args as { timeoutMs?: number }).timeoutMs - : undefined, - force: - typeof (args as { force?: unknown }).force === "boolean" - ? (args as { force?: boolean }).force - : false, - }); - - if (!result.qrDataUrl) { - return { - content: [ - { - type: "text", - text: result.message, - }, - ], - details: { qr: false }, - }; - } - - const text = [ - result.message, - "", - "Open WhatsApp → Linked Devices and scan:", - "", - `![whatsapp-qr](${result.qrDataUrl})`, - ].join("\n"); - return { - content: [{ type: "text", text }], - details: { qr: true }, - }; - }, - }; -} +// Shim: re-exports from extensions/whatsapp/src/agent-tools-login.ts +export * from "../../../../extensions/whatsapp/src/agent-tools-login.js"; diff --git a/src/channels/plugins/directory-config.ts b/src/channels/plugins/directory-config.ts index e1270a9ceed..45fb8bcf46a 100644 --- a/src/channels/plugins/directory-config.ts +++ b/src/channels/plugins/directory-config.ts @@ -1,9 +1,9 @@ +import { inspectDiscordAccount } from "../../../extensions/discord/src/account-inspect.js"; +import { inspectSlackAccount } from "../../../extensions/slack/src/account-inspect.js"; +import { inspectTelegramAccount } from "../../../extensions/telegram/src/account-inspect.js"; +import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js"; import type { OpenClawConfig } from "../../config/types.js"; -import { inspectDiscordAccount } from "../../discord/account-inspect.js"; import { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js"; -import { inspectSlackAccount } from "../../slack/account-inspect.js"; -import { inspectTelegramAccount } from "../../telegram/account-inspect.js"; -import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { applyDirectoryQueryAndLimit, toDirectoryEntries } from "./directory-config-helpers.js"; import { normalizeSlackMessagingTarget } from "./normalize/slack.js"; diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index b7f475677c5..4dac8bbc7f2 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -1,3 +1,4 @@ +import { inspectSlackAccount } from "../../../extensions/slack/src/account-inspect.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveChannelGroupRequireMention, @@ -11,7 +12,6 @@ import type { } from "../../config/types.tools.js"; import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js"; import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js"; -import { inspectSlackAccount } from "../../slack/account-inspect.js"; import type { ChannelGroupContext } from "./types.js"; type GroupMentionParams = ChannelGroupContext; diff --git a/src/channels/plugins/normalize/discord.ts b/src/channels/plugins/normalize/discord.ts index 18855825004..e4fcc4e9c00 100644 --- a/src/channels/plugins/normalize/discord.ts +++ b/src/channels/plugins/normalize/discord.ts @@ -1,47 +1,2 @@ -import { parseDiscordTarget } from "../../../discord/targets.js"; - -export function normalizeDiscordMessagingTarget(raw: string): string | undefined { - // Default bare IDs to channels so routing is stable across tool actions. - const target = parseDiscordTarget(raw, { defaultKind: "channel" }); - return target?.normalized; -} - -/** - * Normalize a Discord outbound target for delivery. Bare numeric IDs are - * prefixed with "channel:" to avoid the ambiguous-target error in - * parseDiscordTarget. All other formats pass through unchanged. - */ -export function normalizeDiscordOutboundTarget( - to?: string, -): { ok: true; to: string } | { ok: false; error: Error } { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: new Error( - 'Discord recipient is required. Use "channel:" for channels or "user:" for DMs.', - ), - }; - } - if (/^\d+$/.test(trimmed)) { - return { ok: true, to: `channel:${trimmed}` }; - } - return { ok: true, to: trimmed }; -} - -export function looksLikeDiscordTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - if (/^<@!?\d+>$/.test(trimmed)) { - return true; - } - if (/^(user|channel|discord):/i.test(trimmed)) { - return true; - } - if (/^\d{6,}$/.test(trimmed)) { - return true; - } - return false; -} +// Shim: re-exports from extension +export * from "../../../../extensions/discord/src/normalize.js"; diff --git a/src/channels/plugins/normalize/imessage.ts b/src/channels/plugins/normalize/imessage.ts index 94cb5833819..3b9ecbe1837 100644 --- a/src/channels/plugins/normalize/imessage.ts +++ b/src/channels/plugins/normalize/imessage.ts @@ -1,4 +1,4 @@ -import { normalizeIMessageHandle } from "../../../imessage/targets.js"; +import { normalizeIMessageHandle } from "../../../../extensions/imessage/src/targets.js"; import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.js"; // Service prefixes that indicate explicit delivery method; must be preserved during normalization diff --git a/src/channels/plugins/normalize/slack.ts b/src/channels/plugins/normalize/slack.ts index 33dcfb7ee23..52d4c905342 100644 --- a/src/channels/plugins/normalize/slack.ts +++ b/src/channels/plugins/normalize/slack.ts @@ -1,4 +1,4 @@ -import { parseSlackTarget } from "../../../slack/targets.js"; +import { parseSlackTarget } from "../../../../extensions/slack/src/targets.js"; export function normalizeSlackMessagingTarget(raw: string): string | undefined { const target = parseSlackTarget(raw, { defaultKind: "channel" }); diff --git a/src/channels/plugins/normalize/telegram.test.ts b/src/channels/plugins/normalize/telegram.test.ts deleted file mode 100644 index 23e90288f0b..00000000000 --- a/src/channels/plugins/normalize/telegram.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./telegram.js"; - -describe("normalizeTelegramMessagingTarget", () => { - it("normalizes t.me links to prefixed usernames", () => { - expect(normalizeTelegramMessagingTarget("https://t.me/MyChannel")).toBe("telegram:@mychannel"); - }); - - it("keeps unprefixed topic targets valid", () => { - expect(normalizeTelegramMessagingTarget("@MyChannel:topic:9")).toBe( - "telegram:@mychannel:topic:9", - ); - expect(normalizeTelegramMessagingTarget("-1001234567890:topic:456")).toBe( - "telegram:-1001234567890:topic:456", - ); - }); - - it("keeps legacy prefixed topic targets valid", () => { - expect(normalizeTelegramMessagingTarget("telegram:group:-1001234567890:topic:456")).toBe( - "telegram:group:-1001234567890:topic:456", - ); - expect(normalizeTelegramMessagingTarget("tg:group:-1001234567890:topic:456")).toBe( - "telegram:group:-1001234567890:topic:456", - ); - }); -}); - -describe("looksLikeTelegramTargetId", () => { - it("recognizes unprefixed topic targets", () => { - expect(looksLikeTelegramTargetId("@mychannel:topic:9")).toBe(true); - expect(looksLikeTelegramTargetId("-1001234567890:topic:456")).toBe(true); - }); - - it("recognizes legacy prefixed topic targets", () => { - expect(looksLikeTelegramTargetId("telegram:group:-1001234567890:topic:456")).toBe(true); - expect(looksLikeTelegramTargetId("tg:group:-1001234567890:topic:456")).toBe(true); - }); - - it("still recognizes normalized lookup targets", () => { - expect(looksLikeTelegramTargetId("https://t.me/MyChannel")).toBe(true); - expect(looksLikeTelegramTargetId("@mychannel")).toBe(true); - }); -}); diff --git a/src/channels/plugins/normalize/telegram.ts b/src/channels/plugins/normalize/telegram.ts index a21ad160d03..ab3971ff32b 100644 --- a/src/channels/plugins/normalize/telegram.ts +++ b/src/channels/plugins/normalize/telegram.ts @@ -1,44 +1 @@ -import { normalizeTelegramLookupTarget, parseTelegramTarget } from "../../../telegram/targets.js"; - -const TELEGRAM_PREFIX_RE = /^(telegram|tg):/i; - -function normalizeTelegramTargetBody(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) { - return undefined; - } - - const prefixStripped = trimmed.replace(TELEGRAM_PREFIX_RE, "").trim(); - if (!prefixStripped) { - return undefined; - } - - const parsed = parseTelegramTarget(trimmed); - const normalizedChatId = normalizeTelegramLookupTarget(parsed.chatId); - if (!normalizedChatId) { - return undefined; - } - - const keepLegacyGroupPrefix = /^group:/i.test(prefixStripped); - const hasTopicSuffix = /:topic:\d+$/i.test(prefixStripped); - const chatSegment = keepLegacyGroupPrefix ? `group:${normalizedChatId}` : normalizedChatId; - if (parsed.messageThreadId == null) { - return chatSegment; - } - const threadSuffix = hasTopicSuffix - ? `:topic:${parsed.messageThreadId}` - : `:${parsed.messageThreadId}`; - return `${chatSegment}${threadSuffix}`; -} - -export function normalizeTelegramMessagingTarget(raw: string): string | undefined { - const normalizedBody = normalizeTelegramTargetBody(raw); - if (!normalizedBody) { - return undefined; - } - return `telegram:${normalizedBody}`.toLowerCase(); -} - -export function looksLikeTelegramTargetId(raw: string): boolean { - return normalizeTelegramTargetBody(raw) !== undefined; -} +export * from "../../../../extensions/telegram/src/normalize.js"; diff --git a/src/channels/plugins/normalize/whatsapp.ts b/src/channels/plugins/normalize/whatsapp.ts index edff8bfe5e1..1e464489818 100644 --- a/src/channels/plugins/normalize/whatsapp.ts +++ b/src/channels/plugins/normalize/whatsapp.ts @@ -1,25 +1,2 @@ -import { normalizeWhatsAppTarget } from "../../../whatsapp/normalize.js"; -import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.js"; - -export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined { - const trimmed = trimMessagingTarget(raw); - if (!trimmed) { - return undefined; - } - return normalizeWhatsAppTarget(trimmed) ?? undefined; -} - -export function normalizeWhatsAppAllowFromEntries(allowFrom: Array): string[] { - return allowFrom - .map((entry) => String(entry).trim()) - .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) - .filter((entry): entry is string => Boolean(entry)); -} - -export function looksLikeWhatsAppTargetId(raw: string): boolean { - return looksLikeHandleOrPhoneTarget({ - raw, - prefixPattern: /^whatsapp:/i, - }); -} +// Shim: re-exports from extensions/whatsapp/src/normalize.ts +export * from "../../../../extensions/whatsapp/src/normalize.js"; diff --git a/src/channels/plugins/onboarding/discord.ts b/src/channels/plugins/onboarding/discord.ts index d6a8c8df370..34fd42d3b98 100644 --- a/src/channels/plugins/onboarding/discord.ts +++ b/src/channels/plugins/onboarding/discord.ts @@ -1,316 +1,2 @@ -import type { OpenClawConfig } from "../../../config/config.js"; -import type { DiscordGuildEntry } from "../../../config/types.discord.js"; -import { hasConfiguredSecretInput } from "../../../config/types.secrets.js"; -import { inspectDiscordAccount } from "../../../discord/account-inspect.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "../../../discord/accounts.js"; -import { normalizeDiscordSlug } from "../../../discord/monitor/allow-list.js"; -import { - resolveDiscordChannelAllowlist, - type DiscordChannelResolution, -} from "../../../discord/resolve-channels.js"; -import { resolveDiscordUserAllowlist } from "../../../discord/resolve-users.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; -import { formatDocsLink } from "../../../terminal/links.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js"; -import { - applySingleTokenPromptResult, - parseMentionOrPrefixedId, - noteChannelLookupFailure, - noteChannelLookupSummary, - patchChannelConfigForAccount, - promptLegacyChannelAllowFrom, - resolveAccountIdForConfigure, - resolveOnboardingAccountId, - runSingleChannelSecretStep, - setAccountGroupPolicyForChannel, - setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "./helpers.js"; - -const channel = "discord" as const; - -async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Discord Developer Portal → Applications → New Application", - "2) Bot → Add Bot → Reset Token → copy token", - "3) OAuth2 → URL Generator → scope 'bot' → invite to your server", - "Tip: enable Message Content Intent if you need message text. (Bot → Privileged Gateway Intents → Message Content Intent)", - `Docs: ${formatDocsLink("/discord", "discord")}`, - ].join("\n"), - "Discord bot token", - ); -} - -function setDiscordGuildChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - entries: Array<{ - guildKey: string; - channelKey?: string; - }>, -): OpenClawConfig { - const baseGuilds = - accountId === DEFAULT_ACCOUNT_ID - ? (cfg.channels?.discord?.guilds ?? {}) - : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); - const guilds: Record = { ...baseGuilds }; - for (const entry of entries) { - const guildKey = entry.guildKey || "*"; - const existing = guilds[guildKey] ?? {}; - if (entry.channelKey) { - const channels = { ...existing.channels }; - channels[entry.channelKey] = { allow: true }; - guilds[guildKey] = { ...existing, channels }; - } else { - guilds[guildKey] = existing; - } - } - return patchChannelConfigForAccount({ - cfg, - channel: "discord", - accountId, - patch: { guilds }, - }); -} - -async function promptDiscordAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = resolveOnboardingAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), - }); - const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId }); - const token = resolved.token; - const existing = - params.cfg.channels?.discord?.allowFrom ?? params.cfg.channels?.discord?.dm?.allowFrom ?? []; - const parseId = (value: string) => - parseMentionOrPrefixedId({ - value, - mentionPattern: /^<@!?(\d+)>$/, - prefixPattern: /^(user:|discord:)/i, - idPattern: /^\d+$/, - }); - - return promptLegacyChannelAllowFrom({ - cfg: params.cfg, - channel: "discord", - prompter: params.prompter, - existing, - token, - noteTitle: "Discord allowlist", - noteLines: [ - "Allowlist Discord DMs by username (we resolve to user ids).", - "Examples:", - "- 123456789012345678", - "- @alice", - "- alice#1234", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/discord", "discord")}`, - ], - message: "Discord allowFrom (usernames or ids)", - placeholder: "@alice, 123456789012345678", - parseId, - invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.", - resolveEntries: ({ token, entries }) => - resolveDiscordUserAllowlist({ - token, - entries, - }), - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Discord", - channel, - policyKey: "channels.discord.dmPolicy", - allowFromKey: "channels.discord.allowFrom", - getCurrent: (cfg) => - cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel: "discord", - dmPolicy: policy, - }), - promptAllowFrom: promptDiscordAllowFrom, -}; - -export const discordOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listDiscordAccountIds(cfg).some((accountId) => { - const account = inspectDiscordAccount({ cfg, accountId }); - return account.configured; - }); - return { - channel, - configured, - statusLines: [`Discord: ${configured ? "configured" : "needs token"}`], - selectionHint: configured ? "configured" : "needs token", - quickstartScore: configured ? 2 : 1, - }; - }, - configure: async ({ cfg, prompter, options, accountOverrides, shouldPromptAccountIds }) => { - const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg); - const discordAccountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Discord", - accountOverride: accountOverrides.discord, - shouldPromptAccountIds, - listAccountIds: listDiscordAccountIds, - defaultAccountId: defaultDiscordAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveDiscordAccount({ - cfg: next, - accountId: discordAccountId, - }); - const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID; - const tokenStep = await runSingleChannelSecretStep({ - cfg: next, - prompter, - providerHint: "discord", - credentialLabel: "Discord bot token", - secretInputMode: options?.secretInputMode, - accountConfigured: Boolean(resolvedAccount.token), - hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.token), - allowEnv, - envValue: process.env.DISCORD_BOT_TOKEN, - envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?", - keepPrompt: "Discord token already configured. Keep it?", - inputPrompt: "Enter Discord bot token", - preferredEnvVar: allowEnv ? "DISCORD_BOT_TOKEN" : undefined, - onMissingConfigured: async () => await noteDiscordTokenHelp(prompter), - applyUseEnv: async (cfg) => - applySingleTokenPromptResult({ - cfg, - channel: "discord", - accountId: discordAccountId, - tokenPatchKey: "token", - tokenResult: { useEnv: true, token: null }, - }), - applySet: async (cfg, value) => - applySingleTokenPromptResult({ - cfg, - channel: "discord", - accountId: discordAccountId, - tokenPatchKey: "token", - tokenResult: { useEnv: false, token: value }, - }), - }); - next = tokenStep.cfg; - - const currentEntries = Object.entries(resolvedAccount.config.guilds ?? {}).flatMap( - ([guildKey, value]) => { - const channels = value?.channels ?? {}; - const channelKeys = Object.keys(channels); - if (channelKeys.length === 0) { - const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey; - return [input]; - } - return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); - }, - ); - next = await configureChannelAccessWithAllowlist({ - cfg: next, - prompter, - label: "Discord channels", - currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist", - currentEntries, - placeholder: "My Server/#general, guildId/channelId, #support", - updatePrompt: Boolean(resolvedAccount.config.guilds), - setPolicy: (cfg, policy) => - setAccountGroupPolicyForChannel({ - cfg, - channel: "discord", - accountId: discordAccountId, - groupPolicy: policy, - }), - resolveAllowlist: async ({ cfg, entries }) => { - const accountWithTokens = resolveDiscordAccount({ - cfg, - accountId: discordAccountId, - }); - let resolved: DiscordChannelResolution[] = entries.map((input) => ({ - input, - resolved: false, - })); - const activeToken = accountWithTokens.token || tokenStep.resolvedValue || ""; - if (activeToken && entries.length > 0) { - try { - resolved = await resolveDiscordChannelAllowlist({ - token: activeToken, - entries, - }); - const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId); - const resolvedGuilds = resolved.filter( - (entry) => entry.resolved && entry.guildId && !entry.channelId, - ); - const unresolved = resolved - .filter((entry) => !entry.resolved) - .map((entry) => entry.input); - await noteChannelLookupSummary({ - prompter, - label: "Discord channels", - resolvedSections: [ - { - title: "Resolved channels", - values: resolvedChannels - .map((entry) => entry.channelId) - .filter((value): value is string => Boolean(value)), - }, - { - title: "Resolved guilds", - values: resolvedGuilds - .map((entry) => entry.guildId) - .filter((value): value is string => Boolean(value)), - }, - ], - unresolved, - }); - } catch (err) { - await noteChannelLookupFailure({ - prompter, - label: "Discord channels", - error: err, - }); - } - } - return resolved; - }, - applyAllowlist: ({ cfg, resolved }) => { - const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = []; - for (const entry of resolved) { - const guildKey = - entry.guildId ?? - (entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ?? - "*"; - const channelKey = - entry.channelId ?? - (entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined); - if (!channelKey && guildKey === "*") { - continue; - } - allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) }); - } - return setDiscordGuildChannelAllowlist(cfg, discordAccountId, allowlistEntries); - }, - }); - - return { cfg: next, accountId: discordAccountId }; - }, - dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), -}; +// Shim: re-exports from extension +export * from "../../../../extensions/discord/src/onboarding.js"; diff --git a/src/channels/plugins/onboarding/imessage.ts b/src/channels/plugins/onboarding/imessage.ts index 7e89047e971..b4941ebd82e 100644 --- a/src/channels/plugins/onboarding/imessage.ts +++ b/src/channels/plugins/onboarding/imessage.ts @@ -1,11 +1,11 @@ -import { detectBinary } from "../../../commands/onboard-helpers.js"; -import type { OpenClawConfig } from "../../../config/config.js"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, resolveIMessageAccount, -} from "../../../imessage/accounts.js"; -import { normalizeIMessageHandle } from "../../../imessage/targets.js"; +} from "../../../../extensions/imessage/src/accounts.js"; +import { normalizeIMessageHandle } from "../../../../extensions/imessage/src/targets.js"; +import { detectBinary } from "../../../commands/onboard-helpers.js"; +import type { OpenClawConfig } from "../../../config/config.js"; import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; diff --git a/src/channels/plugins/onboarding/signal.ts b/src/channels/plugins/onboarding/signal.ts index ce48be2aa7f..6609d4bbd76 100644 --- a/src/channels/plugins/onboarding/signal.ts +++ b/src/channels/plugins/onboarding/signal.ts @@ -1,12 +1,12 @@ -import { formatCliCommand } from "../../../cli/command-format.js"; -import { detectBinary } from "../../../commands/onboard-helpers.js"; -import { installSignalCli } from "../../../commands/signal-install.js"; -import type { OpenClawConfig } from "../../../config/config.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, resolveSignalAccount, -} from "../../../signal/accounts.js"; +} from "../../../../extensions/signal/src/accounts.js"; +import { formatCliCommand } from "../../../cli/command-format.js"; +import { detectBinary } from "../../../commands/onboard-helpers.js"; +import { installSignalCli } from "../../../commands/signal-install.js"; +import type { OpenClawConfig } from "../../../config/config.js"; import { formatDocsLink } from "../../../terminal/links.js"; import { normalizeE164 } from "../../../utils.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts index 0cceb859e4d..8b956edcd23 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/src/channels/plugins/onboarding/slack.ts @@ -1,14 +1,14 @@ -import type { OpenClawConfig } from "../../../config/config.js"; -import { hasConfiguredSecretInput } from "../../../config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; -import { inspectSlackAccount } from "../../../slack/account-inspect.js"; +import { inspectSlackAccount } from "../../../../extensions/slack/src/account-inspect.js"; import { listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackAccount, -} from "../../../slack/accounts.js"; -import { resolveSlackChannelAllowlist } from "../../../slack/resolve-channels.js"; -import { resolveSlackUserAllowlist } from "../../../slack/resolve-users.js"; +} from "../../../../extensions/slack/src/accounts.js"; +import { resolveSlackChannelAllowlist } from "../../../../extensions/slack/src/resolve-channels.js"; +import { resolveSlackUserAllowlist } from "../../../../extensions/slack/src/resolve-users.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { hasConfiguredSecretInput } from "../../../config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; diff --git a/src/channels/plugins/onboarding/telegram.test.ts b/src/channels/plugins/onboarding/telegram.test.ts deleted file mode 100644 index 98661ec9966..00000000000 --- a/src/channels/plugins/onboarding/telegram.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeTelegramAllowFromInput, parseTelegramAllowFromId } from "./telegram.js"; - -describe("normalizeTelegramAllowFromInput", () => { - it("strips telegram/tg prefixes and trims whitespace", () => { - expect(normalizeTelegramAllowFromInput(" telegram:123 ")).toBe("123"); - expect(normalizeTelegramAllowFromInput("tg:@alice")).toBe("@alice"); - expect(normalizeTelegramAllowFromInput(" @bob ")).toBe("@bob"); - }); -}); - -describe("parseTelegramAllowFromId", () => { - it("accepts numeric ids with optional prefixes", () => { - expect(parseTelegramAllowFromId("12345")).toBe("12345"); - expect(parseTelegramAllowFromId("telegram:98765")).toBe("98765"); - expect(parseTelegramAllowFromId("tg:2468")).toBe("2468"); - }); - - it("rejects non-numeric values", () => { - expect(parseTelegramAllowFromId("@alice")).toBeNull(); - expect(parseTelegramAllowFromId("tg:alice")).toBeNull(); - }); -}); diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts index 2c37c24bcee..772f7d1ce71 100644 --- a/src/channels/plugins/onboarding/telegram.ts +++ b/src/channels/plugins/onboarding/telegram.ts @@ -1,243 +1 @@ -import { formatCliCommand } from "../../../cli/command-format.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { hasConfiguredSecretInput } from "../../../config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; -import { inspectTelegramAccount } from "../../../telegram/account-inspect.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "../../../telegram/accounts.js"; -import { formatDocsLink } from "../../../terminal/links.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import { fetchTelegramChatId } from "../../telegram/api.js"; -import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import { - applySingleTokenPromptResult, - patchChannelConfigForAccount, - promptResolvedAllowFrom, - resolveAccountIdForConfigure, - resolveOnboardingAccountId, - runSingleChannelSecretStep, - setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, - splitOnboardingEntries, -} from "./helpers.js"; - -const channel = "telegram" as const; - -async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Open Telegram and chat with @BotFather", - "2) Run /newbot (or /mybots)", - "3) Copy the token (looks like 123456:ABC...)", - "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", - ].join("\n"), - "Telegram bot token", - ); -} - -async function noteTelegramUserIdHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, - "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", - "3) Third-party: DM @userinfobot or @getidsbot", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", - ].join("\n"), - "Telegram user id", - ); -} - -export function normalizeTelegramAllowFromInput(raw: string): string { - return raw - .trim() - .replace(/^(telegram|tg):/i, "") - .trim(); -} - -export function parseTelegramAllowFromId(raw: string): string | null { - const stripped = normalizeTelegramAllowFromInput(raw); - return /^\d+$/.test(stripped) ? stripped : null; -} - -async function promptTelegramAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId: string; - tokenOverride?: string; -}): Promise { - const { cfg, prompter, accountId } = params; - const resolved = resolveTelegramAccount({ cfg, accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - await noteTelegramUserIdHelp(prompter); - - const token = params.tokenOverride?.trim() || resolved.token; - if (!token) { - await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram"); - } - const unique = await promptResolvedAllowFrom({ - prompter, - existing: existingAllowFrom, - token, - message: "Telegram allowFrom (numeric sender id; @username resolves to id)", - placeholder: "@username", - label: "Telegram allowlist", - parseInputs: splitOnboardingEntries, - parseId: parseTelegramAllowFromId, - invalidWithoutTokenNote: - "Telegram token missing; use numeric sender ids (usernames require a bot token).", - resolveEntries: async ({ token: tokenValue, entries }) => { - const results = await Promise.all( - entries.map(async (entry) => { - const numericId = parseTelegramAllowFromId(entry); - if (numericId) { - return { input: entry, resolved: true, id: numericId }; - } - const stripped = normalizeTelegramAllowFromInput(entry); - if (!stripped) { - return { input: entry, resolved: false, id: null }; - } - const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - const id = await fetchTelegramChatId({ token: tokenValue, chatId: username }); - return { input: entry, resolved: Boolean(id), id }; - }), - ); - return results; - }, - }); - - return patchChannelConfigForAccount({ - cfg, - channel: "telegram", - accountId, - patch: { dmPolicy: "allowlist", allowFrom: unique }, - }); -} - -async function promptTelegramAllowFromForAccount(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = resolveOnboardingAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), - }); - return promptTelegramAllowFrom({ - cfg: params.cfg, - prompter: params.prompter, - accountId, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Telegram", - channel, - policyKey: "channels.telegram.dmPolicy", - allowFromKey: "channels.telegram.allowFrom", - getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel: "telegram", - dmPolicy: policy, - }), - promptAllowFrom: promptTelegramAllowFromForAccount, -}; - -export const telegramOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listTelegramAccountIds(cfg).some((accountId) => { - const account = inspectTelegramAccount({ cfg, accountId }); - return account.configured; - }); - return { - channel, - configured, - statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`], - selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly", - quickstartScore: configured ? 1 : 10, - }; - }, - configure: async ({ - cfg, - prompter, - options, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg); - const telegramAccountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Telegram", - accountOverride: accountOverrides.telegram, - shouldPromptAccountIds, - listAccountIds: listTelegramAccountIds, - defaultAccountId: defaultTelegramAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveTelegramAccount({ - cfg: next, - accountId: telegramAccountId, - }); - const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken); - const hasConfigToken = - hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim()); - const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID; - const tokenStep = await runSingleChannelSecretStep({ - cfg: next, - prompter, - providerHint: "telegram", - credentialLabel: "Telegram bot token", - secretInputMode: options?.secretInputMode, - accountConfigured: Boolean(resolvedAccount.token) || hasConfigToken, - hasConfigToken, - allowEnv, - envValue: process.env.TELEGRAM_BOT_TOKEN, - envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", - keepPrompt: "Telegram token already configured. Keep it?", - inputPrompt: "Enter Telegram bot token", - preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined, - onMissingConfigured: async () => await noteTelegramTokenHelp(prompter), - applyUseEnv: async (cfg) => - applySingleTokenPromptResult({ - cfg, - channel: "telegram", - accountId: telegramAccountId, - tokenPatchKey: "botToken", - tokenResult: { useEnv: true, token: null }, - }), - applySet: async (cfg, value) => - applySingleTokenPromptResult({ - cfg, - channel: "telegram", - accountId: telegramAccountId, - tokenPatchKey: "botToken", - tokenResult: { useEnv: false, token: value }, - }), - }); - next = tokenStep.cfg; - - if (forceAllowFrom) { - next = await promptTelegramAllowFrom({ - cfg: next, - prompter, - accountId: telegramAccountId, - tokenOverride: tokenStep.resolvedValue, - }); - } - - return { cfg: next, accountId: telegramAccountId }; - }, - dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), -}; +export * from "../../../../extensions/telegram/src/onboarding.js"; diff --git a/src/channels/plugins/onboarding/whatsapp.ts b/src/channels/plugins/onboarding/whatsapp.ts index 4b0d9ceda14..e2694f8d7c5 100644 --- a/src/channels/plugins/onboarding/whatsapp.ts +++ b/src/channels/plugins/onboarding/whatsapp.ts @@ -1,354 +1,2 @@ -import path from "node:path"; -import { loginWeb } from "../../../channel-web.js"; -import { formatCliCommand } from "../../../cli/command-format.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { mergeWhatsAppConfig } from "../../../config/merge-config.js"; -import type { DmPolicy } from "../../../config/types.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; -import type { RuntimeEnv } from "../../../runtime.js"; -import { formatDocsLink } from "../../../terminal/links.js"; -import { normalizeE164, pathExists } from "../../../utils.js"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAuthDir, -} from "../../../web/accounts.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import type { ChannelOnboardingAdapter } from "../onboarding-types.js"; -import { - normalizeAllowFromEntries, - resolveAccountIdForConfigure, - resolveOnboardingAccountId, - splitOnboardingEntries, -} from "./helpers.js"; - -const channel = "whatsapp" as const; - -function setWhatsAppDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return mergeWhatsAppConfig(cfg, { dmPolicy }); -} - -function setWhatsAppAllowFrom(cfg: OpenClawConfig, allowFrom?: string[]): OpenClawConfig { - return mergeWhatsAppConfig(cfg, { allowFrom }, { unsetOnUndefined: ["allowFrom"] }); -} - -function setWhatsAppSelfChatMode(cfg: OpenClawConfig, selfChatMode: boolean): OpenClawConfig { - return mergeWhatsAppConfig(cfg, { selfChatMode }); -} - -async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise { - const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); - const credsPath = path.join(authDir, "creds.json"); - return await pathExists(credsPath); -} - -async function promptWhatsAppOwnerAllowFrom(params: { - prompter: WizardPrompter; - existingAllowFrom: string[]; -}): Promise<{ normalized: string; allowFrom: string[] }> { - const { prompter, existingAllowFrom } = params; - - await prompter.note( - "We need the sender/owner number so OpenClaw can allowlist you.", - "WhatsApp number", - ); - const entry = await prompter.text({ - message: "Your personal WhatsApp number (the phone you will message from)", - placeholder: "+15555550123", - initialValue: existingAllowFrom[0], - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - const normalized = normalizeE164(raw); - if (!normalized) { - return `Invalid number: ${raw}`; - } - return undefined; - }, - }); - - const normalized = normalizeE164(String(entry).trim()); - if (!normalized) { - throw new Error("Invalid WhatsApp owner number (expected E.164 after validation)."); - } - const allowFrom = normalizeAllowFromEntries( - [...existingAllowFrom.filter((item) => item !== "*"), normalized], - normalizeE164, - ); - return { normalized, allowFrom }; -} - -async function applyWhatsAppOwnerAllowlist(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - existingAllowFrom: string[]; - title: string; - messageLines: string[]; -}): Promise { - const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ - prompter: params.prompter, - existingAllowFrom: params.existingAllowFrom, - }); - let next = setWhatsAppSelfChatMode(params.cfg, true); - next = setWhatsAppDmPolicy(next, "allowlist"); - next = setWhatsAppAllowFrom(next, allowFrom); - await params.prompter.note( - [...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"), - params.title, - ); - return next; -} - -function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { - const parts = splitOnboardingEntries(raw); - if (parts.length === 0) { - return { entries: [] }; - } - const entries: string[] = []; - for (const part of parts) { - if (part === "*") { - entries.push("*"); - continue; - } - const normalized = normalizeE164(part); - if (!normalized) { - return { entries: [], invalidEntry: part }; - } - entries.push(normalized); - } - return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; -} - -async function promptWhatsAppAllowFrom( - cfg: OpenClawConfig, - _runtime: RuntimeEnv, - prompter: WizardPrompter, - options?: { forceAllowlist?: boolean }, -): Promise { - const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing"; - const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? []; - const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; - - if (options?.forceAllowlist) { - return await applyWhatsAppOwnerAllowlist({ - cfg, - prompter, - existingAllowFrom, - title: "WhatsApp allowlist", - messageLines: ["Allowlist mode enabled."], - }); - } - - await prompter.note( - [ - "WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.", - "- pairing (default): unknown senders get a pairing code; owner approves", - "- allowlist: unknown senders are blocked", - '- open: public inbound DMs (requires allowFrom to include "*")', - "- disabled: ignore WhatsApp DMs", - "", - `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, - `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, - ].join("\n"), - "WhatsApp DM access", - ); - - const phoneMode = await prompter.select({ - message: "WhatsApp phone setup", - options: [ - { value: "personal", label: "This is my personal phone number" }, - { value: "separate", label: "Separate phone just for OpenClaw" }, - ], - }); - - if (phoneMode === "personal") { - return await applyWhatsAppOwnerAllowlist({ - cfg, - prompter, - existingAllowFrom, - title: "WhatsApp personal phone", - messageLines: [ - "Personal phone mode enabled.", - "- dmPolicy set to allowlist (pairing skipped)", - ], - }); - } - - const policy = (await prompter.select({ - message: "WhatsApp DM policy", - options: [ - { value: "pairing", label: "Pairing (recommended)" }, - { value: "allowlist", label: "Allowlist only (block unknown senders)" }, - { value: "open", label: "Open (public inbound DMs)" }, - { value: "disabled", label: "Disabled (ignore WhatsApp DMs)" }, - ], - })) as DmPolicy; - - let next = setWhatsAppSelfChatMode(cfg, false); - next = setWhatsAppDmPolicy(next, policy); - if (policy === "open") { - const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); - next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]); - return next; - } - if (policy === "disabled") { - return next; - } - - const allowOptions = - existingAllowFrom.length > 0 - ? ([ - { value: "keep", label: "Keep current allowFrom" }, - { - value: "unset", - label: "Unset allowFrom (use pairing approvals only)", - }, - { value: "list", label: "Set allowFrom to specific numbers" }, - ] as const) - : ([ - { value: "unset", label: "Unset allowFrom (default)" }, - { value: "list", label: "Set allowFrom to specific numbers" }, - ] as const); - - const mode = await prompter.select({ - message: "WhatsApp allowFrom (optional pre-allowlist)", - options: allowOptions.map((opt) => ({ - value: opt.value, - label: opt.label, - })), - }); - - if (mode === "keep") { - // Keep allowFrom as-is. - } else if (mode === "unset") { - next = setWhatsAppAllowFrom(next, undefined); - } else { - const allowRaw = await prompter.text({ - message: "Allowed sender numbers (comma-separated, E.164)", - placeholder: "+15555550123, +447700900123", - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - const parsed = parseWhatsAppAllowFromEntries(raw); - if (parsed.entries.length === 0 && !parsed.invalidEntry) { - return "Required"; - } - if (parsed.invalidEntry) { - return `Invalid number: ${parsed.invalidEntry}`; - } - return undefined; - }, - }); - - const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); - next = setWhatsAppAllowFrom(next, parsed.entries); - } - - return next; -} - -export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg, accountOverrides }) => { - const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg); - const accountId = resolveOnboardingAccountId({ - accountId: accountOverrides.whatsapp, - defaultAccountId, - }); - const linked = await detectWhatsAppLinked(cfg, accountId); - const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId; - return { - channel, - configured: linked, - statusLines: [`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`], - selectionHint: linked ? "linked" : "not linked", - quickstartScore: linked ? 5 : 4, - }; - }, - configure: async ({ - cfg, - runtime, - prompter, - options, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "WhatsApp", - accountOverride: accountOverrides.whatsapp, - shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), - listAccountIds: listWhatsAppAccountIds, - defaultAccountId: resolveDefaultWhatsAppAccountId(cfg), - }); - - let next = cfg; - if (accountId !== DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: { - ...next.channels?.whatsapp?.accounts?.[accountId], - enabled: next.channels?.whatsapp?.accounts?.[accountId]?.enabled ?? true, - }, - }, - }, - }, - }; - } - - const linked = await detectWhatsAppLinked(next, accountId); - const { authDir } = resolveWhatsAppAuthDir({ - cfg: next, - accountId, - }); - - if (!linked) { - await prompter.note( - [ - "Scan the QR with WhatsApp on your phone.", - `Credentials are stored under ${authDir}/ for future runs.`, - `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, - ].join("\n"), - "WhatsApp linking", - ); - } - const wantsLink = await prompter.confirm({ - message: linked ? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?", - initialValue: !linked, - }); - if (wantsLink) { - try { - await loginWeb(false, undefined, runtime, accountId); - } catch (err) { - runtime.error(`WhatsApp login failed: ${String(err)}`); - await prompter.note(`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, "WhatsApp help"); - } - } else if (!linked) { - await prompter.note( - `Run \`${formatCliCommand("openclaw channels login")}\` later to link WhatsApp.`, - "WhatsApp", - ); - } - - next = await promptWhatsAppAllowFrom(next, runtime, prompter, { - forceAllowlist: forceAllowFrom, - }); - - return { cfg: next, accountId }; - }, - onAccountRecorded: (accountId, options) => { - options?.onWhatsAppAccountId?.(accountId); - }, -}; +// Shim: re-exports from extensions/whatsapp/src/onboarding.ts +export * from "../../../../extensions/whatsapp/src/onboarding.js"; diff --git a/src/channels/plugins/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts index b88f3cc09ef..5b2126b8fcc 100644 --- a/src/channels/plugins/outbound/discord.ts +++ b/src/channels/plugins/outbound/discord.ts @@ -1,147 +1,2 @@ -import type { OpenClawConfig } from "../../../config/config.js"; -import { - getThreadBindingManager, - type ThreadBindingRecord, -} from "../../../discord/monitor/thread-bindings.js"; -import { - sendMessageDiscord, - sendPollDiscord, - sendWebhookMessageDiscord, -} from "../../../discord/send.js"; -import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; -import { normalizeDiscordOutboundTarget } from "../normalize/discord.js"; -import type { ChannelOutboundAdapter } from "../types.js"; -import { sendTextMediaPayload } from "./direct-text-media.js"; - -function resolveDiscordOutboundTarget(params: { - to: string; - threadId?: string | number | null; -}): string { - if (params.threadId == null) { - return params.to; - } - const threadId = String(params.threadId).trim(); - if (!threadId) { - return params.to; - } - return `channel:${threadId}`; -} - -function resolveDiscordWebhookIdentity(params: { - identity?: OutboundIdentity; - binding: ThreadBindingRecord; -}): { username?: string; avatarUrl?: string } { - const usernameRaw = params.identity?.name?.trim(); - const fallbackUsername = params.binding.label?.trim() || params.binding.agentId; - const username = (usernameRaw || fallbackUsername || "").slice(0, 80) || undefined; - const avatarUrl = params.identity?.avatarUrl?.trim() || undefined; - return { username, avatarUrl }; -} - -async function maybeSendDiscordWebhookText(params: { - cfg?: OpenClawConfig; - text: string; - threadId?: string | number | null; - accountId?: string | null; - identity?: OutboundIdentity; - replyToId?: string | null; -}): Promise<{ messageId: string; channelId: string } | null> { - if (params.threadId == null) { - return null; - } - const threadId = String(params.threadId).trim(); - if (!threadId) { - return null; - } - const manager = getThreadBindingManager(params.accountId ?? undefined); - if (!manager) { - return null; - } - const binding = manager.getByThreadId(threadId); - if (!binding?.webhookId || !binding?.webhookToken) { - return null; - } - const persona = resolveDiscordWebhookIdentity({ - identity: params.identity, - binding, - }); - const result = await sendWebhookMessageDiscord(params.text, { - webhookId: binding.webhookId, - webhookToken: binding.webhookToken, - accountId: binding.accountId, - threadId: binding.threadId, - cfg: params.cfg, - replyTo: params.replyToId ?? undefined, - username: persona.username, - avatarUrl: persona.avatarUrl, - }); - return result; -} - -export const discordOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - chunker: null, - textChunkLimit: 2000, - pollMaxOptions: 10, - resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), - sendPayload: async (ctx) => - await sendTextMediaPayload({ channel: "discord", ctx, adapter: discordOutbound }), - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { - if (!silent) { - const webhookResult = await maybeSendDiscordWebhookText({ - cfg, - text, - threadId, - accountId, - identity, - replyToId, - }).catch(() => null); - if (webhookResult) { - return { channel: "discord", ...webhookResult }; - } - } - const send = deps?.sendDiscord ?? sendMessageDiscord; - const target = resolveDiscordOutboundTarget({ to, threadId }); - const result = await send(target, text, { - verbose: false, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - cfg, - }); - return { channel: "discord", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - silent, - }) => { - const send = deps?.sendDiscord ?? sendMessageDiscord; - const target = resolveDiscordOutboundTarget({ to, threadId }); - const result = await send(target, text, { - verbose: false, - mediaUrl, - mediaLocalRoots, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - cfg, - }); - return { channel: "discord", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => { - const target = resolveDiscordOutboundTarget({ to, threadId }); - return await sendPollDiscord(target, poll, { - accountId: accountId ?? undefined, - silent: silent ?? undefined, - cfg, - }); - }, -}; +// Shim: re-exports from extension +export * from "../../../../extensions/discord/src/outbound-adapter.js"; diff --git a/src/channels/plugins/outbound/imessage.test.ts b/src/channels/plugins/outbound/imessage.test.ts index 7ebcc853793..b42b5a954c8 100644 --- a/src/channels/plugins/outbound/imessage.test.ts +++ b/src/channels/plugins/outbound/imessage.test.ts @@ -22,7 +22,7 @@ describe("imessageOutbound", () => { text: "hello", accountId: "default", replyToId: "msg-123", - deps: { sendIMessage }, + deps: { imessage: sendIMessage }, }); expect(sendIMessage).toHaveBeenCalledWith( @@ -50,7 +50,7 @@ describe("imessageOutbound", () => { mediaLocalRoots: ["/tmp"], accountId: "acct-1", replyToId: "msg-456", - deps: { sendIMessage }, + deps: { imessage: sendIMessage }, }); expect(sendIMessage).toHaveBeenCalledWith( diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts index 20c92754d28..f088f88cf4e 100644 --- a/src/channels/plugins/outbound/imessage.ts +++ b/src/channels/plugins/outbound/imessage.ts @@ -1,12 +1,14 @@ -import { sendMessageIMessage } from "../../../imessage/send.js"; -import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; +import { sendMessageIMessage } from "../../../../extensions/imessage/src/send.js"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js"; import { createScopedChannelMediaMaxBytesResolver, createDirectTextMediaOutbound, } from "./direct-text-media.js"; function resolveIMessageSender(deps: OutboundSendDeps | undefined) { - return deps?.sendIMessage ?? sendMessageIMessage; + return ( + resolveOutboundSendDep(deps, "imessage") ?? sendMessageIMessage + ); } export const imessageOutbound = createDirectTextMediaOutbound({ diff --git a/src/channels/plugins/outbound/signal.test.ts b/src/channels/plugins/outbound/signal.test.ts index 6d1d0bd0606..9848c558965 100644 --- a/src/channels/plugins/outbound/signal.test.ts +++ b/src/channels/plugins/outbound/signal.test.ts @@ -26,7 +26,7 @@ describe("signalOutbound", () => { to: "+15555550123", text: "hello", accountId: "work", - deps: { sendSignal }, + deps: { signal: sendSignal }, }); expect(sendSignal).toHaveBeenCalledWith( @@ -52,7 +52,7 @@ describe("signalOutbound", () => { mediaUrl: "https://example.com/file.jpg", mediaLocalRoots: ["/tmp/media"], accountId: "default", - deps: { sendSignal }, + deps: { signal: sendSignal }, }); expect(sendSignal).toHaveBeenCalledWith( diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index 0ebf8e57670..16016de2fac 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -1,12 +1,12 @@ -import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; -import { sendMessageSignal } from "../../../signal/send.js"; +import { sendMessageSignal } from "../../../../extensions/signal/src/send.js"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js"; import { createScopedChannelMediaMaxBytesResolver, createDirectTextMediaOutbound, } from "./direct-text-media.js"; function resolveSignalSender(deps: OutboundSendDeps | undefined) { - return deps?.sendSignal ?? sendMessageSignal; + return resolveOutboundSendDep(deps, "signal") ?? sendMessageSignal; } export const signalOutbound = createDirectTextMediaOutbound({ diff --git a/src/channels/plugins/outbound/slack.test.ts b/src/channels/plugins/outbound/slack.test.ts index 18635f0e4a2..9b5c1843ce2 100644 --- a/src/channels/plugins/outbound/slack.test.ts +++ b/src/channels/plugins/outbound/slack.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; -vi.mock("../../../slack/send.js", () => ({ +vi.mock("../../../../extensions/slack/src/send.js", () => ({ sendMessageSlack: vi.fn().mockResolvedValue({ messageId: "1234.5678", channelId: "C123" }), })); @@ -9,8 +9,8 @@ vi.mock("../../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: vi.fn(), })); +import { sendMessageSlack } from "../../../../extensions/slack/src/send.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; -import { sendMessageSlack } from "../../../slack/send.js"; import { slackOutbound } from "./slack.js"; type SlackSendTextCtx = { diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 96ff7b1b0cb..b73f33ff286 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -1,7 +1,8 @@ +import { parseSlackBlocksInput } from "../../../../extensions/slack/src/blocks-input.js"; +import { sendMessageSlack, type SlackSendIdentity } from "../../../../extensions/slack/src/send.js"; +import { resolveOutboundSendDep } from "../../../infra/outbound/deliver.js"; import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; -import { parseSlackBlocksInput } from "../../../slack/blocks-input.js"; -import { sendMessageSlack, type SlackSendIdentity } from "../../../slack/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; import { sendTextMediaPayload } from "./direct-text-media.js"; @@ -56,12 +57,13 @@ async function sendSlackOutboundMessage(params: { mediaLocalRoots?: readonly string[]; blocks?: NonNullable[2]>["blocks"]; accountId?: string | null; - deps?: { sendSlack?: typeof sendMessageSlack } | null; + deps?: { [channelId: string]: unknown } | null; replyToId?: string | null; threadId?: string | number | null; identity?: OutboundIdentity; }) { - const send = params.deps?.sendSlack ?? sendMessageSlack; + const send = + resolveOutboundSendDep(params.deps, "slack") ?? sendMessageSlack; // Use threadId fallback so routed tool notifications stay in the Slack thread. const threadTs = params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined); diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts deleted file mode 100644 index df81947fa5d..00000000000 --- a/src/channels/plugins/outbound/telegram.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { telegramOutbound } from "./telegram.js"; - -describe("telegramOutbound", () => { - it("passes parsed reply/thread ids for sendText", async () => { - const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-text-1", chatId: "123" }); - const sendText = telegramOutbound.sendText; - expect(sendText).toBeDefined(); - - const result = await sendText!({ - cfg: {}, - to: "123", - text: "hello", - accountId: "work", - replyToId: "44", - threadId: "55", - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledWith( - "123", - "hello", - expect.objectContaining({ - textMode: "html", - verbose: false, - accountId: "work", - replyToMessageId: 44, - messageThreadId: 55, - }), - ); - expect(result).toEqual({ channel: "telegram", messageId: "tg-text-1", chatId: "123" }); - }); - - it("parses scoped DM thread ids for sendText", async () => { - const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-text-2", chatId: "12345" }); - const sendText = telegramOutbound.sendText; - expect(sendText).toBeDefined(); - - await sendText!({ - cfg: {}, - to: "12345", - text: "hello", - accountId: "work", - threadId: "12345:99", - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledWith( - "12345", - "hello", - expect.objectContaining({ - textMode: "html", - verbose: false, - accountId: "work", - messageThreadId: 99, - }), - ); - }); - - it("passes media options for sendMedia", async () => { - const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-media-1", chatId: "123" }); - const sendMedia = telegramOutbound.sendMedia; - expect(sendMedia).toBeDefined(); - - const result = await sendMedia!({ - cfg: {}, - to: "123", - text: "caption", - mediaUrl: "https://example.com/a.jpg", - mediaLocalRoots: ["/tmp/media"], - accountId: "default", - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledWith( - "123", - "caption", - expect.objectContaining({ - textMode: "html", - verbose: false, - mediaUrl: "https://example.com/a.jpg", - mediaLocalRoots: ["/tmp/media"], - }), - ); - expect(result).toEqual({ channel: "telegram", messageId: "tg-media-1", chatId: "123" }); - }); - - it("sends payload media list and applies buttons only to first message", async () => { - const sendTelegram = vi - .fn() - .mockResolvedValueOnce({ messageId: "tg-1", chatId: "123" }) - .mockResolvedValueOnce({ messageId: "tg-2", chatId: "123" }); - const sendPayload = telegramOutbound.sendPayload; - expect(sendPayload).toBeDefined(); - - const payload: ReplyPayload = { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - channelData: { - telegram: { - quoteText: "quoted", - buttons: [[{ text: "Approve", callback_data: "ok" }]], - }, - }, - }; - - const result = await sendPayload!({ - cfg: {}, - to: "123", - text: "", - payload, - mediaLocalRoots: ["/tmp/media"], - accountId: "default", - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledTimes(2); - expect(sendTelegram).toHaveBeenNthCalledWith( - 1, - "123", - "caption", - expect.objectContaining({ - mediaUrl: "https://example.com/1.jpg", - quoteText: "quoted", - buttons: [[{ text: "Approve", callback_data: "ok" }]], - }), - ); - expect(sendTelegram).toHaveBeenNthCalledWith( - 2, - "123", - "", - expect.objectContaining({ - mediaUrl: "https://example.com/2.jpg", - quoteText: "quoted", - }), - ); - const secondCallOpts = sendTelegram.mock.calls[1]?.[2] as Record; - expect(secondCallOpts?.buttons).toBeUndefined(); - expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "123" }); - }); -}); diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index c96a44a7047..685ddb6ef31 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -1,157 +1 @@ -import type { ReplyPayload } from "../../../auto-reply/types.js"; -import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; -import type { TelegramInlineButtons } from "../../../telegram/button-types.js"; -import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; -import { - parseTelegramReplyToMessageId, - parseTelegramThreadId, -} from "../../../telegram/outbound-params.js"; -import { sendMessageTelegram } from "../../../telegram/send.js"; -import type { ChannelOutboundAdapter } from "../types.js"; -import { resolvePayloadMediaUrls, sendPayloadMediaSequence } from "./direct-text-media.js"; - -type TelegramSendFn = typeof sendMessageTelegram; -type TelegramSendOpts = Parameters[2]; - -function resolveTelegramSendContext(params: { - cfg: NonNullable["cfg"]; - deps?: OutboundSendDeps; - accountId?: string | null; - replyToId?: string | null; - threadId?: string | number | null; -}): { - send: TelegramSendFn; - baseOpts: { - cfg: NonNullable["cfg"]; - verbose: false; - textMode: "html"; - messageThreadId?: number; - replyToMessageId?: number; - accountId?: string; - }; -} { - const send = params.deps?.sendTelegram ?? sendMessageTelegram; - return { - send, - baseOpts: { - verbose: false, - textMode: "html", - cfg: params.cfg, - messageThreadId: parseTelegramThreadId(params.threadId), - replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), - accountId: params.accountId ?? undefined, - }, - }; -} - -export async function sendTelegramPayloadMessages(params: { - send: TelegramSendFn; - to: string; - payload: ReplyPayload; - baseOpts: Omit, "buttons" | "mediaUrl" | "quoteText">; -}): Promise>> { - const telegramData = params.payload.channelData?.telegram as - | { buttons?: TelegramInlineButtons; quoteText?: string } - | undefined; - const quoteText = - typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; - const text = params.payload.text ?? ""; - const mediaUrls = resolvePayloadMediaUrls(params.payload); - const payloadOpts = { - ...params.baseOpts, - quoteText, - }; - - if (mediaUrls.length === 0) { - return await params.send(params.to, text, { - ...payloadOpts, - buttons: telegramData?.buttons, - }); - } - - // Telegram allows reply_markup on media; attach buttons only to the first send. - const finalResult = await sendPayloadMediaSequence({ - text, - mediaUrls, - send: async ({ text, mediaUrl, isFirst }) => - await params.send(params.to, text, { - ...payloadOpts, - mediaUrl, - ...(isFirst ? { buttons: telegramData?.buttons } : {}), - }), - }); - return finalResult ?? { messageId: "unknown", chatId: params.to }; -} - -export const telegramOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - chunker: markdownToTelegramHtmlChunks, - chunkerMode: "markdown", - textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { - const { send, baseOpts } = resolveTelegramSendContext({ - cfg, - deps, - accountId, - replyToId, - threadId, - }); - const result = await send(to, text, { - ...baseOpts, - }); - return { channel: "telegram", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - }) => { - const { send, baseOpts } = resolveTelegramSendContext({ - cfg, - deps, - accountId, - replyToId, - threadId, - }); - const result = await send(to, text, { - ...baseOpts, - mediaUrl, - mediaLocalRoots, - }); - return { channel: "telegram", ...result }; - }, - sendPayload: async ({ - cfg, - to, - payload, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - }) => { - const { send, baseOpts } = resolveTelegramSendContext({ - cfg, - deps, - accountId, - replyToId, - threadId, - }); - const result = await sendTelegramPayloadMessages({ - send, - to, - payload, - baseOpts: { - ...baseOpts, - mediaLocalRoots, - }, - }); - return { channel: "telegram", ...result }; - }, -}; +export * from "../../../../extensions/telegram/src/outbound-adapter.js"; diff --git a/src/channels/plugins/outbound/whatsapp.ts b/src/channels/plugins/outbound/whatsapp.ts index 0cd797c6c10..112ff4ccf91 100644 --- a/src/channels/plugins/outbound/whatsapp.ts +++ b/src/channels/plugins/outbound/whatsapp.ts @@ -1,40 +1,2 @@ -import { chunkText } from "../../../auto-reply/chunk.js"; -import { shouldLogVerbose } from "../../../globals.js"; -import { sendPollWhatsApp } from "../../../web/outbound.js"; -import type { ChannelOutboundAdapter } from "../types.js"; -import { createWhatsAppOutboundBase } from "../whatsapp-shared.js"; -import { sendTextMediaPayload } from "./direct-text-media.js"; - -function trimLeadingWhitespace(text: string | undefined): string { - return text?.trimStart() ?? ""; -} - -export const whatsappOutbound: ChannelOutboundAdapter = { - ...createWhatsAppOutboundBase({ - chunker: chunkText, - sendMessageWhatsApp: async (...args) => - (await import("../../../web/outbound.js")).sendMessageWhatsApp(...args), - sendPollWhatsApp, - shouldLogVerbose, - normalizeText: trimLeadingWhitespace, - skipEmptyText: true, - }), - sendPayload: async (ctx) => { - const text = trimLeadingWhitespace(ctx.payload.text); - const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0; - if (!text && !hasMedia) { - return { channel: "whatsapp", messageId: "" }; - } - return await sendTextMediaPayload({ - channel: "whatsapp", - ctx: { - ...ctx, - payload: { - ...ctx.payload, - text, - }, - }, - adapter: whatsappOutbound, - }); - }, -}; +// Shim: re-exports from extensions/whatsapp/src/outbound-adapter.ts +export * from "../../../../extensions/whatsapp/src/outbound-adapter.js"; diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts index e6f0e800a03..37fea7e032d 100644 --- a/src/channels/plugins/plugins-channel.test.ts +++ b/src/channels/plugins/plugins-channel.test.ts @@ -87,7 +87,7 @@ describe("telegramOutbound.sendPayload", () => { }, }, }, - deps: { sendTelegram }, + deps: { telegram: sendTelegram }, }); expect(sendTelegram).toHaveBeenCalledTimes(1); @@ -121,7 +121,7 @@ describe("telegramOutbound.sendPayload", () => { }, }, }, - deps: { sendTelegram }, + deps: { telegram: sendTelegram }, }); expect(sendTelegram).toHaveBeenCalledTimes(2); diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index 30ed835873d..8297a6b7519 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -2,16 +2,16 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; +import type { DiscordProbe } from "../../../extensions/discord/src/probe.js"; +import type { DiscordTokenResolution } from "../../../extensions/discord/src/token.js"; +import type { IMessageProbe } from "../../../extensions/imessage/src/probe.js"; +import type { SignalProbe } from "../../../extensions/signal/src/probe.js"; +import type { SlackProbe } from "../../../extensions/slack/src/probe.js"; +import type { TelegramProbe } from "../../../extensions/telegram/src/probe.js"; +import type { TelegramTokenResolution } from "../../../extensions/telegram/src/token.js"; import type { OpenClawConfig } from "../../config/config.js"; -import type { DiscordProbe } from "../../discord/probe.js"; -import type { DiscordTokenResolution } from "../../discord/token.js"; -import type { IMessageProbe } from "../../imessage/probe.js"; import type { LineProbeResult } from "../../line/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import type { SignalProbe } from "../../signal/probe.js"; -import type { SlackProbe } from "../../slack/probe.js"; -import type { TelegramProbe } from "../../telegram/probe.js"; -import type { TelegramTokenResolution } from "../../telegram/token.js"; import { createChannelTestPluginBase, createMSTeamsTestPluginBase, diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index e30e57c9d05..1e9f907d498 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -1,7 +1,10 @@ +import { + extractSlackToolSend, + listSlackMessageActions, +} from "../../../extensions/slack/src/message-actions.js"; +import { resolveSlackChannelId } from "../../../extensions/slack/src/targets.js"; import { handleSlackAction, type SlackActionContext } from "../../agents/tools/slack-actions.js"; import { handleSlackMessageAction } from "../../plugin-sdk/slack-message-actions.js"; -import { extractSlackToolSend, listSlackMessageActions } from "../../slack/message-actions.js"; -import { resolveSlackChannelId } from "../../slack/targets.js"; import type { ChannelMessageActionAdapter } from "./types.js"; export function createSlackActions(providerId: string): ChannelMessageActionAdapter { diff --git a/src/channels/plugins/status-issues/discord.ts b/src/channels/plugins/status-issues/discord.ts index f3e8765093f..f42578df1e9 100644 --- a/src/channels/plugins/status-issues/discord.ts +++ b/src/channels/plugins/status-issues/discord.ts @@ -1,166 +1,2 @@ -import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; -import { - appendMatchMetadata, - asString, - isRecord, - resolveEnabledConfiguredAccountId, -} from "./shared.js"; - -type DiscordIntentSummary = { - messageContent?: "enabled" | "limited" | "disabled"; -}; - -type DiscordApplicationSummary = { - intents?: DiscordIntentSummary; -}; - -type DiscordAccountStatus = { - accountId?: unknown; - enabled?: unknown; - configured?: unknown; - application?: unknown; - audit?: unknown; -}; - -type DiscordPermissionsAuditSummary = { - unresolvedChannels?: number; - channels?: Array<{ - channelId: string; - ok?: boolean; - missing?: string[]; - error?: string | null; - matchKey?: string; - matchSource?: string; - }>; -}; - -function readDiscordAccountStatus(value: ChannelAccountSnapshot): DiscordAccountStatus | null { - if (!isRecord(value)) { - return null; - } - return { - accountId: value.accountId, - enabled: value.enabled, - configured: value.configured, - application: value.application, - audit: value.audit, - }; -} - -function readDiscordApplicationSummary(value: unknown): DiscordApplicationSummary { - if (!isRecord(value)) { - return {}; - } - const intentsRaw = value.intents; - if (!isRecord(intentsRaw)) { - return {}; - } - return { - intents: { - messageContent: - intentsRaw.messageContent === "enabled" || - intentsRaw.messageContent === "limited" || - intentsRaw.messageContent === "disabled" - ? intentsRaw.messageContent - : undefined, - }, - }; -} - -function readDiscordPermissionsAuditSummary(value: unknown): DiscordPermissionsAuditSummary { - if (!isRecord(value)) { - return {}; - } - const unresolvedChannels = - typeof value.unresolvedChannels === "number" && Number.isFinite(value.unresolvedChannels) - ? value.unresolvedChannels - : undefined; - const channelsRaw = value.channels; - const channels = Array.isArray(channelsRaw) - ? (channelsRaw - .map((entry) => { - if (!isRecord(entry)) { - return null; - } - const channelId = asString(entry.channelId); - if (!channelId) { - return null; - } - const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; - const missing = Array.isArray(entry.missing) - ? entry.missing.map((v) => asString(v)).filter(Boolean) - : undefined; - const error = asString(entry.error) ?? null; - const matchKey = asString(entry.matchKey) ?? undefined; - const matchSource = asString(entry.matchSource) ?? undefined; - return { - channelId, - ok, - missing: missing?.length ? missing : undefined, - error, - matchKey, - matchSource, - }; - }) - .filter(Boolean) as DiscordPermissionsAuditSummary["channels"]) - : undefined; - return { unresolvedChannels, channels }; -} - -export function collectDiscordStatusIssues( - accounts: ChannelAccountSnapshot[], -): ChannelStatusIssue[] { - const issues: ChannelStatusIssue[] = []; - for (const entry of accounts) { - const account = readDiscordAccountStatus(entry); - if (!account) { - continue; - } - const accountId = resolveEnabledConfiguredAccountId(account); - if (!accountId) { - continue; - } - - const app = readDiscordApplicationSummary(account.application); - const messageContent = app.intents?.messageContent; - if (messageContent === "disabled") { - issues.push({ - channel: "discord", - accountId, - kind: "intent", - message: "Message Content Intent is disabled. Bot may not see normal channel messages.", - fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.", - }); - } - - const audit = readDiscordPermissionsAuditSummary(account.audit); - if (audit.unresolvedChannels && audit.unresolvedChannels > 0) { - issues.push({ - channel: "discord", - accountId, - kind: "config", - message: `Some configured guild channels are not numeric IDs (unresolvedChannels=${audit.unresolvedChannels}). Permission audit can only check numeric channel IDs.`, - fix: "Use numeric channel IDs as keys in channels.discord.guilds.*.channels (then rerun channels status --probe).", - }); - } - for (const channel of audit.channels ?? []) { - if (channel.ok === true) { - continue; - } - const missing = channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : ""; - const error = channel.error ? `: ${channel.error}` : ""; - const baseMessage = `Channel ${channel.channelId} permission check failed.${missing}${error}`; - issues.push({ - channel: "discord", - accountId, - kind: "permissions", - message: appendMatchMetadata(baseMessage, { - matchKey: channel.matchKey, - matchSource: channel.matchSource, - }), - fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).", - }); - } - } - return issues; -} +// Shim: re-exports from extension +export * from "../../../../extensions/discord/src/status-issues.js"; diff --git a/src/channels/plugins/status-issues/telegram.ts b/src/channels/plugins/status-issues/telegram.ts index 97998eb4da4..26425a07ae4 100644 --- a/src/channels/plugins/status-issues/telegram.ts +++ b/src/channels/plugins/status-issues/telegram.ts @@ -1,145 +1 @@ -import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; -import { - appendMatchMetadata, - asString, - isRecord, - resolveEnabledConfiguredAccountId, -} from "./shared.js"; - -type TelegramAccountStatus = { - accountId?: unknown; - enabled?: unknown; - configured?: unknown; - allowUnmentionedGroups?: unknown; - audit?: unknown; -}; - -type TelegramGroupMembershipAuditSummary = { - unresolvedGroups?: number; - hasWildcardUnmentionedGroups?: boolean; - groups?: Array<{ - chatId: string; - ok?: boolean; - status?: string | null; - error?: string | null; - matchKey?: string; - matchSource?: string; - }>; -}; - -function readTelegramAccountStatus(value: ChannelAccountSnapshot): TelegramAccountStatus | null { - if (!isRecord(value)) { - return null; - } - return { - accountId: value.accountId, - enabled: value.enabled, - configured: value.configured, - allowUnmentionedGroups: value.allowUnmentionedGroups, - audit: value.audit, - }; -} - -function readTelegramGroupMembershipAuditSummary( - value: unknown, -): TelegramGroupMembershipAuditSummary { - if (!isRecord(value)) { - return {}; - } - const unresolvedGroups = - typeof value.unresolvedGroups === "number" && Number.isFinite(value.unresolvedGroups) - ? value.unresolvedGroups - : undefined; - const hasWildcardUnmentionedGroups = - typeof value.hasWildcardUnmentionedGroups === "boolean" - ? value.hasWildcardUnmentionedGroups - : undefined; - const groupsRaw = value.groups; - const groups = Array.isArray(groupsRaw) - ? (groupsRaw - .map((entry) => { - if (!isRecord(entry)) { - return null; - } - const chatId = asString(entry.chatId); - if (!chatId) { - return null; - } - const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; - const status = asString(entry.status) ?? null; - const error = asString(entry.error) ?? null; - const matchKey = asString(entry.matchKey) ?? undefined; - const matchSource = asString(entry.matchSource) ?? undefined; - return { chatId, ok, status, error, matchKey, matchSource }; - }) - .filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"]) - : undefined; - return { unresolvedGroups, hasWildcardUnmentionedGroups, groups }; -} - -export function collectTelegramStatusIssues( - accounts: ChannelAccountSnapshot[], -): ChannelStatusIssue[] { - const issues: ChannelStatusIssue[] = []; - for (const entry of accounts) { - const account = readTelegramAccountStatus(entry); - if (!account) { - continue; - } - const accountId = resolveEnabledConfiguredAccountId(account); - if (!accountId) { - continue; - } - - if (account.allowUnmentionedGroups === true) { - issues.push({ - channel: "telegram", - accountId, - kind: "config", - message: - "Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.", - fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).", - }); - } - - const audit = readTelegramGroupMembershipAuditSummary(account.audit); - if (audit.hasWildcardUnmentionedGroups === true) { - issues.push({ - channel: "telegram", - accountId, - kind: "config", - message: - 'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.', - fix: "Add explicit numeric group ids under channels.telegram.groups (or per-account groups) to enable probing.", - }); - } - if (audit.unresolvedGroups && audit.unresolvedGroups > 0) { - issues.push({ - channel: "telegram", - accountId, - kind: "config", - message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`, - fix: "Use numeric chat IDs (e.g. -100...) as keys in channels.telegram.groups for requireMention=false groups.", - }); - } - for (const group of audit.groups ?? []) { - if (group.ok === true) { - continue; - } - const status = group.status ? ` status=${group.status}` : ""; - const err = group.error ? `: ${group.error}` : ""; - const baseMessage = `Group ${group.chatId} not reachable by bot.${status}${err}`; - issues.push({ - channel: "telegram", - accountId, - kind: "runtime", - message: appendMatchMetadata(baseMessage, { - matchKey: group.matchKey, - matchSource: group.matchSource, - }), - fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.", - }); - } - } - return issues; -} +export * from "../../../../extensions/telegram/src/status-issues.js"; diff --git a/src/channels/plugins/status-issues/whatsapp.ts b/src/channels/plugins/status-issues/whatsapp.ts index 4e1c7c7b0bf..45be4231ed2 100644 --- a/src/channels/plugins/status-issues/whatsapp.ts +++ b/src/channels/plugins/status-issues/whatsapp.ts @@ -1,66 +1,2 @@ -import { formatCliCommand } from "../../../cli/command-format.js"; -import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; -import { asString, collectIssuesForEnabledAccounts, isRecord } from "./shared.js"; - -type WhatsAppAccountStatus = { - accountId?: unknown; - enabled?: unknown; - linked?: unknown; - connected?: unknown; - running?: unknown; - reconnectAttempts?: unknown; - lastError?: unknown; -}; - -function readWhatsAppAccountStatus(value: ChannelAccountSnapshot): WhatsAppAccountStatus | null { - if (!isRecord(value)) { - return null; - } - return { - accountId: value.accountId, - enabled: value.enabled, - linked: value.linked, - connected: value.connected, - running: value.running, - reconnectAttempts: value.reconnectAttempts, - lastError: value.lastError, - }; -} - -export function collectWhatsAppStatusIssues( - accounts: ChannelAccountSnapshot[], -): ChannelStatusIssue[] { - return collectIssuesForEnabledAccounts({ - accounts, - readAccount: readWhatsAppAccountStatus, - collectIssues: ({ account, accountId, issues }) => { - const linked = account.linked === true; - const running = account.running === true; - const connected = account.connected === true; - const reconnectAttempts = - typeof account.reconnectAttempts === "number" ? account.reconnectAttempts : null; - const lastError = asString(account.lastError); - - if (!linked) { - issues.push({ - channel: "whatsapp", - accountId, - kind: "auth", - message: "Not linked (no WhatsApp Web session).", - fix: `Run: ${formatCliCommand("openclaw channels login")} (scan QR on the gateway host).`, - }); - return; - } - - if (running && !connected) { - issues.push({ - channel: "whatsapp", - accountId, - kind: "runtime", - message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`, - fix: `Run: ${formatCliCommand("openclaw doctor")} (or restart the gateway). If it persists, relink via channels login and check logs.`, - }); - } - }, - }); -} +// Shim: re-exports from extensions/whatsapp/src/status-issues.ts +export * from "../../../../extensions/whatsapp/src/status-issues.js"; diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index df84ee4d3d2..257985e133c 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -93,6 +93,8 @@ export type ChannelOutboundContext = { mediaUrl?: string; mediaLocalRoots?: readonly string[]; gifPlayback?: boolean; + /** Send image as document to avoid Telegram compression. */ + forceDocument?: boolean; replyToId?: string | null; threadId?: string | number | null; accountId?: string | null; diff --git a/src/channels/plugins/whatsapp-shared.ts b/src/channels/plugins/whatsapp-shared.ts index 1174dff7c73..99c94aead1d 100644 --- a/src/channels/plugins/whatsapp-shared.ts +++ b/src/channels/plugins/whatsapp-shared.ts @@ -1,3 +1,4 @@ +import { resolveOutboundSendDep } from "../../infra/outbound/deliver.js"; import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js"; import { escapeRegExp } from "../../utils.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; @@ -66,7 +67,8 @@ export function createWhatsAppOutboundBase({ if (skipEmptyText && !normalizedText) { return { channel: "whatsapp", messageId: "" }; } - const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; const result = await send(to, normalizedText, { verbose: false, cfg, @@ -85,7 +87,8 @@ export function createWhatsAppOutboundBase({ deps, gifPlayback, }) => { - const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; const result = await send(to, normalizeText(text), { verbose: false, cfg, diff --git a/src/channels/read-only-account-inspect.ts b/src/channels/read-only-account-inspect.ts index 535fe05c473..c8d99a3a42e 100644 --- a/src/channels/read-only-account-inspect.ts +++ b/src/channels/read-only-account-inspect.ts @@ -1,10 +1,16 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { inspectDiscordAccount, type InspectedDiscordAccount } from "../discord/account-inspect.js"; -import { inspectSlackAccount, type InspectedSlackAccount } from "../slack/account-inspect.js"; +import { + inspectDiscordAccount, + type InspectedDiscordAccount, +} from "../../extensions/discord/src/account-inspect.js"; +import { + inspectSlackAccount, + type InspectedSlackAccount, +} from "../../extensions/slack/src/account-inspect.js"; import { inspectTelegramAccount, type InspectedTelegramAccount, -} from "../telegram/account-inspect.js"; +} from "../../extensions/telegram/src/account-inspect.js"; +import type { OpenClawConfig } from "../config/config.js"; import type { ChannelId } from "./plugins/types.js"; export type ReadOnlyInspectedAccount = diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts index 59f0a29381d..247109fb59d 100644 --- a/src/channels/reply-prefix.ts +++ b/src/channels/reply-prefix.ts @@ -1,3 +1,4 @@ +import { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js"; import { extractShortModelName, @@ -5,7 +6,6 @@ import { } from "../auto-reply/reply/response-prefix-template.js"; import type { GetReplyOptions } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; -import { isSlackInteractiveRepliesEnabled } from "../slack/interactive-replies.js"; type ModelSelectionContext = Parameters>[0]; diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index bd7d0ff1af5..e916c459863 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -194,8 +194,13 @@ export function registerCronAddCommand(cron: Command) { const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main"; const sessionTarget = sessionSource === "cli" ? sessionTargetRaw || "" : inferredSessionTarget; - if (sessionTarget !== "main" && sessionTarget !== "isolated") { - throw new Error("--session must be main or isolated"); + const isCustomSessionTarget = + sessionTarget.toLowerCase().startsWith("session:") && + sessionTarget.slice(8).trim().length > 0; + const isIsolatedLikeSessionTarget = + sessionTarget === "isolated" || sessionTarget === "current" || isCustomSessionTarget; + if (sessionTarget !== "main" && !isIsolatedLikeSessionTarget) { + throw new Error("--session must be main, isolated, current, or session:"); } if (opts.deleteAfterRun && opts.keepAfterRun) { @@ -205,14 +210,14 @@ export function registerCronAddCommand(cron: Command) { if (sessionTarget === "main" && payload.kind !== "systemEvent") { throw new Error("Main jobs require --system-event (systemEvent)."); } - if (sessionTarget === "isolated" && payload.kind !== "agentTurn") { - throw new Error("Isolated jobs require --message (agentTurn)."); + if (isIsolatedLikeSessionTarget && payload.kind !== "agentTurn") { + throw new Error("Isolated/current/custom-session jobs require --message (agentTurn)."); } if ( (opts.announce || typeof opts.deliver === "boolean") && - (sessionTarget !== "isolated" || payload.kind !== "agentTurn") + (!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn") ) { - throw new Error("--announce/--no-deliver require --session isolated."); + throw new Error("--announce/--no-deliver require a non-main agentTurn session target."); } const accountId = @@ -220,12 +225,12 @@ export function registerCronAddCommand(cron: Command) { ? opts.account.trim() : undefined; - if (accountId && (sessionTarget !== "isolated" || payload.kind !== "agentTurn")) { - throw new Error("--account requires an isolated agentTurn job with delivery."); + if (accountId && (!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")) { + throw new Error("--account requires a non-main agentTurn job with delivery."); } const deliveryMode = - sessionTarget === "isolated" && payload.kind === "agentTurn" + isIsolatedLikeSessionTarget && payload.kind === "agentTurn" ? hasAnnounce ? "announce" : hasNoDeliver diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index d3601b6ce40..3574a63ab27 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -247,9 +247,9 @@ export function printCronList(jobs: CronJob[], runtime = defaultRuntime) { })(); const coloredTarget = - job.sessionTarget === "isolated" - ? colorize(rich, theme.accentBright, targetLabel) - : colorize(rich, theme.accent, targetLabel); + job.sessionTarget === "main" + ? colorize(rich, theme.accent, targetLabel) + : colorize(rich, theme.accentBright, targetLabel); const coloredAgent = job.agentId ? colorize(rich, theme.info, agentLabel) : colorize(rich, theme.muted, agentLabel); diff --git a/src/cli/deps-send-discord.runtime.ts b/src/cli/deps-send-discord.runtime.ts deleted file mode 100644 index e451b4fccb6..00000000000 --- a/src/cli/deps-send-discord.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageDiscord } from "../discord/send.js"; diff --git a/src/cli/deps-send-imessage.runtime.ts b/src/cli/deps-send-imessage.runtime.ts deleted file mode 100644 index 502d0c116bd..00000000000 --- a/src/cli/deps-send-imessage.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageIMessage } from "../imessage/send.js"; diff --git a/src/cli/deps-send-signal.runtime.ts b/src/cli/deps-send-signal.runtime.ts deleted file mode 100644 index f19755b8cf0..00000000000 --- a/src/cli/deps-send-signal.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageSignal } from "../signal/send.js"; diff --git a/src/cli/deps-send-slack.runtime.ts b/src/cli/deps-send-slack.runtime.ts deleted file mode 100644 index 039ffb20645..00000000000 --- a/src/cli/deps-send-slack.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageSlack } from "../slack/send.js"; diff --git a/src/cli/deps-send-telegram.runtime.ts b/src/cli/deps-send-telegram.runtime.ts deleted file mode 100644 index 8a052a3cf75..00000000000 --- a/src/cli/deps-send-telegram.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageTelegram } from "../telegram/send.js"; diff --git a/src/cli/deps-send-whatsapp.runtime.ts b/src/cli/deps-send-whatsapp.runtime.ts deleted file mode 100644 index e0ae02b3882..00000000000 --- a/src/cli/deps-send-whatsapp.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageWhatsApp } from "../channels/web/index.js"; diff --git a/src/cli/deps.test.ts b/src/cli/deps.test.ts index 3cba4d63ad8..f345e1a24bb 100644 --- a/src/cli/deps.test.ts +++ b/src/cli/deps.test.ts @@ -24,27 +24,27 @@ vi.mock("../channels/web/index.js", () => { return { sendMessageWhatsApp: sendFns.whatsapp }; }); -vi.mock("../telegram/send.js", () => { +vi.mock("../../extensions/telegram/src/send.js", () => { moduleLoads.telegram(); return { sendMessageTelegram: sendFns.telegram }; }); -vi.mock("../discord/send.js", () => { +vi.mock("../../extensions/discord/src/send.js", () => { moduleLoads.discord(); return { sendMessageDiscord: sendFns.discord }; }); -vi.mock("../slack/send.js", () => { +vi.mock("../../extensions/slack/src/send.js", () => { moduleLoads.slack(); return { sendMessageSlack: sendFns.slack }; }); -vi.mock("../signal/send.js", () => { +vi.mock("../../extensions/signal/src/send.js", () => { moduleLoads.signal(); return { sendMessageSignal: sendFns.signal }; }); -vi.mock("../imessage/send.js", () => { +vi.mock("../../extensions/imessage/src/send.js", () => { moduleLoads.imessage(); return { sendMessageIMessage: sendFns.imessage }; }); @@ -74,9 +74,7 @@ describe("createDefaultDeps", () => { expect(moduleLoads.signal).not.toHaveBeenCalled(); expect(moduleLoads.imessage).not.toHaveBeenCalled(); - const sendTelegram = deps.sendMessageTelegram as unknown as ( - ...args: unknown[] - ) => Promise; + const sendTelegram = deps["telegram"] as (...args: unknown[]) => Promise; await sendTelegram("chat", "hello", { verbose: false }); expect(moduleLoads.telegram).toHaveBeenCalledTimes(1); @@ -86,9 +84,7 @@ describe("createDefaultDeps", () => { it("reuses module cache after first dynamic import", async () => { const deps = createDefaultDeps(); - const sendDiscord = deps.sendMessageDiscord as unknown as ( - ...args: unknown[] - ) => Promise; + const sendDiscord = deps["discord"] as (...args: unknown[]) => Promise; await sendDiscord("channel", "first", { verbose: false }); await sendDiscord("channel", "second", { verbose: false }); diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 478f3862146..81126168e3f 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,89 +1,68 @@ -import type { sendMessageWhatsApp } from "../channels/web/index.js"; -import type { sendMessageDiscord } from "../discord/send.js"; -import type { sendMessageIMessage } from "../imessage/send.js"; import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; -import type { sendMessageSignal } from "../signal/send.js"; -import type { sendMessageSlack } from "../slack/send.js"; -import type { sendMessageTelegram } from "../telegram/send.js"; import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; -export type CliDeps = { - sendMessageWhatsApp: typeof sendMessageWhatsApp; - sendMessageTelegram: typeof sendMessageTelegram; - sendMessageDiscord: typeof sendMessageDiscord; - sendMessageSlack: typeof sendMessageSlack; - sendMessageSignal: typeof sendMessageSignal; - sendMessageIMessage: typeof sendMessageIMessage; -}; +/** + * Lazy-loaded per-channel send functions, keyed by channel ID. + * Values are proxy functions that dynamically import the real module on first use. + */ +export type CliDeps = { [channelId: string]: unknown }; -let whatsappSenderRuntimePromise: Promise | null = - null; -let telegramSenderRuntimePromise: Promise | null = - null; -let discordSenderRuntimePromise: Promise | null = - null; -let slackSenderRuntimePromise: Promise | null = null; -let signalSenderRuntimePromise: Promise | null = - null; -let imessageSenderRuntimePromise: Promise | null = - null; +// Per-channel module caches for lazy loading. +const senderCache = new Map>>(); -function loadWhatsAppSenderRuntime() { - whatsappSenderRuntimePromise ??= import("./deps-send-whatsapp.runtime.js"); - return whatsappSenderRuntimePromise; -} - -function loadTelegramSenderRuntime() { - telegramSenderRuntimePromise ??= import("./deps-send-telegram.runtime.js"); - return telegramSenderRuntimePromise; -} - -function loadDiscordSenderRuntime() { - discordSenderRuntimePromise ??= import("./deps-send-discord.runtime.js"); - return discordSenderRuntimePromise; -} - -function loadSlackSenderRuntime() { - slackSenderRuntimePromise ??= import("./deps-send-slack.runtime.js"); - return slackSenderRuntimePromise; -} - -function loadSignalSenderRuntime() { - signalSenderRuntimePromise ??= import("./deps-send-signal.runtime.js"); - return signalSenderRuntimePromise; -} - -function loadIMessageSenderRuntime() { - imessageSenderRuntimePromise ??= import("./deps-send-imessage.runtime.js"); - return imessageSenderRuntimePromise; +/** + * Create a lazy-loading send function proxy for a channel. + * The channel's module is loaded on first call and cached for reuse. + */ +function createLazySender( + channelId: string, + loader: () => Promise>, + exportName: string, +): (...args: unknown[]) => Promise { + return async (...args: unknown[]) => { + let cached = senderCache.get(channelId); + if (!cached) { + cached = loader(); + senderCache.set(channelId, cached); + } + const mod = await cached; + const fn = mod[exportName] as (...a: unknown[]) => Promise; + return await fn(...args); + }; } export function createDefaultDeps(): CliDeps { return { - sendMessageWhatsApp: async (...args) => { - const { sendMessageWhatsApp } = await loadWhatsAppSenderRuntime(); - return await sendMessageWhatsApp(...args); - }, - sendMessageTelegram: async (...args) => { - const { sendMessageTelegram } = await loadTelegramSenderRuntime(); - return await sendMessageTelegram(...args); - }, - sendMessageDiscord: async (...args) => { - const { sendMessageDiscord } = await loadDiscordSenderRuntime(); - return await sendMessageDiscord(...args); - }, - sendMessageSlack: async (...args) => { - const { sendMessageSlack } = await loadSlackSenderRuntime(); - return await sendMessageSlack(...args); - }, - sendMessageSignal: async (...args) => { - const { sendMessageSignal } = await loadSignalSenderRuntime(); - return await sendMessageSignal(...args); - }, - sendMessageIMessage: async (...args) => { - const { sendMessageIMessage } = await loadIMessageSenderRuntime(); - return await sendMessageIMessage(...args); - }, + whatsapp: createLazySender( + "whatsapp", + () => import("../channels/web/index.js") as Promise>, + "sendMessageWhatsApp", + ), + telegram: createLazySender( + "telegram", + () => import("../../extensions/telegram/src/send.js") as Promise>, + "sendMessageTelegram", + ), + discord: createLazySender( + "discord", + () => import("../../extensions/discord/src/send.js") as Promise>, + "sendMessageDiscord", + ), + slack: createLazySender( + "slack", + () => import("../../extensions/slack/src/send.js") as Promise>, + "sendMessageSlack", + ), + signal: createLazySender( + "signal", + () => import("../../extensions/signal/src/send.js") as Promise>, + "sendMessageSignal", + ), + imessage: createLazySender( + "imessage", + () => import("../../extensions/imessage/src/send.js") as Promise>, + "sendMessageIMessage", + ), }; } @@ -91,4 +70,4 @@ export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); } -export { logWebSelfId } from "../web/auth-store.js"; +export { logWebSelfId } from "../../extensions/whatsapp/src/auth-store.js"; diff --git a/src/cli/outbound-send-deps.ts b/src/cli/outbound-send-deps.ts index 81d7211bf9f..6969ec0b0f0 100644 --- a/src/cli/outbound-send-deps.ts +++ b/src/cli/outbound-send-deps.ts @@ -4,7 +4,7 @@ import { type CliOutboundSendSource, } from "./outbound-send-mapping.js"; -export type CliDeps = Required; +export type CliDeps = CliOutboundSendSource; export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); diff --git a/src/cli/outbound-send-mapping.test.ts b/src/cli/outbound-send-mapping.test.ts index 0b31e21b299..4d68d9ce249 100644 --- a/src/cli/outbound-send-mapping.test.ts +++ b/src/cli/outbound-send-mapping.test.ts @@ -1,29 +1,32 @@ import { describe, expect, it, vi } from "vitest"; -import { - createOutboundSendDepsFromCliSource, - type CliOutboundSendSource, -} from "./outbound-send-mapping.js"; +import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; describe("createOutboundSendDepsFromCliSource", () => { - it("maps CLI send deps to outbound send deps", () => { - const deps: CliOutboundSendSource = { - sendMessageWhatsApp: vi.fn() as CliOutboundSendSource["sendMessageWhatsApp"], - sendMessageTelegram: vi.fn() as CliOutboundSendSource["sendMessageTelegram"], - sendMessageDiscord: vi.fn() as CliOutboundSendSource["sendMessageDiscord"], - sendMessageSlack: vi.fn() as CliOutboundSendSource["sendMessageSlack"], - sendMessageSignal: vi.fn() as CliOutboundSendSource["sendMessageSignal"], - sendMessageIMessage: vi.fn() as CliOutboundSendSource["sendMessageIMessage"], + it("adds legacy aliases for channel-keyed send deps", () => { + const deps = { + whatsapp: vi.fn(), + telegram: vi.fn(), + discord: vi.fn(), + slack: vi.fn(), + signal: vi.fn(), + imessage: vi.fn(), }; const outbound = createOutboundSendDepsFromCliSource(deps); expect(outbound).toEqual({ - sendWhatsApp: deps.sendMessageWhatsApp, - sendTelegram: deps.sendMessageTelegram, - sendDiscord: deps.sendMessageDiscord, - sendSlack: deps.sendMessageSlack, - sendSignal: deps.sendMessageSignal, - sendIMessage: deps.sendMessageIMessage, + whatsapp: deps.whatsapp, + telegram: deps.telegram, + discord: deps.discord, + slack: deps.slack, + signal: deps.signal, + imessage: deps.imessage, + sendWhatsApp: deps.whatsapp, + sendTelegram: deps.telegram, + sendDiscord: deps.discord, + sendSlack: deps.slack, + sendSignal: deps.signal, + sendIMessage: deps.imessage, }); }); }); diff --git a/src/cli/outbound-send-mapping.ts b/src/cli/outbound-send-mapping.ts index cf220084e3b..9233d984f21 100644 --- a/src/cli/outbound-send-mapping.ts +++ b/src/cli/outbound-send-mapping.ts @@ -1,22 +1,49 @@ import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; -export type CliOutboundSendSource = { - sendMessageWhatsApp: OutboundSendDeps["sendWhatsApp"]; - sendMessageTelegram: OutboundSendDeps["sendTelegram"]; - sendMessageDiscord: OutboundSendDeps["sendDiscord"]; - sendMessageSlack: OutboundSendDeps["sendSlack"]; - sendMessageSignal: OutboundSendDeps["sendSignal"]; - sendMessageIMessage: OutboundSendDeps["sendIMessage"]; -}; +/** + * CLI-internal send function sources, keyed by channel ID. + * Each value is a lazily-loaded send function for that channel. + */ +export type CliOutboundSendSource = { [channelId: string]: unknown }; -// Provider docking: extend this mapping when adding new outbound send deps. +const LEGACY_SOURCE_TO_CHANNEL = { + sendMessageWhatsApp: "whatsapp", + sendMessageTelegram: "telegram", + sendMessageDiscord: "discord", + sendMessageSlack: "slack", + sendMessageSignal: "signal", + sendMessageIMessage: "imessage", +} as const; + +const CHANNEL_TO_LEGACY_DEP_KEY = { + whatsapp: "sendWhatsApp", + telegram: "sendTelegram", + discord: "sendDiscord", + slack: "sendSlack", + signal: "sendSignal", + imessage: "sendIMessage", +} as const; + +/** + * Pass CLI send sources through as-is — both CliOutboundSendSource and + * OutboundSendDeps are now channel-ID-keyed records. + */ export function createOutboundSendDepsFromCliSource(deps: CliOutboundSendSource): OutboundSendDeps { - return { - sendWhatsApp: deps.sendMessageWhatsApp, - sendTelegram: deps.sendMessageTelegram, - sendDiscord: deps.sendMessageDiscord, - sendSlack: deps.sendMessageSlack, - sendSignal: deps.sendMessageSignal, - sendIMessage: deps.sendMessageIMessage, - }; + const outbound: OutboundSendDeps = { ...deps }; + + for (const [legacySourceKey, channelId] of Object.entries(LEGACY_SOURCE_TO_CHANNEL)) { + const sourceValue = deps[legacySourceKey]; + if (sourceValue !== undefined && outbound[channelId] === undefined) { + outbound[channelId] = sourceValue; + } + } + + for (const [channelId, legacyDepKey] of Object.entries(CHANNEL_TO_LEGACY_DEP_KEY)) { + const sourceValue = outbound[channelId]; + if (sourceValue !== undefined && outbound[legacyDepKey] === undefined) { + outbound[legacyDepKey] = sourceValue; + } + } + + return outbound; } diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts index 6c3d709f96d..f33bd2a24a8 100644 --- a/src/cli/program/message/register.send.ts +++ b/src/cli/program/message/register.send.ts @@ -24,6 +24,11 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli .option("--reply-to ", "Reply-to message id") .option("--thread-id ", "Thread id (Telegram forum thread)") .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false) + .option( + "--force-document", + "Send media as document to avoid Telegram compression (Telegram only). Applies to images and GIFs.", + false, + ) .option( "--silent", "Send message silently without notification (Telegram + Discord)", diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index baa58df2ef1..5b4fc2c9040 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -218,16 +218,7 @@ async function expectDefaultThinkLevel(params: { function createTelegramOutboundPlugin() { const sendWithTelegram = async ( ctx: { - deps?: { - sendTelegram?: ( - to: string, - text: string, - opts: Record, - ) => Promise<{ - messageId: string; - chatId: string; - }>; - }; + deps?: { [channelId: string]: unknown }; to: string; text: string; accountId?: string | null; @@ -235,7 +226,13 @@ function createTelegramOutboundPlugin() { }, mediaUrl?: string, ) => { - const sendTelegram = ctx.deps?.sendTelegram; + const sendTelegram = ctx.deps?.["telegram"] as + | (( + to: string, + text: string, + opts: Record, + ) => Promise<{ messageId: string; chatId: string }>) + | undefined; if (!sendTelegram) { throw new Error("sendTelegram dependency missing"); } diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 1ecb2cde3c0..f58a7312f74 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -245,9 +245,15 @@ export async function applyAuthChoiceApiProviders( setZaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), }); - // zai-api-key: auto-detect endpoint + choose a working default model. let modelIdOverride: string | undefined; - if (!endpoint) { + if (endpoint) { + const detected = await detectZaiEndpoint({ apiKey, endpoint }); + if (detected) { + modelIdOverride = detected.modelId; + await params.prompter.note(detected.note, "Z.AI endpoint"); + } + } else { + // zai-api-key: auto-detect endpoint + choose a working default model. const detected = await detectZaiEndpoint({ apiKey }); if (detected) { endpoint = detected.endpoint; diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index f77df4a07e4..d5a59e48d46 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -285,7 +285,7 @@ describe("applyAuthChoice", () => { expectedBaseUrl: string; expectedModel?: string; shouldPromptForEndpoint: boolean; - shouldAssertDetectCall?: boolean; + expectedDetectCall?: { apiKey: string; endpoint?: "coding-global" | "coding-cn" }; }> = [ { authChoice: "zai-api-key", @@ -298,8 +298,16 @@ describe("applyAuthChoice", () => { { authChoice: "zai-coding-global", token: "zai-test-key", + detectResult: { + endpoint: "coding-global", + modelId: "glm-4.7", + baseUrl: ZAI_CODING_GLOBAL_BASE_URL, + note: "Detected coding-global endpoint with GLM-4.7 fallback", + }, expectedBaseUrl: ZAI_CODING_GLOBAL_BASE_URL, + expectedModel: "zai/glm-4.7", shouldPromptForEndpoint: false, + expectedDetectCall: { apiKey: "zai-test-key", endpoint: "coding-global" }, }, { authChoice: "zai-api-key", @@ -313,7 +321,7 @@ describe("applyAuthChoice", () => { expectedBaseUrl: ZAI_CODING_GLOBAL_BASE_URL, expectedModel: "zai/glm-4.5", shouldPromptForEndpoint: false, - shouldAssertDetectCall: true, + expectedDetectCall: { apiKey: "zai-detected-key" }, }, ]; for (const scenario of scenarios) { @@ -344,8 +352,8 @@ describe("applyAuthChoice", () => { setDefaultModel: true, }); - if (scenario.shouldAssertDetectCall) { - expect(detectZaiEndpoint).toHaveBeenCalledWith({ apiKey: scenario.token }); + if (scenario.expectedDetectCall) { + expect(detectZaiEndpoint).toHaveBeenCalledWith(scenario.expectedDetectCall); } if (scenario.shouldPromptForEndpoint) { expect(select).toHaveBeenCalledWith( diff --git a/src/commands/channels.mock-harness.ts b/src/commands/channels.mock-harness.ts index a71ae75d44d..d1f412b0399 100644 --- a/src/commands/channels.mock-harness.ts +++ b/src/commands/channels.mock-harness.ts @@ -24,8 +24,9 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("../telegram/update-offset-store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../extensions/telegram/src/update-offset-store.js", async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index ebf80e6a735..3cc2f305870 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -1,3 +1,5 @@ +import { resolveTelegramAccount } from "../../../extensions/telegram/src/accounts.js"; +import { deleteTelegramUpdateOffset } from "../../../extensions/telegram/src/update-offset-store.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; @@ -7,8 +9,6 @@ import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types. import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { resolveTelegramAccount } from "../../telegram/accounts.js"; -import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; import { buildAgentSummaries } from "../agents.config.js"; diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index b85cd750a91..5e838cc4ec8 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -1,9 +1,9 @@ process.env.NO_COLOR = "1"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchSlackScopes } from "../../../extensions/slack/src/scopes.js"; import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; -import { fetchSlackScopes } from "../../slack/scopes.js"; import { channelsCapabilitiesCommand } from "./capabilities.js"; const logs: string[] = []; @@ -21,7 +21,7 @@ vi.mock("../../channels/plugins/index.js", () => ({ getChannelPlugin: vi.fn(), })); -vi.mock("../../slack/scopes.js", () => ({ +vi.mock("../../../extensions/slack/src/scopes.js", () => ({ fetchSlackScopes: vi.fn(), })); diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 37c682448aa..30f64da43d9 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -1,12 +1,12 @@ +import { fetchChannelPermissionsDiscord } from "../../../extensions/discord/src/send.js"; +import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; +import { fetchSlackScopes, type SlackScopesResult } from "../../../extensions/slack/src/scopes.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelCapabilities, ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { fetchChannelPermissionsDiscord } from "../../discord/send.js"; -import { parseDiscordTarget } from "../../discord/targets.js"; import { danger } from "../../globals.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { fetchSlackScopes, type SlackScopesResult } from "../../slack/scopes.js"; import { theme } from "../../terminal/theme.js"; import { formatChannelAccountLabel, requireValidConfig } from "./shared.js"; diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 5766a4250fd..58354170135 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -1,3 +1,4 @@ +import { deleteTelegramUpdateOffset } from "../../../extensions/telegram/src/update-offset-store.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, @@ -7,7 +8,6 @@ import { import { type OpenClawConfig, writeConfigFile } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { type ChatChannel, channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 71cd6926417..f616bfaba55 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,11 +1,16 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { normalizeChatChannelId } from "../channels/registry.js"; +import { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; +import { + listTelegramAccountIds, + resolveTelegramAccount, +} from "../../extensions/telegram/src/accounts.js"; import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, -} from "../channels/telegram/allow-from.js"; -import { fetchTelegramChatId } from "../channels/telegram/api.js"; +} from "../../extensions/telegram/src/allow-from.js"; +import { fetchTelegramChatId } from "../../extensions/telegram/src/api-fetch.js"; +import { normalizeChatChannelId } from "../channels/registry.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js"; @@ -46,8 +51,6 @@ import { isSlackMutableAllowEntry, isZalouserMutableGroupEntry, } from "../security/mutable-allowlist-detectors.js"; -import { inspectTelegramAccount } from "../telegram/account-inspect.js"; -import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js"; import { note } from "../terminal/note.js"; import { resolveHomeDir } from "../utils.js"; import { diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index b15bdfa6234..b75e3bbc5d4 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -258,7 +258,7 @@ vi.mock("../pairing/pairing-store.js", () => ({ upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "000000", created: false }), })); -vi.mock("../telegram/token.js", () => ({ +vi.mock("../../extensions/telegram/src/token.js", () => ({ resolveTelegramToken: vi.fn(() => ({ token: "", source: "none" })), })); diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 64d515c0b4d..452bcb3691b 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { GatewayProbeResult } from "../gateway/probe.js"; import type { RuntimeEnv } from "../runtime.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -33,7 +34,7 @@ const startSshPortForward = vi.fn(async (_opts?: unknown) => ({ stderr: [], stop: sshStop, })); -const probeGateway = vi.fn(async (opts: { url: string }) => { +const probeGateway = vi.fn(async (opts: { url: string }): Promise => { const { url } = opts; if (url.includes("127.0.0.1")) { return { @@ -52,7 +53,16 @@ const probeGateway = vi.fn(async (opts: { url: string }) => { }, sessions: { count: 0 }, }, - presence: [{ mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" }], + presence: [ + { + mode: "gateway", + reason: "self", + host: "local", + ip: "127.0.0.1", + text: "Gateway: local (127.0.0.1) · app test · mode gateway · reason self", + ts: Date.now(), + }, + ], configSnapshot: { path: "/tmp/cfg.json", exists: true, @@ -81,7 +91,16 @@ const probeGateway = vi.fn(async (opts: { url: string }) => { }, sessions: { count: 2 }, }, - presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }], + presence: [ + { + mode: "gateway", + reason: "self", + host: "remote", + ip: "100.64.0.2", + text: "Gateway: remote (100.64.0.2) · app test · mode gateway · reason self", + ts: Date.now(), + }, + ], configSnapshot: { path: "/tmp/remote.json", exists: true, @@ -201,6 +220,54 @@ describe("gateway-status command", () => { expect(targets[0]?.summary).toBeTruthy(); }); + it("treats missing-scope RPC probe failures as degraded but reachable", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "local", + auth: { mode: "token", token: "ltok" }, + }, + } as never); + probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 51, + error: "missing scope: operator.read", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await runGatewayStatus(runtime, { timeout: "1000", json: true }); + + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + ok?: boolean; + degraded?: boolean; + warnings?: Array<{ code?: string; targetIds?: string[] }>; + targets?: Array<{ + connect?: { + ok?: boolean; + rpcOk?: boolean; + scopeLimited?: boolean; + }; + }>; + }; + expect(parsed.ok).toBe(true); + expect(parsed.degraded).toBe(true); + expect(parsed.targets?.[0]?.connect).toMatchObject({ + ok: true, + rpcOk: false, + scopeLimited: true, + }); + const scopeLimitedWarning = parsed.warnings?.find( + (warning) => warning.code === "probe_scope_limited", + ); + expect(scopeLimitedWarning?.targetIds).toContain("localLoopback"); + }); + it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { @@ -361,7 +428,16 @@ describe("gateway-status command", () => { }, sessions: { count: 1 }, }, - presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }], + presence: [ + { + mode: "gateway", + reason: "self", + host: "remote", + ip: "100.64.0.2", + text: "Gateway: remote (100.64.0.2) · app test · mode gateway · reason self", + ts: Date.now(), + }, + ], configSnapshot: { path: "/tmp/secretref-config.json", exists: true, diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index 4ac54eca0c4..be0b9abf69a 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -10,6 +10,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js"; import { buildNetworkHints, extractConfigSummary, + isProbeReachable, + isScopeLimitedProbeFailure, type GatewayStatusTarget, parseTimeoutMs, pickGatewaySelfPresence, @@ -193,8 +195,10 @@ export async function gatewayStatusCommand( }, ); - const reachable = probed.filter((p) => p.probe.ok); + const reachable = probed.filter((p) => isProbeReachable(p.probe)); const ok = reachable.length > 0; + const degradedScopeLimited = probed.filter((p) => isScopeLimitedProbeFailure(p.probe)); + const degraded = degradedScopeLimited.length > 0; const multipleGateways = reachable.length > 1; const primary = reachable.find((p) => p.target.kind === "explicit") ?? @@ -236,12 +240,21 @@ export async function gatewayStatusCommand( }); } } + for (const result of degradedScopeLimited) { + warnings.push({ + code: "probe_scope_limited", + message: + "Probe diagnostics are limited by gateway scopes (missing operator.read). Connection succeeded, but status details may be incomplete. Hint: pair device identity or use credentials with operator.read.", + targetIds: [result.target.id], + }); + } if (opts.json) { runtime.log( JSON.stringify( { ok, + degraded, ts: Date.now(), durationMs: Date.now() - startedAt, timeoutMs: overallTimeoutMs, @@ -274,7 +287,9 @@ export async function gatewayStatusCommand( active: p.target.active, tunnel: p.target.tunnel ?? null, connect: { - ok: p.probe.ok, + ok: isProbeReachable(p.probe), + rpcOk: p.probe.ok, + scopeLimited: isScopeLimitedProbeFailure(p.probe), latencyMs: p.probe.connectLatencyMs, error: p.probe.error, close: p.probe.close, diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index 688959f0748..e0c1ecee763 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../../test-utils/env.js"; -import { extractConfigSummary, resolveAuthForTarget } from "./helpers.js"; +import { + extractConfigSummary, + isProbeReachable, + isScopeLimitedProbeFailure, + renderProbeSummaryLine, + resolveAuthForTarget, +} from "./helpers.js"; describe("extractConfigSummary", () => { it("marks SecretRef-backed gateway auth credentials as configured", () => { @@ -229,3 +235,41 @@ describe("resolveAuthForTarget", () => { ); }); }); + +describe("probe reachability classification", () => { + it("treats missing-scope RPC failures as scope-limited and reachable", () => { + const probe = { + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 51, + error: "missing scope: operator.read", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; + + expect(isScopeLimitedProbeFailure(probe)).toBe(true); + expect(isProbeReachable(probe)).toBe(true); + expect(renderProbeSummaryLine(probe, false)).toContain("RPC: limited"); + }); + + it("keeps non-scope RPC failures as unreachable", () => { + const probe = { + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 43, + error: "unknown method: status", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; + + expect(isScopeLimitedProbeFailure(probe)).toBe(false); + expect(isProbeReachable(probe)).toBe(false); + expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed"); + }); +}); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 7697d6af143..5f1a5e2f5ee 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -9,6 +9,8 @@ import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js"; import { colorize, theme } from "../../terminal/theme.js"; import { pickGatewaySelfPresence } from "../gateway-presence.js"; +const MISSING_SCOPE_PATTERN = /\bmissing scope:\s*[a-z0-9._-]+/i; + type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel"; export type GatewayStatusTarget = { @@ -324,6 +326,17 @@ export function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) { return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`; } +export function isScopeLimitedProbeFailure(probe: GatewayProbeResult): boolean { + if (probe.ok || probe.connectLatencyMs == null) { + return false; + } + return MISSING_SCOPE_PATTERN.test(probe.error ?? ""); +} + +export function isProbeReachable(probe: GatewayProbeResult): boolean { + return probe.ok || isScopeLimitedProbeFailure(probe); +} + export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) { if (probe.ok) { const latency = @@ -335,7 +348,10 @@ export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) if (probe.connectLatencyMs != null) { const latency = typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown"; - return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.error, "RPC: failed")}${detail}`; + const rpcStatus = isScopeLimitedProbeFailure(probe) + ? colorize(rich, theme.warn, "RPC: limited") + : colorize(rich, theme.error, "RPC: failed"); + return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${rpcStatus}${detail}`; } return `${colorize(rich, theme.error, "Connect: failed")}${detail}`; diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts index bc2739d99ec..419aef54447 100644 --- a/src/commands/health.command.coverage.test.ts +++ b/src/commands/health.command.coverage.test.ts @@ -19,7 +19,7 @@ vi.mock("../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), })); -vi.mock("../web/auth-store.js", () => ({ +vi.mock("../../extensions/whatsapp/src/auth-store.js", () => ({ webAuthExists: vi.fn(async () => true), getWebAuthAgeMs: vi.fn(() => 0), logWebSelfId: (...args: unknown[]) => logWebSelfIdMock(...args), diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 8b1231b670d..47d6a10f623 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -27,7 +27,7 @@ vi.mock("../config/sessions.js", () => ({ updateLastRoute: vi.fn().mockResolvedValue(undefined), })); -vi.mock("../web/auth-store.js", () => ({ +vi.mock("../../extensions/whatsapp/src/auth-store.js", () => ({ webAuthExists: vi.fn(async () => true), getWebAuthAgeMs: vi.fn(() => 1234), readWebSelfId: vi.fn(() => ({ e164: null, jid: null })), diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 5178b09f895..adbe4ae7850 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -34,7 +34,7 @@ vi.mock("../gateway/call.js", () => ({ })); const webAuthExists = vi.fn(async () => false); -vi.mock("../web/session.js", () => ({ +vi.mock("../../extensions/whatsapp/src/session.js", () => ({ webAuthExists, })); diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index d1eb0a7749f..5ee3077d1c5 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { withEnvAsync } from "../test-utils/env.js"; import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL } from "./onboard-auth.js"; @@ -18,6 +18,8 @@ type OnboardEnv = { }; const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {})); +type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint; +const detectZaiEndpoint = vi.hoisted(() => vi.fn(async () => null)); vi.mock("./onboard-helpers.js", async (importOriginal) => { const actual = await importOriginal(); @@ -27,6 +29,10 @@ vi.mock("./onboard-helpers.js", async (importOriginal) => { }; }); +vi.mock("./zai-endpoint-detect.js", () => ({ + detectZaiEndpoint, +})); + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); const NON_INTERACTIVE_DEFAULT_OPTIONS = { @@ -180,6 +186,11 @@ describe("onboard (non-interactive): provider auth", () => { ({ ensureAuthProfileStore, upsertAuthProfile } = await import("../agents/auth-profiles.js")); }); + beforeEach(() => { + detectZaiEndpoint.mockReset(); + detectZaiEndpoint.mockResolvedValue(null); + }); + it("stores MiniMax API key and uses global baseUrl by default", async () => { await withOnboardEnv("openclaw-onboard-minimax-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { @@ -220,6 +231,12 @@ describe("onboard (non-interactive): provider auth", () => { it("stores Z.AI API key and uses global baseUrl by default", async () => { await withOnboardEnv("openclaw-onboard-zai-", async (env) => { + detectZaiEndpoint.mockResolvedValueOnce({ + endpoint: "global", + baseUrl: "https://api.z.ai/api/paas/v4", + modelId: "glm-5", + note: "Verified GLM-5 on global endpoint.", + }); const cfg = await runOnboardingAndReadConfig(env, { authChoice: "zai-api-key", zaiApiKey: "zai-test-key", // pragma: allowlist secret @@ -235,6 +252,12 @@ describe("onboard (non-interactive): provider auth", () => { it("supports Z.AI CN coding endpoint auth choice", async () => { await withOnboardEnv("openclaw-onboard-zai-cn-", async (env) => { + detectZaiEndpoint.mockResolvedValueOnce({ + endpoint: "coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + modelId: "glm-4.7", + note: "Coding Plan CN endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.", + }); const cfg = await runOnboardingAndReadConfig(env, { authChoice: "zai-coding-cn", zaiApiKey: "zai-test-key", // pragma: allowlist secret @@ -243,6 +266,25 @@ describe("onboard (non-interactive): provider auth", () => { expect(cfg.models?.providers?.zai?.baseUrl).toBe( "https://open.bigmodel.cn/api/coding/paas/v4", ); + expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-4.7"); + }); + }); + + it("supports Z.AI Coding Plan global endpoint with GLM-5 when available", async () => { + await withOnboardEnv("openclaw-onboard-zai-coding-global-", async (env) => { + detectZaiEndpoint.mockResolvedValueOnce({ + endpoint: "coding-global", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + modelId: "glm-5", + note: "Verified GLM-5 on coding-global endpoint.", + }); + const cfg = await runOnboardingAndReadConfig(env, { + authChoice: "zai-coding-global", + zaiApiKey: "zai-test-key", // pragma: allowlist secret + }); + + expect(cfg.models?.providers?.zai?.baseUrl).toBe("https://api.z.ai/api/coding/paas/v4"); + expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5"); }); }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index d435771d720..500e19ee574 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -291,6 +291,13 @@ export async function applyNonInteractiveAuthChoice(params: { endpoint = "global"; } else if (authChoice === "zai-cn") { endpoint = "cn"; + } + + if (endpoint) { + const detected = await detectZaiEndpoint({ apiKey: resolved.key, endpoint }); + if (detected) { + modelIdOverride = detected.modelId; + } } else { const detected = await detectZaiEndpoint({ apiKey: resolved.key }); if (detected) { diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 1233222bf54..5d1dc20634d 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -60,6 +60,26 @@ describe("onboardCommand", () => { expect(mocks.runNonInteractiveOnboarding).not.toHaveBeenCalled(); }); + it("logs ASCII-safe Windows guidance before onboarding", async () => { + const runtime = makeRuntime(); + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + + try { + await onboardCommand({}, runtime); + + expect(runtime.log).toHaveBeenCalledWith( + [ + "Windows detected - OpenClaw runs great on WSL2!", + "Native Windows might be trickier.", + "Quick setup: wsl --install (one command, one reboot)", + "Guide: https://docs.openclaw.ai/windows", + ].join("\n"), + ); + } finally { + platformSpy.mockRestore(); + } + }); + it("defaults --reset to config+creds+sessions scope", async () => { const runtime = makeRuntime(); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 9c55bddf1d6..6762998f815 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -77,7 +77,7 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = if (process.platform === "win32") { runtime.log( [ - "Windows detected — OpenClaw runs great on WSL2!", + "Windows detected - OpenClaw runs great on WSL2!", "Native Windows might be trickier.", "Quick setup: wsl --install (one command, one reboot)", "Guide: https://docs.openclaw.ai/windows", diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 66f3f7bf07f..e307ffa3694 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -286,7 +286,7 @@ vi.mock("../channels/plugins/index.js", () => ({ }, ] as unknown, })); -vi.mock("../web/session.js", () => ({ +vi.mock("../../extensions/whatsapp/src/session.js", () => ({ webAuthExists: mocks.webAuthExists, getWebAuthAgeMs: mocks.getWebAuthAgeMs, readWebSelfId: mocks.readWebSelfId, diff --git a/src/commands/zai-endpoint-detect.test.ts b/src/commands/zai-endpoint-detect.test.ts index 292ee7ac761..fea72b573ba 100644 --- a/src/commands/zai-endpoint-detect.test.ts +++ b/src/commands/zai-endpoint-detect.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "vitest"; import { detectZaiEndpoint } from "./zai-endpoint-detect.js"; -function makeFetch(map: Record) { - return (async (url: string) => { - const entry = map[url]; +type FetchResponse = { status: number; body?: unknown }; + +function makeFetch(map: Record) { + return (async (url: string, init?: RequestInit) => { + const rawBody = typeof init?.body === "string" ? JSON.parse(init.body) : null; + const entry = map[`${url}::${rawBody?.model ?? ""}`] ?? map[url]; if (!entry) { - throw new Error(`unexpected url: ${url}`); + throw new Error(`unexpected url: ${url} model=${String(rawBody?.model ?? "")}`); } const json = entry.body ?? {}; return new Response(JSON.stringify(json), { @@ -18,39 +21,71 @@ function makeFetch(map: Record) { describe("detectZaiEndpoint", () => { it("resolves preferred/fallback endpoints and null when probes fail", async () => { const scenarios: Array<{ + endpoint?: "global" | "cn" | "coding-global" | "coding-cn"; responses: Record; expected: { endpoint: string; modelId: string } | null; }> = [ { responses: { - "https://api.z.ai/api/paas/v4/chat/completions": { status: 200 }, + "https://api.z.ai/api/paas/v4/chat/completions::glm-5": { status: 200 }, }, expected: { endpoint: "global", modelId: "glm-5" }, }, { responses: { - "https://api.z.ai/api/paas/v4/chat/completions": { + "https://api.z.ai/api/paas/v4/chat/completions::glm-5": { status: 404, body: { error: { message: "not found" } }, }, - "https://open.bigmodel.cn/api/paas/v4/chat/completions": { status: 200 }, + "https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5": { status: 200 }, }, expected: { endpoint: "cn", modelId: "glm-5" }, }, { responses: { - "https://api.z.ai/api/paas/v4/chat/completions": { status: 404 }, - "https://open.bigmodel.cn/api/paas/v4/chat/completions": { status: 404 }, - "https://api.z.ai/api/coding/paas/v4/chat/completions": { status: 200 }, + "https://api.z.ai/api/paas/v4/chat/completions::glm-5": { status: 404 }, + "https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5": { status: 404 }, + "https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5": { status: 200 }, + }, + expected: { endpoint: "coding-global", modelId: "glm-5" }, + }, + { + endpoint: "coding-global", + responses: { + "https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5": { + status: 404, + body: { error: { message: "glm-5 unavailable" } }, + }, + "https://api.z.ai/api/coding/paas/v4/chat/completions::glm-4.7": { status: 200 }, }, expected: { endpoint: "coding-global", modelId: "glm-4.7" }, }, { + endpoint: "coding-cn", responses: { - "https://api.z.ai/api/paas/v4/chat/completions": { status: 401 }, - "https://open.bigmodel.cn/api/paas/v4/chat/completions": { status: 401 }, - "https://api.z.ai/api/coding/paas/v4/chat/completions": { status: 401 }, - "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions": { status: 401 }, + "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5": { status: 200 }, + }, + expected: { endpoint: "coding-cn", modelId: "glm-5" }, + }, + { + endpoint: "coding-cn", + responses: { + "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5": { + status: 404, + body: { error: { message: "glm-5 unavailable" } }, + }, + "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-4.7": { status: 200 }, + }, + expected: { endpoint: "coding-cn", modelId: "glm-4.7" }, + }, + { + responses: { + "https://api.z.ai/api/paas/v4/chat/completions::glm-5": { status: 401 }, + "https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5": { status: 401 }, + "https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5": { status: 401 }, + "https://api.z.ai/api/coding/paas/v4/chat/completions::glm-4.7": { status: 401 }, + "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5": { status: 401 }, + "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-4.7": { status: 401 }, }, expected: null, }, @@ -59,6 +94,7 @@ describe("detectZaiEndpoint", () => { for (const scenario of scenarios) { const detected = await detectZaiEndpoint({ apiKey: "sk-test", // pragma: allowlist secret + ...(scenario.endpoint ? { endpoint: scenario.endpoint } : {}), fetchFn: makeFetch(scenario.responses), }); diff --git a/src/commands/zai-endpoint-detect.ts b/src/commands/zai-endpoint-detect.ts index 6f53c6c58cc..b0799088559 100644 --- a/src/commands/zai-endpoint-detect.ts +++ b/src/commands/zai-endpoint-detect.ts @@ -88,6 +88,7 @@ async function probeZaiChatCompletions(params: { export async function detectZaiEndpoint(params: { apiKey: string; + endpoint?: ZaiEndpointId; timeoutMs?: number; fetchFn?: typeof fetch; }): Promise { @@ -97,50 +98,80 @@ export async function detectZaiEndpoint(params: { } const timeoutMs = params.timeoutMs ?? 5_000; - - // Prefer GLM-5 on the general API endpoints. - const glm5: Array<{ endpoint: ZaiEndpointId; baseUrl: string }> = [ - { endpoint: "global", baseUrl: ZAI_GLOBAL_BASE_URL }, - { endpoint: "cn", baseUrl: ZAI_CN_BASE_URL }, - ]; - for (const candidate of glm5) { - const result = await probeZaiChatCompletions({ - baseUrl: candidate.baseUrl, - apiKey: params.apiKey, - modelId: "glm-5", - timeoutMs, - fetchFn: params.fetchFn, - }); - if (result.ok) { - return { - endpoint: candidate.endpoint, - baseUrl: candidate.baseUrl, + const probeCandidates = (() => { + const general = [ + { + endpoint: "global" as const, + baseUrl: ZAI_GLOBAL_BASE_URL, modelId: "glm-5", - note: `Verified GLM-5 on ${candidate.endpoint} endpoint.`, - }; - } - } + note: "Verified GLM-5 on global endpoint.", + }, + { + endpoint: "cn" as const, + baseUrl: ZAI_CN_BASE_URL, + modelId: "glm-5", + note: "Verified GLM-5 on cn endpoint.", + }, + ]; + const codingGlm5 = [ + { + endpoint: "coding-global" as const, + baseUrl: ZAI_CODING_GLOBAL_BASE_URL, + modelId: "glm-5", + note: "Verified GLM-5 on coding-global endpoint.", + }, + { + endpoint: "coding-cn" as const, + baseUrl: ZAI_CODING_CN_BASE_URL, + modelId: "glm-5", + note: "Verified GLM-5 on coding-cn endpoint.", + }, + ]; + const codingFallback = [ + { + endpoint: "coding-global" as const, + baseUrl: ZAI_CODING_GLOBAL_BASE_URL, + modelId: "glm-4.7", + note: "Coding Plan endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.", + }, + { + endpoint: "coding-cn" as const, + baseUrl: ZAI_CODING_CN_BASE_URL, + modelId: "glm-4.7", + note: "Coding Plan CN endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.", + }, + ]; - // Fallback: Coding Plan endpoint (GLM-5 not available there). - const coding: Array<{ endpoint: ZaiEndpointId; baseUrl: string }> = [ - { endpoint: "coding-global", baseUrl: ZAI_CODING_GLOBAL_BASE_URL }, - { endpoint: "coding-cn", baseUrl: ZAI_CODING_CN_BASE_URL }, - ]; - for (const candidate of coding) { + switch (params.endpoint) { + case "global": + return general.filter((candidate) => candidate.endpoint === "global"); + case "cn": + return general.filter((candidate) => candidate.endpoint === "cn"); + case "coding-global": + return [ + ...codingGlm5.filter((candidate) => candidate.endpoint === "coding-global"), + ...codingFallback.filter((candidate) => candidate.endpoint === "coding-global"), + ]; + case "coding-cn": + return [ + ...codingGlm5.filter((candidate) => candidate.endpoint === "coding-cn"), + ...codingFallback.filter((candidate) => candidate.endpoint === "coding-cn"), + ]; + default: + return [...general, ...codingGlm5, ...codingFallback]; + } + })(); + + for (const candidate of probeCandidates) { const result = await probeZaiChatCompletions({ baseUrl: candidate.baseUrl, apiKey: params.apiKey, - modelId: "glm-4.7", + modelId: candidate.modelId, timeoutMs, fetchFn: params.fetchFn, }); if (result.ok) { - return { - endpoint: candidate.endpoint, - baseUrl: candidate.baseUrl, - modelId: "glm-4.7", - note: "Coding Plan endpoint detected; GLM-5 is not available there. Defaulting to GLM-4.7.", - }; + return candidate; } } diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 5c365fb5cc8..4e0cae1209f 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,3 +1,4 @@ +import { hasAnyWhatsAppAuth } from "../../extensions/whatsapp/src/accounts.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { getChannelPluginCatalogEntry, @@ -13,7 +14,6 @@ import { type PluginManifestRegistry, } from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; -import { hasAnyWhatsAppAuth } from "../web/accounts.js"; import type { OpenClawConfig } from "./config.js"; import { ensurePluginAllowlisted } from "./plugins-allowlist.js"; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index b12d893753b..20ae33fb063 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 "../discord/monitor/timeouts.js"; +} from "../../extensions/discord/src/monitor/timeouts.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/explicit-session-key-normalization.ts b/src/config/sessions/explicit-session-key-normalization.ts index 71a74bb5db3..984b70487a3 100644 --- a/src/config/sessions/explicit-session-key-normalization.ts +++ b/src/config/sessions/explicit-session-key-normalization.ts @@ -1,5 +1,5 @@ +import { normalizeExplicitDiscordSessionKey } from "../../../extensions/discord/src/session-key-normalization.js"; import type { MsgContext } from "../../auto-reply/templating.js"; -import { normalizeExplicitDiscordSessionKey } from "../../discord/session-key-normalization.js"; type ExplicitSessionKeyNormalizer = (sessionKey: string, ctx: MsgContext) => string; type ExplicitSessionKeyNormalizerEntry = { diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 2d005dd7d7a..e25f7c5f592 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -1,4 +1,4 @@ -import type { DiscordPluralKitConfig } from "../discord/pluralkit.js"; +import type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js"; import type { BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 023c1e9eedc..5678b75e4f7 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -138,11 +138,10 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("handles media heartbeat delivery and last-target text delivery", async () => { + it("delivers media payloads even when heartbeat text is suppressed", async () => { await withTempHome(async (home) => { const { storePath, deps } = await createTelegramDeliveryFixture(home); - // Media should still be delivered even if text is just HEARTBEAT_OK. mockEmbeddedAgentPayloads([ { text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }, ]); @@ -156,9 +155,15 @@ describe("runCronIsolatedAgentTurn", () => { expect(mediaRes.status).toBe("ok"); expect(deps.sendMessageTelegram).toHaveBeenCalled(); expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + }); + }); + + it("keeps non-empty heartbeat text when last-target ack suppression is disabled", async () => { + await withTempHome(async (home) => { + const { storePath, deps } = await createTelegramDeliveryFixture(home); vi.mocked(runSubagentAnnounceFlow).mockClear(); - vi.mocked(deps.sendMessageTelegram).mockClear(); + vi.mocked(deps.sendMessageTelegram as (...args: unknown[]) => unknown).mockClear(); mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]); const cfg = makeCfg(home, storePath); @@ -194,8 +199,25 @@ describe("runCronIsolatedAgentTurn", () => { "HEARTBEAT_OK 🦞", expect.objectContaining({ accountId: undefined }), ); + }); + }); - vi.mocked(deps.sendMessageTelegram).mockClear(); + it("deletes the direct cron session after last-target text delivery", async () => { + await withTempHome(async (home) => { + const { storePath, deps } = await createTelegramDeliveryFixture(home); + + mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]); + + const cfg = makeCfg(home, storePath); + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + heartbeat: { ackMaxChars: 0 }, + }, + }; + + vi.mocked(deps.sendMessageTelegram as (...args: unknown[]) => unknown).mockClear(); vi.mocked(runSubagentAnnounceFlow).mockClear(); vi.mocked(callGateway).mockClear(); diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index df7d29d419f..9914043b2ff 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -21,15 +21,15 @@ vi.mock("../../pairing/pairing-store.js", () => ({ readChannelAllowFromStoreSync: vi.fn(() => []), })); -vi.mock("../../web/accounts.js", () => ({ +vi.mock("../../../extensions/whatsapp/src/accounts.js", () => ({ resolveWhatsAppAccount: vi.fn(() => ({ allowFrom: [] })), })); +import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js"; import { loadSessionStore } from "../../config/sessions.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js"; import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; -import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { resolveDeliveryTarget } from "./delivery-target.js"; function makeCfg(overrides?: Partial): OpenClawConfig { diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 33bd80d4118..4a70352e233 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -1,3 +1,4 @@ +import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js"; import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -15,7 +16,6 @@ import { import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js"; -import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; export type DeliveryTargetResolution = diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 6f34c85ebed..969faa6bb6f 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -414,6 +414,42 @@ describe("normalizeCronJobCreate", () => { expect(delivery.mode).toBeUndefined(); expect(delivery.to).toBe("123"); }); + + it("resolves current sessionTarget to a persistent session when context is available", () => { + const normalized = normalizeCronJobCreate( + { + name: "current-session", + schedule: { kind: "cron", expr: "* * * * *" }, + sessionTarget: "current", + payload: { kind: "agentTurn", message: "hello" }, + }, + { sessionContext: { sessionKey: "agent:main:discord:group:ops" } }, + ) as unknown as Record; + + expect(normalized.sessionTarget).toBe("session:agent:main:discord:group:ops"); + }); + + it("falls back current sessionTarget to isolated without context", () => { + const normalized = normalizeCronJobCreate({ + name: "current-without-context", + schedule: { kind: "cron", expr: "* * * * *" }, + sessionTarget: "current", + payload: { kind: "agentTurn", message: "hello" }, + }) as unknown as Record; + + expect(normalized.sessionTarget).toBe("isolated"); + }); + + it("preserves custom session ids with a session: prefix", () => { + const normalized = normalizeCronJobCreate({ + name: "custom-session", + schedule: { kind: "cron", expr: "* * * * *" }, + sessionTarget: "session:MySessionID", + payload: { kind: "agentTurn", message: "hello" }, + }) as unknown as Record; + + expect(normalized.sessionTarget).toBe("session:MySessionID"); + }); }); describe("normalizeCronJobPatch", () => { diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 5a6c66ff356..b1afdfaaa12 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -11,6 +11,8 @@ type UnknownRecord = Record; type NormalizeOptions = { applyDefaults?: boolean; + /** Session context for resolving "current" sessionTarget or auto-binding when not specified */ + sessionContext?: { sessionKey?: string }; }; const DEFAULT_OPTIONS: NormalizeOptions = { @@ -218,9 +220,17 @@ function normalizeSessionTarget(raw: unknown) { if (typeof raw !== "string") { return undefined; } - const trimmed = raw.trim().toLowerCase(); - if (trimmed === "main" || trimmed === "isolated") { - return trimmed; + const trimmed = raw.trim(); + const lower = trimmed.toLowerCase(); + if (lower === "main" || lower === "isolated" || lower === "current") { + return lower; + } + // Support custom session IDs with "session:" prefix + if (lower.startsWith("session:")) { + const sessionId = trimmed.slice(8).trim(); + if (sessionId) { + return `session:${sessionId}`; + } } return undefined; } @@ -431,10 +441,37 @@ export function normalizeCronJobInput( } if (!next.sessionTarget && isRecord(next.payload)) { const kind = typeof next.payload.kind === "string" ? next.payload.kind : ""; + // Keep default behavior unchanged for backward compatibility: + // - systemEvent defaults to "main" + // - agentTurn defaults to "isolated" (NOT "current", to avoid token accumulation) + // Users must explicitly specify "current" or "session:xxx" for custom session binding if (kind === "systemEvent") { next.sessionTarget = "main"; + } else if (kind === "agentTurn") { + next.sessionTarget = "isolated"; } - if (kind === "agentTurn") { + } + + // Resolve "current" sessionTarget to the actual sessionKey from context + if (next.sessionTarget === "current") { + if (options.sessionContext?.sessionKey) { + const sessionKey = options.sessionContext.sessionKey.trim(); + if (sessionKey) { + // Store as session:customId format for persistence + next.sessionTarget = `session:${sessionKey}`; + } + } + // If "current" wasn't resolved, fall back to "isolated" behavior + // This handles CLI/headless usage where no session context exists + if (next.sessionTarget === "current") { + next.sessionTarget = "isolated"; + } + } + if (next.sessionTarget === "current") { + const sessionKey = options.sessionContext?.sessionKey?.trim(); + if (sessionKey) { + next.sessionTarget = `session:${sessionKey}`; + } else { next.sessionTarget = "isolated"; } } @@ -462,8 +499,12 @@ export function normalizeCronJobInput( const payload = isRecord(next.payload) ? next.payload : null; const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : ""; const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : ""; + // Support "isolated", custom session IDs (session:xxx), and resolved "current" as isolated-like targets const isIsolatedAgentTurn = - sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); + sessionTarget === "isolated" || + sessionTarget === "current" || + sessionTarget.startsWith("session:") || + (sessionTarget === "" && payloadKind === "agentTurn"); const hasDelivery = "delivery" in next && next.delivery !== undefined; const normalizedLegacy = normalizeLegacyDeliveryInput({ delivery: isRecord(next.delivery) ? next.delivery : null, @@ -487,7 +528,7 @@ export function normalizeCronJobInput( export function normalizeCronJobCreate( raw: unknown, - options?: NormalizeOptions, + options?: Omit, ): CronJobCreate | null { return normalizeCronJobInput(raw, { applyDefaults: true, diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 053ea8764de..c514f7528ba 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -103,6 +103,29 @@ describe("applyJobPatch", () => { }); }); + it("maps legacy payload delivery updates for custom session targets", () => { + const job = createIsolatedAgentTurnJob( + "job-custom-session", + { + mode: "announce", + channel: "telegram", + to: "123", + }, + { sessionTarget: "session:project-alpha" }, + ); + + applyJobPatch(job, { + payload: { kind: "agentTurn", to: "555" }, + }); + + expect(job.delivery).toEqual({ + mode: "announce", + channel: "telegram", + to: "555", + bestEffort: undefined, + }); + }); + it("treats legacy payload targets as announce requests", () => { const job = createIsolatedAgentTurnJob("job-3", { mode: "none", diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 555750bd738..75ffb262d4d 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -759,7 +759,7 @@ describe("CronService", () => { wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "nope" }, }), - ).rejects.toThrow(/isolated cron jobs require/); + ).rejects.toThrow(/isolated.*cron jobs require/); cron.stop(); await store.cleanup(); diff --git a/src/cron/service.store-migration.test.ts b/src/cron/service.store-migration.test.ts index 52c9f571b08..216154fa503 100644 --- a/src/cron/service.store-migration.test.ts +++ b/src/cron/service.store-migration.test.ts @@ -72,6 +72,39 @@ function createLegacyIsolatedAgentTurnJob( } describe("CronService store migrations", () => { + it("treats stored current session targets as isolated-like for default delivery migration", async () => { + const { store, cron } = await startCronWithStoredJobs([ + createLegacyIsolatedAgentTurnJob({ + id: "stored-current-job", + name: "stored current", + sessionTarget: "current", + }), + ]); + + const job = await listJobById(cron, "stored-current-job"); + expect(job).toBeDefined(); + expect(job?.sessionTarget).toBe("isolated"); + expect(job?.delivery).toEqual({ mode: "announce" }); + + await stopCronAndCleanup(cron, store); + }); + + it("preserves stored custom session targets", async () => { + const { store, cron } = await startCronWithStoredJobs([ + createLegacyIsolatedAgentTurnJob({ + id: "custom-session-job", + name: "custom session", + sessionTarget: "session:ProjectAlpha", + }), + ]); + + const job = await listJobById(cron, "custom-session-job"); + expect(job?.sessionTarget).toBe("session:ProjectAlpha"); + expect(job?.delivery).toEqual({ mode: "announce" }); + + await stopCronAndCleanup(cron, store); + }); + it("migrates legacy top-level agentTurn fields and initializes missing state", async () => { const { store, cron } = await startCronWithStoredJobs([ createLegacyIsolatedAgentTurnJob({ diff --git a/src/cron/service.store.migration.test.ts b/src/cron/service.store.migration.test.ts index 8daa0b39e9a..973efca67a6 100644 --- a/src/cron/service.store.migration.test.ts +++ b/src/cron/service.store.migration.test.ts @@ -133,6 +133,24 @@ describe("cron store migration", () => { expect(schedule.at).toBe(new Date(atMs).toISOString()); }); + it("preserves stored custom session targets", async () => { + const migrated = await migrateLegacyJob( + makeLegacyJob({ + id: "job-custom-session", + name: "Custom session", + schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" }, + sessionTarget: "session:ProjectAlpha", + payload: { + kind: "agentTurn", + message: "hello", + }, + }), + ); + + expect(migrated.sessionTarget).toBe("session:ProjectAlpha"); + expect(migrated.delivery).toEqual({ mode: "announce" }); + }); + it("adds anchorMs to legacy every schedules", async () => { const createdAtMs = 1_700_000_000_000; const migrated = await migrateLegacyJob( diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 5579e5430f0..542ba81053d 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -132,11 +132,15 @@ function resolveEveryAnchorMs(params: { } export function assertSupportedJobSpec(job: Pick) { + const isIsolatedLike = + job.sessionTarget === "isolated" || + job.sessionTarget === "current" || + job.sessionTarget.startsWith("session:"); if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") { throw new Error('main cron jobs require payload.kind="systemEvent"'); } - if (job.sessionTarget === "isolated" && job.payload.kind !== "agentTurn") { - throw new Error('isolated cron jobs require payload.kind="agentTurn"'); + if (isIsolatedLike && job.payload.kind !== "agentTurn") { + throw new Error('isolated/current/session cron jobs require payload.kind="agentTurn"'); } } @@ -181,6 +185,7 @@ function assertDeliverySupport(job: Pick) if (!job.delivery || job.delivery.mode === "none") { return; } + // Webhook delivery is allowed for any session target if (job.delivery.mode === "webhook") { const target = normalizeHttpWebhookUrl(job.delivery.to); if (!target) { @@ -189,7 +194,11 @@ function assertDeliverySupport(job: Pick) job.delivery.to = target; return; } - if (job.sessionTarget !== "isolated") { + const isIsolatedLike = + job.sessionTarget === "isolated" || + job.sessionTarget === "current" || + job.sessionTarget.startsWith("session:"); + if (!isIsolatedLike) { throw new Error('cron channel delivery config is only supported for sessionTarget="isolated"'); } if (job.delivery.channel === "telegram") { @@ -606,11 +615,11 @@ export function applyJobPatch( if (!patch.delivery && patch.payload?.kind === "agentTurn") { // Back-compat: legacy clients still update delivery via payload fields. const legacyDeliveryPatch = buildLegacyDeliveryPatch(patch.payload); - if ( - legacyDeliveryPatch && - job.sessionTarget === "isolated" && - job.payload.kind === "agentTurn" - ) { + const isIsolatedLike = + job.sessionTarget === "isolated" || + job.sessionTarget === "current" || + job.sessionTarget.startsWith("session:"); + if (legacyDeliveryPatch && isIsolatedLike && job.payload.kind === "agentTurn") { job.delivery = mergeCronDelivery(job.delivery, legacyDeliveryPatch); } } diff --git a/src/cron/store-migration.ts b/src/cron/store-migration.ts index 1e9dcb1b136..0a460174bd2 100644 --- a/src/cron/store-migration.ts +++ b/src/cron/store-migration.ts @@ -451,11 +451,25 @@ export function normalizeStoredCronJobs( const payloadKind = payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : ""; - const normalizedSessionTarget = - typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; - if (normalizedSessionTarget === "main" || normalizedSessionTarget === "isolated") { - if (raw.sessionTarget !== normalizedSessionTarget) { - raw.sessionTarget = normalizedSessionTarget; + const rawSessionTarget = typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim() : ""; + const loweredSessionTarget = rawSessionTarget.toLowerCase(); + if (loweredSessionTarget === "main" || loweredSessionTarget === "isolated") { + if (raw.sessionTarget !== loweredSessionTarget) { + raw.sessionTarget = loweredSessionTarget; + mutated = true; + } + } else if (loweredSessionTarget.startsWith("session:")) { + const customSessionId = rawSessionTarget.slice(8).trim(); + if (customSessionId) { + const normalizedSessionTarget = `session:${customSessionId}`; + if (raw.sessionTarget !== normalizedSessionTarget) { + raw.sessionTarget = normalizedSessionTarget; + mutated = true; + } + } + } else if (loweredSessionTarget === "current") { + if (raw.sessionTarget !== "isolated") { + raw.sessionTarget = "isolated"; mutated = true; } } else { @@ -469,7 +483,10 @@ export function normalizeStoredCronJobs( const sessionTarget = typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; const isIsolatedAgentTurn = - sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); + sessionTarget === "isolated" || + sessionTarget === "current" || + sessionTarget.startsWith("session:") || + (sessionTarget === "" && payloadKind === "agentTurn"); const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery); const normalizedLegacy = normalizeLegacyDeliveryInput({ delivery: hasDelivery ? (delivery as Record) : null, diff --git a/src/cron/types.ts b/src/cron/types.ts index 2a93bc30311..02078d15424 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -13,7 +13,7 @@ export type CronSchedule = staggerMs?: number; }; -export type CronSessionTarget = "main" | "isolated"; +export type CronSessionTarget = "main" | "isolated" | "current" | `session:${string}`; export type CronWakeMode = "next-heartbeat" | "now"; export type CronMessageChannel = ChannelId | "last"; diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index 4d8d6325366..55e678052f3 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -59,6 +59,14 @@ function expectStartupFallbackSpawn(env: Record) { ); } +function expectGatewayTermination(pid: number) { + if (process.platform === "win32") { + expect(killProcessTree).not.toHaveBeenCalled(); + return; + } + expect(killProcessTree).toHaveBeenCalledWith(pid, { graceMs: 300 }); +} + function addStartupFallbackMissingResponses( extraResponses: Array<{ code: number; stdout: string; stderr: string }> = [], ) { @@ -179,7 +187,7 @@ describe("Windows startup fallback", () => { await expect(restartScheduledTask({ env, stdout })).resolves.toEqual({ outcome: "completed", }); - expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); + expectGatewayTermination(5151); expectStartupFallbackSpawn(env); }); }); @@ -214,7 +222,7 @@ describe("Windows startup fallback", () => { delete envWithoutPort.OPENCLAW_GATEWAY_PORT; await stopScheduledTask({ env: envWithoutPort, stdout }); - expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); + expectGatewayTermination(5151); }); }); }); diff --git a/src/daemon/schtasks.stop.test.ts b/src/daemon/schtasks.stop.test.ts index 2844196e5ad..04e5f1fced1 100644 --- a/src/daemon/schtasks.stop.test.ts +++ b/src/daemon/schtasks.stop.test.ts @@ -59,6 +59,14 @@ function busyPortUsage( }; } +function expectGatewayTermination(pid: number) { + if (process.platform === "win32") { + expect(killProcessTree).not.toHaveBeenCalled(); + return; + } + expect(killProcessTree).toHaveBeenCalledWith(pid, { graceMs: 300 }); +} + async function withPreparedGatewayTask( run: (context: { env: Record; stdout: PassThrough }) => Promise, ) { @@ -92,7 +100,7 @@ describe("Scheduled Task stop/restart cleanup", () => { await stopScheduledTask({ env, stdout }); expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT); - expect(killProcessTree).toHaveBeenCalledWith(4242, { graceMs: 300 }); + expectGatewayTermination(4242); expect(inspectPortUsage).toHaveBeenCalledTimes(2); }); }); @@ -111,8 +119,12 @@ describe("Scheduled Task stop/restart cleanup", () => { await stopScheduledTask({ env, stdout }); - expect(killProcessTree).toHaveBeenNthCalledWith(1, 4242, { graceMs: 300 }); - expect(killProcessTree).toHaveBeenNthCalledWith(2, expect.any(Number), { graceMs: 300 }); + if (process.platform !== "win32") { + expect(killProcessTree).toHaveBeenNthCalledWith(1, 4242, { graceMs: 300 }); + expect(killProcessTree).toHaveBeenNthCalledWith(2, expect.any(Number), { graceMs: 300 }); + } else { + expect(killProcessTree).not.toHaveBeenCalled(); + } expect(inspectPortUsage.mock.calls.length).toBeGreaterThanOrEqual(22); }); }); @@ -132,7 +144,7 @@ describe("Scheduled Task stop/restart cleanup", () => { await stopScheduledTask({ env, stdout }); - expect(killProcessTree).toHaveBeenCalledWith(6262, { graceMs: 300 }); + expectGatewayTermination(6262); expect(inspectPortUsage).toHaveBeenCalledTimes(2); }); }); @@ -150,7 +162,7 @@ describe("Scheduled Task stop/restart cleanup", () => { }); expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT); - expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); + expectGatewayTermination(5151); expect(inspectPortUsage).toHaveBeenCalledTimes(2); expect(schtasksCalls.at(-1)).toEqual(["/Run", "/TN", "OpenClaw Gateway"]); }); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index e4d8d28f562..7fd9b7c84cb 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -18,6 +18,11 @@ let lastClientOptions: { onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise; onClose?: (code: number, reason: string) => void; } | null = null; +let lastRequestOptions: { + method?: string; + params?: unknown; + opts?: { expectFinal?: boolean; timeoutMs?: number | null }; +} | null = null; type StartMode = "hello" | "close" | "silent"; let startMode: StartMode = "hello"; let closeCode = 1006; @@ -45,7 +50,12 @@ vi.mock("./client.js", () => ({ }) { lastClientOptions = opts; } - async request() { + async request( + method: string, + params: unknown, + opts?: { expectFinal?: boolean; timeoutMs?: number | null }, + ) { + lastRequestOptions = { method, params, opts }; return { ok: true }; } start() { @@ -72,6 +82,7 @@ function resetGatewayCallMocks() { pickPrimaryTailnetIPv4.mockClear(); pickPrimaryLanIPv4.mockClear(); lastClientOptions = null; + lastRequestOptions = null; startMode = "hello"; closeCode = 1006; closeReason = ""; @@ -574,6 +585,25 @@ describe("callGateway error details", () => { expect(errMessage).toContain("gateway closed (1006"); }); + it("forwards caller timeout to client requests", async () => { + setLocalLoopbackGatewayConfig(); + + await callGateway({ method: "health", timeoutMs: 45_000 }); + + expect(lastRequestOptions?.method).toBe("health"); + expect(lastRequestOptions?.opts?.timeoutMs).toBe(45_000); + }); + + it("does not inject wrapper timeout defaults into expectFinal requests", async () => { + setLocalLoopbackGatewayConfig(); + + await callGateway({ method: "health", expectFinal: true }); + + expect(lastRequestOptions?.method).toBe("health"); + expect(lastRequestOptions?.opts?.expectFinal).toBe(true); + expect(lastRequestOptions?.opts?.timeoutMs).toBeUndefined(); + }); + it("fails fast when remote mode is missing remote url", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 8e8f449fc59..f163a45ef06 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -848,6 +848,7 @@ async function executeGatewayRequestWithScopes(params: { }); const result = await client.request(opts.method, opts.params, { expectFinal: opts.expectFinal, + timeoutMs: opts.timeoutMs, }); ignoreClose = true; stop(undefined, result); diff --git a/src/gateway/client-callsites.guard.test.ts b/src/gateway/client-callsites.guard.test.ts index 9563a0ea75a..c32b5e21c45 100644 --- a/src/gateway/client-callsites.guard.test.ts +++ b/src/gateway/client-callsites.guard.test.ts @@ -6,7 +6,7 @@ const GATEWAY_CLIENT_CONSTRUCTOR_PATTERN = /new\s+GatewayClient\s*\(/; const ALLOWED_GATEWAY_CLIENT_CALLSITES = new Set([ "src/acp/server.ts", - "src/discord/monitor/exec-approvals.ts", + "extensions/discord/src/monitor/exec-approvals.ts", "src/gateway/call.ts", "src/gateway/probe.ts", "src/node-host/runner.ts", diff --git a/src/gateway/client.ts b/src/gateway/client.ts index b559995ace4..0e30cef34e8 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -44,6 +44,7 @@ type Pending = { resolve: (value: unknown) => void; reject: (err: unknown) => void; expectFinal: boolean; + timeout: NodeJS.Timeout | null; }; type GatewayClientErrorShape = { @@ -78,6 +79,7 @@ export type GatewayClientOptions = { url?: string; // ws://127.0.0.1:18789 connectDelayMs?: number; tickWatchMinIntervalMs?: number; + requestTimeoutMs?: number; token?: string; bootstrapToken?: string; deviceToken?: string; @@ -136,6 +138,7 @@ export class GatewayClient { private lastTick: number | null = null; private tickIntervalMs = 30_000; private tickTimer: NodeJS.Timeout | null = null; + private readonly requestTimeoutMs: number; constructor(opts: GatewayClientOptions) { this.opts = { @@ -145,6 +148,10 @@ export class GatewayClient { ? undefined : (opts.deviceIdentity ?? loadOrCreateDeviceIdentity()), }; + this.requestTimeoutMs = + typeof opts.requestTimeoutMs === "number" && Number.isFinite(opts.requestTimeoutMs) + ? Math.max(1, Math.min(Math.floor(opts.requestTimeoutMs), 2_147_483_647)) + : 30_000; } start() { @@ -586,6 +593,9 @@ export class GatewayClient { return; } this.pending.delete(parsed.id); + if (pending.timeout) { + clearTimeout(pending.timeout); + } if (parsed.ok) { pending.resolve(parsed.payload); } else { @@ -638,6 +648,9 @@ export class GatewayClient { private flushPendingErrors(err: Error) { for (const [, p] of this.pending) { + if (p.timeout) { + clearTimeout(p.timeout); + } p.reject(err); } this.pending.clear(); @@ -697,7 +710,7 @@ export class GatewayClient { async request>( method: string, params?: unknown, - opts?: { expectFinal?: boolean }, + opts?: { expectFinal?: boolean; timeoutMs?: number | null }, ): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error("gateway not connected"); @@ -710,11 +723,27 @@ export class GatewayClient { ); } const expectFinal = opts?.expectFinal === true; + const timeoutMs = + opts?.timeoutMs === null + ? null + : typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.min(Math.floor(opts.timeoutMs), 2_147_483_647)) + : expectFinal + ? null + : this.requestTimeoutMs; const p = new Promise((resolve, reject) => { + const timeout = + timeoutMs === null + ? null + : setTimeout(() => { + this.pending.delete(id); + reject(new Error(`gateway request timeout for ${method}`)); + }, timeoutMs); this.pending.set(id, { resolve: (value) => resolve(value as T), reject, expectFinal, + timeout, }); }); this.ws.send(JSON.stringify(frame)); diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index f723c3fdcb5..603c36a229b 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -1,7 +1,7 @@ import { createServer as createHttpsServer } from "node:https"; import { createServer } from "node:net"; -import { afterEach, describe, expect, test } from "vitest"; -import { WebSocketServer } from "ws"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; import { rawDataToString } from "../infra/ws.js"; import { GatewayClient } from "./client.js"; @@ -85,6 +85,160 @@ describe("GatewayClient", () => { } }, 4000); + test("times out unresolved requests and clears pending state", async () => { + vi.useFakeTimers(); + try { + const client = new GatewayClient({ + requestTimeoutMs: 25, + }); + const send = vi.fn(); + ( + client as unknown as { + ws: WebSocket | { readyState: number; send: () => void; close: () => void }; + } + ).ws = { + readyState: WebSocket.OPEN, + send, + close: vi.fn(), + }; + + const requestPromise = client.request("status"); + const requestExpectation = expect(requestPromise).rejects.toThrow( + "gateway request timeout for status", + ); + expect(send).toHaveBeenCalledTimes(1); + expect((client as unknown as { pending: Map }).pending.size).toBe(1); + + await vi.advanceTimersByTimeAsync(25); + + await requestExpectation; + expect((client as unknown as { pending: Map }).pending.size).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + test("does not auto-timeout expectFinal requests", async () => { + vi.useFakeTimers(); + try { + const client = new GatewayClient({ + requestTimeoutMs: 25, + }); + const send = vi.fn(); + ( + client as unknown as { + ws: WebSocket | { readyState: number; send: () => void; close: () => void }; + } + ).ws = { + readyState: WebSocket.OPEN, + send, + close: vi.fn(), + }; + + let settled = false; + const requestPromise = client.request("chat.send", undefined, { expectFinal: true }); + void requestPromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + expect(send).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(25); + + expect(settled).toBe(false); + expect((client as unknown as { pending: Map }).pending.size).toBe(1); + + client.stop(); + await expect(requestPromise).rejects.toThrow("gateway client stopped"); + } finally { + vi.useRealTimers(); + } + }); + + test("clamps oversized explicit request timeouts before scheduling", async () => { + vi.useFakeTimers(); + try { + const client = new GatewayClient({ + requestTimeoutMs: 25, + }); + const send = vi.fn(); + ( + client as unknown as { + ws: WebSocket | { readyState: number; send: () => void; close: () => void }; + } + ).ws = { + readyState: WebSocket.OPEN, + send, + close: vi.fn(), + }; + + let settled = false; + const requestPromise = client.request("status", undefined, { timeoutMs: 2_592_010_000 }); + void requestPromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + await vi.advanceTimersByTimeAsync(1); + + expect(settled).toBe(false); + expect((client as unknown as { pending: Map }).pending.size).toBe(1); + + client.stop(); + await expect(requestPromise).rejects.toThrow("gateway client stopped"); + } finally { + vi.useRealTimers(); + } + }); + + test("clamps oversized default request timeouts before scheduling", async () => { + vi.useFakeTimers(); + try { + const client = new GatewayClient({ + requestTimeoutMs: 2_592_010_000, + }); + const send = vi.fn(); + ( + client as unknown as { + ws: WebSocket | { readyState: number; send: () => void; close: () => void }; + } + ).ws = { + readyState: WebSocket.OPEN, + send, + close: vi.fn(), + }; + + let settled = false; + const requestPromise = client.request("status"); + void requestPromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + await vi.advanceTimersByTimeAsync(1); + + expect(settled).toBe(false); + expect((client as unknown as { pending: Map }).pending.size).toBe(1); + + client.stop(); + await expect(requestPromise).rejects.toThrow("gateway client stopped"); + } finally { + vi.useRealTimers(); + } + }); + test("rejects mismatched tls fingerprint", async () => { const key = [ "-----BEGIN PRIVATE KEY-----", // pragma: allowlist secret diff --git a/src/gateway/protocol/cron-validators.test.ts b/src/gateway/protocol/cron-validators.test.ts index 33df9d478e9..1de9db206b9 100644 --- a/src/gateway/protocol/cron-validators.test.ts +++ b/src/gateway/protocol/cron-validators.test.ts @@ -21,6 +21,29 @@ describe("cron protocol validators", () => { expect(validateCronAddParams(minimalAddParams)).toBe(true); }); + it("accepts current and custom session targets", () => { + expect( + validateCronAddParams({ + ...minimalAddParams, + sessionTarget: "current", + payload: { kind: "agentTurn", message: "tick" }, + }), + ).toBe(true); + expect( + validateCronAddParams({ + ...minimalAddParams, + sessionTarget: "session:project-alpha", + payload: { kind: "agentTurn", message: "tick" }, + }), + ).toBe(true); + expect( + validateCronUpdateParams({ + id: "job-1", + patch: { sessionTarget: "session:project-alpha" }, + }), + ).toBe(true); + }); + it("rejects add params when required scheduling fields are missing", () => { const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams; expect(validateCronAddParams(withoutWakeMode)).toBe(false); diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 3cba5a65781..f61d3e42711 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -21,7 +21,12 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema }) { ); } -const CronSessionTargetSchema = Type.Union([Type.Literal("main"), Type.Literal("isolated")]); +const CronSessionTargetSchema = Type.Union([ + Type.Literal("main"), + Type.Literal("isolated"), + Type.Literal("current"), + Type.String({ pattern: "^session:.+" }), +]); const CronWakeModeSchema = Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]); const CronRunStatusSchema = Type.Union([ Type.Literal("ok"), diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 2608560e20f..d7a6b375d10 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -5,10 +5,19 @@ import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; -const enqueueSystemEventMock = vi.fn(); -const requestHeartbeatNowMock = vi.fn(); -const loadConfigMock = vi.fn(); -const fetchWithSsrFGuardMock = vi.fn(); +const { + enqueueSystemEventMock, + requestHeartbeatNowMock, + loadConfigMock, + fetchWithSsrFGuardMock, + runCronIsolatedAgentTurnMock, +} = vi.hoisted(() => ({ + enqueueSystemEventMock: vi.fn(), + requestHeartbeatNowMock: vi.fn(), + loadConfigMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + runCronIsolatedAgentTurnMock: vi.fn(async () => ({ status: "ok" as const, summary: "ok" })), +})); function enqueueSystemEvent(...args: unknown[]) { return enqueueSystemEventMock(...args); @@ -35,7 +44,11 @@ vi.mock("../config/config.js", async () => { }); vi.mock("../infra/net/fetch-guard.js", () => ({ - fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +vi.mock("../cron/isolated-agent.js", () => ({ + runCronIsolatedAgentTurn: runCronIsolatedAgentTurnMock, })); import { buildGatewayCronService } from "./server-cron.js"; @@ -58,6 +71,7 @@ describe("buildGatewayCronService", () => { requestHeartbeatNowMock.mockClear(); loadConfigMock.mockClear(); fetchWithSsrFGuardMock.mockClear(); + runCronIsolatedAgentTurnMock.mockClear(); }); it("routes main-target jobs to the scoped session for enqueue + wake", async () => { @@ -142,4 +156,44 @@ describe("buildGatewayCronService", () => { state.cron.stop(); } }); + + it("passes custom session targets through to isolated cron runs", async () => { + const tmpDir = path.join(os.tmpdir(), `server-cron-custom-session-${Date.now()}`); + const cfg = { + session: { + mainKey: "main", + }, + cron: { + store: path.join(tmpDir, "cron.json"), + }, + } as OpenClawConfig; + loadConfigMock.mockReturnValue(cfg); + + const state = buildGatewayCronService({ + cfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + try { + const job = await state.cron.add({ + name: "custom-session", + enabled: true, + schedule: { kind: "at", at: new Date(1).toISOString() }, + sessionTarget: "session:project-alpha-monitor", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + }); + + await state.cron.run(job.id, "force"); + + expect(runCronIsolatedAgentTurnMock).toHaveBeenCalledWith( + expect.objectContaining({ + job: expect.objectContaining({ id: job.id }), + sessionKey: "project-alpha-monitor", + }), + ); + } finally { + state.cron.stop(); + } + }); }); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 1f1cd1f5359..8a288866721 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -284,6 +284,13 @@ export function buildGatewayCronService(params: { }, runIsolatedAgentJob: async ({ job, message, abortSignal }) => { const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId); + let sessionKey = `cron:${job.id}`; + if (job.sessionTarget.startsWith("session:")) { + const customSessionId = job.sessionTarget.slice(8).trim(); + if (customSessionId) { + sessionKey = customSessionId; + } + } return await runCronIsolatedAgentTurn({ cfg: runtimeConfig, deps: params.deps, @@ -291,7 +298,7 @@ export function buildGatewayCronService(params: { message, abortSignal, agentId, - sessionKey: `cron:${job.id}`, + sessionKey, lane: "cron", }); }, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 4a6fc780d4d..75af96dd545 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -8,13 +8,13 @@ import { import { createServer as createHttpsServer } from "node:https"; import type { TlsOptions } from "node:tls"; import type { WebSocketServer } from "ws"; +import { handleSlackHttpRequest } from "../../extensions/slack/src/http/index.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"; import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import { safeEqualSecret } from "../security/secret-equal.js"; -import { handleSlackHttpRequest } from "../slack/http/index.js"; import { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, createAuthRateLimiter, diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 830d12c9509..7eccb895534 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -89,7 +89,14 @@ export const cronHandlers: GatewayRequestHandlers = { respond(true, status, undefined); }, "cron.add": async ({ params, respond, context }) => { - const normalized = normalizeCronJobCreate(params) ?? params; + const sessionKey = + typeof (params as { sessionKey?: unknown } | null)?.sessionKey === "string" + ? (params as { sessionKey: string }).sessionKey + : undefined; + const normalized = + normalizeCronJobCreate(params, { + sessionContext: { sessionKey }, + }) ?? params; if (!validateCronAddParams(normalized)) { respond( false, diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 6b95ff62d25..ef461ce4a7a 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -51,18 +51,21 @@ beforeAll(async () => { const whatsappOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", sendText: async ({ deps, to, text }) => { - if (!deps?.sendWhatsApp) { - throw new Error("Missing sendWhatsApp dep"); - } - return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, { verbose: false })) }; - }, - sendMedia: async ({ deps, to, text, mediaUrl }) => { - if (!deps?.sendWhatsApp) { + if (!deps?.["whatsapp"]) { throw new Error("Missing sendWhatsApp dep"); } return { channel: "whatsapp", - ...(await deps.sendWhatsApp(to, text, { verbose: false, mediaUrl })), + ...(await (deps["whatsapp"] as Function)(to, text, { verbose: false })), + }; + }, + sendMedia: async ({ deps, to, text, mediaUrl }) => { + if (!deps?.["whatsapp"]) { + throw new Error("Missing sendWhatsApp dep"); + } + return { + channel: "whatsapp", + ...(await (deps["whatsapp"] as Function)(to, text, { verbose: false, mediaUrl })), }; }, }; diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 034020a61fe..fdce44e33f4 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -102,8 +102,11 @@ vi.mock("../plugins/hook-runner-global.js", async (importOriginal) => { }; }); -vi.mock("../discord/monitor/thread-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../extensions/discord/src/monitor/thread-bindings.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../extensions/discord/src/monitor/thread-bindings.js") + >(); return { ...actual, unbindThreadBindingsBySessionKey: (params: unknown) => diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index b0a5b0a54f0..b07bf0095dd 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { unbindThreadBindingsBySessionKey } from "../../extensions/discord/src/monitor/thread-bindings.js"; import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { clearBootstrapSnapshot } from "../agents/bootstrap-cache.js"; @@ -12,7 +13,6 @@ import { type SessionEntry, updateSessionStore, } from "../config/sessions.js"; -import { unbindThreadBindingsBySessionKey } from "../discord/monitor/thread-bindings.js"; import { logVerbose } from "../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 43811da1492..c8032527294 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -563,7 +563,7 @@ vi.mock("../commands/health.js", () => ({ vi.mock("../commands/status.js", () => ({ getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), })); -vi.mock("../web/outbound.js", () => ({ +vi.mock("../../extensions/whatsapp/src/send.js", () => ({ sendMessageWhatsApp: (...args: unknown[]) => (hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args), sendPollWhatsApp: (...args: unknown[]) => diff --git a/src/infra/exec-allowlist-pattern.test.ts b/src/infra/exec-allowlist-pattern.test.ts index f7834a4c9fc..50e241f912d 100644 --- a/src/infra/exec-allowlist-pattern.test.ts +++ b/src/infra/exec-allowlist-pattern.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { describe, expect, it } from "vitest"; import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js"; @@ -28,9 +29,11 @@ describe("matchesExecAllowlistPattern", () => { const prevHome = process.env.HOME; process.env.OPENCLAW_HOME = "/srv/openclaw-home"; process.env.HOME = "/home/other"; + const openClawHome = path.join(path.resolve("/srv/openclaw-home"), "bin", "tool"); + const fallbackHome = path.join(path.resolve("/home/other"), "bin", "tool"); try { - expect(matchesExecAllowlistPattern("~/bin/tool", "/srv/openclaw-home/bin/tool")).toBe(true); - expect(matchesExecAllowlistPattern("~/bin/tool", "/home/other/bin/tool")).toBe(false); + expect(matchesExecAllowlistPattern("~/bin/tool", openClawHome)).toBe(true); + expect(matchesExecAllowlistPattern("~/bin/tool", fallbackHome)).toBe(false); } finally { if (prevOpenClawHome === undefined) { delete process.env.OPENCLAW_HOME; diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 7a1672e3e76..de3a54a4c77 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -1,3 +1,5 @@ +import { buildTelegramExecApprovalButtons } from "../../extensions/telegram/src/approval-buttons.js"; +import { sendTypingTelegram } from "../../extensions/telegram/src/send.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; @@ -8,8 +10,6 @@ import type { import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js"; -import { buildTelegramExecApprovalButtons } from "../telegram/approval-buttons.js"; -import { sendTypingTelegram } from "../telegram/send.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, diff --git a/src/infra/exec-approval-surface.test.ts b/src/infra/exec-approval-surface.test.ts index b263330104a..17f6789967c 100644 --- a/src/infra/exec-approval-surface.test.ts +++ b/src/infra/exec-approval-surface.test.ts @@ -11,20 +11,20 @@ vi.mock("../config/config.js", () => ({ loadConfig: (...args: unknown[]) => loadConfigMock(...args), })); -vi.mock("../discord/accounts.js", () => ({ +vi.mock("../../extensions/discord/src/accounts.js", () => ({ listEnabledDiscordAccounts: (...args: unknown[]) => listEnabledDiscordAccountsMock(...args), })); -vi.mock("../discord/exec-approvals.js", () => ({ +vi.mock("../../extensions/discord/src/exec-approvals.js", () => ({ isDiscordExecApprovalClientEnabled: (...args: unknown[]) => isDiscordExecApprovalClientEnabledMock(...args), })); -vi.mock("../telegram/accounts.js", () => ({ +vi.mock("../../extensions/telegram/src/accounts.js", () => ({ listEnabledTelegramAccounts: (...args: unknown[]) => listEnabledTelegramAccountsMock(...args), })); -vi.mock("../telegram/exec-approvals.js", () => ({ +vi.mock("../../extensions/telegram/src/exec-approvals.js", () => ({ isTelegramExecApprovalClientEnabled: (...args: unknown[]) => isTelegramExecApprovalClientEnabledMock(...args), })); diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts index b20e31850b8..8cf43c79a3e 100644 --- a/src/infra/exec-approval-surface.ts +++ b/src/infra/exec-approval-surface.ts @@ -1,8 +1,8 @@ +import { listEnabledDiscordAccounts } from "../../extensions/discord/src/accounts.js"; +import { isDiscordExecApprovalClientEnabled } from "../../extensions/discord/src/exec-approvals.js"; +import { listEnabledTelegramAccounts } from "../../extensions/telegram/src/accounts.js"; +import { isTelegramExecApprovalClientEnabled } from "../../extensions/telegram/src/exec-approvals.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { listEnabledDiscordAccounts } from "../discord/accounts.js"; -import { isDiscordExecApprovalClientEnabled } from "../discord/exec-approvals.js"; -import { listEnabledTelegramAccounts } from "../telegram/accounts.js"; -import { isTelegramExecApprovalClientEnabled } from "../telegram/exec-approvals.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../utils/message-channel.js"; export type ExecApprovalInitiatingSurfaceState = diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index d30b3263129..4dc6ab71c7e 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -112,7 +112,7 @@ describe("exec approvals store helpers", () => { expect(missing.exists).toBe(false); expect(missing.raw).toBeNull(); expect(missing.file).toEqual(normalizeExecApprovals({ version: 1, agents: {} })); - expect(missing.path).toBe(approvalsFilePath(dir)); + expect(path.normalize(missing.path)).toBe(path.normalize(approvalsFilePath(dir))); fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); fs.writeFileSync(approvalsFilePath(dir), "{invalid", "utf8"); diff --git a/src/infra/exec-command-resolution.test.ts b/src/infra/exec-command-resolution.test.ts index 4621383a547..4bdff0947a9 100644 --- a/src/infra/exec-command-resolution.test.ts +++ b/src/infra/exec-command-resolution.test.ts @@ -80,12 +80,13 @@ describe("exec-command-resolution", () => { setup: () => { const dir = makeTempDir(); const cwd = path.join(dir, "project"); - const script = path.join(cwd, "scripts", "run.sh"); + const scriptName = process.platform === "win32" ? "run.cmd" : "run.sh"; + const script = path.join(cwd, "scripts", scriptName); fs.mkdirSync(path.dirname(script), { recursive: true }); fs.writeFileSync(script, ""); fs.chmodSync(script, 0o755); return { - command: "./scripts/run.sh --flag", + command: `./scripts/${scriptName} --flag`, cwd, envPath: undefined as NodeJS.ProcessEnv | undefined, expectedPath: script, @@ -98,12 +99,13 @@ describe("exec-command-resolution", () => { setup: () => { const dir = makeTempDir(); const cwd = path.join(dir, "project"); - const script = path.join(cwd, "bin", "tool"); + const scriptName = process.platform === "win32" ? "tool.cmd" : "tool"; + const script = path.join(cwd, "bin", scriptName); fs.mkdirSync(path.dirname(script), { recursive: true }); fs.writeFileSync(script, ""); fs.chmodSync(script, 0o755); return { - command: '"./bin/tool" --version', + command: `"./bin/${scriptName}" --version`, cwd, envPath: undefined as NodeJS.ProcessEnv | undefined, expectedPath: script, diff --git a/src/infra/executable-path.test.ts b/src/infra/executable-path.test.ts index 31437cafe49..8c7412fb385 100644 --- a/src/infra/executable-path.test.ts +++ b/src/infra/executable-path.test.ts @@ -66,8 +66,12 @@ describe("executable path helpers", () => { await fs.chmod(pathTool, 0o755); expect(resolveExecutablePath(absoluteTool)).toBe(absoluteTool); - expect(resolveExecutablePath("~/home-tool", { env: { HOME: homeDir } })).toBe(homeTool); - expect(resolveExecutablePath("runner", { env: { Path: binDir } })).toBe(pathTool); + expect( + path.normalize(resolveExecutablePath("~/home-tool", { env: { HOME: homeDir } }) ?? ""), + ).toBe(path.normalize(homeTool)); + expect(path.normalize(resolveExecutablePath("runner", { env: { Path: binDir } }) ?? "")).toBe( + path.normalize(pathTool), + ); expect(resolveExecutablePath("~/missing-tool", { env: { HOME: homeDir } })).toBeUndefined(); }); }); diff --git a/src/infra/executable-path.ts b/src/infra/executable-path.ts index bf648c7cb6a..a1d596cccf8 100644 --- a/src/infra/executable-path.ts +++ b/src/infra/executable-path.ts @@ -12,15 +12,33 @@ function resolveWindowsExecutableExtensions( if (path.extname(executable).length > 0) { return [""]; } - return ( - env?.PATHEXT ?? - env?.Pathext ?? - process.env.PATHEXT ?? - process.env.Pathext ?? - ".EXE;.CMD;.BAT;.COM" - ) - .split(";") - .map((ext) => ext.toLowerCase()); + return [ + "", + ...( + env?.PATHEXT ?? + env?.Pathext ?? + process.env.PATHEXT ?? + process.env.Pathext ?? + ".EXE;.CMD;.BAT;.COM" + ) + .split(";") + .map((ext) => ext.toLowerCase()), + ]; +} + +function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set { + return new Set( + ( + env?.PATHEXT ?? + env?.Pathext ?? + process.env.PATHEXT ?? + process.env.Pathext ?? + ".EXE;.CMD;.BAT;.COM" + ) + .split(";") + .map((ext) => ext.toLowerCase()) + .filter(Boolean), + ); } export function isExecutableFile(filePath: string): boolean { @@ -29,9 +47,14 @@ export function isExecutableFile(filePath: string): boolean { if (!stat.isFile()) { return false; } - if (process.platform !== "win32") { - fs.accessSync(filePath, fs.constants.X_OK); + if (process.platform === "win32") { + const ext = path.extname(filePath).toLowerCase(); + if (!ext) { + return true; + } + return resolveWindowsExecutableExtSet(undefined).has(ext); } + fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { return false; diff --git a/src/infra/hardlink-guards.test.ts b/src/infra/hardlink-guards.test.ts index e96d826c1d8..1a8f7205bcb 100644 --- a/src/infra/hardlink-guards.test.ts +++ b/src/infra/hardlink-guards.test.ts @@ -50,6 +50,7 @@ describe("assertNoHardlinkedFinalPath", () => { await fs.writeFile(source, "hello", "utf8"); await fs.link(source, linked); const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(root); + const expectedLinkedPath = path.join("~", "linked.txt"); try { await expect( @@ -58,7 +59,9 @@ describe("assertNoHardlinkedFinalPath", () => { root, boundaryLabel: "workspace", }), - ).rejects.toThrow("Hardlinked path is not allowed under workspace (~): ~/linked.txt"); + ).rejects.toThrow( + `Hardlinked path is not allowed under workspace (~): ${expectedLinkedPath}`, + ); } finally { homedirSpy.mockRestore(); } diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 648acf1813c..f215b8313d1 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -118,7 +118,7 @@ describe("Ghost reminder bug (issue #13317)", () => { agentId: "main", reason: params.reason, deps: { - sendTelegram, + telegram: sendTelegram, }, }); const calledCtx = (getReplySpy.mock.calls[0]?.[0] ?? null) as { diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index d0f4fd19bd7..fcc3f7556ae 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -48,9 +48,7 @@ describe("runHeartbeatOnce ack handling", () => { } = {}, ) { return { - ...(params.sendWhatsApp - ? { sendWhatsApp: params.sendWhatsApp as unknown as HeartbeatDeps["sendWhatsApp"] } - : {}), + ...(params.sendWhatsApp ? { whatsapp: params.sendWhatsApp as unknown } : {}), getQueueSize: params.getQueueSize ?? (() => 0), nowMs: params.nowMs ?? (() => 0), webAuthExists: params.webAuthExists ?? (async () => true), @@ -66,9 +64,7 @@ describe("runHeartbeatOnce ack handling", () => { } = {}, ) { return { - ...(params.sendTelegram - ? { sendTelegram: params.sendTelegram as unknown as HeartbeatDeps["sendTelegram"] } - : {}), + ...(params.sendTelegram ? { telegram: params.sendTelegram as unknown } : {}), getQueueSize: params.getQueueSize ?? (() => 0), nowMs: params.nowMs ?? (() => 0), } satisfies HeartbeatDeps; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 2ac6a8be0f3..dc28784870a 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -59,20 +59,20 @@ beforeAll(async () => { outbound: { deliveryMode: "direct", sendText: async ({ to, text, deps, accountId }) => { - if (!deps?.sendTelegram) { + if (!deps?.["telegram"]) { throw new Error("sendTelegram missing"); } - const res = await deps.sendTelegram(to, text, { + const res = await (deps["telegram"] as Function)(to, text, { verbose: false, accountId: accountId ?? undefined, }); return { channel: "telegram", messageId: res.messageId, chatId: res.chatId }; }, sendMedia: async ({ to, text, mediaUrl, deps, accountId }) => { - if (!deps?.sendTelegram) { + if (!deps?.["telegram"]) { throw new Error("sendTelegram missing"); } - const res = await deps.sendTelegram(to, text, { + const res = await (deps["telegram"] as Function)(to, text, { verbose: false, accountId: accountId ?? undefined, mediaUrl, @@ -468,10 +468,14 @@ describe("resolveHeartbeatSenderContext", () => { describe("runHeartbeatOnce", () => { const createHeartbeatDeps = ( - sendWhatsApp: NonNullable, + sendWhatsApp: ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }>, nowMs = 0, ): HeartbeatDeps => ({ - sendWhatsApp, + whatsapp: sendWhatsApp, getQueueSize: () => 0, nowMs: () => nowMs, webAuthExists: async () => true, @@ -547,10 +551,18 @@ describe("runHeartbeatOnce", () => { ); replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]); - const sendWhatsApp = vi.fn>().mockResolvedValue({ - messageId: "m1", - toJid: "jid", - }); + const sendWhatsApp = vi + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() + .mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); await runHeartbeatOnce({ cfg, @@ -604,10 +616,18 @@ describe("runHeartbeatOnce", () => { }), ); replySpy.mockResolvedValue([{ text: "Final alert" }]); - const sendWhatsApp = vi.fn>().mockResolvedValue({ - messageId: "m1", - toJid: "jid", - }); + const sendWhatsApp = vi + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() + .mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); await runHeartbeatOnce({ cfg, agentId: "ops", @@ -682,10 +702,18 @@ describe("runHeartbeatOnce", () => { ); replySpy.mockResolvedValue([{ text: "Final alert" }]); - const sendWhatsApp = vi.fn>().mockResolvedValue({ - messageId: "m1", - toJid: "jid", - }); + const sendWhatsApp = vi + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() + .mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); const result = await runHeartbeatOnce({ cfg, agentId, @@ -799,7 +827,13 @@ describe("runHeartbeatOnce", () => { replySpy.mockClear(); replySpy.mockResolvedValue([{ text: testCase.message }]); const sendWhatsApp = vi - .fn>() + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); await runHeartbeatOnce({ @@ -863,7 +897,13 @@ describe("runHeartbeatOnce", () => { replySpy.mockResolvedValue([{ text: "Final alert" }]); const sendWhatsApp = vi - .fn>() + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); await runHeartbeatOnce({ @@ -935,7 +975,13 @@ describe("runHeartbeatOnce", () => { replySpy.mockClear(); replySpy.mockResolvedValue(testCase.replies); const sendWhatsApp = vi - .fn>() + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); await runHeartbeatOnce({ @@ -990,10 +1036,18 @@ describe("runHeartbeatOnce", () => { ); replySpy.mockResolvedValue({ text: "Hello from heartbeat" }); - const sendWhatsApp = vi.fn>().mockResolvedValue({ - messageId: "m1", - toJid: "jid", - }); + const sendWhatsApp = vi + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() + .mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); await runHeartbeatOnce({ cfg, @@ -1073,7 +1127,9 @@ describe("runHeartbeatOnce", () => { const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: params.replyText ?? "Checked logs and PRs" }); const sendWhatsApp = vi - .fn>() + .fn< + (to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); const res = await runHeartbeatOnce({ cfg, @@ -1239,7 +1295,9 @@ describe("runHeartbeatOnce", () => { const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: "Handled internally" }); const sendWhatsApp = vi - .fn>() + .fn< + (to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); try { @@ -1292,7 +1350,9 @@ describe("runHeartbeatOnce", () => { const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: "Handled internally" }); const sendWhatsApp = vi - .fn>() + .fn< + (to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); try { diff --git a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts index 71a190c844b..352dbd1c84c 100644 --- a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts +++ b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts @@ -47,7 +47,7 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, deps: { - sendSlack, + slack: sendSlack, getQueueSize: () => 0, nowMs: () => 0, }, diff --git a/src/infra/home-dir.test.ts b/src/infra/home-dir.test.ts index e0f19f865b6..9faeda1dee5 100644 --- a/src/infra/home-dir.test.ts +++ b/src/infra/home-dir.test.ts @@ -109,7 +109,7 @@ describe("expandHomePrefix", () => { name: "expands exact ~ using explicit home", input: "~", opts: { home: " /srv/openclaw-home " }, - expected: path.resolve("/srv/openclaw-home"), + expected: "/srv/openclaw-home", }, { name: "expands ~\\\\ using resolved env home", diff --git a/src/infra/json-file.test.ts b/src/infra/json-file.test.ts index 60dd0e3a237..4b204fb21bc 100644 --- a/src/infra/json-file.test.ts +++ b/src/infra/json-file.test.ts @@ -35,8 +35,12 @@ describe("json-file helpers", () => { const fileMode = fs.statSync(pathname).mode & 0o777; const dirMode = fs.statSync(path.dirname(pathname)).mode & 0o777; - expect(fileMode).toBe(0o600); - expect(dirMode).toBe(0o700); + if (process.platform === "win32") { + expect(fileMode & 0o111).toBe(0); + } else { + expect(fileMode).toBe(0o600); + expect(dirMode).toBe(0o700); + } }); }); diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index ff4d0533c1b..3fdbb68e10b 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -62,9 +62,9 @@ function listExtensionFiles(): { function listHighRiskRuntimeCfgFiles(): string[] { return [ "src/agents/tools/telegram-actions.ts", - "src/discord/monitor/reply-delivery.ts", - "src/discord/monitor/thread-bindings.discord-api.ts", - "src/discord/monitor/thread-bindings.manager.ts", + "extensions/discord/src/monitor/reply-delivery.ts", + "extensions/discord/src/monitor/thread-bindings.discord-api.ts", + "extensions/discord/src/monitor/thread-bindings.manager.ts", ]; } diff --git a/src/infra/outbound/channel-adapters.test.ts b/src/infra/outbound/channel-adapters.test.ts index ee2b5fe6dc8..d8a01aadb2b 100644 --- a/src/infra/outbound/channel-adapters.test.ts +++ b/src/infra/outbound/channel-adapters.test.ts @@ -1,6 +1,6 @@ import { Separator, TextDisplay } from "@buape/carbon"; import { describe, expect, it } from "vitest"; -import { DiscordUiContainer } from "../../discord/ui.js"; +import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js"; import { getChannelMessageAdapter } from "./channel-adapters.js"; describe("getChannelMessageAdapter", () => { diff --git a/src/infra/outbound/channel-adapters.ts b/src/infra/outbound/channel-adapters.ts index ba6a1b59444..da62e2932bb 100644 --- a/src/infra/outbound/channel-adapters.ts +++ b/src/infra/outbound/channel-adapters.ts @@ -1,7 +1,7 @@ import { Separator, TextDisplay, type TopLevelComponents } from "@buape/carbon"; +import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js"; import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { DiscordUiContainer } from "../../discord/ui.js"; export type CrossContextComponentsBuilder = (message: string) => TopLevelComponents[]; diff --git a/src/infra/outbound/deliver.test-helpers.ts b/src/infra/outbound/deliver.test-helpers.ts index e043e8ef84e..bc70c456dc5 100644 --- a/src/infra/outbound/deliver.test-helpers.ts +++ b/src/infra/outbound/deliver.test-helpers.ts @@ -7,11 +7,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; -import type { - DeliverOutboundPayloadsParams, - OutboundDeliveryResult, - OutboundSendDeps, -} from "./deliver.js"; +import type { DeliverOutboundPayloadsParams, OutboundDeliveryResult } from "./deliver.js"; type DeliverMockState = { sessions: { @@ -111,6 +107,15 @@ vi.mock("../../config/sessions.js", async () => { appendAssistantMessageToSessionTranscript: _mocks.appendAssistantMessageToSessionTranscript, }; }); +vi.mock("../../config/sessions/transcript.js", async () => { + const actual = await vi.importActual( + "../../config/sessions/transcript.js", + ); + return { + ...actual, + appendAssistantMessageToSessionTranscript: _mocks.appendAssistantMessageToSessionTranscript, + }; +}); vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => _hookMocks.runner, })); @@ -215,7 +220,9 @@ export async function runChunkedWhatsAppDelivery(params: { mirror?: DeliverOutboundPayloadsParams["mirror"]; }) { const sendWhatsApp = vi - .fn>() + .fn< + (to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValueOnce({ messageId: "w1", toJid: "jid" }) .mockResolvedValueOnce({ messageId: "w2", toJid: "jid" }); const cfg: OpenClawConfig = { diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 119e7b3c5d7..2df0510cccb 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1,10 +1,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { markdownToSignalTextChunks } from "../../../extensions/signal/src/format.js"; import type { ChannelOutboundAdapter } from "../../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../config/config.js"; import { STATE_DIR } from "../../config/paths.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { markdownToSignalTextChunks } from "../../signal/format.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index bd2bb85d2e7..b67f1b7d2a0 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,3 +1,8 @@ +import { + markdownToSignalTextChunks, + type SignalTextStyleRange, +} from "../../../extensions/signal/src/format.js"; +import { sendMessageSignal } from "../../../extensions/signal/src/send.js"; import { chunkByParagraph, chunkMarkdownTextWithMode, @@ -17,7 +22,6 @@ import { appendAssistantMessageToSessionTranscript, resolveMirroredTranscriptText, } from "../../config/sessions.js"; -import type { sendMessageDiscord } from "../../discord/send.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { @@ -26,15 +30,9 @@ import { toPluginMessageContext, toPluginMessageSentEvent, } from "../../hooks/message-hook-mappers.js"; -import type { sendMessageIMessage } from "../../imessage/send.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; -import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js"; -import { sendMessageSignal } from "../../signal/send.js"; -import type { sendMessageSlack } from "../../slack/send.js"; -import type { sendMessageTelegram } from "../../telegram/send.js"; -import type { sendMessageWhatsApp } from "../../web/outbound.js"; import { throwIfAborted } from "./abort.js"; import { ackDelivery, enqueueDelivery, failDelivery } from "./delivery-queue.js"; import type { OutboundIdentity } from "./identity.js"; @@ -51,33 +49,48 @@ export { normalizeOutboundPayloads } from "./payloads.js"; const log = createSubsystemLogger("outbound/deliver"); const TELEGRAM_TEXT_LIMIT = 4096; -type SendMatrixMessage = ( - to: string, - text: string, - opts?: { - cfg?: OpenClawConfig; - mediaUrl?: string; - replyToId?: string; - threadId?: string; - timeoutMs?: number; - }, -) => Promise<{ messageId: string; roomId: string }>; - -export type OutboundSendDeps = { - sendWhatsApp?: typeof sendMessageWhatsApp; - sendTelegram?: typeof sendMessageTelegram; - sendDiscord?: typeof sendMessageDiscord; - sendSlack?: typeof sendMessageSlack; - sendSignal?: typeof sendMessageSignal; - sendIMessage?: typeof sendMessageIMessage; - sendMatrix?: SendMatrixMessage; - sendMSTeams?: ( - to: string, - text: string, - opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, - ) => Promise<{ messageId: string; conversationId: string }>; +type LegacyOutboundSendDeps = { + sendWhatsApp?: unknown; + sendTelegram?: unknown; + sendDiscord?: unknown; + sendSlack?: unknown; + sendSignal?: unknown; + sendIMessage?: unknown; + sendMatrix?: unknown; + sendMSTeams?: unknown; }; +/** + * Dynamic bag of per-channel send functions, keyed by channel ID. + * Each outbound adapter resolves its own function from this record and + * falls back to a direct import when the key is absent. + */ +export type OutboundSendDeps = LegacyOutboundSendDeps & { [channelId: string]: unknown }; + +const LEGACY_SEND_DEP_KEYS = { + whatsapp: "sendWhatsApp", + telegram: "sendTelegram", + discord: "sendDiscord", + slack: "sendSlack", + signal: "sendSignal", + imessage: "sendIMessage", + matrix: "sendMatrix", + msteams: "sendMSTeams", +} as const satisfies Record; + +export function resolveOutboundSendDep( + deps: OutboundSendDeps | null | undefined, + channelId: keyof typeof LEGACY_SEND_DEP_KEYS, +): T | undefined { + const dynamic = deps?.[channelId]; + if (dynamic !== undefined) { + return dynamic as T; + } + const legacyKey = LEGACY_SEND_DEP_KEYS[channelId]; + const legacy = deps?.[legacyKey]; + return legacy as T | undefined; +} + export type OutboundDeliveryResult = { channel: Exclude; messageId: string; @@ -133,6 +146,7 @@ type ChannelHandlerParams = { identity?: OutboundIdentity; deps?: OutboundSendDeps; gifPlayback?: boolean; + forceDocument?: boolean; silent?: boolean; mediaLocalRoots?: readonly string[]; }; @@ -213,6 +227,7 @@ function createChannelOutboundContextBase( threadId: params.threadId, identity: params.identity, gifPlayback: params.gifPlayback, + forceDocument: params.forceDocument, deps: params.deps, silent: params.silent, mediaLocalRoots: params.mediaLocalRoots, @@ -232,6 +247,7 @@ type DeliverOutboundPayloadsCoreParams = { identity?: OutboundIdentity; deps?: OutboundSendDeps; gifPlayback?: boolean; + forceDocument?: boolean; abortSignal?: AbortSignal; bestEffort?: boolean; onError?: (err: unknown, payload: NormalizedOutboundPayload) => void; @@ -476,6 +492,7 @@ export async function deliverOutboundPayloads( replyToId: params.replyToId, bestEffort: params.bestEffort, gifPlayback: params.gifPlayback, + forceDocument: params.forceDocument, silent: params.silent, mirror: params.mirror, }).catch(() => null); // Best-effort — don't block delivery if queue write fails. @@ -527,7 +544,8 @@ async function deliverOutboundPayloadsCore( const accountId = params.accountId; const deps = params.deps; const abortSignal = params.abortSignal; - const sendSignal = params.deps?.sendSignal ?? sendMessageSignal; + const sendSignal = + resolveOutboundSendDep(params.deps, "signal") ?? sendMessageSignal; const mediaLocalRoots = getAgentScopedMediaLocalRoots( cfg, params.session?.agentId ?? params.mirror?.agentId, @@ -543,6 +561,7 @@ async function deliverOutboundPayloadsCore( threadId: params.threadId, identity: params.identity, gifPlayback: params.gifPlayback, + forceDocument: params.forceDocument, silent: params.silent, mediaLocalRoots, }); diff --git a/src/infra/outbound/delivery-queue.ts b/src/infra/outbound/delivery-queue.ts index 97c37f911e4..e0d7abcb9ee 100644 --- a/src/infra/outbound/delivery-queue.ts +++ b/src/infra/outbound/delivery-queue.ts @@ -33,6 +33,7 @@ type QueuedDeliveryPayload = { replyToId?: string | null; bestEffort?: boolean; gifPlayback?: boolean; + forceDocument?: boolean; silent?: boolean; mirror?: OutboundMirror; }; @@ -117,6 +118,7 @@ export async function enqueueDelivery( replyToId: params.replyToId, bestEffort: params.bestEffort, gifPlayback: params.gifPlayback, + forceDocument: params.forceDocument, silent: params.silent, mirror: params.mirror, retryCount: 0, @@ -379,6 +381,7 @@ export async function recoverPendingDeliveries(opts: { replyToId: entry.replyToId, bestEffort: entry.bestEffort, gifPlayback: entry.gifPlayback, + forceDocument: entry.forceDocument, silent: entry.silent, mirror: entry.mirror, skipQueue: true, // Prevent re-enqueueing during recovery diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index 037a7806f16..ea527a74bd6 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -1,5 +1,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; +import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; +import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js"; import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js"; import { readStringParam } from "../../agents/tools/common.js"; import type { @@ -11,9 +14,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import { createRootScopedReadFile } from "../../infra/fs-safe.js"; import { extensionForMime } from "../../media/mime.js"; import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boolean-param.js"; -import { parseSlackTarget } from "../../slack/targets.js"; -import { parseTelegramTarget } from "../../telegram/targets.js"; -import { loadWebMedia } from "../../web/media.js"; export const readBooleanParam = readBooleanParamShared; diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index 287f8e3c677..1715ea090f2 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -3,17 +3,19 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { slackPlugin } from "../../../extensions/slack/src/channel.js"; +import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js"; 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 { loadWebMedia } from "../../web/media.js"; import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; import { runMessageAction } from "./message-action-runner.js"; -vi.mock("../../web/media.js", async () => { - const actual = await vi.importActual("../../web/media.js"); +vi.mock("../../../extensions/whatsapp/src/media.js", async () => { + const actual = await vi.importActual( + "../../../extensions/whatsapp/src/media.js", + ); return { ...actual, loadWebMedia: vi.fn(actual.loadWebMedia), @@ -154,8 +156,9 @@ describe("runMessageAction media behavior", () => { }); async function restoreRealMediaLoader() { - const actual = - await vi.importActual("../../web/media.js"); + const actual = await vi.importActual< + typeof import("../../../extensions/whatsapp/src/media.js") + >("../../../extensions/whatsapp/src/media.js"); vi.mocked(loadWebMedia).mockImplementation(actual.loadWebMedia); } diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index c703cd34d24..0b6ad1ba16e 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -478,6 +478,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise ({ deliveryMode: "direct", sendText: async ({ deps, to, text }) => { - const send = deps?.sendMSTeams; + const send = deps?.sendMSTeams as + | ((to: string, text: string, opts?: unknown) => Promise<{ messageId: string }>) + | undefined; if (!send) { throw new Error("sendMSTeams missing"); } @@ -312,7 +314,9 @@ const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboun return { channel: "msteams", ...result }; }, sendMedia: async ({ deps, to, text, mediaUrl }) => { - const send = deps?.sendMSTeams; + const send = deps?.sendMSTeams as + | ((to: string, text: string, opts?: unknown) => Promise<{ messageId: string }>) + | undefined; if (!send) { throw new Error("sendMSTeams missing"); } diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 3596bef59c9..d6e27b8a65f 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -39,6 +39,7 @@ type MessageSendParams = { mediaUrl?: string; mediaUrls?: string[]; gifPlayback?: boolean; + forceDocument?: boolean; accountId?: string; replyToId?: string; threadId?: string | number; @@ -245,6 +246,7 @@ export async function sendMessage(params: MessageSendParams): Promise { expect(resolved.to).toBe("63448508"); }); - const resolveHeartbeatTarget = ( - entry: Parameters[0]["entry"], - directPolicy?: "allow" | "block", - ) => + const resolveHeartbeatTarget = (entry: SessionEntry, directPolicy?: "allow" | "block") => resolveHeartbeatDeliveryTarget({ cfg: {}, entry, @@ -341,7 +339,7 @@ describe("resolveSessionDeliveryTarget", () => { const expectHeartbeatTarget = (params: { name: string; - entry: Parameters[0]["entry"]; + entry: SessionEntry; directPolicy?: "allow" | "block"; expectedChannel: string; expectedTo?: string; diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 52e98a3089d..9859176abbf 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,14 +1,17 @@ +import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; +import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; +import { + parseTelegramTarget, + resolveTelegramTargetChatType, +} from "../../../extensions/telegram/src/targets.js"; import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; -import { parseDiscordTarget } from "../../discord/targets.js"; import { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js"; import { normalizeAccountId } from "../../routing/session-key.js"; -import { parseSlackTarget } from "../../slack/targets.js"; -import { parseTelegramTarget, resolveTelegramTargetChatType } from "../../telegram/targets.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.js"; import type { DeliverableMessageChannel, diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 1007b2c6141..0b8cf1090bc 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -137,8 +137,8 @@ describe("run-node script", () => { it("returns the build exit code when the compiler step fails", async () => { await withTempDir(async (tmp) => { - const spawn = (cmd: string) => { - if (cmd === "pnpm") { + const spawn = (cmd: string, args: string[] = []) => { + if (cmd === "pnpm" || (cmd === "cmd.exe" && args.includes("pnpm"))) { return createExitedProcess(23); } return createExitedProcess(0); diff --git a/src/infra/stable-node-path.ts b/src/infra/stable-node-path.ts index 116b040eefa..9d4730f5cd7 100644 --- a/src/infra/stable-node-path.ts +++ b/src/infra/stable-node-path.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import path from "node:path"; /** * Homebrew Cellar paths (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node) @@ -8,15 +9,18 @@ import fs from "node:fs/promises"; * - Versioned formula "node@22": /opt/node@22/bin/node (keg-only) */ export async function resolveStableNodePath(nodePath: string): Promise { - const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/); + const cellarMatch = nodePath.match( + /^(.+?)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/, + ); if (!cellarMatch) { return nodePath; } const prefix = cellarMatch[1]; // e.g. /opt/homebrew const formula = cellarMatch[2]; // e.g. "node" or "node@22" + const pathModule = nodePath.includes("\\") ? path.win32 : path.posix; // Try the Homebrew opt symlink first — works for both default and versioned formulas. - const optPath = `${prefix}/opt/${formula}/bin/node`; + const optPath = pathModule.join(prefix, "opt", formula, "bin", "node"); try { await fs.access(optPath); return optPath; @@ -26,7 +30,7 @@ export async function resolveStableNodePath(nodePath: string): Promise { // For the default "node" formula, also try the direct bin symlink. if (formula === "node") { - const binPath = `${prefix}/bin/node`; + const binPath = pathModule.join(prefix, "bin", "node"); try { await fs.access(binPath); return binPath; diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 2aa50037e0c..96f3071bd57 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { listTelegramAccountIds } from "../../extensions/telegram/src/accounts.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -21,7 +22,6 @@ import { DEFAULT_MAIN_KEY, normalizeAgentId, } from "../routing/session-key.js"; -import { listTelegramAccountIds } from "../telegram/accounts.js"; import { isWithinDir } from "./path-safety.js"; import { ensureDir, diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index b95727febbf..54cda49a407 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -56,7 +56,7 @@ describe("update global helpers", () => { path.join(".bun", "install", "global", "node_modules"), ); await expect(resolveGlobalPackageRoot("npm", runCommand, 1000)).resolves.toBe( - "/tmp/npm-root/openclaw", + path.join("/tmp/npm-root", "openclaw"), ); }); diff --git a/src/media/fetch.telegram-network.test.ts b/src/media/fetch.telegram-network.test.ts index 5dbda7bc019..d7a4d8e217d 100644 --- a/src/media/fetch.telegram-network.test.ts +++ b/src/media/fetch.telegram-network.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveTelegramTransport, shouldRetryTelegramIpv4Fallback } from "../telegram/fetch.js"; +import { + resolveTelegramTransport, + shouldRetryTelegramIpv4Fallback, +} from "../../extensions/telegram/src/fetch.js"; import { fetchRemoteMedia } from "./fetch.js"; const undiciMocks = vi.hoisted(() => { @@ -90,7 +93,7 @@ describe("fetchRemoteMedia telegram network policy", () => { }); it("keeps explicit proxy routing for file downloads", async () => { - const { makeProxyFetch } = await import("../telegram/proxy.js"); + const { makeProxyFetch } = await import("../../extensions/telegram/src/proxy.js"); const lookupFn = vi.fn(async () => [ { address: "149.154.167.220", family: 4 }, ]) as unknown as LookupFn; diff --git a/src/media/load-options.ts b/src/media/load-options.ts index 69400e98ffb..da4545ae10e 100644 --- a/src/media/load-options.ts +++ b/src/media/load-options.ts @@ -1,11 +1,13 @@ export type OutboundMediaLoadParams = { maxBytes?: number; mediaLocalRoots?: readonly string[]; + optimizeImages?: boolean; }; export type OutboundMediaLoadOptions = { maxBytes?: number; localRoots?: readonly string[]; + optimizeImages?: boolean; }; export function resolveOutboundMediaLocalRoots( @@ -21,5 +23,6 @@ export function buildOutboundMediaLoadOptions( return { ...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}), ...(localRoots ? { localRoots } : {}), + ...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}), }; } diff --git a/src/media/outbound-attachment.ts b/src/media/outbound-attachment.ts index 155d234457b..374f0696b96 100644 --- a/src/media/outbound-attachment.ts +++ b/src/media/outbound-attachment.ts @@ -1,4 +1,4 @@ -import { loadWebMedia } from "../web/media.js"; +import { loadWebMedia } from "../../extensions/whatsapp/src/media.js"; import { buildOutboundMediaLoadOptions } from "./load-options.js"; import { saveMediaBuffer } from "./store.js"; diff --git a/src/node-host/invoke-browser.test.ts b/src/node-host/invoke-browser.test.ts index 6586b54b34e..4dc5b520d43 100644 --- a/src/node-host/invoke-browser.test.ts +++ b/src/node-host/invoke-browser.test.ts @@ -22,7 +22,7 @@ const configMocks = vi.hoisted(() => ({ const browserConfigMocks = vi.hoisted(() => ({ resolveBrowserConfig: vi.fn(() => ({ enabled: true, - defaultProfile: "chrome", + defaultProfile: "openclaw", })), })); @@ -45,7 +45,7 @@ describe("runBrowserProxyCommand", () => { }); browserConfigMocks.resolveBrowserConfig.mockReturnValue({ enabled: true, - defaultProfile: "chrome", + defaultProfile: "openclaw", }); controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true); }); @@ -70,12 +70,12 @@ describe("runBrowserProxyCommand", () => { JSON.stringify({ method: "GET", path: "/snapshot", - profile: "chrome", + profile: "chrome-relay", timeoutMs: 5, }), ), ).rejects.toThrow( - /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/, + /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome-relay; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/, ); }); @@ -100,12 +100,12 @@ describe("runBrowserProxyCommand", () => { JSON.stringify({ method: "GET", path: "/snapshot", - profile: "chrome-live", + profile: "user", timeoutMs: 5, }), ), ).rejects.toThrow( - /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome-live; status\(running=true, cdpHttp=true, cdpReady=false, transport=chrome-mcp\)/, + /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=user; status\(running=true, cdpHttp=true, cdpReady=false, transport=chrome-mcp\)/, ); }); @@ -120,7 +120,7 @@ describe("runBrowserProxyCommand", () => { JSON.stringify({ method: "POST", path: "/act", - profile: "chrome", + profile: "chrome-relay", timeoutMs: 50, }), ), diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 29cec3074aa..372e66f6521 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -41,6 +41,7 @@ type RuntimeFixture = { expectedArgvIndex: number; binName?: string; binNames?: string[]; + skipOnWin32?: boolean; }; type UnsafeRuntimeInvocationCase = { @@ -508,6 +509,7 @@ describe("hardenApprovedExecutionPaths", () => { scriptName: "run.ts", initialBody: 'console.log("SAFE");\n', expectedArgvIndex: 3, + skipOnWin32: true, }, { name: "pnpm exec double-dash tsx file", @@ -557,6 +559,9 @@ describe("hardenApprovedExecutionPaths", () => { for (const runtimeCase of mutableOperandCases) { it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => { + if (runtimeCase.skipOnWin32 && process.platform === "win32") { + return; + } const binNames = runtimeCase.binNames ?? (runtimeCase.binName ? [runtimeCase.binName] : ["bunx", "pnpm", "npm", "npx", "tsx"]); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index d183f9087c3..045897a5fc4 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -746,6 +746,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { security: "full", ask: "off", }); + if (process.platform === "win32") { + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval requires a stable executable path", + exact: true, + }); + return; + } expectCommandPinnedToCanonicalPath({ runCommand, expected: fs.realpathSync(script), @@ -779,6 +787,13 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ask: "off", }); expect(runCommand).not.toHaveBeenCalled(); + if (process.platform === "win32") { + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval requires a stable executable path", + exact: true, + }); + return; + } expectInvokeErrorMessage(sendInvokeResult, { message: "SYSTEM_RUN_DENIED: approval cwd changed before execution", exact: true, diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 7b01eec368b..02619206fce 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -68,13 +68,13 @@ export { export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; -export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js"; +export type { ParsedChatTarget } from "../../extensions/imessage/src/target-parsing-helpers.js"; export { parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, resolveServicePrefixedAllowTarget, resolveServicePrefixedTarget, -} from "../imessage/target-parsing-helpers.js"; +} from "../../extensions/imessage/src/target-parsing-helpers.js"; export { stripMarkdown } from "../line/markdown-to-line.js"; export { parseFiniteNumber } from "../infra/parse-finite-number.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index a0e9f25f3d8..ccd5de7a31a 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -1,3 +1,5 @@ +import { resolveIMessageAccount } from "../../extensions/imessage/src/accounts.js"; +import { resolveWhatsAppAccount } from "../../extensions/whatsapp/src/accounts.js"; import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -6,10 +8,8 @@ import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers. import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveIMessageAccount } from "../imessage/accounts.js"; import { normalizeAccountId } from "../routing/session-key.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; -import { resolveWhatsAppAccount } from "../web/accounts.js"; export function mapAllowFromEntries( allowFrom: Array | null | undefined, diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts index 537ec5d7662..d3cdaf38a22 100644 --- a/src/plugin-sdk/discord-send.ts +++ b/src/plugin-sdk/discord-send.ts @@ -1,4 +1,4 @@ -import type { DiscordSendResult } from "../discord/send.types.js"; +import type { DiscordSendResult } from "../../extensions/discord/src/send.types.js"; type DiscordSendOptionInput = { replyToId?: string | null; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 458bebabdc5..5b4897f46e9 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,15 +1,15 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; -export type { InspectedDiscordAccount } from "../discord/account-inspect.js"; -export type { ResolvedDiscordAccount } from "../discord/accounts.js"; +export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +export type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js"; export * from "./channel-plugin-common.js"; export { listDiscordAccountIds, resolveDefaultDiscordAccountId, resolveDiscordAccount, -} from "../discord/accounts.js"; -export { inspectDiscordAccount } from "../discord/account-inspect.js"; +} from "../../extensions/discord/src/accounts.js"; +export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, @@ -23,7 +23,7 @@ export { normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "../channels/plugins/normalize/discord.js"; -export { collectDiscordAuditChannelIds } from "../discord/audit.js"; +export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; export { collectDiscordStatusIssues } from "../channels/plugins/status-issues/discord.js"; export { @@ -41,7 +41,7 @@ export { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey, -} from "../discord/monitor/thread-bindings.js"; +} from "../../extensions/discord/src/monitor/thread-bindings.js"; export { buildComputedAccountStatusSnapshot, diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index dd181fee26c..4c3160e95cb 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -1,10 +1,10 @@ -export type { ResolvedIMessageAccount } from "../imessage/accounts.js"; +export type { ResolvedIMessageAccount } from "../../extensions/imessage/src/accounts.js"; export * from "./channel-plugin-common.js"; export { listIMessageAccountIds, resolveDefaultIMessageAccountId, resolveIMessageAccount, -} from "../imessage/accounts.js"; +} from "../../extensions/imessage/src/accounts.js"; export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index e734b79ec3f..eaae5d08968 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -65,12 +65,12 @@ export type { ThreadBindingManager, ThreadBindingRecord, ThreadBindingTargetKind, -} from "../discord/monitor/thread-bindings.js"; +} from "../../extensions/discord/src/monitor/thread-bindings.js"; export { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey, -} from "../discord/monitor/thread-bindings.js"; +} from "../../extensions/discord/src/monitor/thread-bindings.js"; export type { AcpRuntimeCapabilities, AcpRuntimeControl, @@ -651,10 +651,10 @@ export { resolveDefaultDiscordAccountId, resolveDiscordAccount, type ResolvedDiscordAccount, -} from "../discord/accounts.js"; -export { inspectDiscordAccount } from "../discord/account-inspect.js"; -export type { InspectedDiscordAccount } from "../discord/account-inspect.js"; -export { collectDiscordAuditChannelIds } from "../discord/audit.js"; +} from "../../extensions/discord/src/accounts.js"; +export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; export { discordOnboardingAdapter } from "../channels/plugins/onboarding/discord.js"; export { looksLikeDiscordTargetId, @@ -669,7 +669,7 @@ export { resolveDefaultIMessageAccountId, resolveIMessageAccount, type ResolvedIMessageAccount, -} from "../imessage/accounts.js"; +} from "../../extensions/imessage/src/accounts.js"; export { imessageOnboardingAdapter } from "../channels/plugins/onboarding/imessage.js"; export { looksLikeIMessageTargetId, @@ -683,11 +683,11 @@ export { resolveServicePrefixedAllowTarget, resolveServicePrefixedOrChatAllowTarget, resolveServicePrefixedTarget, -} from "../imessage/target-parsing-helpers.js"; +} from "../../extensions/imessage/src/target-parsing-helpers.js"; export type { ChatSenderAllowParams, ParsedChatTarget, -} from "../imessage/target-parsing-helpers.js"; +} from "../../extensions/imessage/src/target-parsing-helpers.js"; // Channel: Slack export { @@ -697,16 +697,19 @@ export { resolveSlackAccount, resolveSlackReplyToMode, type ResolvedSlackAccount, -} from "../slack/accounts.js"; -export { inspectSlackAccount } from "../slack/account-inspect.js"; -export type { InspectedSlackAccount } from "../slack/account-inspect.js"; -export { extractSlackToolSend, listSlackMessageActions } from "../slack/message-actions.js"; +} from "../../extensions/slack/src/accounts.js"; +export { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +export type { InspectedSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +export { + extractSlackToolSend, + listSlackMessageActions, +} from "../../extensions/slack/src/message-actions.js"; export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, } from "../channels/plugins/normalize/slack.js"; -export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; +export { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; // Channel: Telegram export { @@ -714,9 +717,9 @@ export { resolveDefaultTelegramAccountId, resolveTelegramAccount, type ResolvedTelegramAccount, -} from "../telegram/accounts.js"; -export { inspectTelegramAccount } from "../telegram/account-inspect.js"; -export type { InspectedTelegramAccount } from "../telegram/account-inspect.js"; +} from "../../extensions/telegram/src/accounts.js"; +export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; +export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js"; export { looksLikeTelegramTargetId, @@ -726,8 +729,8 @@ export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/t export { parseTelegramReplyToMessageId, parseTelegramThreadId, -} from "../telegram/outbound-params.js"; -export { type TelegramProbe } from "../telegram/probe.js"; +} from "../../extensions/telegram/src/outbound-params.js"; +export { type TelegramProbe } from "../../extensions/telegram/src/probe.js"; // Channel: Signal export { @@ -735,34 +738,16 @@ export { resolveDefaultSignalAccountId, resolveSignalAccount, type ResolvedSignalAccount, -} from "../signal/accounts.js"; +} from "../../extensions/signal/src/accounts.js"; export { signalOnboardingAdapter } from "../channels/plugins/onboarding/signal.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, } from "../channels/plugins/normalize/signal.js"; -// Channel: WhatsApp -export { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, - type ResolvedWhatsAppAccount, -} from "../web/accounts.js"; +// Channel: WhatsApp — WhatsApp-specific exports moved to extensions/whatsapp/src/ export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; -export { whatsappOnboardingAdapter } from "../channels/plugins/onboarding/whatsapp.js"; -export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; -export { - looksLikeWhatsAppTargetId, - normalizeWhatsAppAllowFromEntries, - normalizeWhatsAppMessagingTarget, -} from "../channels/plugins/normalize/whatsapp.js"; -export { - resolveWhatsAppGroupIntroHint, - resolveWhatsAppMentionStripPatterns, -} from "../channels/plugins/whatsapp-shared.js"; -export { collectWhatsAppStatusIssues } from "../channels/plugins/status-issues/whatsapp.js"; // Channel: BlueBubbles export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js"; @@ -798,7 +783,7 @@ export { export type { ProcessedLineMessage } from "../line/markdown-to-line.js"; // Media utilities -export { loadWebMedia, type WebMediaResult } from "../web/media.js"; +export { loadWebMedia, type WebMediaResult } from "../../extensions/whatsapp/src/media.js"; // Context engine export type { diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 32036f60a35..b73aec7c779 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -101,7 +101,7 @@ export { } from "./group-access.js"; export { formatDocsLink } from "../terminal/links.js"; export { sleep } from "../utils.js"; -export { loadWebMedia } from "../web/media.js"; +export { loadWebMedia } from "../../extensions/whatsapp/src/media.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { keepHttpServerTaskAlive } from "./channel-lifecycle.js"; export { withFileLock } from "./file-lock.js"; diff --git a/src/plugin-sdk/outbound-media.test.ts b/src/plugin-sdk/outbound-media.test.ts index bb1ef547973..bc56f2e6ea4 100644 --- a/src/plugin-sdk/outbound-media.test.ts +++ b/src/plugin-sdk/outbound-media.test.ts @@ -3,7 +3,7 @@ import { loadOutboundMediaFromUrl } from "./outbound-media.js"; const loadWebMediaMock = vi.hoisted(() => vi.fn()); -vi.mock("../web/media.js", () => ({ +vi.mock("../../extensions/whatsapp/src/media.js", () => ({ loadWebMedia: loadWebMediaMock, })); diff --git a/src/plugin-sdk/outbound-media.ts b/src/plugin-sdk/outbound-media.ts index 49e8b92f681..b1e89b17866 100644 --- a/src/plugin-sdk/outbound-media.ts +++ b/src/plugin-sdk/outbound-media.ts @@ -1,4 +1,4 @@ -import { loadWebMedia } from "../web/media.js"; +import { loadWebMedia } from "../../extensions/whatsapp/src/media.js"; export type OutboundMediaLoadOptions = { maxBytes?: number; diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index 32f291913a5..d8be4ddc9e4 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,11 +1,11 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; -export type { ResolvedSignalAccount } from "../signal/accounts.js"; +export type { ResolvedSignalAccount } from "../../extensions/signal/src/accounts.js"; export * from "./channel-plugin-common.js"; export { listSignalAccountIds, resolveDefaultSignalAccountId, resolveSignalAccount, -} from "../signal/accounts.js"; +} from "../../extensions/signal/src/accounts.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, diff --git a/src/plugin-sdk/slack-message-actions.ts b/src/plugin-sdk/slack-message-actions.ts index d9e0fa333a5..5470be86df1 100644 --- a/src/plugin-sdk/slack-message-actions.ts +++ b/src/plugin-sdk/slack-message-actions.ts @@ -1,7 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js"; import { readNumberParam, readStringParam } from "../agents/tools/common.js"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; -import { parseSlackBlocksInput } from "../slack/blocks-input.js"; type SlackActionInvoke = ( action: Record, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index c3aabde6fe2..740a0fabef0 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,15 +1,15 @@ export type { OpenClawConfig } from "../config/config.js"; -export type { InspectedSlackAccount } from "../slack/account-inspect.js"; -export type { ResolvedSlackAccount } from "../slack/accounts.js"; +export type { InspectedSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +export type { ResolvedSlackAccount } from "../../extensions/slack/src/accounts.js"; export * from "./channel-plugin-common.js"; export { listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, -} from "../slack/accounts.js"; -export { isSlackInteractiveRepliesEnabled } from "../slack/interactive-replies.js"; -export { inspectSlackAccount } from "../slack/account-inspect.js"; +} from "../../extensions/slack/src/accounts.js"; +export { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; +export { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js"; export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, @@ -23,8 +23,11 @@ export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, } from "../channels/plugins/normalize/slack.js"; -export { extractSlackToolSend, listSlackMessageActions } from "../slack/message-actions.js"; -export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; +export { + extractSlackToolSend, + listSlackMessageActions, +} from "../../extensions/slack/src/message-actions.js"; +export { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; export { diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index ccdcd1eeb5e..ce66f789857 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -84,8 +84,9 @@ describe("plugin-sdk subpath exports", () => { }); it("exports WhatsApp helpers", () => { - expect(typeof whatsappSdk.resolveWhatsAppAccount).toBe("function"); - expect(typeof whatsappSdk.whatsappOnboardingAdapter).toBe("object"); + // WhatsApp-specific functions (resolveWhatsAppAccount, whatsappOnboardingAdapter) moved to extensions/whatsapp/src/ + expect(typeof whatsappSdk.WhatsAppConfigSchema).toBe("object"); + expect(typeof whatsappSdk.resolveWhatsAppOutboundTarget).toBe("function"); }); it("exports LINE helpers", () => { diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index cdbfc317208..d816ca4125d 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -7,9 +7,9 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.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 { InspectedTelegramAccount } from "../telegram/account-inspect.js"; -export type { ResolvedTelegramAccount } from "../telegram/accounts.js"; -export type { TelegramProbe } from "../telegram/probe.js"; +export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; +export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; +export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; @@ -34,8 +34,8 @@ export { listTelegramAccountIds, resolveDefaultTelegramAccountId, resolveTelegramAccount, -} from "../telegram/accounts.js"; -export { inspectTelegramAccount } from "../telegram/account-inspect.js"; +} from "../../extensions/telegram/src/accounts.js"; +export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, @@ -51,7 +51,7 @@ export { export { parseTelegramReplyToMessageId, parseTelegramThreadId, -} from "../telegram/outbound-params.js"; +} from "../../extensions/telegram/src/outbound-params.js"; export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js"; export { sendTelegramPayloadMessages } from "../channels/plugins/outbound/telegram.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index c28ad976ff7..0227322f868 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,7 +1,6 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; -export type { ResolvedWhatsAppAccount } from "../web/accounts.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; @@ -17,11 +16,6 @@ export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { getChatChannelMeta } from "../channels/registry.js"; -export { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, -} from "../web/accounts.js"; export { formatWhatsAppConfigAllowFromEntries, resolveWhatsAppConfigAllowFrom, @@ -31,10 +25,6 @@ export { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; -export { - looksLikeWhatsAppTargetId, - normalizeWhatsAppMessagingTarget, -} from "../channels/plugins/normalize/whatsapp.js"; export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; export { @@ -51,8 +41,6 @@ export { resolveWhatsAppMentionStripPatterns, } from "../channels/plugins/whatsapp-shared.js"; export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; -export { whatsappOnboardingAdapter } from "../channels/plugins/onboarding/whatsapp.js"; -export { collectWhatsAppStatusIssues } from "../channels/plugins/status-issues/whatsapp.js"; export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; export { createActionGate, readStringParam } from "../agents/tools/common.js"; diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 13c87d70805..53a8f0ca936 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -1,3 +1,36 @@ +import { auditDiscordChannelPermissions } from "../../../extensions/discord/src/audit.js"; +import { + listDiscordDirectoryGroupsLive, + listDiscordDirectoryPeersLive, +} from "../../../extensions/discord/src/directory-live.js"; +import { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.js"; +import { probeDiscord } from "../../../extensions/discord/src/probe.js"; +import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js"; +import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; +import { sendMessageDiscord, sendPollDiscord } from "../../../extensions/discord/src/send.js"; +import { monitorIMessageProvider } from "../../../extensions/imessage/src/monitor.js"; +import { probeIMessage } from "../../../extensions/imessage/src/probe.js"; +import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js"; +import { monitorSignalProvider } from "../../../extensions/signal/src/index.js"; +import { probeSignal } from "../../../extensions/signal/src/probe.js"; +import { sendMessageSignal } from "../../../extensions/signal/src/send.js"; +import { + listSlackDirectoryGroupsLive, + listSlackDirectoryPeersLive, +} from "../../../extensions/slack/src/directory-live.js"; +import { monitorSlackProvider } from "../../../extensions/slack/src/index.js"; +import { probeSlack } from "../../../extensions/slack/src/probe.js"; +import { resolveSlackChannelAllowlist } from "../../../extensions/slack/src/resolve-channels.js"; +import { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js"; +import { sendMessageSlack } from "../../../extensions/slack/src/send.js"; +import { + auditTelegramGroupMembership, + collectTelegramUnmentionedGroupIds, +} from "../../../extensions/telegram/src/audit.js"; +import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js"; +import { probeTelegram } from "../../../extensions/telegram/src/probe.js"; +import { sendMessageTelegram, sendPollTelegram } from "../../../extensions/telegram/src/send.js"; +import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; import { handleSlackAction } from "../../agents/tools/slack-actions.js"; import { @@ -51,19 +84,6 @@ import { resolveStorePath, updateLastRoute, } from "../../config/sessions.js"; -import { auditDiscordChannelPermissions } from "../../discord/audit.js"; -import { - listDiscordDirectoryGroupsLive, - listDiscordDirectoryPeersLive, -} from "../../discord/directory-live.js"; -import { monitorDiscordProvider } from "../../discord/monitor.js"; -import { probeDiscord } from "../../discord/probe.js"; -import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js"; -import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js"; -import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; -import { monitorIMessageProvider } from "../../imessage/monitor.js"; -import { probeIMessage } from "../../imessage/probe.js"; -import { sendMessageIMessage } from "../../imessage/send.js"; import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js"; import { listLineAccountIds, @@ -93,26 +113,6 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js"; -import { monitorSignalProvider } from "../../signal/index.js"; -import { probeSignal } from "../../signal/probe.js"; -import { sendMessageSignal } from "../../signal/send.js"; -import { - listSlackDirectoryGroupsLive, - listSlackDirectoryPeersLive, -} from "../../slack/directory-live.js"; -import { monitorSlackProvider } from "../../slack/index.js"; -import { probeSlack } from "../../slack/probe.js"; -import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js"; -import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js"; -import { sendMessageSlack } from "../../slack/send.js"; -import { - auditTelegramGroupMembership, - collectTelegramUnmentionedGroupIds, -} from "../../telegram/audit.js"; -import { monitorTelegramProvider } from "../../telegram/monitor.js"; -import { probeTelegram } from "../../telegram/probe.js"; -import { sendMessageTelegram, sendPollTelegram } from "../../telegram/send.js"; -import { resolveTelegramToken } from "../../telegram/token.js"; import { createRuntimeWhatsApp } from "./runtime-whatsapp.js"; import type { PluginRuntime } from "./types.js"; diff --git a/src/plugins/runtime/runtime-media.ts b/src/plugins/runtime/runtime-media.ts index b52822e142b..90b28eea31e 100644 --- a/src/plugins/runtime/runtime-media.ts +++ b/src/plugins/runtime/runtime-media.ts @@ -1,8 +1,8 @@ +import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js"; import { isVoiceCompatibleAudio } from "../../media/audio.js"; import { mediaKindFromMime } from "../../media/constants.js"; import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js"; import { detectMime } from "../../media/mime.js"; -import { loadWebMedia } from "../../web/media.js"; import type { PluginRuntime } from "./types.js"; export function createRuntimeMedia(): PluginRuntime["media"] { diff --git a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts index eb38f5eda69..584b9d8d524 100644 --- a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts @@ -1 +1 @@ -export { loginWeb } from "../../web/login.js"; +export { loginWeb } from "../../../extensions/whatsapp/src/login.js"; diff --git a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts index e6be144c081..fca645e90b0 100644 --- a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts @@ -1 +1 @@ -export { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; +export { sendMessageWhatsApp, sendPollWhatsApp } from "../../../extensions/whatsapp/src/send.js"; diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index cf7daa6daa9..20d36a936f0 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -1,12 +1,12 @@ -import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js"; -import { getActiveWebListener } from "../../web/active-listener.js"; +import { getActiveWebListener } from "../../../extensions/whatsapp/src/active-listener.js"; import { getWebAuthAgeMs, logoutWeb, logWebSelfId, readWebSelfId, webAuthExists, -} from "../../web/auth-store.js"; +} from "../../../extensions/whatsapp/src/auth-store.js"; +import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js"; import type { PluginRuntime } from "./types.js"; const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async ( @@ -55,7 +55,9 @@ const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhat return handleWhatsAppAction(...args); }; -let webLoginQrPromise: Promise | null = null; +let webLoginQrPromise: Promise< + typeof import("../../../extensions/whatsapp/src/login-qr.js") +> | null = null; let webChannelPromise: Promise | null = null; let webOutboundPromise: Promise | null = null; @@ -75,7 +77,7 @@ function loadWebLogin() { } function loadWebLoginQr() { - webLoginQrPromise ??= import("../../web/login-qr.js"); + webLoginQrPromise ??= import("../../../extensions/whatsapp/src/login-qr.js"); return webLoginQrPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 0d1da0e24fd..bf2f2387d46 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -88,59 +88,59 @@ export type PluginRuntimeChannel = { }; discord: { messageActions: typeof import("../../channels/plugins/actions/discord.js").discordMessageActions; - auditChannelPermissions: typeof import("../../discord/audit.js").auditDiscordChannelPermissions; - listDirectoryGroupsLive: typeof import("../../discord/directory-live.js").listDiscordDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../discord/directory-live.js").listDiscordDirectoryPeersLive; - probeDiscord: typeof import("../../discord/probe.js").probeDiscord; - resolveChannelAllowlist: typeof import("../../discord/resolve-channels.js").resolveDiscordChannelAllowlist; - resolveUserAllowlist: typeof import("../../discord/resolve-users.js").resolveDiscordUserAllowlist; - sendMessageDiscord: typeof import("../../discord/send.js").sendMessageDiscord; - sendPollDiscord: typeof import("../../discord/send.js").sendPollDiscord; - monitorDiscordProvider: typeof import("../../discord/monitor.js").monitorDiscordProvider; + auditChannelPermissions: typeof import("../../../extensions/discord/src/audit.js").auditDiscordChannelPermissions; + listDirectoryGroupsLive: typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryPeersLive; + probeDiscord: typeof import("../../../extensions/discord/src/probe.js").probeDiscord; + resolveChannelAllowlist: typeof import("../../../extensions/discord/src/resolve-channels.js").resolveDiscordChannelAllowlist; + resolveUserAllowlist: typeof import("../../../extensions/discord/src/resolve-users.js").resolveDiscordUserAllowlist; + sendMessageDiscord: typeof import("../../../extensions/discord/src/send.js").sendMessageDiscord; + sendPollDiscord: typeof import("../../../extensions/discord/src/send.js").sendPollDiscord; + monitorDiscordProvider: typeof import("../../../extensions/discord/src/monitor.js").monitorDiscordProvider; }; slack: { - listDirectoryGroupsLive: typeof import("../../slack/directory-live.js").listSlackDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../slack/directory-live.js").listSlackDirectoryPeersLive; - probeSlack: typeof import("../../slack/probe.js").probeSlack; - resolveChannelAllowlist: typeof import("../../slack/resolve-channels.js").resolveSlackChannelAllowlist; - resolveUserAllowlist: typeof import("../../slack/resolve-users.js").resolveSlackUserAllowlist; - sendMessageSlack: typeof import("../../slack/send.js").sendMessageSlack; - monitorSlackProvider: typeof import("../../slack/index.js").monitorSlackProvider; + listDirectoryGroupsLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryPeersLive; + probeSlack: typeof import("../../../extensions/slack/src/probe.js").probeSlack; + resolveChannelAllowlist: typeof import("../../../extensions/slack/src/resolve-channels.js").resolveSlackChannelAllowlist; + resolveUserAllowlist: typeof import("../../../extensions/slack/src/resolve-users.js").resolveSlackUserAllowlist; + sendMessageSlack: typeof import("../../../extensions/slack/src/send.js").sendMessageSlack; + monitorSlackProvider: typeof import("../../../extensions/slack/src/index.js").monitorSlackProvider; handleSlackAction: typeof import("../../agents/tools/slack-actions.js").handleSlackAction; }; telegram: { - auditGroupMembership: typeof import("../../telegram/audit.js").auditTelegramGroupMembership; - collectUnmentionedGroupIds: typeof import("../../telegram/audit.js").collectTelegramUnmentionedGroupIds; - probeTelegram: typeof import("../../telegram/probe.js").probeTelegram; - resolveTelegramToken: typeof import("../../telegram/token.js").resolveTelegramToken; - sendMessageTelegram: typeof import("../../telegram/send.js").sendMessageTelegram; - sendPollTelegram: typeof import("../../telegram/send.js").sendPollTelegram; - monitorTelegramProvider: typeof import("../../telegram/monitor.js").monitorTelegramProvider; + auditGroupMembership: typeof import("../../../extensions/telegram/src/audit.js").auditTelegramGroupMembership; + collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/src/audit.js").collectTelegramUnmentionedGroupIds; + probeTelegram: typeof import("../../../extensions/telegram/src/probe.js").probeTelegram; + resolveTelegramToken: typeof import("../../../extensions/telegram/src/token.js").resolveTelegramToken; + sendMessageTelegram: typeof import("../../../extensions/telegram/src/send.js").sendMessageTelegram; + sendPollTelegram: typeof import("../../../extensions/telegram/src/send.js").sendPollTelegram; + monitorTelegramProvider: typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider; messageActions: typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions; }; signal: { - probeSignal: typeof import("../../signal/probe.js").probeSignal; - sendMessageSignal: typeof import("../../signal/send.js").sendMessageSignal; - monitorSignalProvider: typeof import("../../signal/index.js").monitorSignalProvider; + probeSignal: typeof import("../../../extensions/signal/src/probe.js").probeSignal; + sendMessageSignal: typeof import("../../../extensions/signal/src/send.js").sendMessageSignal; + monitorSignalProvider: typeof import("../../../extensions/signal/src/index.js").monitorSignalProvider; messageActions: typeof import("../../channels/plugins/actions/signal.js").signalMessageActions; }; imessage: { - monitorIMessageProvider: typeof import("../../imessage/monitor.js").monitorIMessageProvider; - probeIMessage: typeof import("../../imessage/probe.js").probeIMessage; - sendMessageIMessage: typeof import("../../imessage/send.js").sendMessageIMessage; + monitorIMessageProvider: typeof import("../../../extensions/imessage/src/monitor.js").monitorIMessageProvider; + probeIMessage: typeof import("../../../extensions/imessage/src/probe.js").probeIMessage; + sendMessageIMessage: typeof import("../../../extensions/imessage/src/send.js").sendMessageIMessage; }; whatsapp: { - getActiveWebListener: typeof import("../../web/active-listener.js").getActiveWebListener; - getWebAuthAgeMs: typeof import("../../web/auth-store.js").getWebAuthAgeMs; - logoutWeb: typeof import("../../web/auth-store.js").logoutWeb; - logWebSelfId: typeof import("../../web/auth-store.js").logWebSelfId; - readWebSelfId: typeof import("../../web/auth-store.js").readWebSelfId; - webAuthExists: typeof import("../../web/auth-store.js").webAuthExists; - sendMessageWhatsApp: typeof import("../../web/outbound.js").sendMessageWhatsApp; - sendPollWhatsApp: typeof import("../../web/outbound.js").sendPollWhatsApp; - loginWeb: typeof import("../../web/login.js").loginWeb; - startWebLoginWithQr: typeof import("../../web/login-qr.js").startWebLoginWithQr; - waitForWebLogin: typeof import("../../web/login-qr.js").waitForWebLogin; + getActiveWebListener: typeof import("../../../extensions/whatsapp/src/active-listener.js").getActiveWebListener; + getWebAuthAgeMs: typeof import("../../../extensions/whatsapp/src/auth-store.js").getWebAuthAgeMs; + logoutWeb: typeof import("../../../extensions/whatsapp/src/auth-store.js").logoutWeb; + logWebSelfId: typeof import("../../../extensions/whatsapp/src/auth-store.js").logWebSelfId; + readWebSelfId: typeof import("../../../extensions/whatsapp/src/auth-store.js").readWebSelfId; + webAuthExists: typeof import("../../../extensions/whatsapp/src/auth-store.js").webAuthExists; + sendMessageWhatsApp: typeof import("../../../extensions/whatsapp/src/send.js").sendMessageWhatsApp; + sendPollWhatsApp: typeof import("../../../extensions/whatsapp/src/send.js").sendPollWhatsApp; + loginWeb: typeof import("../../../extensions/whatsapp/src/login.js").loginWeb; + startWebLoginWithQr: typeof import("../../../extensions/whatsapp/src/login-qr.js").startWebLoginWithQr; + waitForWebLogin: typeof import("../../../extensions/whatsapp/src/login-qr.js").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; handleWhatsAppAction: typeof import("../../agents/tools/whatsapp-actions.js").handleWhatsAppAction; createLoginTool: typeof import("../../channels/plugins/agent-tools/whatsapp-login.js").createWhatsAppLoginTool; diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index bfbb747c9c4..c25c3afa86b 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -20,7 +20,7 @@ export type PluginRuntimeCore = { formatNativeDependencyHint: typeof import("./native-deps.js").formatNativeDependencyHint; }; media: { - loadWebMedia: typeof import("../../web/media.js").loadWebMedia; + loadWebMedia: typeof import("../../../extensions/whatsapp/src/media.js").loadWebMedia; detectMime: typeof import("../../media/mime.js").detectMime; mediaKindFromMime: typeof import("../../media/constants.js").mediaKindFromMime; isVoiceCompatibleAudio: typeof import("../../media/audio.js").isVoiceCompatibleAudio; diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index a46db8646a4..ca0e69722e3 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -1,3 +1,7 @@ +import { + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +} from "../../extensions/telegram/src/allow-from.js"; import { hasConfiguredUnavailableCredentialStatus, hasResolvedCredentialValue, @@ -6,10 +10,6 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import type { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; -import { - isNumericTelegramUserId, - normalizeTelegramAllowFromEntry, -} from "../channels/telegram/allow-from.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/config.js"; diff --git a/src/security/dm-policy-channel-smoke.test.ts b/src/security/dm-policy-channel-smoke.test.ts index 7a57317d628..189f169648c 100644 --- a/src/security/dm-policy-channel-smoke.test.ts +++ b/src/security/dm-policy-channel-smoke.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { isAllowedBlueBubblesSender } from "../../extensions/bluebubbles/src/targets.js"; import { isMattermostSenderAllowed } from "../../extensions/mattermost/src/mattermost/monitor-auth.js"; -import { isSignalSenderAllowed, type SignalSender } from "../signal/identity.js"; +import { isSignalSenderAllowed, type SignalSender } from "../../extensions/signal/src/identity.js"; import { DM_GROUP_ACCESS_REASON, resolveDmGroupAccessWithLists } from "./dm-policy-shared.js"; type ChannelSmokeCase = { diff --git a/src/shared/config-eval.test.ts b/src/shared/config-eval.test.ts index 7891c17142c..199c22a3462 100644 --- a/src/shared/config-eval.test.ts +++ b/src/shared/config-eval.test.ts @@ -86,6 +86,7 @@ describe("config-eval helpers", () => { }); it("caches binary lookups until PATH changes", () => { + setPlatform("linux"); process.env.PATH = ["/missing/bin", "/found/bin"].join(path.delimiter); const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation((candidate) => { if (String(candidate) === path.join("/found/bin", "tool")) { @@ -110,10 +111,14 @@ describe("config-eval helpers", () => { it("checks PATHEXT candidates on Windows", () => { setPlatform("win32"); - process.env.PATH = "/tools"; + const toolsDir = path.join(path.sep, "tools"); + process.env.PATH = toolsDir; process.env.PATHEXT = ".EXE;.CMD"; + const plainCandidate = path.join(toolsDir, "tool"); + const exeCandidate = path.join(toolsDir, "tool.EXE"); + const cmdCandidate = path.join(toolsDir, "tool.CMD"); const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation((candidate) => { - if (String(candidate) === "/tools/tool.CMD") { + if (String(candidate) === cmdCandidate) { return undefined; } throw new Error("missing"); @@ -121,9 +126,9 @@ describe("config-eval helpers", () => { expect(hasBinary("tool")).toBe(true); expect(accessSpy.mock.calls.map(([candidate]) => String(candidate))).toEqual([ - "/tools/tool", - "/tools/tool.EXE", - "/tools/tool.CMD", + plainCandidate, + exeCandidate, + cmdCandidate, ]); }); }); diff --git a/src/slack/monitor/slash-dispatch.runtime.ts b/src/slack/monitor/slash-dispatch.runtime.ts deleted file mode 100644 index 4c4832cff3b..00000000000 --- a/src/slack/monitor/slash-dispatch.runtime.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { resolveChunkMode } from "../../auto-reply/chunk.js"; -export { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -export { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; -export { resolveConversationLabel } from "../../channels/conversation-label.js"; -export { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; -export { recordInboundSessionMetaSafe } from "../../channels/session-meta.js"; -export { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; -export { resolveAgentRoute } from "../../routing/resolve-route.js"; -export { deliverSlackSlashReplies } from "./replies.js"; diff --git a/src/slack/monitor/slash-skill-commands.runtime.ts b/src/slack/monitor/slash-skill-commands.runtime.ts deleted file mode 100644 index 4d49d66190b..00000000000 --- a/src/slack/monitor/slash-skill-commands.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; diff --git a/src/telegram/proxy.ts b/src/telegram/proxy.ts deleted file mode 100644 index 3ac2bb10159..00000000000 --- a/src/telegram/proxy.ts +++ /dev/null @@ -1 +0,0 @@ -export { getProxyUrlFromFetch, makeProxyFetch } from "../infra/net/proxy-fetch.js"; diff --git a/src/test-utils/imessage-test-plugin.ts b/src/test-utils/imessage-test-plugin.ts index 5a072141644..962a1f7c33e 100644 --- a/src/test-utils/imessage-test-plugin.ts +++ b/src/test-utils/imessage-test-plugin.ts @@ -1,6 +1,6 @@ +import { normalizeIMessageHandle } from "../../extensions/imessage/src/targets.js"; import { imessageOutbound } from "../channels/plugins/outbound/imessage.js"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js"; -import { normalizeIMessageHandle } from "../imessage/targets.js"; import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; export const createIMessageTestPlugin = (params?: { diff --git a/src/test-utils/runtime-source-guardrail-scan.ts b/src/test-utils/runtime-source-guardrail-scan.ts index f5ef1b2100b..1e41fce3d3f 100644 --- a/src/test-utils/runtime-source-guardrail-scan.ts +++ b/src/test-utils/runtime-source-guardrail-scan.ts @@ -50,7 +50,13 @@ async function readRuntimeSourceFiles( if (!absolutePath) { continue; } - const source = await fs.readFile(absolutePath, "utf8"); + let source: string; + try { + source = await fs.readFile(absolutePath, "utf8"); + } catch { + // File tracked by git but deleted on disk (e.g. pending deletion). + continue; + } output[index] = { relativePath: path.relative(repoRoot, absolutePath), source, diff --git a/test/setup.ts b/test/setup.ts index 659956cc2c8..f0e1bdc4549 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -48,22 +48,7 @@ const [ installProcessWarningFilter(); const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { - switch (id) { - case "discord": - return deps?.sendDiscord; - case "slack": - return deps?.sendSlack; - case "telegram": - return deps?.sendTelegram; - case "whatsapp": - return deps?.sendWhatsApp; - case "signal": - return deps?.sendSignal; - case "imessage": - return deps?.sendIMessage; - default: - return undefined; - } + return deps?.[id] as ((...args: unknown[]) => Promise) | undefined; }; const createStubOutbound = ( @@ -75,7 +60,9 @@ const createStubOutbound = ( const send = pickSendFn(id, deps); if (send) { // oxlint-disable-next-line typescript/no-explicit-any - const result = await send(to, text, { verbose: false } as any); + const result = (await send(to, text, { verbose: false } as any)) as { + messageId: string; + }; return { channel: id, ...result }; } return { channel: id, messageId: "test" }; @@ -84,7 +71,9 @@ const createStubOutbound = ( const send = pickSendFn(id, deps); if (send) { // oxlint-disable-next-line typescript/no-explicit-any - const result = await send(to, text, { verbose: false, mediaUrl } as any); + const result = (await send(to, text, { verbose: false, mediaUrl } as any)) as { + messageId: string; + }; return { channel: id, ...result }; } return { channel: id, messageId: "test" }; diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index 7e2b76d745e..f938dcc8262 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -5,9 +5,9 @@ "declarationMap": false, "emitDeclarationOnly": true, "noEmit": false, - "noEmitOnError": true, + "noEmitOnError": false, "outDir": "dist/plugin-sdk", - "rootDir": "src", + "rootDir": ".", "tsBuildInfoFile": "dist/plugin-sdk/.tsbuildinfo" }, "include": [ diff --git a/tsdown.config.ts b/tsdown.config.ts index 1806debd474..acd4fc3e0c8 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -109,8 +109,8 @@ export default defineConfig([ "channels/plugins/actions/discord": "src/channels/plugins/actions/discord.ts", "channels/plugins/actions/signal": "src/channels/plugins/actions/signal.ts", "channels/plugins/actions/telegram": "src/channels/plugins/actions/telegram.ts", - "telegram/audit": "src/telegram/audit.ts", - "telegram/token": "src/telegram/token.ts", + "telegram/audit": "extensions/telegram/src/audit.ts", + "telegram/token": "extensions/telegram/src/token.ts", "line/accounts": "src/line/accounts.ts", "line/send": "src/line/send.ts", "line/template-messages": "src/line/template-messages.ts", diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index eaf94616032..a7ecb15c370 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -575,6 +575,7 @@ export function isCronSessionKey(key: string): boolean { type SessionOptionEntry = { key: string; label: string; + scopeLabel: string; title: string; }; @@ -625,10 +626,12 @@ export function resolveSessionOptionGroups( resolveAgentGroupLabel(state, parsed.agentId), ) : ensureGroup("other", "Other Sessions"); + const scopeLabel = parsed?.rest?.trim() || key; const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest); group.options.push({ key, label, + scopeLabel, title: key, }); }; @@ -643,6 +646,19 @@ export function resolveSessionOptionGroups( addOption(row.key); } addOption(sessionKey); + + for (const group of groups.values()) { + const counts = new Map(); + for (const option of group.options) { + counts.set(option.label, (counts.get(option.label) ?? 0) + 1); + } + for (const option of group.options) { + if ((counts.get(option.label) ?? 0) > 1 && option.scopeLabel !== option.label) { + option.label = `${option.label} · ${option.scopeLabel}`; + } + } + } + return Array.from(groups.values()); } @@ -673,18 +689,14 @@ function resolveSessionScopedOptionLabel( if (!row) { return base; } - const displayName = - typeof row.displayName === "string" && row.displayName.trim().length > 0 - ? row.displayName.trim() - : null; - const label = typeof row.label === "string" ? row.label.trim() : ""; - const showDisplayName = Boolean( - displayName && displayName !== key && displayName !== label && displayName !== base, - ); - if (!showDisplayName) { - return base; + + const label = row.label?.trim() || ""; + const displayName = row.displayName?.trim() || ""; + if ((label && label !== key) || (displayName && displayName !== key)) { + return resolveSessionDisplayName(key, row); } - return `${base} · ${displayName}`; + + return base; } type ThemeOption = { id: ThemeName; label: string; icon: string }; diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index c81d69c57ea..c6073a8e626 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -84,7 +84,7 @@ export type CronModelSuggestionsState = { export function supportsAnnounceDelivery( form: Pick, ) { - return form.sessionTarget === "isolated" && form.payloadKind === "agentTurn"; + return form.sessionTarget !== "main" && form.payloadKind === "agentTurn"; } export function normalizeCronFormState(form: CronFormState): CronFormState { diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 17ff4293afa..d9764a024e6 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -427,7 +427,7 @@ export type CronSchedule = | { kind: "every"; everyMs: number; anchorMs?: number } | { kind: "cron"; expr: string; tz?: string; staggerMs?: number }; -export type CronSessionTarget = "main" | "isolated"; +export type CronSessionTarget = "main" | "isolated" | "current" | `session:${string}`; export type CronWakeMode = "next-heartbeat" | "now"; export type CronPayload = diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index c01e2cf0f7d..2cd1709d841 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -33,7 +33,7 @@ export type CronFormState = { scheduleExact: boolean; staggerAmount: string; staggerUnit: "seconds" | "minutes"; - sessionTarget: "main" | "isolated"; + sessionTarget: "main" | "isolated" | "current" | `session:${string}`; wakeMode: "next-heartbeat" | "now"; payloadKind: "systemEvent" | "agentTurn"; payloadText: string; diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index b21936e0bb8..22c141c3919 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -647,4 +647,124 @@ describe("chat view", () => { expect(rerendered?.value).toBe("gpt-5-mini"); vi.unstubAllGlobals(); }); + + it("prefers the session label over displayName in the grouped chat session selector", () => { + const { state } = createChatHeaderState({ omitSessionFromList: true }); + state.sessionKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b"; + state.settings.sessionKey = state.sessionKey; + state.sessionsResult = { + ts: 0, + path: "", + count: 1, + defaults: { model: "gpt-5", contextTokens: null }, + sessions: [ + { + key: state.sessionKey, + kind: "direct", + updatedAt: null, + label: "cron-config-check", + displayName: "webchat:g-agent-main-subagent-4f2146de-887b-4176-9abe-91140082959b", + }, + ], + }; + const container = document.createElement("div"); + render(renderChatSessionSelect(state), container); + + const [sessionSelect] = Array.from(container.querySelectorAll("select")); + const labels = Array.from(sessionSelect?.querySelectorAll("option") ?? []).map((option) => + option.textContent?.trim(), + ); + + expect(labels).toContain("Subagent: cron-config-check"); + expect(labels).not.toContain(state.sessionKey); + expect(labels).not.toContain( + "subagent:4f2146de-887b-4176-9abe-91140082959b · webchat:g-agent-main-subagent-4f2146de-887b-4176-9abe-91140082959b", + ); + }); + + it("keeps a unique scoped fallback when the current grouped session is missing from sessions.list", () => { + const { state } = createChatHeaderState({ omitSessionFromList: true }); + state.sessionKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b"; + state.settings.sessionKey = state.sessionKey; + const container = document.createElement("div"); + render(renderChatSessionSelect(state), container); + + const [sessionSelect] = Array.from(container.querySelectorAll("select")); + const labels = Array.from(sessionSelect?.querySelectorAll("option") ?? []).map((option) => + option.textContent?.trim(), + ); + + expect(labels).toContain("subagent:4f2146de-887b-4176-9abe-91140082959b"); + expect(labels).not.toContain("Subagent:"); + }); + + it("keeps a unique scoped fallback when a grouped session row has no label or displayName", () => { + const { state } = createChatHeaderState({ omitSessionFromList: true }); + state.sessionKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b"; + state.settings.sessionKey = state.sessionKey; + state.sessionsResult = { + ts: 0, + path: "", + count: 1, + defaults: { model: "gpt-5", contextTokens: null }, + sessions: [ + { + key: state.sessionKey, + kind: "direct", + updatedAt: null, + }, + ], + }; + const container = document.createElement("div"); + render(renderChatSessionSelect(state), container); + + const [sessionSelect] = Array.from(container.querySelectorAll("select")); + const labels = Array.from(sessionSelect?.querySelectorAll("option") ?? []).map((option) => + option.textContent?.trim(), + ); + + expect(labels).toContain("subagent:4f2146de-887b-4176-9abe-91140082959b"); + expect(labels).not.toContain("Subagent:"); + }); + + it("disambiguates duplicate grouped labels with the scoped key suffix", () => { + const { state } = createChatHeaderState({ omitSessionFromList: true }); + state.sessionKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b"; + state.settings.sessionKey = state.sessionKey; + state.sessionsResult = { + ts: 0, + path: "", + count: 2, + defaults: { model: "gpt-5", contextTokens: null }, + sessions: [ + { + key: "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b", + kind: "direct", + updatedAt: null, + label: "cron-config-check", + }, + { + key: "agent:main:subagent:6fb8b84b-c31f-410f-b7df-1553c82e43c9", + kind: "direct", + updatedAt: null, + label: "cron-config-check", + }, + ], + }; + const container = document.createElement("div"); + render(renderChatSessionSelect(state), container); + + const [sessionSelect] = Array.from(container.querySelectorAll("select")); + const labels = Array.from(sessionSelect?.querySelectorAll("option") ?? []).map((option) => + option.textContent?.trim(), + ); + + expect(labels).toContain( + "Subagent: cron-config-check · subagent:4f2146de-887b-4176-9abe-91140082959b", + ); + expect(labels).toContain( + "Subagent: cron-config-check · subagent:6fb8b84b-c31f-410f-b7df-1553c82e43c9", + ); + expect(labels).not.toContain("Subagent: cron-config-check"); + }); }); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 836b72dbbcc..1509637b46f 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -374,7 +374,7 @@ export function renderCron(props: CronProps) { const statusSummary = summarizeSelection(selectedStatusLabels, t("cron.runs.allStatuses")); const deliverySummary = summarizeSelection(selectedDeliveryLabels, t("cron.runs.allDelivery")); const supportsAnnounce = - props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn"; + props.form.sessionTarget !== "main" && props.form.payloadKind === "agentTurn"; const selectedDeliveryMode = props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode; const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode); diff --git a/vitest.channels.config.ts b/vitest.channels.config.ts index 0b32080b1d5..aac2d9feeea 100644 --- a/vitest.channels.config.ts +++ b/vitest.channels.config.ts @@ -9,12 +9,12 @@ export default defineConfig({ test: { ...baseTest, include: [ - "src/telegram/**/*.test.ts", - "src/discord/**/*.test.ts", - "src/web/**/*.test.ts", + "extensions/telegram/**/*.test.ts", + "extensions/discord/**/*.test.ts", + "extensions/whatsapp/**/*.test.ts", "src/browser/**/*.test.ts", "src/line/**/*.test.ts", ], - exclude: [...(baseTest.exclude ?? []), "src/gateway/**", "extensions/**"], + exclude: [...(baseTest.exclude ?? []), "src/gateway/**"], }, }); diff --git a/vitest.config.ts b/vitest.config.ts index 2c14f06a1c6..5e0a192d5a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -183,16 +183,8 @@ export default defineConfig({ "src/tui/**", "src/wizard/**", // Channel surfaces are largely integration-tested (or manually validated). - "src/discord/**", - "src/imessage/**", - "src/signal/**", - "src/slack/**", "src/browser/**", "src/channels/web/**", - "src/telegram/index.ts", - "src/telegram/proxy.ts", - "src/telegram/webhook-set.ts", - "src/telegram/**", "src/webchat/**", "src/gateway/server.ts", "src/gateway/client.ts", diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index 8116da0592b..28d18d0250d 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -17,9 +17,9 @@ export default defineConfig({ ...exclude, "src/gateway/**", "extensions/**", - "src/telegram/**", - "src/discord/**", - "src/web/**", + "extensions/telegram/**", + "extensions/discord/**", + "extensions/whatsapp/**", "src/browser/**", "src/line/**", "src/agents/**",