diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..46ee3da04 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Report a problem or unexpected behavior in Clawdbot. +title: "[Bug]: " +labels: bug +--- + +## Summary +What went wrong? + +## Steps to reproduce +1. +2. +3. + +## Expected behavior +What did you expect to happen? + +## Actual behavior +What actually happened? + +## Environment +- Clawdbot version: +- OS: +- Install method (pnpm/npx/docker/etc): + +## Logs or screenshots +Paste relevant logs or add screenshots (redact secrets). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..26c896f06 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Onboarding + url: https://discord.gg/clawd + about: New to Clawdbot? Join Discord for setup guidance from Krill in #help. + - name: Support + url: https://discord.gg/clawd + about: Get help from Krill and the community on Discord in #help. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..742bf184e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature request +about: Suggest an idea or improvement for Clawdbot. +title: "[Feature]: " +labels: enhancement +--- + +## Summary +Describe the problem you are trying to solve or the opportunity you see. + +## Proposed solution +What would you like Clawdbot to do? + +## Alternatives considered +Any other approaches you have considered? + +## Additional context +Links, screenshots, or related issues. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6577bfcb..062e5736c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,32 +52,21 @@ jobs: exit 1 - name: Setup Node.js - if: matrix.runtime == 'node' uses: actions/setup-node@v4 with: node-version: 24 check-latest: true - name: Setup Bun - if: matrix.runtime == 'bun' uses: oven-sh/setup-bun@v2 with: - # bun.sh downloads currently fail with: - # "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400" - bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip" - - - name: Setup Node.js (tooling for bun) - if: matrix.runtime == 'bun' - uses: actions/setup-node@v4 - with: - node-version: 24 - check-latest: true + bun-version: latest - name: Runtime versions run: | node -v npm -v - if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi + bun -v - name: Capture node path run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" diff --git a/.gitignore b/.gitignore index 8bc3ebb67..85b83cb81 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules dist *.bun-build pnpm-lock.yaml +bun.lock +bun.lockb coverage .pnpm-store .worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 56ab3d72a..5712f572c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,12 @@ - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. ## Build, Test, and Development Commands +- Runtime baseline: Node **22+** (keep Node + Bun paths working). - Install deps: `pnpm install` -- Run CLI in dev: `pnpm clawdbot ...` (tsx entry) or `pnpm dev` for `src/index.ts`. +- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches). +- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun ` / `bunx `. +- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`. +- Node remains supported for running built output (`dist/*`) and production installs. - Type-check/build: `pnpm build` (tsc) - Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format) - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` @@ -30,6 +34,16 @@ - Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`). - Group related changes; avoid bundling unrelated refactors. - PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. +- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches. +- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`. +- When working on a PR: add a changelog entry with the PR number and thank the contributor. +- When working on an issue: reference the issue in the changelog entry. +- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes. +- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list. + +### PR Workflow (Review vs Land) +- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code. +- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). ## Security & Configuration Tips - Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out. diff --git a/CHANGELOG.md b/CHANGELOG.md index cb980435d..f695aa2f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,50 @@ ## Unreleased ### Breaking +- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack. + - Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots). + - New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`). + - To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`). + - Approve requests via `clawdbot pairing list --provider ` + `clawdbot pairing approve --provider ` (Telegram also supports `clawdbot telegram pairing ...`). - Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only). - Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. - Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. +- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. ### Fixes +- Heartbeat: default interval now 30m with a new default prompt + HEARTBEAT.md template. +- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327. +- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300. +- Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312. +- Browser: fix `browser snapshot`/`browser act` timeouts under Bun by patching Playwright’s CDP WebSocket selection. Thanks @azade-c for PR #307. +- Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324. +- Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322. +- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing. +- Auto-reply: require slash for control commands to avoid false triggers in normal text. +- Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331. +- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275. +- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. +- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure. +- Gateway/CLI: stop forcing localhost URL in remote mode so remote gateway config works. Thanks @oswalpalash for PR #293. +- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins. +- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding). +- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`). +- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`). +- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion). +- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings. +- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284. +- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp. +- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270. +- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266. +- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272. +- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75. +- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178. +- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. - Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth. - CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup). - Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running. +- Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229. - Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order. - Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save. - Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256. @@ -21,7 +56,9 @@ - Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use). - Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237. - TUI: migrate key handling to the updated pi-tui Key matcher API. +- TUI: add `/elev` alias for `/elevated`. - Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns). +- macOS: keep app connection settings local in remote mode to avoid overwriting gateway config. Thanks @ngutman for PR #310. - macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable). - macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`. - macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets. @@ -34,32 +71,58 @@ - Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`. - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. +- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`). - Control UI: show a reading indicator bubble while the assistant is responding. - Control UI: animate reading indicator dots (honors reduced-motion). - Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). +- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268. +- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274. +- Control UI: add Chat focus mode toggle to collapse header + sidebar. +- Control UI: tighten focus mode spacing (reduce top padding, add comfortable compose inset). +- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported). - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). +- Status: fix zero token counters for Anthropic (Opus) sessions by normalizing usage fields and ignoring empty usage updates. - Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split. - Block streaming: preserve leading indentation in block replies (lists, indented fences). - Docs: document systemd lingering and logged-in session requirements on macOS/Windows. -- Auto-reply: unify tool/block/final delivery across providers and apply consistent heartbeat/prefix handling. Thanks @MSch for PR #225 (superseded commit 92c953d0749143eb2a3f31f3cd6ad0e8eabf48c3). +- Auto-reply: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225. - Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman +- Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271. - WhatsApp: set sender E.164 for direct chats so owner commands work in DMs. - Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251. +- Slack: send typing status updates via assistant threads. Thanks @thewilloftheshadow for PR #320. +- Slack: fix Slack provider startup under Bun by using a named import for Bolt `App`. Thanks @snopoke for PR #299. - Discord: surface missing-permission hints (muted/role overrides) when replies fail. +- Discord: use channel IDs for DMs instead of user IDs. Thanks @VACInc for PR #261. - Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235. - Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249. -- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks @regenrek for PR #242. +- Auth: rotate across multiple OAuth profiles with cooldown tracking and email-based profile IDs. Thanks @mukhtharcm for PR #269. +- Auth: fix multi-account OAuth rotation so round-robin alternates instead of pinning to lastGood. Thanks @mukhtharcm for PR #281. +- Configure: stop auto-writing `auth.order` for newly added auth profiles (round-robin default unless explicitly pinned). +- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242. +- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241. +- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220. +- Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194. - Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs. - Auto-reply: reset corrupted Gemini sessions when function-call ordering breaks. Thanks @VACInc for PR #297. - Auto-reply: track compaction count in session status; verbose mode announces auto-compactions. +- Telegram: notify users when inbound media exceeds size limits. Thanks @jarvis-medmatic for PR #283. - Telegram: send GIF media as animations (auto-play) and improve filename sniffing. +- Bash tool: inherit gateway PATH so Nix-provided tools resolve during commands. Thanks @joshp123 for PR #202. +- Delivery chunking: keep Markdown fenced code blocks valid when splitting long replies (close + reopen fences). +- Auth: prefer OAuth profiles over API keys during round-robin selection (prevents OAuth “lost after one message” when both are configured). +- Models: extend `clawdbot models` status output with a masked auth overview (profiles, env sources, and OAuth counts). ### Maintenance +- Agent: add `skipBootstrap` config option. Thanks @onutc for PR #292. +- UI: add favicon.ico derived from the macOS app icon. Thanks @jeffersonwarrior for PR #305. +- Tooling: replace tsx with bun for TypeScript execution. Thanks @obviyus for PR #278. - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome. - Skills: add CodexBar model usage helper with macOS requirement metadata. - Skills: add 1Password CLI skill with op examples. - Lint: organize imports and wrap long lines in reply commands. +- Refactor: centralize group allowlist/mention policy across providers. - Deps: update to latest across the repo. ## 2026.1.5-3 @@ -86,6 +149,7 @@ - Agent tools: new `image` tool routed to the image model (when configured). - Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`). - Docs: document built-in model shorthands + precedence (user config wins). +- Bun: optional local install/build workflow without maintaining a Bun lockfile (see `docs/bun.md`). ### Fixes - Control UI: render Markdown in tool result cards. @@ -104,11 +168,16 @@ - Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`. - Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env). - Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). -- Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`). +- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`). - Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). - Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. - Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. - Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler. +- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode. + +## 2026.1.5 + +### Fixes - Control UI: render Markdown in chat messages (sanitized). diff --git a/Dockerfile b/Dockerfile index 10c4f3614..8fc107f10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,9 @@ FROM node:22-bookworm +# Install Bun (required for build scripts) +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:${PATH}" + RUN corepack enable WORKDIR /app diff --git a/README.md b/README.md index 3ec3328be..23d1827b9 100644 --- a/README.md +++ b/README.md @@ -16,109 +16,127 @@

**Clawdbot** is a *personal AI assistant* you run on your own devices. -It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. +It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. -Website: [https://clawdbot.com](https://clawdbot.com) · Docs: [https://docs.clawdbot.com](https://docs.clawdbot.com/) · Showcase: [https://docs.clawdbot.com/showcase](https://docs.clawdbot.com/showcase) · FAQ: [https://docs.clawdbot.com/faq](https://docs.clawdbot.com/faq) · Wizard: [https://docs.clawdbot.com/wizard](https://docs.clawdbot.com/wizard) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://docs.clawdbot.com/docker](https://docs.clawdbot.com/docker) · Discord: [https://discord.gg/clawd](https://discord.gg/clawd) +[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · Getting Started: [https://docs.clawd.bot/getting-started](https://docs.clawd.bot/getting-started) · Updating: [https://docs.clawd.bot/updating](https://docs.clawd.bot/updating) · Showcase: [https://docs.clawd.bot/showcase](https://docs.clawd.bot/showcase) · FAQ: [https://docs.clawd.bot/faq](https://docs.clawd.bot/faq) · Wizard: [https://docs.clawd.bot/wizard](https://docs.clawd.bot/wizard) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://docs.clawd.bot/docker](https://docs.clawd.bot/docker) · Discord: [https://discord.gg/clawd](https://discord.gg/clawd) Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**. Works with npm, pnpm, or bun. +New install? Start here: https://docs.clawd.bot/getting-started **Subscriptions (OAuth):** - **Anthropic** (Claude Pro/Max) - **OpenAI** (ChatGPT/Codex) -Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawdbot.com/onboarding). +Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/onboarding). + +## Models (selection + auth) + +- Models config + CLI: https://docs.clawd.bot/models +- Auth profile rotation (OAuth vs API keys) + fallbacks: https://docs.clawd.bot/model-failover ## Recommended setup (from source) -Do **not** download prebuilt binaries. Build from source. +Do **not** download prebuilt binaries. Run from source. + +Prefer **Bun**. `pnpm` is also supported (see https://docs.clawd.bot/getting-started). ```bash # Clone this repo git clone https://github.com/clawdbot/clawdbot.git cd clawdbot -pnpm install -pnpm build -pnpm ui:build -pnpm clawdbot onboard +bun install +bun run ui:install +bun run ui:build +bun run build +bun run clawdbot onboard ``` -## Quick start (from source) +Note: `bun run clawdbot ...` runs TypeScript directly. `bun run build` produces `dist/` for running via Node / the packaged `clawdbot` binary. -Runtime: **Node ≥22** + **pnpm**. +## Quick start (TL;DR) + +Runtime: **Node ≥22**. + +Full beginner guide (auth, pairing, providers): https://docs.clawd.bot/getting-started ```bash -pnpm install -pnpm build -pnpm ui:build +bun run clawdbot onboard -# Recommended: run the onboarding wizard -pnpm clawdbot onboard - -# Link WhatsApp (stores creds in ~/.clawdbot/credentials) -pnpm clawdbot login - -# Start the gateway -pnpm clawdbot gateway --port 18789 --verbose +bun run clawdbot gateway --port 18789 --verbose # Dev loop (auto-reload on TS changes) -pnpm gateway:watch +bun run gateway:watch # Send a message -pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot" +bun run clawdbot send --to +1234567890 --message "Hello from Clawdbot" # Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord) -pnpm clawdbot agent --message "Ship checklist" --thinking high +bun run clawdbot agent --message "Ship checklist" --thinking high ``` -Upgrading? `clawdbot doctor`. +Upgrading? https://docs.clawd.bot/updating (and run `clawdbot doctor`). -If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`). +If you run from source, prefer `bun run clawdbot …` or `pnpm clawdbot …` (not global `clawdbot`). + +## Security defaults (DM access) + +Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**. + +Full security guide: https://docs.clawd.bot/security + +Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack: +- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message. +- Approve with: `clawdbot pairing approve --provider ` (then the sender is added to a local allowlist store). +- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`). + +Run `clawdbot doctor` to surface risky/misconfigured DM policies. ## Highlights -- **[Local-first Gateway](https://docs.clawdbot.com/gateway)** — single control plane for sessions, providers, tools, and events. -- **[Multi-surface inbox](https://docs.clawdbot.com/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android. -- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs. -- **[Live Canvas](https://docs.clawdbot.com/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui). -- **[First-class tools](https://docs.clawdbot.com/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. -- **[Companion apps](https://docs.clawdbot.com/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawdbot.com/nodes). -- **[Onboarding](https://docs.clawdbot.com/wizard) + [skills](https://docs.clawdbot.com/skills)** — wizard-driven setup with bundled/managed/workspace skills. +- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events. +- **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android. +- **[Multi-agent routing](https://docs.clawd.bot/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions). +- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs. +- **[Live Canvas](https://docs.clawd.bot/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui). +- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. +- **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes). +- **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills. ## Everything we built so far ### Core platform -- [Gateway WS control plane](https://docs.clawdbot.com/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawdbot.com/web), and [Canvas host](https://docs.clawdbot.com/refactor/canvas-a2ui). -- [CLI surface](https://docs.clawdbot.com/agent-send): gateway, agent, send, [wizard](https://docs.clawdbot.com/wizard), and [doctor](https://docs.clawdbot.com/doctor). -- [Pi agent runtime](https://docs.clawdbot.com/agent) in RPC mode with tool streaming and block streaming. -- [Session model](https://docs.clawdbot.com/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawdbot.com/groups). -- [Media pipeline](https://docs.clawdbot.com/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawdbot.com/audio). +- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui). +- [CLI surface](https://docs.clawd.bot/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/wizard), and [doctor](https://docs.clawd.bot/doctor). +- [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming. +- [Session model](https://docs.clawd.bot/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/groups). +- [Media pipeline](https://docs.clawd.bot/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/audio). -### Surfaces + providers -- [Providers](https://docs.clawdbot.com/surface): [WhatsApp](https://docs.clawdbot.com/whatsapp) (Baileys), [Telegram](https://docs.clawdbot.com/telegram) (grammY), [Slack](https://docs.clawdbot.com/slack) (Bolt), [Discord](https://docs.clawdbot.com/discord) (discord.js), [Signal](https://docs.clawdbot.com/signal) (signal-cli), [iMessage](https://docs.clawdbot.com/imessage) (imsg), [WebChat](https://docs.clawdbot.com/webchat). -- [Group routing](https://docs.clawdbot.com/group-messages): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://docs.clawdbot.com/surface). +### Providers +- [Providers](https://docs.clawd.bot/surface): [WhatsApp](https://docs.clawd.bot/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/telegram) (grammY), [Slack](https://docs.clawd.bot/slack) (Bolt), [Discord](https://docs.clawd.bot/discord) (discord.js), [Signal](https://docs.clawd.bot/signal) (signal-cli), [iMessage](https://docs.clawd.bot/imessage) (imsg), [WebChat](https://docs.clawd.bot/webchat). +- [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface). ### Apps + nodes -- [macOS app](https://docs.clawdbot.com/macos): menu bar control plane, [Voice Wake](https://docs.clawdbot.com/voicewake)/PTT, [Talk Mode](https://docs.clawdbot.com/talk) overlay, [WebChat](https://docs.clawdbot.com/webchat), debug tools, [remote gateway](https://docs.clawdbot.com/remote) control. -- [iOS node](https://docs.clawdbot.com/ios): [Canvas](https://docs.clawdbot.com/mac/canvas), [Voice Wake](https://docs.clawdbot.com/voicewake), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, Bonjour pairing. -- [Android node](https://docs.clawdbot.com/android): [Canvas](https://docs.clawdbot.com/mac/canvas), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, optional SMS. -- [macOS node mode](https://docs.clawdbot.com/nodes): system.run/notify + canvas/camera exposure. +- [macOS app](https://docs.clawd.bot/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/talk) overlay, [WebChat](https://docs.clawd.bot/webchat), debug tools, [remote gateway](https://docs.clawd.bot/remote) control. +- [iOS node](https://docs.clawd.bot/ios): [Canvas](https://docs.clawd.bot/mac/canvas), [Voice Wake](https://docs.clawd.bot/voicewake), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, Bonjour pairing. +- [Android node](https://docs.clawd.bot/android): [Canvas](https://docs.clawd.bot/mac/canvas), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, optional SMS. +- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure. ### Tools + automation -- [Browser control](https://docs.clawdbot.com/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles. -- [Canvas](https://docs.clawdbot.com/mac/canvas): [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui) push/reset, eval, snapshot. -- [Nodes](https://docs.clawdbot.com/nodes): camera snap/clip, screen record, [location.get](https://docs.clawdbot.com/location-command), notifications. -- [Cron + wakeups](https://docs.clawdbot.com/cron); [webhooks](https://docs.clawdbot.com/webhook); [Gmail Pub/Sub](https://docs.clawdbot.com/gmail-pubsub). -- [Skills platform](https://docs.clawdbot.com/skills): bundled, managed, and workspace skills with install gating + UI. +- [Browser control](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles. +- [Canvas](https://docs.clawd.bot/mac/canvas): [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui) push/reset, eval, snapshot. +- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/location-command), notifications. +- [Cron + wakeups](https://docs.clawd.bot/cron); [webhooks](https://docs.clawd.bot/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub). +- [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI. ### Ops + packaging -- [Control UI](https://docs.clawdbot.com/web) + [WebChat](https://docs.clawdbot.com/webchat) served directly from the Gateway. -- [Tailscale Serve/Funnel](https://docs.clawdbot.com/tailscale) or [SSH tunnels](https://docs.clawdbot.com/remote) with token/password auth. -- [Nix mode](https://docs.clawdbot.com/nix) for declarative config; [Docker](https://docs.clawdbot.com/docker)-based installs. -- [Doctor](https://docs.clawdbot.com/doctor) migrations, [logging](https://docs.clawdbot.com/logging). +- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway. +- [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale) or [SSH tunnels](https://docs.clawd.bot/remote) with token/password auth. +- [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs. +- [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging). ## How it works (short) @@ -140,12 +158,12 @@ WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat ## Key subsystems -- **[Gateway WebSocket network](https://docs.clawdbot.com/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawdbot.com/gateway)). -- **[Tailscale exposure](https://docs.clawdbot.com/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawdbot.com/remote)). -- **[Browser control](https://docs.clawdbot.com/browser)** — clawd‑managed Chrome/Chromium with CDP control. -- **[Canvas + A2UI](https://docs.clawdbot.com/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui)). -- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always‑on speech and continuous conversation. -- **[Nodes](https://docs.clawdbot.com/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`. +- **[Gateway WebSocket network](https://docs.clawd.bot/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)). +- **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)). +- **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control. +- **[Canvas + A2UI](https://docs.clawd.bot/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)). +- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation. +- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`. ## Tailscale access (Gateway dashboard) @@ -161,7 +179,7 @@ Notes: - Funnel refuses to start unless `gateway.auth.mode: "password"` is set. - Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown. -Details: [Tailscale guide](https://docs.clawdbot.com/tailscale) · [Web surfaces](https://docs.clawdbot.com/web) +Details: [Tailscale guide](https://docs.clawd.bot/tailscale) · [Web surfaces](https://docs.clawd.bot/web) ## Remote Gateway (Linux is great) @@ -171,7 +189,7 @@ It’s perfectly fine to run the Gateway on a small Linux instance. Clients (mac - **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`. In short: bash runs where the Gateway lives; device actions run where the device lives. -Details: [Remote access](https://docs.clawdbot.com/remote) · [Nodes](https://docs.clawdbot.com/nodes) · [Security](https://docs.clawdbot.com/security) +Details: [Remote access](https://docs.clawd.bot/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/security) ## macOS permissions via the Gateway protocol @@ -186,7 +204,7 @@ Elevated bash (host permissions) is separate from macOS TCC: - Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted. - Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`. -Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.clawdbot.com/macos) · [Gateway protocol](https://docs.clawdbot.com/architecture) +Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/macos) · [Gateway protocol](https://docs.clawd.bot/architecture) ## Agent to Agent (sessions_* tools) @@ -195,7 +213,7 @@ Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.cl - `sessions_history` — fetch transcript logs for a session. - `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`). -Details: [Session tools](https://docs.clawdbot.com/session-tool) +Details: [Session tools](https://docs.clawd.bot/session-tool) ## Skills registry (ClawdHub) @@ -241,13 +259,13 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see - Voice trigger forwarding + Canvas surface. - Controlled via `clawdbot nodes …`. -Runbook: [iOS connect](https://docs.clawdbot.com/ios). +Runbook: [iOS connect](https://docs.clawd.bot/ios). ### Android node (optional) - Pairs via the same Bridge + pairing flow as iOS. - Exposes Canvas, Camera, and Screen capture commands. -- Runbook: [Android connect](https://docs.clawdbot.com/android). +- Runbook: [Android connect](https://docs.clawd.bot/android). ## Agent workspace + skills @@ -267,25 +285,26 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults): } ``` -[Full configuration reference (all keys + examples).](https://docs.clawdbot.com/configuration) +[Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration) ## Security model (important) - **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you. - **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions. -- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`. +- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`. -Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandboxing](https://docs.clawdbot.com/docker) · [Sandbox config](https://docs.clawdbot.com/configuration) +Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration) -### [WhatsApp](https://docs.clawdbot.com/whatsapp) +### [WhatsApp](https://docs.clawd.bot/whatsapp) - Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`). - Allowlist who can talk to the assistant via `whatsapp.allowFrom`. +- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all. -### [Telegram](https://docs.clawdbot.com/telegram) +### [Telegram](https://docs.clawd.bot/telegram) - Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins). -- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed. +- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed. ```json5 { @@ -295,14 +314,14 @@ Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandb } ``` -### [Slack](https://docs.clawdbot.com/slack) +### [Slack](https://docs.clawd.bot/slack) - Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`). -### [Discord](https://docs.clawdbot.com/discord) +### [Discord](https://docs.clawd.bot/discord) - Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). -- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed. +- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed. ```json5 { @@ -312,15 +331,16 @@ Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandb } ``` -### [Signal](https://docs.clawdbot.com/signal) +### [Signal](https://docs.clawd.bot/signal) - Requires `signal-cli` and a `signal` config section. -### [iMessage](https://docs.clawdbot.com/imessage) +### [iMessage](https://docs.clawd.bot/imessage) - macOS only; Messages must be signed in. +- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all. -### [WebChat](https://docs.clawdbot.com/webchat) +### [WebChat](https://docs.clawd.bot/webchat) - Uses the Gateway WebSocket; no separate WebChat port/config. @@ -339,69 +359,69 @@ Browser control (optional): ## Docs Use these when you’re past the onboarding flow and want the deeper reference. -- [Start with the docs index for navigation and “what’s where.”](https://docs.clawdbot.com/) -- [Read the architecture overview for the gateway + protocol model.](https://docs.clawdbot.com/architecture) -- [Use the full configuration reference when you need every key and example.](https://docs.clawdbot.com/configuration) -- [Run the Gateway by the book with the operational runbook.](https://docs.clawdbot.com/gateway) -- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawdbot.com/web) -- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawdbot.com/remote) -- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawdbot.com/wizard) -- [Wire external triggers via the webhook surface.](https://docs.clawdbot.com/webhook) -- [Set up Gmail Pub/Sub triggers.](https://docs.clawdbot.com/gmail-pubsub) -- [Learn the macOS menu bar companion details.](https://docs.clawdbot.com/mac/menu-bar) -- [Platform guides: Windows](https://docs.clawdbot.com/windows), [Linux](https://docs.clawdbot.com/linux), [macOS](https://docs.clawdbot.com/macos), [iOS](https://docs.clawdbot.com/ios), [Android](https://docs.clawdbot.com/android) -- [Debug common failures with the troubleshooting guide.](https://docs.clawdbot.com/troubleshooting) -- [Review security guidance before exposing anything.](https://docs.clawdbot.com/security) +- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot) +- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture) +- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration) +- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway) +- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web) +- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote) +- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard) +- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook) +- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub) +- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar) +- [Platform guides: Windows](https://docs.clawd.bot/windows), [Linux](https://docs.clawd.bot/linux), [macOS](https://docs.clawd.bot/macos), [iOS](https://docs.clawd.bot/ios), [Android](https://docs.clawd.bot/android) +- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting) +- [Review security guidance before exposing anything.](https://docs.clawd.bot/security) ## Advanced docs (discovery + control) -- [Discovery + transports](https://docs.clawdbot.com/discovery) -- [Bonjour/mDNS](https://docs.clawdbot.com/bonjour) -- [Gateway pairing](https://docs.clawdbot.com/gateway/pairing) -- [Remote gateway README](https://docs.clawdbot.com/remote-gateway-readme) -- [Control UI](https://docs.clawdbot.com/control-ui) -- [Dashboard](https://docs.clawdbot.com/dashboard) +- [Discovery + transports](https://docs.clawd.bot/discovery) +- [Bonjour/mDNS](https://docs.clawd.bot/bonjour) +- [Gateway pairing](https://docs.clawd.bot/gateway/pairing) +- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme) +- [Control UI](https://docs.clawd.bot/control-ui) +- [Dashboard](https://docs.clawd.bot/dashboard) ## Operations & troubleshooting -- [Health checks](https://docs.clawdbot.com/health) -- [Gateway lock](https://docs.clawdbot.com/gateway-lock) -- [Background process](https://docs.clawdbot.com/background-process) -- [Browser troubleshooting (Linux)](https://docs.clawdbot.com/browser-linux-troubleshooting) -- [Logging](https://docs.clawdbot.com/logging) +- [Health checks](https://docs.clawd.bot/health) +- [Gateway lock](https://docs.clawd.bot/gateway-lock) +- [Background process](https://docs.clawd.bot/background-process) +- [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting) +- [Logging](https://docs.clawd.bot/logging) ## Deep dives -- [Agent loop](https://docs.clawdbot.com/agent-loop) -- [Presence](https://docs.clawdbot.com/presence) -- [TypeBox schemas](https://docs.clawdbot.com/typebox) -- [RPC adapters](https://docs.clawdbot.com/rpc) -- [Queue](https://docs.clawdbot.com/queue) +- [Agent loop](https://docs.clawd.bot/agent-loop) +- [Presence](https://docs.clawd.bot/presence) +- [TypeBox schemas](https://docs.clawd.bot/typebox) +- [RPC adapters](https://docs.clawd.bot/rpc) +- [Queue](https://docs.clawd.bot/queue) ## Workspace & skills -- [Skills config](https://docs.clawdbot.com/skills-config) -- [Default AGENTS](https://docs.clawdbot.com/AGENTS.default) -- [Templates: AGENTS](https://docs.clawdbot.com/templates/AGENTS) -- [Templates: BOOTSTRAP](https://docs.clawdbot.com/templates/BOOTSTRAP) -- [Templates: IDENTITY](https://docs.clawdbot.com/templates/IDENTITY) -- [Templates: SOUL](https://docs.clawdbot.com/templates/SOUL) -- [Templates: TOOLS](https://docs.clawdbot.com/templates/TOOLS) -- [Templates: USER](https://docs.clawdbot.com/templates/USER) +- [Skills config](https://docs.clawd.bot/skills-config) +- [Default AGENTS](https://docs.clawd.bot/AGENTS.default) +- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS) +- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP) +- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY) +- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL) +- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS) +- [Templates: USER](https://docs.clawd.bot/templates/USER) ## Platform internals -- [macOS dev setup](https://docs.clawdbot.com/mac/dev-setup) -- [macOS menu bar](https://docs.clawdbot.com/mac/menu-bar) -- [macOS voice wake](https://docs.clawdbot.com/mac/voicewake) -- [iOS node](https://docs.clawdbot.com/ios) -- [Android node](https://docs.clawdbot.com/android) -- [Windows app](https://docs.clawdbot.com/windows) -- [Linux app](https://docs.clawdbot.com/linux) +- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup) +- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar) +- [macOS voice wake](https://docs.clawd.bot/mac/voicewake) +- [iOS node](https://docs.clawd.bot/ios) +- [Android node](https://docs.clawd.bot/android) +- [Windows app](https://docs.clawd.bot/windows) +- [Linux app](https://docs.clawd.bot/linux) ## Email hooks (Gmail) -[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawdbot.com/gmail-pubsub) +[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawd.bot/gmail-pubsub) Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you don’t want auto-start. @@ -431,5 +451,7 @@ Thanks to all clawtributors: mbelinky julianengel CashWilliams omniwired jverdi Syhids meaningfool rafaelreis-r wstock vsabavat scald sreekaransrinath ratulsarna osolmaz conhecendocontato hrdwdmrbl jayhickey jamesgroat gtsifrikas djangonavarro220 azade-c andranik-sahakyan - adamgall jalehman jarvis-medmatic mneves75 regenrek tobiasbischoff MSch obviyus + adamgall jalehman jarvis-medmatic mneves75 regenrek tobiasbischoff MSch obviyus dbhurley + Asleep123 Iamadig imfing kitze nachoiacovino VACInc cash-echo-bot claude kiranjd pcty-nextgen-service-account minghinmatthewlam + ngutman onutc oswalpalash snopoke ManuelHettich loukotal hugobarauna AbhisekBasu1 emanuelst

diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index 02f51aa54..8811ef58f 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -416,7 +416,8 @@ final class AppState { : nil Task { @MainActor in - var root = await ConfigStore.load() + // Keep app-only connection settings local to avoid overwriting remote gateway config. + var root = ClawdbotConfigFile.loadDict() var gateway = root["gateway"] as? [String: Any] ?? [:] var changed = false @@ -446,8 +447,12 @@ final class AppState { } guard changed else { return } - root["gateway"] = gateway - try? await ConfigStore.save(root) + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + ClawdbotConfigFile.saveDict(root) } } diff --git a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift index 1ece2507a..60d01459a 100644 --- a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift +++ b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift @@ -187,7 +187,7 @@ actor BridgeServer { thinking: "low", deliver: false, to: nil, - channel: .last)) + provider: .last)) case "agent.request": guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { @@ -205,7 +205,7 @@ actor BridgeServer { ?? "node-\(nodeId)" let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - let channel = GatewayAgentChannel(raw: link.channel) + let provider = GatewayAgentProvider(raw: link.channel) _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( message: message, @@ -213,7 +213,7 @@ actor BridgeServer { thinking: thinking, deliver: link.deliver, to: to, - channel: channel)) + provider: provider)) default: break diff --git a/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift index 7fdff8afb..6a02cfd8c 100644 --- a/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift +++ b/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift @@ -79,14 +79,15 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { GatewayProcessManager.shared.setActive(true) } - let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( - message: text, - sessionKey: self.sessionKey, - thinking: "low", - deliver: false, - to: nil, - channel: .last, - idempotencyKey: actionId)) + let result = await GatewayConnection.shared.sendAgent( + GatewayAgentInvocation( + message: text, + sessionKey: self.sessionKey, + thinking: "low", + deliver: false, + to: nil, + provider: .last, + idempotencyKey: actionId)) await MainActor.run { guard let webView else { return } diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift index c8ef08668..ec07cc5e4 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift @@ -33,13 +33,13 @@ extension CronJobEditor { case let .systemEvent(text): self.payloadKind = .systemEvent self.systemEventText = text - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): + case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver): self.payloadKind = .agentTurn self.agentMessage = message self.thinking = thinking ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.deliver = deliver ?? false - self.channel = GatewayAgentChannel(raw: channel) + self.provider = GatewayAgentProvider(raw: provider) self.to = to ?? "" self.bestEffortDeliver = bestEffortDeliver ?? false } @@ -166,7 +166,7 @@ extension CronJobEditor { if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } payload["deliver"] = self.deliver if self.deliver { - payload["channel"] = self.channel.rawValue + payload["provider"] = self.provider.rawValue let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) if !to.isEmpty { payload["to"] = to } payload["bestEffortDeliver"] = self.bestEffortDeliver diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift index 803964bcc..75b5ed6b6 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift @@ -13,7 +13,7 @@ extension CronJobEditor { self.payloadKind = .agentTurn self.agentMessage = "Run diagnostic" self.deliver = true - self.channel = .last + self.provider = .last self.to = "+15551230000" self.thinking = "low" self.timeoutSeconds = "90" diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift index 093978ebb..93d2615bf 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift @@ -17,7 +17,7 @@ struct CronJobEditor: View { static let scheduleKindNote = "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." static let isolatedPayloadNote = - "Isolated jobs always run an agent turn. The result can be delivered to a surface, " + "Isolated jobs always run an agent turn. The result can be delivered to a provider, " + "and a short summary is posted back to your main chat." static let mainPayloadNote = "System events are injected into the current main session. Agent turns require an isolated session target." @@ -42,7 +42,7 @@ struct CronJobEditor: View { @State var systemEventText: String = "" @State var agentMessage: String = "" @State var deliver: Bool = false - @State var channel: GatewayAgentChannel = .last + @State var provider: GatewayAgentProvider = .last @State var to: String = "" @State var thinking: String = "" @State var timeoutSeconds: String = "" @@ -309,7 +309,7 @@ struct CronJobEditor: View { } GridRow { self.gridLabel("Deliver") - Toggle("Deliver result to a surface", isOn: self.$deliver) + Toggle("Deliver result to a provider", isOn: self.$deliver) .toggleStyle(.switch) } } @@ -317,15 +317,15 @@ struct CronJobEditor: View { if self.deliver { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { GridRow { - self.gridLabel("Channel") - Picker("", selection: self.$channel) { - Text("last").tag(GatewayAgentChannel.last) - Text("whatsapp").tag(GatewayAgentChannel.whatsapp) - Text("telegram").tag(GatewayAgentChannel.telegram) - Text("discord").tag(GatewayAgentChannel.discord) - Text("slack").tag(GatewayAgentChannel.slack) - Text("signal").tag(GatewayAgentChannel.signal) - Text("imessage").tag(GatewayAgentChannel.imessage) + self.gridLabel("Provider") + Picker("", selection: self.$provider) { + Text("last").tag(GatewayAgentProvider.last) + Text("whatsapp").tag(GatewayAgentProvider.whatsapp) + Text("telegram").tag(GatewayAgentProvider.telegram) + Text("discord").tag(GatewayAgentProvider.discord) + Text("slack").tag(GatewayAgentProvider.slack) + Text("signal").tag(GatewayAgentProvider.signal) + Text("imessage").tag(GatewayAgentProvider.imessage) } .labelsHidden() .pickerStyle(.segmented) diff --git a/apps/macos/Sources/Clawdbot/CronModels.swift b/apps/macos/Sources/Clawdbot/CronModels.swift index 78c009576..388ef8afb 100644 --- a/apps/macos/Sources/Clawdbot/CronModels.swift +++ b/apps/macos/Sources/Clawdbot/CronModels.swift @@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable { thinking: String?, timeoutSeconds: Int?, deliver: Bool?, - channel: String?, + provider: String?, to: String?, bestEffortDeliver: Bool?) enum CodingKeys: String, CodingKey { - case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver + case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver } var kind: String { @@ -101,7 +101,7 @@ enum CronPayload: Codable, Equatable { thinking: container.decodeIfPresent(String.self, forKey: .thinking), timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), - channel: container.decodeIfPresent(String.self, forKey: .channel), + provider: container.decodeIfPresent(String.self, forKey: .provider), to: container.decodeIfPresent(String.self, forKey: .to), bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) default: @@ -118,12 +118,12 @@ enum CronPayload: Codable, Equatable { switch self { case let .systemEvent(text): try container.encode(text, forKey: .text) - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): + case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver): try container.encode(message, forKey: .message) try container.encodeIfPresent(thinking, forKey: .thinking) try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) try container.encodeIfPresent(deliver, forKey: .deliver) - try container.encodeIfPresent(channel, forKey: .channel) + try container.encodeIfPresent(provider, forKey: .provider) try container.encodeIfPresent(to, forKey: .to) try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) } diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift index b815bb234..fc5ceb51f 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift @@ -206,7 +206,7 @@ extension CronSettings { Text(text) .font(.callout) .textSelection(.enabled) - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _): + case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _): VStack(alignment: .leading, spacing: 4) { Text(message) .font(.callout) @@ -216,7 +216,7 @@ extension CronSettings { if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } if deliver ?? false { StatusPill(text: "deliver", tint: .secondary) - if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) } + if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) } if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) } } } diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift index bf6898016..788ec9fdb 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift @@ -20,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider { thinking: "low", timeoutSeconds: 600, deliver: true, - channel: "last", + provider: "last", to: nil, bestEffortDeliver: true), isolation: CronIsolation(postToMainPrefix: "Cron"), @@ -72,7 +72,7 @@ extension CronSettings { thinking: "low", timeoutSeconds: 120, deliver: true, - channel: "whatsapp", + provider: "whatsapp", to: "+15551234567", bestEffortDeliver: true), isolation: CronIsolation(postToMainPrefix: "[cron] "), diff --git a/apps/macos/Sources/Clawdbot/DeepLinks.swift b/apps/macos/Sources/Clawdbot/DeepLinks.swift index 0ffa87908..b1960b239 100644 --- a/apps/macos/Sources/Clawdbot/DeepLinks.swift +++ b/apps/macos/Sources/Clawdbot/DeepLinks.swift @@ -59,7 +59,7 @@ final class DeepLinkHandler { } do { - let channel = GatewayAgentChannel(raw: link.channel) + let provider = GatewayAgentProvider(raw: link.channel) let explicitSessionKey = link.sessionKey? .trimmingCharacters(in: .whitespacesAndNewlines) .nonEmpty @@ -72,9 +72,9 @@ final class DeepLinkHandler { message: messagePreview, sessionKey: resolvedSessionKey, thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, - deliver: channel.shouldDeliver(link.deliver), + deliver: provider.shouldDeliver(link.deliver), to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, - channel: channel, + provider: provider, timeoutSeconds: link.timeoutSeconds, idempotencyKey: UUID().uuidString) diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index d176be624..fb1f0a7d6 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -5,7 +5,7 @@ import OSLog private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection") -enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { +enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable { case last case whatsapp case telegram @@ -17,7 +17,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { init(raw: String?) { let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - self = GatewayAgentChannel(rawValue: normalized) ?? .last + self = GatewayAgentProvider(rawValue: normalized) ?? .last } var isDeliverable: Bool { self != .webchat } @@ -31,7 +31,7 @@ struct GatewayAgentInvocation: Sendable { var thinking: String? var deliver: Bool = false var to: String? - var channel: GatewayAgentChannel = .last + var provider: GatewayAgentProvider = .last var timeoutSeconds: Int? var idempotencyKey: String = UUID().uuidString } @@ -368,7 +368,7 @@ extension GatewayConnection { "thinking": AnyCodable(invocation.thinking ?? "default"), "deliver": AnyCodable(invocation.deliver), "to": AnyCodable(invocation.to ?? ""), - "channel": AnyCodable(invocation.channel.rawValue), + "provider": AnyCodable(invocation.provider.rawValue), "idempotencyKey": AnyCodable(invocation.idempotencyKey), ] if let timeout = invocation.timeoutSeconds { @@ -389,7 +389,7 @@ extension GatewayConnection { sessionKey: String, deliver: Bool, to: String?, - channel: GatewayAgentChannel = .last, + provider: GatewayAgentProvider = .last, timeoutSeconds: Int? = nil, idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) { @@ -399,7 +399,7 @@ extension GatewayConnection { thinking: thinking, deliver: deliver, to: to, - channel: channel, + provider: provider, timeoutSeconds: timeoutSeconds, idempotencyKey: idempotencyKey)) } diff --git a/apps/macos/Sources/Clawdbot/SessionData.swift b/apps/macos/Sources/Clawdbot/SessionData.swift index b1d8b7886..7ce1dc8fc 100644 --- a/apps/macos/Sources/Clawdbot/SessionData.swift +++ b/apps/macos/Sources/Clawdbot/SessionData.swift @@ -9,7 +9,7 @@ struct GatewaySessionDefaultsRecord: Codable { struct GatewaySessionEntryRecord: Codable { let key: String let displayName: String? - let surface: String? + let provider: String? let subject: String? let room: String? let space: String? @@ -71,7 +71,7 @@ struct SessionRow: Identifiable { let key: String let kind: SessionKind let displayName: String? - let surface: String? + let provider: String? let subject: String? let room: String? let space: String? @@ -141,7 +141,7 @@ extension SessionRow { key: "user@example.com", kind: .direct, displayName: nil, - surface: nil, + provider: nil, subject: nil, room: nil, space: nil, @@ -158,7 +158,7 @@ extension SessionRow { key: "discord:channel:release-squad", kind: .group, displayName: "discord:#release-squad", - surface: "discord", + provider: "discord", subject: nil, room: "#release-squad", space: nil, @@ -175,7 +175,7 @@ extension SessionRow { key: "global", kind: .global, displayName: nil, - surface: nil, + provider: nil, subject: nil, room: nil, space: nil, @@ -298,7 +298,7 @@ enum SessionLoader { key: entry.key, kind: SessionKind.from(key: entry.key), displayName: entry.displayName, - surface: entry.surface, + provider: entry.provider, subject: entry.subject, room: entry.room, space: entry.space, diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift index 3fd9f827b..a30d389f3 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift @@ -37,7 +37,7 @@ enum VoiceWakeForwarder { var thinking: String = "low" var deliver: Bool = true var to: String? - var channel: GatewayAgentChannel = .last + var provider: GatewayAgentProvider = .last } @discardableResult @@ -46,14 +46,14 @@ enum VoiceWakeForwarder { options: ForwardOptions = ForwardOptions()) async -> Result { let payload = Self.prefixedTranscript(transcript) - let deliver = options.channel.shouldDeliver(options.deliver) + let deliver = options.provider.shouldDeliver(options.deliver) let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( message: payload, sessionKey: options.sessionKey, thinking: options.thinking, deliver: deliver, to: options.to, - channel: options.channel)) + provider: options.provider)) if result.ok { self.logger.info("voice wake forward ok") diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 49690fbe3..e7566b297 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -342,6 +342,7 @@ public struct SendParams: Codable, Sendable { public let mediaurl: String? public let gifplayback: Bool? public let provider: String? + public let accountid: String? public let idempotencykey: String public init( @@ -350,6 +351,7 @@ public struct SendParams: Codable, Sendable { mediaurl: String?, gifplayback: Bool?, provider: String?, + accountid: String?, idempotencykey: String ) { self.to = to @@ -357,6 +359,7 @@ public struct SendParams: Codable, Sendable { self.mediaurl = mediaurl self.gifplayback = gifplayback self.provider = provider + self.accountid = accountid self.idempotencykey = idempotencykey } private enum CodingKeys: String, CodingKey { @@ -365,6 +368,48 @@ public struct SendParams: Codable, Sendable { case mediaurl = "mediaUrl" case gifplayback = "gifPlayback" case provider + case accountid = "accountId" + case idempotencykey = "idempotencyKey" + } +} + +public struct PollParams: Codable, Sendable { + public let to: String + public let question: String + public let options: [String] + public let maxselections: Int? + public let durationhours: Int? + public let provider: String? + public let accountid: String? + public let idempotencykey: String + + public init( + to: String, + question: String, + options: [String], + maxselections: Int?, + durationhours: Int?, + provider: String?, + accountid: String?, + idempotencykey: String + ) { + self.to = to + self.question = question + self.options = options + self.maxselections = maxselections + self.durationhours = durationhours + self.provider = provider + self.accountid = accountid + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case to + case question + case options + case maxselections = "maxSelections" + case durationhours = "durationHours" + case provider + case accountid = "accountId" case idempotencykey = "idempotencyKey" } } @@ -376,7 +421,7 @@ public struct AgentParams: Codable, Sendable { public let sessionkey: String? public let thinking: String? public let deliver: Bool? - public let channel: String? + public let provider: String? public let timeout: Int? public let lane: String? public let extrasystemprompt: String? @@ -389,7 +434,7 @@ public struct AgentParams: Codable, Sendable { sessionkey: String?, thinking: String?, deliver: Bool?, - channel: String?, + provider: String?, timeout: Int?, lane: String?, extrasystemprompt: String?, @@ -401,7 +446,7 @@ public struct AgentParams: Codable, Sendable { self.sessionkey = sessionkey self.thinking = thinking self.deliver = deliver - self.channel = channel + self.provider = provider self.timeout = timeout self.lane = lane self.extrasystemprompt = extrasystemprompt @@ -414,7 +459,7 @@ public struct AgentParams: Codable, Sendable { case sessionkey = "sessionKey" case thinking case deliver - case channel + case provider case timeout case lane case extrasystemprompt = "extraSystemPrompt" @@ -618,23 +663,27 @@ public struct SessionsListParams: Codable, Sendable { public let activeminutes: Int? public let includeglobal: Bool? public let includeunknown: Bool? + public let spawnedby: String? public init( limit: Int?, activeminutes: Int?, includeglobal: Bool?, - includeunknown: Bool? + includeunknown: Bool?, + spawnedby: String? ) { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown + self.spawnedby = spawnedby } private enum CodingKeys: String, CodingKey { case limit case activeminutes = "activeMinutes" case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" + case spawnedby = "spawnedBy" } } @@ -644,6 +693,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let verboselevel: AnyCodable? public let elevatedlevel: AnyCodable? public let model: AnyCodable? + public let spawnedby: AnyCodable? public let sendpolicy: AnyCodable? public let groupactivation: AnyCodable? @@ -653,6 +703,7 @@ public struct SessionsPatchParams: Codable, Sendable { verboselevel: AnyCodable?, elevatedlevel: AnyCodable?, model: AnyCodable?, + spawnedby: AnyCodable?, sendpolicy: AnyCodable?, groupactivation: AnyCodable? ) { @@ -661,6 +712,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.verboselevel = verboselevel self.elevatedlevel = elevatedlevel self.model = model + self.spawnedby = spawnedby self.sendpolicy = sendpolicy self.groupactivation = groupactivation } @@ -670,6 +722,7 @@ public struct SessionsPatchParams: Codable, Sendable { case verboselevel = "verboseLevel" case elevatedlevel = "elevatedLevel" case model + case spawnedby = "spawnedBy" case sendpolicy = "sendPolicy" case groupactivation = "groupActivation" } @@ -980,33 +1033,41 @@ public struct WebLoginStartParams: Codable, Sendable { public let force: Bool? public let timeoutms: Int? public let verbose: Bool? + public let accountid: String? public init( force: Bool?, timeoutms: Int?, - verbose: Bool? + verbose: Bool?, + accountid: String? ) { self.force = force self.timeoutms = timeoutms self.verbose = verbose + self.accountid = accountid } private enum CodingKeys: String, CodingKey { case force case timeoutms = "timeoutMs" case verbose + case accountid = "accountId" } } public struct WebLoginWaitParams: Codable, Sendable { public let timeoutms: Int? + public let accountid: String? public init( - timeoutms: Int? + timeoutms: Int?, + accountid: String? ) { self.timeoutms = timeoutms + self.accountid = accountid } private enum CodingKeys: String, CodingKey { case timeoutms = "timeoutMs" + case accountid = "accountId" } } diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index cdbb83258..e60a3a7b0 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -83,7 +83,7 @@ git commit -m "Add Clawd workspace" ## What Clawdbot Does - Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac. - macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdbot` CLI via its bundled binary. -- Direct chats collapse into the shared `main` session by default; groups stay isolated as `surface:group:` (rooms: `surface:channel:`); heartbeats keep background tasks alive. +- Direct chats collapse into the agent's `main` session by default; groups stay isolated as `agent:::group:` (rooms/channels: `agent:::channel:`); heartbeats keep background tasks alive. ## Core Skills (enable in Settings → Skills) - **mcporter** — Tool server runtime/CLI for managing external skill backends. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index db9b1e009..32f750a17 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -12,12 +12,12 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag 1) **Version & metadata** - [ ] Bump `package.json` version (e.g., `1.1.0`). -- [ ] Update CLI/version strings: `src/cli/program.ts` and the Baileys user agent in `src/provider-web.ts`. -- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to `dist/index.js` for `clawdbot`. +- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts). +- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`. - [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current. 2) **Build & artifacts** -- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated `src/canvas-host/a2ui/a2ui.bundle.js`. +- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js). - [ ] `pnpm run build` (regenerates `dist/`). - [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it). @@ -34,11 +34,11 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag 5) **macOS app (Sparkle)** - [ ] Build + sign the macOS app, then zip it for distribution. -- [ ] Generate the Sparkle appcast (HTML notes via `scripts/make_appcast.sh`) and update `appcast.xml`. +- [ ] Generate the Sparkle appcast (HTML notes via [`scripts/make_appcast.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/make_appcast.sh)) and update `appcast.xml`. - [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release. -- [ ] Follow `docs/mac/release.md` for the exact commands and required env vars. +- [ ] Follow [`docs/mac/release.md`](https://docs.clawd.bot/mac/release) for the exact commands and required env vars. - `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly. - - If notarizing, use the `clawdbot-notary` keychain profile created from App Store Connect API env vars (see `docs/mac/release.md`). + - If notarizing, use the `clawdbot-notary` keychain profile created from App Store Connect API env vars (see [`docs/mac/release.md`](https://docs.clawd.bot/mac/release)). 6) **Publish (npm)** - [ ] Confirm git status is clean; commit and push as needed. diff --git a/docs/agent-loop.md b/docs/agent-loop.md index 69bfe2a24..a60d02138 100644 --- a/docs/agent-loop.md +++ b/docs/agent-loop.md @@ -8,8 +8,8 @@ read_when: Short, exact flow of one agent run. Source of truth: current code in `src/`. ## Entry points -- Gateway RPC: `agent` and `agent.wait` in `src/gateway/server-methods/agent.ts`. -- CLI: `agentCommand` in `src/commands/agent.ts`. +- Gateway RPC: `agent` and `agent.wait` in [`src/gateway/server-methods/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/agent.ts). +- CLI: `agentCommand` in [`src/commands/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/agent.ts). ## High-level flow 1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately. @@ -36,8 +36,8 @@ Short, exact flow of one agent run. Source of truth: current code in `src/`. - `assistant`: streamed deltas from pi-agent-core - `tool`: streamed tool events from pi-agent-core -## Chat surface handling -- `createAgentEventHandler` in `src/gateway/server-chat.ts`: +## Chat provider handling +- `createAgentEventHandler` in [`src/gateway/server-chat.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-chat.ts): - buffers assistant deltas - emits chat `delta` messages - emits chat `final` when **lifecycle end/error** arrives @@ -53,9 +53,9 @@ Short, exact flow of one agent run. Source of truth: current code in `src/`. - `agent.wait` timeout (wait-only, does not stop agent) ## Files -- `src/gateway/server-methods/agent.ts` -- `src/gateway/server-methods/agent-job.ts` -- `src/commands/agent.ts` -- `src/agents/pi-embedded-runner.ts` -- `src/agents/pi-embedded-subscribe.ts` -- `src/gateway/server-chat.ts` +- [`src/gateway/server-methods/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/agent.ts) +- [`src/gateway/server-methods/agent-job.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/agent-job.ts) +- [`src/commands/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/agent.ts) +- [`src/agents/pi-embedded-runner.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-embedded-runner.ts) +- [`src/agents/pi-embedded-subscribe.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-embedded-subscribe.ts) +- [`src/gateway/server-chat.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-chat.ts) diff --git a/docs/agent.md b/docs/agent.md index f477ca257..44b449748 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -9,13 +9,13 @@ CLAWDBOT runs a single embedded agent runtime derived from **p-mono** (internal ## Workspace (required) -You must set an agent home directory via `agent.workspace`. CLAWDBOT uses this as the agent’s **only** working directory (`cwd`) for tools and context. +CLAWDBOT uses a single agent workspace directory (`agent.workspace`) as the agent’s **only** working directory (`cwd`) for tools and context. Recommended: use `clawdbot setup` to create `~/.clawdbot/clawdbot.json` if missing and initialize the workspace files. If `agent.sandbox` is enabled, non-main sessions can override this with per-session workspaces under `agent.sandbox.workspaceRoot` (see -`docs/configuration.md`). +[`docs/configuration.md`](https://docs.clawd.bot/configuration)). ## Bootstrap files (injected) @@ -31,6 +31,14 @@ On the first turn of a new session, CLAWDBOT injects the contents of these files If a file is missing, CLAWDBOT injects a single “missing file” marker line (and `clawdbot setup` will create a safe default template). +`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). If you delete it after completing the ritual, it should not be recreated on later restarts. + +To disable bootstrap file creation entirely (for pre-seeded workspaces), set: + +```json5 +{ agent: { skipBootstrap: true } } +``` + ## Built-in tools (internal) p’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used. @@ -42,7 +50,7 @@ Clawdbot loads skills from three locations (workspace wins on name conflict): - Managed/local: `~/.clawdbot/skills` - Workspace: `/skills` -Skills can be gated by config/env (see `skills` in `docs/configuration.md`). +Skills can be gated by config/env (see `skills` in [`docs/configuration.md`](https://docs.clawd.bot/configuration)). ## p-mono integration @@ -66,7 +74,7 @@ Apply these notes **only** when the user is Peter Steinberger at steipete. ## Sessions Session transcripts are stored as JSONL at: -- `~/.clawdbot/sessions/.jsonl` +- `~/.clawdbot/agents//sessions/.jsonl` The session ID is stable and chosen by CLAWDBOT. Legacy Pi/Tau session folders are **not** read. @@ -81,7 +89,7 @@ message is injected before the next assistant response. When queue mode is `followup` or `collect`, inbound messages are held until the current turn ends, then a new agent turn starts with the queued payloads. See -`docs/queue.md` for mode + debounce/cap behavior. +[`docs/queue.md`](https://docs.clawd.bot/queue) for mode + debounce/cap behavior. Block streaming sends completed assistant blocks as soon as they finish; disable via `agent.blockStreamingDefault: "off"` if you only want the final response. @@ -99,4 +107,4 @@ At minimum, set: --- -*Next: [Group Chats](./group-messages.md)* 🦞 +*Next: [Group Chats](https://docs.clawd.bot/group-messages)* 🦞 diff --git a/docs/android.md b/docs/android.md index ea09269e5..b33760e98 100644 --- a/docs/android.md +++ b/docs/android.md @@ -47,7 +47,7 @@ From the gateway machine: dns-sd -B _clawdbot-bridge._tcp local. ``` -More debugging notes: `docs/bonjour.md`. +More debugging notes: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour). #### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD @@ -56,7 +56,7 @@ Android NSD/mDNS discovery won’t cross networks. If your Android node and the 1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-bridge._tcp` records. 2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server. -Details and example CoreDNS config: `docs/bonjour.md`. +Details and example CoreDNS config: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour). ### 3) Connect from Android @@ -80,7 +80,7 @@ clawdbot nodes pending clawdbot nodes approve ``` -Pairing details: `docs/gateway/pairing.md`. +Pairing details: [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing). ### 5) Verify the node is connected @@ -130,4 +130,4 @@ Camera commands (foreground only; permission-gated): - `camera.snap` (jpg) - `camera.clip` (mp4) -See `docs/camera.md` for parameters and CLI helpers. +See [`docs/camera.md`](https://docs.clawd.bot/camera) for parameters and CLI helpers. diff --git a/docs/architecture.md b/docs/architecture.md index ee98631ae..2933c38bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,14 +15,14 @@ Last updated: 2026-01-05 ## Implementation snapshot (current code) -### TypeScript Gateway (`src/gateway/server.ts`) +### TypeScript Gateway ([`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts)) - Single HTTP + WebSocket server (default `18789`); bind policy `loopback|lan|tailnet|auto`. Refuses non-loopback binds without auth; Tailscale serve/funnel requires loopback. - Handshake: first frame must be a `connect` request; AJV validates request + params against TypeBox schemas; protocol negotiated via `minProtocol`/`maxProtocol`. - `hello-ok` includes snapshot (presence/health/stateVersion/uptime/configPath/stateDir), features (methods/events), policy (max payload/buffer/tick), and `canvasHostUrl` when available. - Events emitted: `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `talk.mode`, `node.pair.requested`, `node.pair.resolved`, `voicewake.changed`, `shutdown`. - Idempotency keys are required for `send`, `agent`, `chat.send`, and node invokes; the dedupe cache avoids double-sends on reconnects. Payload sizes are capped per connection. -- Optional node bridge (`src/infra/bridge/server.ts`): TCP JSONL frames (`hello`, `pair-request`, `req/res`, `event`, `invoke`, `ping`). Node connect/disconnect updates presence and flows into the session bus. -- Control UI + Canvas host: HTTP serves Control UI (base path configurable) and can host the A2UI canvas via `src/canvas-host/server.ts` (live reload). Canvas host URL is advertised to nodes + clients. +- Optional node bridge ([`src/infra/bridge/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/bridge/server.ts)): TCP JSONL frames (`hello`, `pair-request`, `req/res`, `event`, `invoke`, `ping`). Node connect/disconnect updates presence and flows into the session bus. +- Control UI + Canvas host: HTTP serves Control UI (base path configurable) and can host the A2UI canvas via [`src/canvas-host/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/server.ts) (live reload). Canvas host URL is advertised to nodes + clients. ### iOS node (`apps/ios`) - Discovery + pairing: `BridgeDiscoveryModel` uses `NWBrowser` Bonjour discovery and reads TXT fields for LAN/tailnet host hints plus gateway/bridge/canvas ports. @@ -46,7 +46,7 @@ Last updated: 2026-01-05 - **Clients (mac app / CLI / web admin)** - One WS connection per client. - Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`). - - On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see `docs/macos.md`). + - On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see [`docs/macos.md`](https://docs.clawd.bot/macos)). - **Agent process (Pi)** - Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection. - **WebChat** diff --git a/docs/assets/terminal.css b/docs/assets/terminal.css index 00242acb1..23283d651 100644 --- a/docs/assets/terminal.css +++ b/docs/assets/terminal.css @@ -70,11 +70,14 @@ html[data-theme="dark"] { box-sizing: border-box; } -html, -body { +html { height: 100%; } +body { + min-height: 100%; +} + body { margin: 0; font-family: var(--font-body); diff --git a/docs/bonjour.md b/docs/bonjour.md index 1e44ddd88..099530008 100644 --- a/docs/bonjour.md +++ b/docs/bonjour.md @@ -69,7 +69,7 @@ The bridge port (default `18790`) is a plain TCP service. By default it binds to For a tailnet-only setup, bind it to the Tailscale IP instead: - Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json`. -- Restart the Gateway (or restart the macOS menubar app via `./scripts/restart-mac.sh` on that machine). +- Restart the Gateway (or restart the macOS menubar app via [`./scripts/restart-mac.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/restart-mac.sh) on that machine). This keeps the bridge reachable only from devices on your tailnet (while still listening on loopback for local/SSH port-forwards). @@ -77,8 +77,8 @@ This keeps the bridge reachable only from devices on your tailnet (while still l Only the **Node Gateway** (`clawd` / `clawdbot gateway`) advertises Bonjour beacons. -- Implementation: `src/infra/bonjour.ts` -- Gateway wiring: `src/gateway/server.ts` +- Implementation: [`src/infra/bonjour.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/bonjour.ts) +- Gateway wiring: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) ## Service types @@ -136,7 +136,7 @@ The log includes browser state transitions (`ready`, `waiting`, `failed`, `cance - **Sleep / interface churn**: macOS may temporarily drop mDNS results when switching networks; retry. - **Browse works but resolve fails (iOS “NoSuchRecord”)**: make sure the advertiser publishes a valid SRV target hostname. - Implementation detail: `@homebridge/ciao` defaults `hostname` to the *service instance name* when `hostname` is omitted. If your instance name contains spaces/parentheses, some resolvers can fail to resolve the implied A/AAAA record. - - Fix: set an explicit DNS-safe `hostname` (single label; no `.local`) in `src/infra/bonjour.ts`. + - Fix: set an explicit DNS-safe `hostname` (single label; no `.local`) in [`src/infra/bonjour.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/bonjour.ts). ## Escaped instance names (`\\032`) Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\\DDD` sequences (e.g. spaces become `\\032`). @@ -155,5 +155,5 @@ Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\\DDD` ## Related docs -- Discovery policy and transport selection: `docs/discovery.md` -- Node pairing + approvals: `docs/gateway/pairing.md` +- Discovery policy and transport selection: [`docs/discovery.md`](https://docs.clawd.bot/discovery) +- Node pairing + approvals: [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing) diff --git a/docs/browser.md b/docs/browser.md index 5684d7c17..af162af05 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -189,10 +189,26 @@ All existing endpoints accept optional `?profile=` query parameter: - `GET /?profile=work` — status for work profile - `POST /start?profile=work` — start work profile browser - `GET /tabs?profile=work` — list tabs for work profile +- `POST /tabs/open?profile=work` — open tab in work profile - etc. When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd"). +### Agent browser tool + +The `browser` tool accepts an optional `profile` parameter for all actions: + +```json +{ + "action": "open", + "targetUrl": "https://example.com", + "profile": "work" +} +``` + +This routes the operation to the specified profile's browser instance. Omitting +`profile` uses the default profile. + ### Profile naming rules - Lowercase alphanumeric characters and hyphens only @@ -221,7 +237,7 @@ The agent should not assume tabs are ephemeral. It should: ## CLI quick reference (one example each) -All commands accept `--profile ` to target a specific profile (default: `clawd`). +All commands accept `--browser-profile ` to target a specific profile (default: `clawd`). Profile management: - `clawdbot browser profiles` @@ -290,4 +306,4 @@ Notes: ## Troubleshooting -For Linux-specific issues (especially Ubuntu with snap Chromium), see [browser-linux-troubleshooting.md](./browser-linux-troubleshooting.md). +For Linux-specific issues (especially Ubuntu with snap Chromium), see [browser-linux-troubleshooting](https://docs.clawd.bot/browser-linux-troubleshooting). diff --git a/docs/bun.md b/docs/bun.md new file mode 100644 index 000000000..e1a4803cd --- /dev/null +++ b/docs/bun.md @@ -0,0 +1,68 @@ +--- +summary: "Bun workflow (preferred): installs, patches, and gotchas vs pnpm" +read_when: + - You want the fastest local dev loop (bun + watch) + - You hit Bun install/patch/lifecycle script issues +--- + +# Bun + +Goal: run this repo with **Bun** (preferred) without losing pnpm patch behavior. + +## Status + +- Bun is the preferred local runtime for running TypeScript directly (`bun run …`, `bun --watch …`). +- `pnpm` is still fully supported (and used by some docs tooling). +- Bun cannot use `pnpm-lock.yaml` and will ignore it. + +## Install + +Default: + +```sh +bun install +``` + +Note: `bun.lock`/`bun.lockb` are gitignored, so there’s no repo churn either way. If you want *no lockfile writes*: + +```sh +bun install --no-save +``` + +## Build / Test (Bun) + +```sh +bun run build +bun run vitest run +``` + +## pnpm patchedDependencies under Bun + +pnpm supports `package.json#pnpm.patchedDependencies` and records it in `pnpm-lock.yaml`. +Bun does not support pnpm patches, so we apply them in `postinstall` when Bun is detected: + +- [`scripts/postinstall.js`](https://github.com/clawdbot/clawdbot/blob/main/scripts/postinstall.js) runs only for Bun installs and applies every entry from `package.json#pnpm.patchedDependencies` into `node_modules/...` using `git apply` (idempotent). + +To add a new patch that works in both pnpm + Bun: + +1. Add an entry to `package.json#pnpm.patchedDependencies` +2. Add the patch file under `patches/` +3. Run `pnpm install` (updates `pnpm-lock.yaml` patch hash) + +## Bun lifecycle scripts (blocked by default) + +Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`). +For this repo, the commonly blocked scripts are not required: + +- `@whiskeysockets/baileys` `preinstall`: checks Node major >= 20 (we run Node 22+). +- `protobufjs` `postinstall`: emits warnings about incompatible version schemes (no build artifacts). + +If you hit a real runtime issue that requires these scripts, trust them explicitly: + +```sh +bun pm trust @whiskeysockets/baileys protobufjs +``` + +## Caveats + +- Some scripts still hardcode pnpm (e.g. `docs:build`, `ui:*`, `protocol:check`). Run those via pnpm for now. diff --git a/docs/clawd.md b/docs/clawd.md index 044c89536..1c62d396b 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -18,7 +18,7 @@ You’re putting an agent in a position to: Start conservative: - Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac). - Use a dedicated WhatsApp number for the assistant. -- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`). +- Heartbeats now default to every 30 minutes. Disable until you trust the setup by setting `agent.heartbeat.every: "0m"`. ## Prerequisites @@ -81,11 +81,11 @@ clawdbot gateway --port 18789 Now message the assistant number from your allowlisted phone. -## Give the agent a workspace (AGENTS.md) +## Give the agent a workspace (AGENTS) Clawd reads operating instructions and “memory” from its workspace directory. -By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`) automatically on setup/first agent run. +By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it). Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up. @@ -103,6 +103,16 @@ Optional: choose a different workspace with `agent.workspace` (supports `~`). } ``` +If you already ship your own workspace files from a repo, you can disable bootstrap file creation entirely: + +```json5 +{ + agent: { + skipBootstrap: true + } +} +``` + ## The config that turns it into “an assistant” CLAWDBOT defaults to a good assistant setup, but you’ll usually want to tune: @@ -144,14 +154,16 @@ Example: ## Sessions and memory -- Session files: `~/.clawdbot/sessions/{{SessionId}}.jsonl` -- Session metadata (token usage, last route, etc): `~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`) +- Session files: `~/.clawdbot/agents//sessions/{{SessionId}}.jsonl` +- Session metadata (token usage, last route, etc): `~/.clawdbot/agents//sessions/sessions.json` (legacy: `~/.clawdbot/sessions/sessions.json`) - `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, the agent replies with a short hello to confirm the reset. - `/compact [instructions]` compacts the session context and reports the remaining context budget. ## Heartbeats (proactive mode) -When `agent.heartbeat.every` is set to a positive interval, CLAWDBOT periodically runs a heartbeat prompt (default: `HEARTBEAT`). +By default, CLAWDBOT runs a heartbeat every 30 minutes with the prompt: +`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` +Set `agent.heartbeat.every: "0m"` to disable. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agent.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat. @@ -191,12 +203,12 @@ Logs live under `/tmp/clawdbot/` (default: `clawdbot-YYYY-MM-DD.log`). ## Next steps -- WebChat: [WebChat](./webchat.md) -- Gateway ops: [Gateway runbook](./gateway.md) -- Cron + wakeups: [Cron + wakeups](./cron.md) -- macOS menu bar companion: [Clawdbot macOS app](./macos.md) -- iOS node app: [iOS app](./ios.md) -- Android node app: [Android app](./android.md) -- Windows status: [Windows app](./windows.md) -- Linux status: [Linux app](./linux.md) -- Security: [Security](./security.md) +- WebChat: [WebChat](https://docs.clawd.bot/webchat) +- Gateway ops: [Gateway runbook](https://docs.clawd.bot/gateway) +- Cron + wakeups: [Cron + wakeups](https://docs.clawd.bot/cron) +- macOS menu bar companion: [Clawdbot macOS app](https://docs.clawd.bot/macos) +- iOS node app: [iOS app](https://docs.clawd.bot/ios) +- Android node app: [Android app](https://docs.clawd.bot/android) +- Windows status: [Windows app](https://docs.clawd.bot/windows) +- Linux status: [Linux app](https://docs.clawd.bot/linux) +- Security: [Security](https://docs.clawd.bot/security) diff --git a/docs/configuration.md b/docs/configuration.md index 70eabc4e7..869945fa0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,7 @@ CLAWDBOT reads an optional **JSON5** config from `~/.clawdbot/clawdbot.json` (co If the file is missing, CLAWDBOT uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to: - restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.) -- control group mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`) +- control group allowlists + mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`) - customize message prefixes (`messages`) - set the agent's workspace (`agent.workspace`) - tune the embedded agent (`agent`) and session behavior (`session`) @@ -91,18 +91,21 @@ Env var equivalent: ### Auth storage (OAuth + API keys) -Clawdbot stores **auth profiles** (OAuth + API keys) in: -- `~/.clawdbot/agent/auth-profiles.json` +Clawdbot stores **per-agent** auth profiles (OAuth + API keys) in: +- `/auth-profiles.json` (default: `~/.clawdbot/agents//agent/auth-profiles.json`) Legacy OAuth imports: - `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`) The embedded Pi agent maintains a runtime cache at: -- `~/.clawdbot/agent/auth.json` (managed automatically; don’t edit manually) +- `/auth.json` (managed automatically; don’t edit manually) + +Legacy agent dir (pre multi-agent): +- `~/.clawdbot/agent/*` (migrated by `clawdbot doctor` into `~/.clawdbot/agents//agent/*`) Overrides: - OAuth dir (legacy import only): `CLAWDBOT_OAUTH_DIR` -- Agent dir: `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy) +- Agent dir (default agent root override): `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy) On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`. @@ -131,7 +134,7 @@ rotation order used for failover. Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant. If set, CLAWDBOT derives defaults (only when you haven’t set them explicitly): -- `messages.responsePrefix` from `identity.emoji` +- `messages.ackReaction` from `identity.emoji` (falls back to 👀) - `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) ```json5 @@ -184,20 +187,57 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). } ``` +### `whatsapp.dmPolicy` + +Controls how WhatsApp direct chats (DMs) are handled: +- `"pairing"` (default): unknown senders get a pairing code; owner must approve +- `"allowlist"`: only allow senders in `whatsapp.allowFrom` (or paired allow store) +- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`) +- `"disabled"`: ignore all inbound DMs + +Pairing approvals: +- `clawdbot pairing list --provider whatsapp` +- `clawdbot pairing approve --provider whatsapp ` + ### `whatsapp.allowFrom` -Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (DMs only). -If empty, the default allowlist is your own WhatsApp number (self-chat mode). +Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (**DMs only**). +If empty and `whatsapp.dmPolicy="pairing"`, unknown senders will receive a pairing code. +For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`. ```json5 { whatsapp: { + dmPolicy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["+15555550123", "+447700900123"], textChunkLimit: 4000 // optional outbound chunk size (chars) } } ``` +### `whatsapp.accounts` (multi-account) + +Run multiple WhatsApp accounts in one gateway: + +```json5 +{ + whatsapp: { + accounts: { + default: {}, // optional; keeps the default id stable + personal: {}, + biz: { + // Optional override. Default: ~/.clawdbot/credentials/whatsapp/biz + // authDir: "~/.clawdbot/credentials/whatsapp/biz", + } + } + } +} +``` + +Notes: +- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted). +- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`. + ### `routing.groupChat` Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats. @@ -218,7 +258,7 @@ Group messages default to **require mention** (either metadata mention or regex } ``` -Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`). +Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`). When `*.groups` is set, it also acts as a group allowlist; include `"*"` to allow all groups. To respond **only** to specific text triggers (ignoring native @-mentions): ```json5 @@ -237,6 +277,114 @@ To respond **only** to specific text triggers (ignoring native @-mentions): } ``` +### Group policy (per provider) + +Use `*.groupPolicy` to control whether group/room messages are accepted at all: + +```json5 +{ + whatsapp: { + groupPolicy: "allowlist", + groupAllowFrom: ["+15551234567"] + }, + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: ["tg:123456789", "@alice"] + }, + signal: { + groupPolicy: "allowlist", + groupAllowFrom: ["+15551234567"] + }, + imessage: { + groupPolicy: "allowlist", + groupAllowFrom: ["chat_id:123"] + }, + discord: { + groupPolicy: "allowlist", + guilds: { + "GUILD_ID": { + channels: { help: { allow: true } } + } + } + }, + slack: { + groupPolicy: "allowlist", + channels: { "#general": { allow: true } } + } +} +``` + +Notes: +- `"open"` (default): groups bypass allowlists; mention-gating still applies. +- `"disabled"`: block all group/room messages. +- `"allowlist"`: only allow groups/rooms that match the configured allowlist. +- WhatsApp/Telegram/Signal/iMessage use `groupAllowFrom` (fallback: explicit `allowFrom`). +- Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`). +- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`. + +### Multi-agent routing (`routing.agents` + `routing.bindings`) + +Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside one Gateway. Inbound messages are routed to an agent via bindings. + +- `routing.defaultAgentId`: fallback when no binding matches (default: `main`). +- `routing.agents.`: per-agent overrides. + - `workspace`: default `~/clawd-` (for `main`, falls back to legacy `agent.workspace`). + - `agentDir`: default `~/.clawdbot/agents//agent`. +- `routing.bindings[]`: routes inbound messages to an `agentId`. + - `match.provider` (required) + - `match.accountId` (optional; `*` = any account; omitted = default account) + - `match.peer` (optional; `{ kind: dm|group|channel, id }`) + - `match.guildId` / `match.teamId` (optional; provider-specific) + +Deterministic match order: +1) `match.peer` +2) `match.guildId` +3) `match.teamId` +4) `match.accountId` (exact, no peer/guild/team) +5) `match.accountId: "*"` (provider-wide, no peer/guild/team) +6) `routing.defaultAgentId` + +Within each match tier, the first matching entry in `routing.bindings` wins. + +Example: two WhatsApp accounts → two agents: + +```json5 +{ + routing: { + defaultAgentId: "home", + agents: { + home: { workspace: "~/clawd-home" }, + work: { workspace: "~/clawd-work" }, + }, + bindings: [ + { agentId: "home", match: { provider: "whatsapp", accountId: "personal" } }, + { agentId: "work", match: { provider: "whatsapp", accountId: "biz" } }, + ], + }, + whatsapp: { + accounts: { + personal: {}, + biz: {}, + } + } +} +``` + +### `routing.agentToAgent` (optional) + +Agent-to-agent messaging is opt-in: + +```json5 +{ + routing: { + agentToAgent: { + enabled: false, + allow: ["home", "work"] + } + } +} +``` + ### `routing.queue` Controls how inbound messages behave when an agent run is already active. @@ -249,7 +397,7 @@ Controls how inbound messages behave when an agent run is already active. debounceMs: 1000, cap: 20, drop: "summarize", // old | new | summarize - bySurface: { + byProvider: { whatsapp: "collect", telegram: "collect", discord: "collect", @@ -261,6 +409,27 @@ Controls how inbound messages behave when an agent run is already active. } ``` +### `commands` (chat command handling) + +Controls how chat commands are enabled across connectors. + +```json5 +{ + commands: { + native: false, // register native commands when supported + text: true, // parse slash commands in chat messages + useAccessGroups: true // enforce access-group allowlists/policies for commands + } +} +``` + +Notes: +- Text commands must be sent as a **standalone** message and use the leading `/` (no plain-text aliases). +- `commands.text: false` disables parsing chat messages for commands. +- `commands.native: true` registers native commands on supported connectors (Discord/Slack/Telegram). Platforms without native commands still rely on text commands. +- `commands.native: false` skips native registration; Discord/Telegram clear previously registered commands on startup. Slack commands are managed in the Slack app. +- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies. + ### `web` (WhatsApp web provider) WhatsApp runs through the gateway’s web provider. It starts automatically when a linked session exists. @@ -292,8 +461,9 @@ Set `telegram.enabled: false` to disable automatic startup. telegram: { enabled: true, botToken: "your-bot-token", - requireMention: true, - allowFrom: ["123456789"], + dmPolicy: "pairing", // pairing | allowlist | open | disabled + allowFrom: ["tg:123456789"], // optional; "open" requires ["*"] + groups: { "*": { requireMention: true } }, mediaMaxMb: 5, proxy: "socks5://localhost:9050", webhookUrl: "https://example.com/telegram-webhook", @@ -331,15 +501,10 @@ Configure the Discord bot by setting the bot token and optional gating: moderation: false }, replyToMode: "off", // off | first | all - slashCommand: { // user-installed app slash commands - enabled: true, - name: "clawd", - sessionPrefix: "discord:slash", - ephemeral: true - }, dm: { enabled: true, // disable all DMs when false - allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names) + policy: "pairing", // pairing | allowlist | open | disabled + allowFrom: ["1234567890", "steipete"], // optional DM allowlist ("open" requires ["*"]) groupEnabled: false, // enable group DMs groupChannels: ["clawd-dm"] // optional group DM allowlist }, @@ -380,7 +545,8 @@ Slack runs in Socket Mode and requires both a bot token and app token: appToken: "xapp-...", dm: { enabled: true, - allowFrom: ["U123", "U456", "*"], + policy: "pairing", // pairing | allowlist | open | disabled + allowFrom: ["U123", "U456", "*"], // optional; "open" requires ["*"] groupEnabled: false, groupChannels: ["G123"] }, @@ -435,6 +601,7 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required. enabled: true, cliPath: "imsg", dbPath: "~/Library/Messages/chat.db", + dmPolicy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["+15555550123", "user@example.com", "chat_id:123"], includeAttachments: false, mediaMaxMb: 16, @@ -464,6 +631,18 @@ Default: `~/clawd`. If `agent.sandbox` is enabled, non-main sessions can override this with their own per-session workspaces under `agent.sandbox.workspaceRoot`. +### `agent.skipBootstrap` + +Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`). + +Use this for pre-seeded deployments where your workspace files come from a repo. + +```json5 +{ + agent: { skipBootstrap: true } +} +``` + ### `agent.userTimezone` Sets the user’s timezone for **system prompt context** (not for timestamps in @@ -477,13 +656,15 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime. ### `messages` -Controls inbound/outbound prefixes. +Controls inbound/outbound prefixes and optional ack reactions. ```json5 { messages: { messagePrefix: "[clawdbot]", - responsePrefix: "🦞" + responsePrefix: "🦞", + ackReaction: "👀", + ackReactionScope: "group-mentions" } } ``` @@ -491,6 +672,16 @@ Controls inbound/outbound prefixes. `responsePrefix` is applied to **all outbound replies** (tool summaries, block streaming, final replies) across providers unless already present. +`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages +on providers that support reactions (Slack/Discord/Telegram). Defaults to the +configured `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable. + +`ackReactionScope` controls when reactions fire: +- `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned +- `group-all`: all group/room messages +- `direct`: direct messages only +- `all`: all messages + ### `talk` Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset. @@ -595,14 +786,17 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require `ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment. `agent.heartbeat` configures periodic heartbeat runs: -- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set - `0m` to disable. +- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default: + `30m`. Set `0m` to disable. - `model`: optional override model for heartbeat runs (`provider/model`). -- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `imessage`, `none`). Default: `last`. -- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram). -- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). +- `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`. +- `to`: optional recipient override (provider-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). +- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30). +Heartbeats run full agent turns. Shorter intervals burn more tokens; adjust `every` +and/or `model` accordingly. + `agent.bash` configures background bash defaults: - `backgroundMs`: time before auto-background (ms, default 10000) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800) @@ -624,7 +818,7 @@ Example (disable browser/canvas everywhere): `agent.elevated` controls elevated (host) bash access: - `enabled`: allow elevated mode (default true) -- `allowFrom`: per-surface allowlists (empty = disabled) +- `allowFrom`: per-provider allowlists (empty = disabled) - `whatsapp`: E.164 numbers - `telegram`: chat ids or usernames - `discord`: user ids or usernames (falls back to `discord.dm.allowFrom` if omitted) @@ -661,7 +855,7 @@ Defaults (if enabled): - Debian bookworm-slim based image - workspace per session under `~/.clawdbot/sandboxes` - auto-prune: idle > 24h OR age > 7d -- tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins) +- tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins) - optional sandboxed browser (Chromium + CDP, noVNC observer) - hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile` @@ -707,7 +901,7 @@ Defaults (if enabled): enableNoVnc: true }, tools: { - allow: ["bash", "process", "read", "write", "edit"], + allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"], deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] }, prune: { @@ -742,11 +936,11 @@ URL is injected per session. Clawdbot uses the **pi-coding-agent** model catalog. You can add custom providers (LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.) by writing -`~/.clawdbot/agent/models.json` or by defining the same schema inside your +`~/.clawdbot/agents//agent/models.json` or by defining the same schema inside your Clawdbot config under `models.providers`. When `models.providers` is present, Clawdbot writes/merges a `models.json` into -`~/.clawdbot/agent/` on startup: +`~/.clawdbot/agents//agent/` on startup: - default behavior: **merge** (keeps existing providers, overrides on name) - set `models.mode: "replace"` to overwrite the file contents @@ -832,7 +1026,7 @@ Notes: `google-generative-ai` - Use `authHeader: true` + `headers` for custom auth needs. - Override the agent config root with `CLAWDBOT_AGENT_DIR` (or `PI_CODING_AGENT_DIR`) - if you want `models.json` stored elsewhere. + if you want `models.json` stored elsewhere (default: `~/.clawdbot/agents/main/agent`). ### `session` @@ -844,15 +1038,18 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto scope: "per-sender", idleMinutes: 60, resetTriggers: ["/new", "/reset"], - store: "~/.clawdbot/sessions/sessions.json", - // mainKey is ignored; primary key is fixed to "main" + // Default is already per-agent under ~/.clawdbot/agents//sessions/sessions.json + // You can override with {agentId} templating: + store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", + // Direct chats collapse to agent:: (default: "main"). + mainKey: "main", agentToAgent: { // Max ping-pong reply turns between requester/target (0–5). maxPingPongTurns: 5 }, sendPolicy: { rules: [ - { action: "deny", match: { surface: "discord", chatType: "group" } } + { action: "deny", match: { provider: "discord", chatType: "group" } } ], default: "allow" } @@ -861,9 +1058,10 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto ``` Fields: +- `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`. - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. -- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. +- `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. ### `skills` (skills config) @@ -1085,7 +1283,7 @@ Convenience flags (CLI): - `clawdbot --dev …` → uses `~/.clawdbot-dev` + shifts ports from base `19001` - `clawdbot --profile …` → uses `~/.clawdbot-` (port via config/env/flags) -See `docs/gateway.md` for the derived port mapping (gateway/bridge/browser/canvas). +See [`docs/gateway.md`](https://docs.clawd.bot/gateway) for the derived port mapping (gateway/bridge/browser/canvas). Example: ```bash @@ -1096,7 +1294,7 @@ clawdbot gateway --port 19001 ### `hooks` (Gateway webhooks) -Enable a simple HTTP webhook surface on the Gateway HTTP server. +Enable a simple HTTP webhook endpoint on the Gateway HTTP server. Defaults: - enabled: `false` @@ -1133,7 +1331,7 @@ Requests must include the hook token: Endpoints: - `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }` -- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, thinking?, timeoutSeconds? }` +- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, provider?, to?, thinking?, timeoutSeconds? }` - `POST /hooks/` → resolved via `hooks.mappings` `/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`). @@ -1263,7 +1461,7 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any |----------|-------------| | `{{Body}}` | Full inbound message body | | `{{BodyStripped}}` | Body with group mentions stripped (best default for agents) | -| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per surface) | +| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per provider) | | `{{To}}` | Destination identifier | | `{{MessageSid}}` | Provider message id (when available) | | `{{SessionId}}` | Current session UUID | @@ -1277,11 +1475,11 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any | `{{GroupMembers}}` | Group members preview (best effort) | | `{{SenderName}}` | Sender display name (best effort) | | `{{SenderE164}}` | Sender phone number (best effort) | -| `{{Surface}}` | Surface hint (whatsapp|telegram|discord|imessage|webchat|…) | +| `{{Provider}}` | Provider hint (whatsapp|telegram|discord|imessage|webchat|…) | ## Cron (Gateway scheduler) -Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wakeups](./cron.md) for the full RFC and CLI examples. +Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wakeups](https://docs.clawd.bot/cron) for the full RFC and CLI examples. ```json5 { @@ -1294,4 +1492,4 @@ Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wa --- -*Next: [Agent Runtime](./agent.md)* 🦞 +*Next: [Agent Runtime](https://docs.clawd.bot/agent)* 🦞 diff --git a/docs/control-api.md b/docs/control-api.md deleted file mode 100644 index 1708f6543..000000000 --- a/docs/control-api.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -summary: "Deprecated newline-delimited control channel API (pre-gateway)" -read_when: - - Maintaining legacy control channel support ---- -# Control channel API (newline-delimited JSON) - -**Deprecated (historical):** superseded by the WebSocket Gateway protocol (`clawdbot gateway`, see `docs/architecture.md` and `docs/gateway.md`). -Current builds use a WebSocket server on `ws://127.0.0.1:18789` and do **not** expose this TCP control channel. - -Legacy endpoint (if present in an older build): `127.0.0.1:18789` (TCP, localhost only), typically reached via SSH port forward in remote mode. - -## Frame format -Each line is a JSON object. Two shapes exist: -- **Request**: `{ "type": "request", "id": "", "method": "health" | "status" | "last-heartbeat" | "set-heartbeats" | "ping", "params"?: { ... } }` -- **Response**: `{ "type": "response", "id": "", "ok": true, "payload"?: { ... } }` or `{ "type": "response", "id": "", "ok": false, "error": "message" }` -- **Event**: `{ "type": "event", "event": "heartbeat" | "gateway-status" | "log", "payload": { ... } }` - -## Methods -- `ping`: sanity check. Payload: `{ pong: true, ts }`. -- `health`: returns the gateway health snapshot (same shape as `clawdbot health --json`). -- `status`: shorter summary (linked/authAge/heartbeatSeconds, session counts). -- `last-heartbeat`: returns the most recent heartbeat event the gateway has seen. -- `set-heartbeats { enabled: boolean }`: toggle heartbeat scheduling. - -## Events -- `heartbeat` payload: - ```json - { - "ts": 1765224052664, - "status": "sent" | "ok-empty" | "ok-token" | "skipped" | "failed", - "to": "+15551234567", - "preview": "Heartbeat OK", - "hasMedia": false, - "durationMs": 1025, - "reason": "" // only on failed/skipped - } - ``` -- `gateway-status` payload: `{ "state": "starting" | "running" | "restarting" | "failed" | "stopped", "pid"?: number, "reason"?: string }` -- `log` payload: arbitrary log line; optional, can be disabled. - -## Suggested client flow -1) Connect (or reconnect) → send `ping`. -2) Send `health` and `last-heartbeat` to populate UI. -3) Listen for `event` frames; update UI in real time. -4) For user toggles, send `set-heartbeats` and await response. - -## Backward compatibility -- If the control channel is unavailable: that’s expected on modern builds. Use the Gateway WS protocol instead. diff --git a/docs/control-ui.md b/docs/control-ui.md index 315e1415c..0e4fe0c32 100644 --- a/docs/control-ui.md +++ b/docs/control-ui.md @@ -63,21 +63,21 @@ Paste the token into the UI settings (sent as `connect.params.auth.token`). The Gateway serves static files from `dist/control-ui`. Build them with: ```bash -pnpm ui:install -pnpm ui:build +bun run ui:install +bun run ui:build ``` Optional absolute base (when you want fixed asset URLs): ```bash -CLAWDBOT_CONTROL_UI_BASE_PATH=/clawdbot/ pnpm ui:build +CLAWDBOT_CONTROL_UI_BASE_PATH=/clawdbot/ bun run ui:build ``` For local development (separate dev server): ```bash -pnpm ui:install -pnpm ui:dev +bun run ui:install +bun run ui:dev ``` Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`). diff --git a/docs/cron.md b/docs/cron.md index 374086e68..5c76c8b8d 100644 --- a/docs/cron.md +++ b/docs/cron.md @@ -14,9 +14,9 @@ Last updated: 2025-12-13 ## Context Clawdbot already has: -- A **gateway heartbeat runner** that runs the agent with `HEARTBEAT` and suppresses `HEARTBEAT_OK` (`src/infra/heartbeat-runner.ts`). -- A lightweight, in-memory **system event queue** (`enqueueSystemEvent`) that is injected into the next **main session** turn (`drainSystemEvents` in `src/auto-reply/reply.ts`). -- A WebSocket **Gateway** daemon that is intended to be always-on (`docs/gateway.md`). +- A **gateway heartbeat runner** that runs the agent with the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) and suppresses `HEARTBEAT_OK` ([`src/infra/heartbeat-runner.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/heartbeat-runner.ts)). +- A lightweight, in-memory **system event queue** (`enqueueSystemEvent`) that is injected into the next **main session** turn (`drainSystemEvents` in [`src/auto-reply/reply.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/auto-reply/reply.ts)). +- A WebSocket **Gateway** daemon that is intended to be always-on ([`docs/gateway.md`](https://docs.clawd.bot/gateway)). This RFC adds a small “cron job system” so Clawd can schedule future work and reliably wake itself up: - **Delayed**: run on the *next* normal heartbeat tick @@ -75,7 +75,7 @@ Each job is a JSON object with stable keys (unknown keys ignored for forward com - For `sessionTarget:"main"`, `wakeMode` controls whether we trigger the heartbeat immediately or just enqueue and wait. - `payload` (one of) - `{"kind":"systemEvent","text":string}` (enqueue as `System:`) - - `{"kind":"agentTurn","message":string,"deliver"?:boolean,"channel"?: "last"|"whatsapp"|"telegram"|"discord"|"signal"|"imessage","to"?:string,"timeoutSeconds"?:number}` + - `{"kind":"agentTurn","message":string,"deliver"?:boolean,"provider"?: "last"|"whatsapp"|"telegram"|"discord"|"signal"|"imessage","to"?:string,"timeoutSeconds"?:number}` - `isolation` (optional; only meaningful for isolated jobs) - `{"postToMainPrefix"?: string}` - `runtime` (optional) @@ -173,14 +173,14 @@ When due: - Execute via the same agent runner path as other command-mode runs, but pinned to: - `sessionKey = cron:` - `sessionId = store[sessionKey].sessionId` (create if missing) -- Optionally deliver output (`payload.deliver === true`) to the configured channel/to. +- Optionally deliver output (`payload.deliver === true`) to the configured provider/to. - Isolated jobs always enqueue a summary system event to the main session when they finish (derived from the last agent text output). - Prefix defaults to `Cron`, and can be customized via `isolation.postToMainPrefix`. - If `deliver` is omitted/false, nothing is sent to external providers; you still get the main-session summary and can inspect the full isolated transcript in `cron:`. ### “Run in parallel to main” -Clawdbot currently serializes command execution through a global in-process queue (`src/process/command-queue.ts`) to avoid collisions. +Clawdbot currently serializes command execution through a global in-process queue ([`src/process/command-queue.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/process/command-queue.ts)) to avoid collisions. To support isolated cron jobs running “in parallel”, we should introduce **lanes** (keyed queues) plus a global concurrency cap: - Lane `"main"`: inbound auto-replies + main heartbeat. @@ -198,7 +198,7 @@ We need a way for the Gateway (or the scheduler) to request an immediate heartbe Design: - `startHeartbeatRunner` owns the real heartbeat execution and installs a wake handler. -- Wake hook lives in `src/infra/heartbeat-wake.ts`: +- Wake hook lives in [`src/infra/heartbeat-wake.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/heartbeat-wake.ts): - `setHeartbeatWakeHandler(fn | null)` installed by the heartbeat runner - `requestHeartbeatNow({ reason, coalesceMs? })` - If the handler is absent, the request is stored as “pending”; the next time the handler is installed, it runs once. @@ -275,7 +275,7 @@ Add a `cron` command group (all commands should also support `--json` where sens - `--wake now|next-heartbeat` - payload flags (choose one): - `--system-event ""` - - `--message "" [--deliver] [--channel last|whatsapp|telegram|discord|slack|signal|imessage] [--to ]` + - `--message "" [--deliver] [--provider last|whatsapp|telegram|discord|slack|signal|imessage] [--to ]` - `clawdbot cron edit ...` (patch-by-flags, non-interactive) - `clawdbot cron rm ` @@ -313,7 +313,7 @@ clawdbot cron add \ --wake now \ --message "Daily check: scan calendar + inbox; deliver only if urgent." \ --deliver \ - --channel last + --provider last ``` ### Run weekly (every Wednesday) @@ -328,7 +328,7 @@ clawdbot cron add \ --wake now \ --message "Weekly: summarize status and remind me of goals." \ --deliver \ - --channel last + --provider last ``` ### “Next heartbeat” diff --git a/docs/dashboard.md b/docs/dashboard.md index e859fb7c7..e44a1c72b 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -9,9 +9,9 @@ The Gateway dashboard is the browser Control UI served at `/` by default (override with `gateway.controlUi.basePath`). Key references: -- `docs/control-ui.md` for usage and UI capabilities. -- `docs/tailscale.md` for Serve/Funnel automation. -- `docs/web.md` for bind modes and security notes. +- [`docs/control-ui.md`](https://docs.clawd.bot/control-ui) for usage and UI capabilities. +- [`docs/tailscale.md`](https://docs.clawd.bot/tailscale) for Serve/Funnel automation. +- [`docs/web.md`](https://docs.clawd.bot/web) for bind modes and security notes. Authentication is enforced at the WebSocket handshake via `connect.params.auth` -(token or password). See `gateway.auth` in `docs/configuration.md`. +(token or password). See `gateway.auth` in [`docs/configuration.md`](https://docs.clawd.bot/configuration). diff --git a/docs/discord.md b/docs/discord.md index a96bfff0b..6b006c068 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -1,7 +1,7 @@ --- summary: "Discord bot support status, capabilities, and configuration" read_when: - - Working on Discord surface features + - Working on Discord provider features --- # Discord (Bot API) @@ -11,9 +11,9 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa ## Goals - Talk to Clawdbot via Discord DMs or guild channels. -- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `discord:group:` (display names use `discord:#`). +- Direct chats collapse into the agent's main session (default `agent:main:main`); guild channels stay isolated as `agent::discord:channel:` (display names use `discord:#`). - Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`. -- Keep routing deterministic: replies always go back to the surface they arrived on. +- Keep routing deterministic: replies always go back to the provider they arrived on. ## How it works 1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token. @@ -23,13 +23,18 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa - If you prefer env vars, still add `discord: { enabled: true }` to `~/.clawdbot/clawdbot.json` and set `DISCORD_BOT_TOKEN`. 5. Direct chats: use `user:` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. 6. Guild channels: use `channel:` for delivery. Mentions are required by default and can be set per guild or per channel. -7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs. -8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. -9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists. -10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. -11. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`). - - The `discord` tool is only exposed when the current surface is Discord. -12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session. +7. Direct chats: secure by default via `discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code; approve via `clawdbot pairing approve --provider discord `. + - To keep old “open to anyone” behavior: set `discord.dm.policy="open"` and `discord.dm.allowFrom=["*"]`. + - To hard-allowlist: set `discord.dm.policy="allowlist"` and list senders in `discord.dm.allowFrom`. + - To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`. +8. Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`. +9. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. +10. Optional native commands: set `commands.native: true` to register native commands in Discord; set `commands.native: false` to clear previously registered native commands. Text commands are controlled by `commands.text` and must be sent as standalone `/...` messages. Use `commands.useAccessGroups: false` to bypass access-group checks for commands. + - Full command list + config: https://docs.clawd.bot/slash-commands +11. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. +12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`). + - The `discord` tool is only exposed when the current provider is Discord. +13. Native commands use isolated session keys (`discord:slash:${userId}`) rather than the shared `main` session. Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`. @@ -59,7 +64,7 @@ In your app: **OAuth2** → **URL Generator** **Scopes** - ✅ `bot` -- ✅ `applications.commands` (only if you want slash commands; otherwise leave unchecked) +- ✅ `applications.commands` (required for native commands) **Bot Permissions** (minimal baseline) - ✅ View Channels @@ -138,7 +143,7 @@ Notes: - The bot lacks channel permissions (View/Send/Read History), or - Your config requires mentions and you didn’t mention it, or - Your guild/channel allowlist denies the channel/user. -- **DMs don’t work**: `discord.dm.enabled` may be `false` or `discord.dm.allowFrom` doesn’t include you. +- **DMs don’t work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you haven’t been approved yet (`discord.dm.policy="pairing"`). ## Capabilities & limits - DMs and guild text channels (threads are treated as separate channels; voice not supported). @@ -155,6 +160,7 @@ Notes: discord: { enabled: true, token: "abc.123", + groupPolicy: "open", mediaMaxMb: 8, actions: { reactions: true, @@ -174,14 +180,9 @@ Notes: moderation: false }, replyToMode: "off", - slashCommand: { - enabled: true, - name: "clawd", - sessionPrefix: "discord:slash", - ephemeral: true - }, dm: { enabled: true, + policy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["123456789012345678", "steipete"], groupEnabled: false, groupChannels: ["clawd-dm"] @@ -203,10 +204,15 @@ Notes: } ``` +Ack reactions are controlled globally via `messages.ackReaction` + +`messages.ackReactionScope`. + - `dm.enabled`: set `false` to ignore all DMs (default `true`). -- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender. +- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`. +- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. - `dm.groupEnabled`: enable group DMs (default `false`). - `dm.groupChannels`: optional allowlist for group DM channel ids or slugs. +- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists. - `guilds`: per-guild rules keyed by guild id (preferred) or slug. - `guilds."*"`: default per-guild settings applied when no explicit entry exists. - `guilds..slug`: optional friendly slug used for display names. @@ -214,7 +220,6 @@ Notes: - `guilds..channels`: channel rules (keys are channel slugs or ids). - `guilds..requireMention`: per-guild mention requirement (overridable per channel). - `guilds..reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`). -- `slashCommand`: optional config for user-installed slash commands (ephemeral responses). - `mediaMaxMb`: clamp inbound media saved to disk. - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). - `actions`: per-action tool gates; omit to allow all (set `false` to disable). @@ -268,11 +273,9 @@ Allowlist matching notes: - Use `*` to allow any sender/channel. - When `guilds..channels` is present, channels not listed are denied by default. -Slash command notes: -- Register a chat input command in Discord with at least one string option (e.g., `prompt`). -- The first non-empty string option is treated as the prompt. -- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules). -- Clawdbot will auto-register `/clawd` (or the configured name) if it doesn't already exist. +Native command notes: +- The registered commands mirror Clawdbot’s chat commands. +- Native commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules). ## Tool actions The agent can call `discord` with actions like: diff --git a/docs/discovery.md b/docs/discovery.md index bbe782ab8..bc7f8a4fb 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -42,7 +42,7 @@ Target direction: - The **gateway** advertises its bridge via Bonjour. - Clients browse and show a “pick a gateway” list, then store the chosen endpoint. -Troubleshooting and beacon details: `docs/bonjour.md`. +Troubleshooting and beacon details: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour). #### Current implementation @@ -77,7 +77,7 @@ If the gateway can detect it is running under Tailscale, it publishes `tailnetDn When there is no direct route (or direct is disabled), clients can always connect via SSH by forwarding the loopback gateway port. -See `docs/remote.md`. +See [`docs/remote.md`](https://docs.clawd.bot/remote). ## Transport selection (client policy) @@ -92,7 +92,7 @@ Recommended client behavior: The gateway is the source of truth for node/client admission. -- Pairing requests are created/approved/rejected in the gateway (see `docs/gateway/pairing.md`). +- Pairing requests are created/approved/rejected in the gateway (see [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing)). - The bridge enforces: - auth (token / keypair) - scopes/ACLs (bridge is not a raw proxy to every gateway method) diff --git a/docs/docker.md b/docs/docker.md index c587233c6..5950c3fb4 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -68,7 +68,7 @@ pnpm test:docker:qr ### Notes - Gateway bind defaults to `lan` for container use. -- The gateway container is the source of truth for sessions (`~/.clawdbot/sessions`). +- The gateway container is the source of truth for sessions (`~/.clawdbot/agents//sessions/`). ## Per-session Agent Sandbox (host gateway + Docker tools) @@ -88,7 +88,7 @@ container. The gateway stays on your host, but the tool execution is isolated: - Workspace per session under `~/.clawdbot/sandboxes` - Auto-prune: idle > 24h OR age > 7d - Network: `none` by default (explicitly opt-in if you need egress) -- Default allow: `bash`, `process`, `read`, `write`, `edit` +- Default allow: `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` - Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway` ### Enable sandboxing @@ -124,7 +124,7 @@ container. The gateway stays on your host, but the tool execution is isolated: extraHosts: ["internal.service:10.0.0.5"] }, tools: { - allow: ["bash", "process", "read", "write", "edit"], + allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"], deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] }, prune: { @@ -252,7 +252,7 @@ Example: ## Troubleshooting -- Image missing: build with `scripts/sandbox-setup.sh` or set `agent.sandbox.docker.image`. +- Image missing: build with [`scripts/sandbox-setup.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/sandbox-setup.sh) or set `agent.sandbox.docker.image`. - Container not running: it will auto-create per session on demand. - Permission errors in sandbox: set `docker.user` to a UID:GID that matches your mounted workspace ownership (or chown the workspace folder). diff --git a/docs/docs.json b/docs/docs.json index 26ceef64b..8802512e9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -23,23 +23,25 @@ "navigation": { "groups": [ { - "group": "Getting Started", + "group": "Start Here", "pages": [ + "getting-started", + "wizard", "index", + "setup", + "pairing", + "faq", + "clawd", "showcase", "hubs", - "onboarding", - "clawd", - "faq" + "onboarding" ] }, { - "group": "Installation", + "group": "Install Options", "pages": [ - "wizard", "nix", - "docker", - "setup" + "docker" ] }, { diff --git a/docs/doctor.md b/docs/doctor.md index 51292f5fa..a68c36be0 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -16,6 +16,7 @@ read_when: - Checks sandbox Docker images when sandboxing is enabled (offers to build or switch to legacy names). - Detects legacy Clawdis services (launchd/systemd/schtasks) and offers to migrate them. - On Linux, checks if systemd user lingering is enabled and can enable it (required to keep the Gateway alive after logout). +- Migrates legacy on-disk state layouts (sessions, agentDir, provider auth dirs) into the current per-agent/per-account structure. ## Legacy config file migration If `~/.clawdis/clawdis.json` exists and `~/.clawdbot/clawdbot.json` does not, doctor will migrate the file and normalize old paths/image names. @@ -35,6 +36,19 @@ Current migrations: - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` → `agent.models` + `agent.model.primary/fallbacks` + `agent.imageModel.primary/fallbacks` +## Legacy state migrations (disk layout) + +Doctor can migrate older on-disk layouts into the current structure: +- Sessions store + transcripts: + - from `~/.clawdbot/sessions/` to `~/.clawdbot/agents//sessions/` +- Agent dir: + - from `~/.clawdbot/agent/` to `~/.clawdbot/agents//agent/` +- WhatsApp auth state (Baileys): + - from legacy `~/.clawdbot/credentials/*.json` (except `oauth.json`) + - to `~/.clawdbot/credentials/whatsapp//...` (default account id: `default`) + +These migrations are best-effort and idempotent; doctor will emit warnings when it leaves any legacy folders behind as backups. + ## Usage ```bash diff --git a/docs/elevated.md b/docs/elevated.md index fcffe2de6..b95a9eb78 100644 --- a/docs/elevated.md +++ b/docs/elevated.md @@ -22,7 +22,7 @@ read_when: ## Availability + allowlists - Feature gate: `agent.elevated.enabled` (default can be off via config even if the code supports it). -- Sender allowlist: `agent.elevated.allowFrom` with per-surface allowlists (e.g. `discord`, `whatsapp`). +- Sender allowlist: `agent.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`). - Both must pass; otherwise elevated is treated as unavailable. - Discord fallback: if `agent.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `agent.elevated.allowFrom.discord` (even `[]`) to override. diff --git a/docs/faq.md b/docs/faq.md index f51fe824d..2614ebe36 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -3,7 +3,7 @@ summary: "Frequently asked questions about Clawdbot setup, configuration, and us --- # FAQ 🦞 -Common questions from the community. For detailed configuration, see [configuration.md](./configuration.md). +Common questions from the community. For detailed configuration, see [Configuration](https://docs.clawd.bot/configuration). ## Installation & Setup @@ -14,12 +14,15 @@ Everything lives under `~/.clawdbot/`: | Path | Purpose | |------|---------| | `~/.clawdbot/clawdbot.json` | Main config (JSON5) | -| `~/.clawdbot/credentials/oauth.json` | OAuth credentials (Anthropic/OpenAI, etc.) | -| `~/.clawdbot/agent/auth-profiles.json` | Auth profiles (OAuth + API keys) | -| `~/.clawdbot/agent/auth.json` | Runtime API key cache (managed automatically) | -| `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens | -| `~/.clawdbot/sessions/` | Conversation history & state | -| `~/.clawdbot/sessions/sessions.json` | Session metadata | +| `~/.clawdbot/credentials/oauth.json` | Legacy OAuth import (copied into auth profiles on first use) | +| `~/.clawdbot/agents//agent/auth-profiles.json` | Auth profiles (OAuth + API keys) | +| `~/.clawdbot/agents//agent/auth.json` | Runtime auth cache (managed automatically) | +| `~/.clawdbot/credentials/` | Provider auth state (e.g. `whatsapp//creds.json`) | +| `~/.clawdbot/agents/` | Per-agent state (agentDir + sessions) | +| `~/.clawdbot/agents//sessions/` | Conversation history & state (per agent) | +| `~/.clawdbot/agents//sessions/sessions.json` | Session metadata (per agent) | + +Legacy single-agent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor`). Your **workspace** (AGENTS.md, memory files, skills) is separate — configured via `agent.workspace` in your config (default: `~/clawd`). @@ -37,7 +40,7 @@ Some features are platform-specific: ### What are the minimum system requirements? -**Basically nothing!** The gateway is very lightweight — all heavy compute happens on Anthropic's servers. +**Basically nothing!** The gateway is very lightweight — heavy compute happens on your model provider’s servers (Anthropic/OpenAI/etc.). - **RAM:** 512MB-1GB is enough (community member runs on 1GB VPS!) - **CPU:** 1 core is fine for personal use @@ -119,7 +122,7 @@ They're **separate billing**! An API key does NOT use your subscription. pnpm clawdbot login ``` -**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/credentials/oauth.json` to your server. The auth is just a JSON file. +**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/agents//agent/auth-profiles.json` (and `auth.json` if present) to your server. Legacy installs can still import `~/.clawdbot/credentials/oauth.json` on first use. ### How are env vars loaded? @@ -141,15 +144,15 @@ Or set `CLAWDBOT_LOAD_SHELL_ENV=1` (timeout: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=1500 ### Does enterprise OAuth work? -**Not currently.** Enterprise accounts use SSO which requires a different auth flow that pi-coding-agent doesn't support yet. +**Not currently.** Enterprise accounts use SSO which requires a different auth flow that Clawdbot’s OAuth login doesn’t support yet. -**Workaround:** Ask your enterprise admin to provision an API key via the Anthropic console, then use that with `ANTHROPIC_API_KEY`. +**Workaround:** Ask your enterprise admin to provision an API key (Anthropic or OpenAI) and use it via `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`. ### OAuth callback not working (containers/headless)? OAuth needs the callback to reach the machine running the CLI. Options: -1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/credentials/oauth.json` to the container. +1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/agents//agent/auth-profiles.json` (and `auth.json` if present) to the container. Legacy flow: copy `~/.clawdbot/credentials/oauth.json` to trigger import. 2. **SSH tunnel** — `ssh -L 18789:localhost:18789 user@server` 3. **Tailscale** — Put both machines on your tailnet. @@ -191,7 +194,15 @@ OAuth needs the callback to reach the machine running the CLI. Options: ### Can I run Clawdbot in Docker? -There's no official Docker setup yet, but it works. Key considerations: +Yes — Docker is optional but supported. Recommended: run the setup script: + +```bash +./docker-setup.sh +``` + +It builds the image, runs onboarding + login, and starts Docker Compose. For manual steps and sandbox notes, see `docs/docker.md`. + +Key considerations: - **WhatsApp login:** QR code works in terminal — no display needed. - **Persistence:** Mount `~/.clawdbot/` and your workspace as volumes. @@ -218,7 +229,7 @@ pnpm clawdbot gateway bash /app/start.sh ``` -Docker support is on the roadmap — PRs welcome! +For more detail, see `docs/docker.md`. ### Can I run Clawdbot headless on a VPS? @@ -290,7 +301,7 @@ Per-group activation can be changed by the owner: - `/activation mention` — respond only when mentioned (default) - `/activation always` — respond to all messages -See [groups.md](./groups.md) for details. +See [Groups](https://docs.clawd.bot/groups) for details. --- @@ -298,7 +309,7 @@ See [groups.md](./groups.md) for details. ### How much context can Clawdbot handle? -Claude Opus has a 200k token context window, and Clawdbot uses **autocompaction** — older conversation gets summarized to stay under the limit. +Context window depends on the model. Clawdbot uses **autocompaction** — older conversation gets summarized to stay under the limit. Practical tips: - Keep `AGENTS.md` focused, not bloated. @@ -327,7 +338,7 @@ cat ~/.clawdbot/clawdbot.json | grep workspace - **Telegram** — Via Bot API (grammY). - **Discord** — Bot integration. - **iMessage** — Via `imsg` CLI (macOS only). -- **Signal** — Via `signal-cli` (see [signal.md](./signal.md)). +- **Signal** — Via `signal-cli` (see [Signal](https://docs.clawd.bot/signal)). - **WebChat** — Browser-based chat UI. ### Discord: Bot works in channels but not DMs? @@ -365,7 +376,7 @@ If you send an image but your Clawd doesn't "see" it, check these: Not all models support images! Check `agent.model` in your config: -- ✅ Vision: `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5`, `gpt-5.2`, `gpt-4o`, `gemini-pro` +- ✅ Vision (examples): `anthropic/claude-opus-4-5`, `anthropic/claude-sonnet-4-5`, `anthropic/claude-haiku-4-5`, `openai/gpt-5.2`, `openai/gpt-4o`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview` - ❌ No vision: Most local LLMs (Llama, Mistral), older models, text-only configs **2. Is media being downloaded?** @@ -486,24 +497,16 @@ Headless/system services are not configured out of the box. The gateway runs under a supervisor that auto-restarts it. You need to stop the supervisor, not just kill the process. -**macOS (launchd)** +**macOS (Clawdbot.app)** + +- Quit the menu bar app to stop the gateway. +- For debugging, restart via the app (or `scripts/restart-mac.sh` when working in the repo). +- To inspect launchd state: `launchctl print gui/$UID | grep clawdbot` + +**macOS (CLI launchd service, if installed)** ```bash -# Check if running -launchctl list | grep clawdbot - -# Stop (disable does NOT stop a running job) clawdbot gateway stop - -# Stop and disable -launchctl disable gui/$UID/com.clawdbot.gateway -launchctl bootout gui/$UID/com.clawdbot.gateway - -# Re-enable later -launchctl enable gui/$UID/com.clawdbot.gateway -launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.gateway.plist - -# Or just restart clawdbot gateway restart ``` @@ -535,8 +538,11 @@ pm2 delete clawdbot launchctl disable gui/$UID/com.clawdbot.gateway launchctl bootout gui/$UID/com.clawdbot.gateway 2>/dev/null -# Linux: stop systemd service -sudo systemctl disable --now clawdbot +# Linux: stop systemd user service +systemctl --user disable --now clawdbot-gateway.service + +# Linux (system-wide unit, if installed) +sudo systemctl disable --now clawdbot-gateway.service # Kill any remaining processes pkill -f "clawdbot" @@ -544,10 +550,10 @@ pkill -f "clawdbot" # Remove data trash ~/.clawdbot -# Remove repo and re-clone -trash ~/clawdbot -git clone https://github.com/clawdbot/clawdbot.git -cd clawdbot && pnpm install && pnpm build +# Remove repo and re-clone (adjust path if you cloned elsewhere) +trash ~/Projects/clawdbot +git clone https://github.com/clawdbot/clawdbot.git ~/Projects/clawdbot +cd ~/Projects/clawdbot && pnpm install && pnpm build pnpm clawdbot onboard ``` @@ -559,17 +565,21 @@ Quick reference (send these in chat): | Command | Action | |---------|--------| +| `/help` | Show available commands | | `/status` | Health + session info | | `/new` or `/reset` | Reset the session | -| `/compact` | Compact session context | - -Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authorization on other surfaces). +| `/compact [notes]` | Compact session context | +| `/restart` | Restart Clawdbot | +| `/activation mention\|always` | Group activation (owner-only) | | `/think ` | Set thinking level (off\|minimal\|low\|medium\|high) | | `/verbose on\|off` | Toggle verbose mode | | `/elevated on\|off` | Toggle elevated bash mode (approved senders only) | -| `/activation mention\|always` | Group activation (owner-only) | | `/model ` | Switch AI model (see below) | -| `/queue instant\|batch\|serial` | Message queuing mode | +| `/queue ` | Queue mode (see below) | + +Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authorization on other surfaces). +Commands are only recognized when the entire message is the command (slash required; no plain-text aliases). +Full list + config: https://docs.clawd.bot/slash-commands ### How do I switch models on the fly? @@ -615,8 +625,8 @@ If you don't want to use Anthropic directly, you can use alternative providers: ```json5 { agent: { - model: { primary: "openrouter/anthropic/claude-sonnet-4" }, - models: { "openrouter/anthropic/claude-sonnet-4": {} }, + model: { primary: "openrouter/anthropic/claude-sonnet-4-5" }, + models: { "openrouter/anthropic/claude-sonnet-4-5": {} }, env: { OPENROUTER_API_KEY: "sk-or-..." } } } @@ -647,11 +657,8 @@ If you get weird errors after switching models, try `/think off` and `/new` to r ### How do I stop/cancel a running task? -Send `/stop` to immediately abort the current agent run. Other stop words also work: -- `/stop` -- `/abort` -- `/esc` -- `/exit` +Send one of these **as a standalone message** (no slash): `stop`, `abort`, `esc`, `wait`, `exit`. +These are abort triggers, not slash commands. For background processes (like Codex), use: ``` @@ -660,8 +667,10 @@ process action:kill sessionId:XXX You can also configure `routing.queue.mode` to control how new messages interact with running tasks: - `steer` — New messages redirect the current task -- `interrupt` — Kills current run, starts fresh -- `collect` — Queues messages for after +- `followup` — Run messages one at a time +- `collect` — Batch messages, reply once after things settle +- `steer-backlog` — Steer now, process backlog afterward +- `interrupt` — Abort current run, start fresh ### Does Codex CLI use my ChatGPT Pro subscription or API credits? @@ -684,11 +693,13 @@ If you have a ChatGPT subscription, use browser auth to avoid API charges! Use `/queue` to control how messages sent in quick succession are handled: -- **`/queue instant`** — New messages interrupt/steer the current response -- **`/queue batch`** — Messages queue up, processed after current turn -- **`/queue serial`** — One at a time, in order +- **`/queue steer`** — New messages steer the current response +- **`/queue collect`** — Batch messages, reply once after things settle +- **`/queue followup`** — One at a time, in order +- **`/queue steer-backlog`** — Steer now, process backlog afterward +- **`/queue interrupt`** — Abort current run, start fresh -If you tend to send multiple short messages, `/queue instant` feels most natural. +If you tend to send multiple short messages, `/queue steer` feels most natural. --- diff --git a/docs/gateway.md b/docs/gateway.md index 6a3eb4e54..6fbf3aa0e 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -111,7 +111,7 @@ CLAWDBOT_CONFIG_PATH=~/.clawdbot/b.json CLAWDBOT_STATE_DIR=~/.clawdbot-b clawdbo - `node.invoke` — invoke a command on a node (e.g. `canvas.*`, `camera.*`). - `node.pair.*` — pairing lifecycle (`request`, `list`, `approve`, `reject`, `verify`). -See also: `docs/presence.md` for how presence is produced/deduped and why `instanceId` matters. +See also: [`docs/presence.md`](https://docs.clawd.bot/presence) for how presence is produced/deduped and why `instanceId` matters. ## Events - `agent` — streamed tool/output events from the agent run (seq-tagged). @@ -127,7 +127,7 @@ See also: `docs/presence.md` for how presence is produced/deduped and why `insta ## Typing and validation - Server validates every inbound frame with AJV against JSON Schema emitted from the protocol definitions. - Clients (TS/Swift) consume generated types (TS directly; Swift via the repo’s generator). -- Types live in `src/gateway/protocol/*.ts`; regenerate schemas/models with `pnpm protocol:gen` (writes `dist/protocol.schema.json`) and `pnpm protocol:gen:swift` (writes `apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`). +- Types live in [`src/gateway/protocol/*.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/protocol/*.ts); regenerate schemas/models with `pnpm protocol:gen` (writes [`dist/protocol.schema.json`](https://github.com/clawdbot/clawdbot/blob/main/dist/protocol.schema.json)) and `pnpm protocol:gen:swift` (writes [`apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift)). ## Connection snapshot - `hello-ok` includes a `snapshot` with `presence`, `health`, `stateVersion`, and `uptimeMs` plus `policy {maxPayload,maxBufferedBytes,tickIntervalMs}` so clients can render immediately without extra requests. diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 1cc10c53a..9afa96397 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -20,7 +20,7 @@ This enables: - **Bridge**: direct transport endpoint owned by the gateway. The bridge does not decide membership. ## API surface (gateway protocol) -These are conceptual method names; wire them into `src/gateway/protocol/schema.ts` and regenerate Swift types. +These are conceptual method names; wire them into [`src/gateway/protocol/schema.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/protocol/schema.ts) and regenerate Swift types. ### Events - `node.pair.requested` @@ -78,10 +78,10 @@ Optional interactive helper: - `clawdbot nodes watch` (subscribe to `node.pair.requested` and prompt in-place) Implementation pointers: -- CLI commands: `src/cli/nodes-cli.ts` -- Gateway handlers + events: `src/gateway/server.ts` -- Pairing store: `src/infra/node-pairing.ts` (under `~/.clawdbot/nodes/`) -- Optional macOS UI prompt (frontend only): `apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift` +- CLI commands: [`src/cli/nodes-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/nodes-cli.ts) +- Gateway handlers + events: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) + [`src/gateway/server-methods/nodes.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/nodes.ts) +- Pairing store: [`src/infra/node-pairing.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/node-pairing.ts) (under `~/.clawdbot/nodes/`) +- Optional macOS UI prompt (frontend only): [`apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift) - Push-first: listens to `node.pair.requested`/`node.pair.resolved`, does a `node.pair.list` on startup/reconnect, and only runs a slow safety poll while a request is pending/visible. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..3244784fb --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,133 @@ +--- +summary: "Beginner guide: from repo checkout to first message (wizard, auth, providers, pairing)" +read_when: + - First time setup from zero + - You want the fastest path from checkout → onboarding → first message +--- + +# Getting Started + +Goal: go from **zero** → **first working chat** (with sane defaults) as quickly as possible. + +Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up: +- model/auth (OAuth recommended) +- gateway settings +- providers (WhatsApp/Telegram/Discord/…) +- pairing defaults (secure DMs) +- workspace bootstrap + skills +- optional background daemon + +If you want the deeper reference pages, jump to: [Wizard](https://docs.clawd.bot/wizard), [Setup](https://docs.clawd.bot/setup), [Pairing](https://docs.clawd.bot/pairing), [Security](https://docs.clawd.bot/security). + +## 0) Prereqs + +- Node `>=22` +- `bun` (preferred) or `pnpm` +- Git + +macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough. + +## 1) Check out from source + +```bash +git clone https://github.com/clawdbot/clawdbot.git +cd clawdbot +bun install +``` + +Note: `pnpm` is also supported: + +```bash +pnpm install +``` + +## 2) Build the Control UI (recommended) + +The Gateway serves the browser dashboard (Control UI) when assets exist. + +```bash +bun run ui:install +bun run ui:build +bun run build +``` + +If you skip UI build, the gateway still works — you just won’t get the dashboard. + +## 3) Run the onboarding wizard + +```bash +bun run clawdbot onboard +``` + +What you’ll choose: +- **Local vs Remote** gateway +- **Auth**: Anthropic OAuth or OpenAI OAuth (recommended), API key (optional), or skip for now +- **Providers**: WhatsApp QR login, bot tokens, etc. +- **Daemon**: optional background install (launchd/systemd/Task Scheduler) + +Wizard doc: https://docs.clawd.bot/wizard + +### Auth: where it lives (important) + +- OAuth credentials (legacy import): `~/.clawdbot/credentials/oauth.json` +- Auth profiles (OAuth + API keys): `~/.clawdbot/agents//agent/auth-profiles.json` + +Headless/server tip: do OAuth on a normal machine first, then copy `oauth.json` to the gateway host. + +## 4) Start the Gateway + +If the wizard didn’t start it for you: + +```bash +bun run clawdbot gateway --port 18789 --verbose +``` + +Dashboard (local loopback): `http://127.0.0.1:18789/` + +## 5) Pair + connect your first chat surface + +### WhatsApp (QR login) + +```bash +bun run clawdbot login +``` + +Scan via WhatsApp → Settings → Linked Devices. + +WhatsApp doc: https://docs.clawd.bot/whatsapp + +### Telegram / Discord / others + +The wizard can write tokens/config for you. If you prefer manual config, start with: +- Telegram: https://docs.clawd.bot/telegram +- Discord: https://docs.clawd.bot/discord + +## 6) DM safety (pairing approvals) + +Default posture: unknown DMs get a short code and messages are not processed until approved. + +Approve: + +```bash +bun run clawdbot pairing list --provider telegram +bun run clawdbot pairing approve --provider telegram +``` + +Pairing doc: https://docs.clawd.bot/pairing + +## 7) Verify end-to-end + +In a new terminal: + +```bash +bun run clawdbot health +bun run clawdbot send --to +15555550123 --message "Hello from Clawdbot" +``` + +If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it. + +## Next steps (optional, but great) + +- macOS menu bar app + voice wake: https://docs.clawd.bot/macos +- iOS/Android nodes (Canvas/camera/voice): https://docs.clawd.bot/nodes +- Remote access (SSH tunnel / Tailscale Serve): https://docs.clawd.bot/remote and https://docs.clawd.bot/tailscale diff --git a/docs/gmail-pubsub.md b/docs/gmail-pubsub.md index 63aa0333c..94961b2f0 100644 --- a/docs/gmail-pubsub.md +++ b/docs/gmail-pubsub.md @@ -13,7 +13,7 @@ Goal: Gmail watch -> Pub/Sub push -> `gog gmail watch serve` -> Clawdbot webhook - `gcloud` installed and logged in. - `gog` (gogcli) installed and authorized for the Gmail account. -- Clawdbot hooks enabled (see `docs/webhook.md`). +- Clawdbot hooks enabled (see [`docs/webhook.md`](https://docs.clawd.bot/webhook)). - `tailscale` logged in if you want a public HTTPS endpoint via Funnel. Example hook config (enable Gmail preset mapping): @@ -30,7 +30,7 @@ Example hook config (enable Gmail preset mapping): ``` To customize payload handling, add `hooks.mappings` or a JS/TS transform module -under `hooks.transformsDir` (see `docs/webhook.md`). +under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/webhook)). ## Wizard (recommended) diff --git a/docs/grammy.md b/docs/grammy.md index fb212f3ec..e5fc77b48 100644 --- a/docs/grammy.md +++ b/docs/grammy.md @@ -17,8 +17,8 @@ Updated: 2025-12-07 - **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`. - **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls). -- **Sessions:** direct chats map to `main`; groups map to `telegram:group:`; replies route back to the same surface. -- **Config knobs:** `telegram.botToken`, `telegram.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`. +- **Sessions:** direct chats collapse into the agent main session (`agent::`); groups use `agent::telegram:group:`; replies route back to the same provider. +- **Config knobs:** `telegram.botToken`, `telegram.dmPolicy`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. Open questions diff --git a/docs/group-messages.md b/docs/group-messages.md index 07be1e4f6..e403634d2 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -10,9 +10,9 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. ## What’s implemented (2025-12-03) -- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. -- Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies. -- Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. +- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. When `whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). +- Group policy: `whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`). +- Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. - Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. - Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. - Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. @@ -52,13 +52,13 @@ Use the group chat command: - `/activation mention` - `/activation always` -Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode. +Only the owner number (from `whatsapp.allowFrom`, or the bot’s own E.164 when unset) can change this. Send `/status` as a standalone message in the group to see the current activation mode. ## How to use 1) Add Clawd UK (`+447700900123`) to the group. 2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it. 3) The agent prompt will include recent group context plus the trailing `[from: …]` marker so it can address the right person. -4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; your personal DM session remains independent. +4) Session-level directives (`/verbose on`, `/think high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; send them as standalone messages so they register. Your personal DM session remains independent. ## Testing / verification - Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix). @@ -70,4 +70,4 @@ Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own ## Known considerations - Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. - Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response. -- Session store entries will appear as `whatsapp:group:` in the session store (`~/.clawdbot/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet. +- Session store entries will appear as `agent::whatsapp:group:` in the session store (`~/.clawdbot/agents//sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet. diff --git a/docs/groups.md b/docs/groups.md index 48a562ed4..98faa8547 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -1,21 +1,69 @@ --- -summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/iMessage)" +summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage)" read_when: - Changing group chat behavior or mention gating --- # Groups -Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage. +Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage. ## Session keys -- Group sessions use `surface:group:` session keys (rooms/channels use `surface:channel:`). +- Group sessions use `agent:::group:` session keys (rooms/channels use `agent:::channel:`). - Direct chats use the main session (or per-sender if configured). - Heartbeats are skipped for group sessions. ## Display labels -- UI labels use `displayName` when available, formatted as `surface:`. +- UI labels use `displayName` when available, formatted as `:`. - `#room` is reserved for rooms/channels; group chats use `g-` (lowercase, spaces -> `-`, keep `#@+._-`). +## Group policy +Control how group/room messages are handled per provider: + +```json5 +{ + whatsapp: { + groupPolicy: "disabled", // "open" | "disabled" | "allowlist" + groupAllowFrom: ["+15551234567"] + }, + telegram: { + groupPolicy: "disabled", + groupAllowFrom: ["123456789", "@username"] + }, + signal: { + groupPolicy: "disabled", + groupAllowFrom: ["+15551234567"] + }, + imessage: { + groupPolicy: "disabled", + groupAllowFrom: ["chat_id:123"] + }, + discord: { + groupPolicy: "allowlist", + guilds: { + "GUILD_ID": { channels: { help: { allow: true } } } + } + }, + slack: { + groupPolicy: "allowlist", + channels: { "#general": { allow: true } } + } +} +``` + +| Policy | Behavior | +|--------|----------| +| `"open"` | Default. Groups bypass allowlists; mention-gating still applies. | +| `"disabled"` | Block all group messages entirely. | +| `"allowlist"` | Only allow groups/rooms that match the configured allowlist. | + +Notes: +- `groupPolicy` is separate from mention-gating (which requires @mentions). +- WhatsApp/Telegram/Signal/iMessage: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- Discord: allowlist uses `discord.guilds..channels`. +- Slack: allowlist uses `slack.channels`. +- Group DMs are controlled separately (`discord.dm.*`, `slack.dm.*`). +- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. + ## Mention gating (default) Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. @@ -54,12 +102,15 @@ Notes: - Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - Discord defaults live in `discord.guilds."*"` (overridable per guild/channel). +## Group allowlists +When `whatsapp.groups`, `telegram.groups`, or `imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior. + ## Activation (owner-only) Group owners can toggle per-group activation: - `/activation mention` - `/activation always` -Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Other surfaces currently ignore `/activation`. +Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Send the command as a standalone message. Other surfaces currently ignore `/activation`. ## Context fields Group inbound payloads set: @@ -76,4 +127,4 @@ The agent system prompt includes a group intro on the first turn of a new group - Group replies always go back to the same `chat_id`. ## WhatsApp specifics -See `docs/group-messages.md` for WhatsApp-only behavior (history injection, mention handling details). +See [`docs/group-messages.md`](https://docs.clawd.bot/group-messages) for WhatsApp-only behavior (history injection, mention handling details). diff --git a/docs/health.md b/docs/health.md index d880e8955..5647bb050 100644 --- a/docs/health.md +++ b/docs/health.md @@ -11,18 +11,18 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. - `clawdbot status` — local summary: whether creds exist, auth age, session store path + recent sessions. - `clawdbot status --deep` — also probes the running Gateway (WhatsApp connect + Telegram + Discord APIs). - `clawdbot health --json` — asks the running Gateway for a full health snapshot (WS-only; no direct Baileys socket). -- Send `/status` in WhatsApp/WebChat to get a status reply without invoking the agent. +- Send `/status` as a standalone message in WhatsApp/WebChat to get a status reply without invoking the agent. - Logs: tail `/tmp/clawdbot/clawdbot-*.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`. ## Deep diagnostics -- Creds on disk: `ls -l ~/.clawdbot/credentials/creds.json` (mtime should be recent). -- Session store: `ls -l ~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`; path can be overridden in config). Count and recent recipients are surfaced via `status`. +- Creds on disk: `ls -l ~/.clawdbot/credentials/whatsapp//creds.json` (mtime should be recent). +- Session store: `ls -l ~/.clawdbot/agents//sessions/sessions.json` (path can be overridden in config). Count and recent recipients are surfaced via `status`. - Relink flow: `clawdbot logout && clawdbot login --verbose` when status codes 409–515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.) ## When something fails - `logged out` or status 409–515 → relink with `clawdbot logout` then `clawdbot login`. - Gateway unreachable → start it: `clawdbot gateway --port 18789` (use `--force` if the port is busy). -- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat.mentionPatterns` and `whatsapp.groups`). +- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`whatsapp.groups`, `routing.groupChat.mentionPatterns`). ## Dedicated "health" command `clawdbot health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout ` to override the 10s default. diff --git a/docs/heartbeat.md b/docs/heartbeat.md index fee828592..6fb021a4d 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -9,13 +9,16 @@ Heartbeat runs periodic agent turns in the **main session** so the model can surface anything that needs attention without spamming the user. ## Prompt contract -- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`). +- Heartbeat body defaults to: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` (configurable via `agent.heartbeat.prompt`). - If nothing needs attention, the model should reply `HEARTBEAT_OK`. - During heartbeat runs, Clawdbot treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. Clawdbot strips the token and discards the reply if the remaining content is **≤ `ackMaxChars`** (default: 30). - If `HEARTBEAT_OK` is in the **middle** of a reply, it is not treated specially. - For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text. +- Heartbeat prompt text is sent **verbatim** as the user message. Clawdbot does + not append extra body text. The system prompt includes a Heartbeats section + and the run is flagged as a heartbeat internally. ### Stray `HEARTBEAT_OK` outside heartbeats If the model accidentally includes `HEARTBEAT_OK` at the start or end of a @@ -35,11 +38,11 @@ and final replies: { agent: { heartbeat: { - every: "30m", // duration string: ms|s|m|h (0m disables) + every: "30m", // default: 30m (0m disables) model: "anthropic/claude-opus-4-5", - target: "last", // last | whatsapp | telegram | none - to: "+15551234567", // optional override for whatsapp/telegram - prompt: "HEARTBEAT", // optional override + target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none + to: "+15551234567", // optional provider-specific override (e.g. E.164 or chat id) + prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.", ackMaxChars: 30 // max chars allowed after HEARTBEAT_OK } } @@ -47,17 +50,28 @@ and final replies: ``` ### Fields -- `every`: heartbeat interval (duration string; default unit minutes). Omit or set - to `0m` to disable. +- `every`: heartbeat interval (duration string; default unit minutes). Default: + `30m`. Set to `0m` to disable. - `model`: optional model override for heartbeat runs (`provider/model`). - `target`: where heartbeat output is delivered. - - `last` (default): send to the last used external channel. - - `whatsapp` / `telegram`: force the channel (optionally set `to`). + - `last` (default): send to the last used external provider. + - `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage`: force the provider (optionally set `to`). - `none`: do not deliver externally; output stays in the session (WebChat-visible). - `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram). -- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). +- `prompt`: optional override for the heartbeat body (default shown above). Safe to + change; heartbeat acks are still keyed off `HEARTBEAT_OK`. - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30). +## Cost awareness +Heartbeats run full agent turns. Shorter intervals burn more tokens. If you +don’t need frequent checks, increase `every`, pick a cheaper `model`, or set +`target: "none"` to keep results internal. + +## HEARTBEAT.md (optional) +If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the +agent to read it. Keep it tiny (short checklist or reminders) to avoid prompt +bloat. + ## Behavior - Runs in the main session (`main`, or `global` when scope is global). - Uses the main lane queue; if requests are in flight, the wake is retried. @@ -66,6 +80,12 @@ and final replies: - If `target` resolves to no external destination (no last route or `none`), the heartbeat still runs but no outbound message is sent. +## Ideas for use +- Check up on the user (light, respectful pings during daytime). +- Handle mundane tasks (triage inboxes, summarize queues, refresh notes). +- Nudge on open loops or reminders. +- Background monitoring (health checks, status polling, low-priority alerts). + ## Wake hook - The gateway exposes a heartbeat wake hook so cron/jobs/webhooks can request an immediate run (`requestHeartbeatNow`). diff --git a/docs/hubs.md b/docs/hubs.md index cd9a9e16d..87e74d053 100644 --- a/docs/hubs.md +++ b/docs/hubs.md @@ -9,140 +9,142 @@ Use these hubs to discover every page, including deep dives and reference docs t ## Start here -- [Index](./index.md) -- [Onboarding](./onboarding.md) -- [Wizard](./wizard.md) -- [Setup](./setup.md) -- [FAQ](./faq.md) -- [Configuration](./configuration.md) -- [Clawd (personal assistant)](./clawd.md) -- [Lore](./lore.md) +- [Index](https://docs.clawd.bot) +- [Getting Started](https://docs.clawd.bot/getting-started) +- [Onboarding](https://docs.clawd.bot/onboarding) +- [Wizard](https://docs.clawd.bot/wizard) +- [Setup](https://docs.clawd.bot/setup) +- [FAQ](https://docs.clawd.bot/faq) +- [Configuration](https://docs.clawd.bot/configuration) +- [Clawd (personal assistant)](https://docs.clawd.bot/clawd) +- [Lore](https://docs.clawd.bot/lore) ## Installation + distribution -- [Docker](./docker.md) -- [Nix](./nix.md) +- [Docker](https://docs.clawd.bot/docker) +- [Nix](https://docs.clawd.bot/nix) ## Core concepts -- [Architecture](./architecture.md) -- [Agent runtime](./agent.md) -- [Agent loop](./agent-loop.md) -- [Sessions](./session.md) -- [Sessions (alias)](./sessions.md) -- [Session tools](./session-tool.md) -- [Queue](./queue.md) -- [RPC adapters](./rpc.md) -- [TypeBox schemas](./typebox.md) -- [Presence](./presence.md) -- [Discovery + transports](./discovery.md) -- [Bonjour](./bonjour.md) -- [Surface routing](./surface.md) -- [Groups](./groups.md) -- [Group messages](./group-messages.md) +- [Architecture](https://docs.clawd.bot/architecture) +- [Agent runtime](https://docs.clawd.bot/agent) +- [Agent loop](https://docs.clawd.bot/agent-loop) +- [Multi-agent routing](https://docs.clawd.bot/multi-agent) +- [Sessions](https://docs.clawd.bot/session) +- [Sessions (alias)](https://docs.clawd.bot/sessions) +- [Session tools](https://docs.clawd.bot/session-tool) +- [Queue](https://docs.clawd.bot/queue) +- [Slash commands](https://docs.clawd.bot/slash-commands) +- [RPC adapters](https://docs.clawd.bot/rpc) +- [TypeBox schemas](https://docs.clawd.bot/typebox) +- [Presence](https://docs.clawd.bot/presence) +- [Discovery + transports](https://docs.clawd.bot/discovery) +- [Bonjour](https://docs.clawd.bot/bonjour) +- [Provider routing](https://docs.clawd.bot/provider-routing) +- [Groups](https://docs.clawd.bot/groups) +- [Group messages](https://docs.clawd.bot/group-messages) ## Providers + ingress -- [WhatsApp](./whatsapp.md) -- [Telegram](./telegram.md) -- [Telegram (grammY notes)](./grammy.md) -- [Slack](./slack.md) -- [Discord](./discord.md) -- [Signal](./signal.md) -- [iMessage](./imessage.md) -- [WebChat](./webchat.md) -- [Webhooks](./webhook.md) -- [Gmail Pub/Sub](./gmail-pubsub.md) +- [WhatsApp](https://docs.clawd.bot/whatsapp) +- [Telegram](https://docs.clawd.bot/telegram) +- [Telegram (grammY notes)](https://docs.clawd.bot/grammy) +- [Slack](https://docs.clawd.bot/slack) +- [Discord](https://docs.clawd.bot/discord) +- [Signal](https://docs.clawd.bot/signal) +- [iMessage](https://docs.clawd.bot/imessage) +- [WebChat](https://docs.clawd.bot/webchat) +- [Webhooks](https://docs.clawd.bot/webhook) +- [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub) ## Gateway + operations -- [Gateway runbook](./gateway.md) -- [Gateway pairing](./gateway/pairing.md) -- [Gateway lock](./gateway-lock.md) -- [Background process](./background-process.md) -- [Health](./health.md) -- [Heartbeat](./heartbeat.md) -- [Doctor](./doctor.md) -- [Logging](./logging.md) -- [Dashboard](./dashboard.md) -- [Control UI](./control-ui.md) -- [Control API (legacy)](./control-api.md) -- [Remote access](./remote.md) -- [Remote gateway README](./remote-gateway-readme.md) -- [Tailscale](./tailscale.md) -- [Security](./security.md) -- [Troubleshooting](./troubleshooting.md) +- [Gateway runbook](https://docs.clawd.bot/gateway) +- [Gateway pairing](https://docs.clawd.bot/gateway/pairing) +- [Gateway lock](https://docs.clawd.bot/gateway-lock) +- [Background process](https://docs.clawd.bot/background-process) +- [Health](https://docs.clawd.bot/health) +- [Heartbeat](https://docs.clawd.bot/heartbeat) +- [Doctor](https://docs.clawd.bot/doctor) +- [Logging](https://docs.clawd.bot/logging) +- [Dashboard](https://docs.clawd.bot/dashboard) +- [Control UI](https://docs.clawd.bot/control-ui) +- [Remote access](https://docs.clawd.bot/remote) +- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme) +- [Tailscale](https://docs.clawd.bot/tailscale) +- [Security](https://docs.clawd.bot/security) +- [Troubleshooting](https://docs.clawd.bot/troubleshooting) ## Tools + automation -- [Tools surface](./tools.md) -- [Bash tool](./bash.md) -- [Elevated mode](./elevated.md) -- [Cron + wakeups](./cron.md) -- [Thinking + verbose](./thinking.md) -- [Models](./models.md) -- [Agent send CLI](./agent-send.md) -- [Terminal UI](./tui.md) -- [Browser control](./browser.md) -- [Browser (Linux troubleshooting)](./browser-linux-troubleshooting.md) +- [Tools surface](https://docs.clawd.bot/tools) +- [Bash tool](https://docs.clawd.bot/bash) +- [Elevated mode](https://docs.clawd.bot/elevated) +- [Cron + wakeups](https://docs.clawd.bot/cron) +- [Thinking + verbose](https://docs.clawd.bot/thinking) +- [Models](https://docs.clawd.bot/models) +- [Agent send CLI](https://docs.clawd.bot/agent-send) +- [Terminal UI](https://docs.clawd.bot/tui) +- [Browser control](https://docs.clawd.bot/browser) +- [Browser (Linux troubleshooting)](https://docs.clawd.bot/browser-linux-troubleshooting) ## Nodes, media, voice -- [Nodes overview](./nodes.md) -- [Camera](./camera.md) -- [Images](./images.md) -- [Audio](./audio.md) -- [Location command](./location-command.md) -- [Voice wake](./voicewake.md) -- [Talk mode](./talk.md) +- [Nodes overview](https://docs.clawd.bot/nodes) +- [Camera](https://docs.clawd.bot/camera) +- [Images](https://docs.clawd.bot/images) +- [Audio](https://docs.clawd.bot/audio) +- [Location command](https://docs.clawd.bot/location-command) +- [Voice wake](https://docs.clawd.bot/voicewake) +- [Talk mode](https://docs.clawd.bot/talk) ## Platforms -- [macOS app overview](./macos.md) -- [macOS dev setup](./mac/dev-setup.md) -- [macOS menu bar](./mac/menu-bar.md) -- [macOS voice wake](./mac/voicewake.md) -- [macOS voice overlay](./mac/voice-overlay.md) -- [macOS WebChat](./mac/webchat.md) -- [macOS Canvas](./mac/canvas.md) -- [macOS child process](./mac/child-process.md) -- [macOS health](./mac/health.md) -- [macOS icon](./mac/icon.md) -- [macOS logging](./mac/logging.md) -- [macOS permissions](./mac/permissions.md) -- [macOS remote](./mac/remote.md) -- [macOS signing](./mac/signing.md) -- [macOS release](./mac/release.md) -- [macOS bun gateway](./mac/bun.md) -- [macOS XPC](./mac/xpc.md) -- [macOS skills](./mac/skills.md) -- [macOS Peekaboo plan](./mac/peekaboo.md) -- [iOS node](./ios.md) -- [Android node](./android.md) -- [Windows app](./windows.md) -- [Linux app](./linux.md) -- [Web surfaces](./web.md) +- [macOS app overview](https://docs.clawd.bot/macos) +- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup) +- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar) +- [macOS voice wake](https://docs.clawd.bot/mac/voicewake) +- [macOS voice overlay](https://docs.clawd.bot/mac/voice-overlay) +- [macOS WebChat](https://docs.clawd.bot/mac/webchat) +- [macOS Canvas](https://docs.clawd.bot/mac/canvas) +- [macOS child process](https://docs.clawd.bot/mac/child-process) +- [macOS health](https://docs.clawd.bot/mac/health) +- [macOS icon](https://docs.clawd.bot/mac/icon) +- [macOS logging](https://docs.clawd.bot/mac/logging) +- [macOS permissions](https://docs.clawd.bot/mac/permissions) +- [macOS remote](https://docs.clawd.bot/mac/remote) +- [macOS signing](https://docs.clawd.bot/mac/signing) +- [macOS release](https://docs.clawd.bot/mac/release) +- [macOS bun gateway](https://docs.clawd.bot/mac/bun) +- [macOS XPC](https://docs.clawd.bot/mac/xpc) +- [macOS skills](https://docs.clawd.bot/mac/skills) +- [macOS Peekaboo plan](https://docs.clawd.bot/mac/peekaboo) +- [iOS node](https://docs.clawd.bot/ios) +- [Android node](https://docs.clawd.bot/android) +- [Windows app](https://docs.clawd.bot/windows) +- [Linux app](https://docs.clawd.bot/linux) +- [Web surfaces](https://docs.clawd.bot/web) ## Workspace + templates -- [Skills](./skills.md) -- [Skills config](./skills-config.md) -- [Default AGENTS](./AGENTS.default.md) -- [Templates: AGENTS](./templates/AGENTS.md) -- [Templates: BOOTSTRAP](./templates/BOOTSTRAP.md) -- [Templates: IDENTITY](./templates/IDENTITY.md) -- [Templates: SOUL](./templates/SOUL.md) -- [Templates: TOOLS](./templates/TOOLS.md) -- [Templates: USER](./templates/USER.md) +- [Skills](https://docs.clawd.bot/skills) +- [Skills config](https://docs.clawd.bot/skills-config) +- [Default AGENTS](https://docs.clawd.bot/AGENTS.default) +- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS) +- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP) +- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY) +- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL) +- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS) +- [Templates: USER](https://docs.clawd.bot/templates/USER) ## Experiments + proposals -- [Onboarding config protocol](./onboarding-config-protocol.md) -- [Research: memory](./research/memory.md) -- [Proposal: model config](./proposals/model-config.md) +- [Onboarding config protocol](https://docs.clawd.bot/onboarding-config-protocol) +- [Research: memory](https://docs.clawd.bot/research/memory) +- [Proposal: model config](https://docs.clawd.bot/proposals/model-config) ## Testing + release -- [Testing](./test.md) -- [Release checklist](./RELEASING.md) -- [Device models](./device-models.md) +- [Testing](https://docs.clawd.bot/test) +- [Release checklist](https://docs.clawd.bot/RELEASING) +- [Device models](https://docs.clawd.bot/device-models) diff --git a/docs/imessage.md b/docs/imessage.md index f602f6de7..17ddbc21b 100644 --- a/docs/imessage.md +++ b/docs/imessage.md @@ -13,6 +13,31 @@ Status: external CLI integration. No daemon. - JSON-RPC runs over stdin/stdout (one JSON object per line). - Gateway owns the process; no TCP port needed. +## Multi-account (Apple IDs) + +iMessage “multi-account” in one Gateway process is not currently supported in a meaningful way: +- Messages accounts are owned by the signed-in macOS user session. +- `imsg` reads the local Messages DB and sends via that user’s configured services. +- There isn’t a robust “pick AppleID X as the sender” switch we can depend on. + +### Practical approach: multiple gateways on multiple Macs/users + +If you need two iMessage identities: +- Run one Gateway on each macOS user/machine that’s signed into the desired Apple ID. +- Connect to the desired Gateway remotely (Tailscale preferred; SSH tunnel is the universal fallback). + +See: +- `docs/remote.md` (SSH tunnel to `127.0.0.1:18789`) +- `docs/discovery.md` (bridge vs SSH transport model) + +### Could we do “iMessage over SSH” from a single Gateway? + +Maybe, but it’s a new design: +- Outbound could theoretically pipe `imsg rpc` over SSH (stdio bridge). +- Inbound still needs a remote watcher (DB polling / event stream) and a transport back to the main Gateway. + +That’s closer to “remote provider instances” (or “multi-gateway aggregation”) than a small config tweak. + ## Requirements - macOS with Messages signed in. - Full Disk Access for Clawdbot + the `imsg` binary (Messages DB access). @@ -26,7 +51,10 @@ Status: external CLI integration. No daemon. enabled: true, cliPath: "imsg", dbPath: "~/Library/Messages/chat.db", + dmPolicy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["+15555550123", "user@example.com", "chat_id:123"], + groupPolicy: "open", + groupAllowFrom: ["chat_id:123"], includeAttachments: false, mediaMaxMb: 16, service: "auto", @@ -37,6 +65,9 @@ Status: external CLI integration. No daemon. Notes: - `allowFrom` accepts handles (phone/email) or `chat_id:` entries. +- Default: `imessage.dmPolicy="pairing"` — unknown DM senders get a pairing code (approve via `clawdbot pairing approve --provider imessage `). `"open"` requires `allowFrom=["*"]`. +- `groupPolicy` controls group handling (`open|disabled|allowlist`). +- `groupAllowFrom` accepts the same entries as `allowFrom`. - `service` defaults to `auto` (use `imessage` or `sms` to pin). - `region` is only used for SMS targeting. @@ -55,7 +86,7 @@ imsg chats --limit 20 ## Group chat behavior - Group messages set `ChatType=group`, `GroupSubject`, and `GroupMembers`. -- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns` (patterns are required to detect mentions on iMessage). +- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns` (patterns are required to detect mentions on iMessage). When `imessage.groups` is set, it also acts as a group allowlist; include `"*"` to allow all groups. - Replies go back to the same `chat_id` (group or direct). ## Troubleshooting diff --git a/docs/index.md b/docs/index.md index 76893a0b7..862aa4b39 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,23 +19,29 @@ read_when:

GitHub · Releases · - Docs · - Clawd setup + Docs · + Clawd setup

CLAWDBOT bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono). It’s built for [Clawd](https://clawd.me), a space lobster who needed a TARDIS. +## Start here + +- **New install from zero:** https://docs.clawd.bot/getting-started +- **Guided setup (recommended):** https://docs.clawd.bot/wizard (`clawdbot onboard`) + ## How it works ``` WhatsApp / Telegram / Discord │ ▼ - ┌──────────────────────────┐ + ┌───────────────────────────┐ │ Gateway │ ws://127.0.0.1:18789 (loopback-only) │ (single source) │ tcp://0.0.0.0:18790 (Bridge) - │ │ http://:18793/__clawdbot__/canvas/ (Canvas host) + │ │ http://:18793 + │ │ /__clawdbot__/canvas/ (Canvas host) └───────────┬───────────────┘ │ ├─ Pi agent (RPC) @@ -54,8 +60,8 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long - **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`. - For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds). - **Bridge for nodes**: optional LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). -- **Canvas host**: HTTP file server on `canvasHost.port` (default `18793`), serving `/__clawdbot__/canvas/` for node WebViews; see `docs/configuration.md` (`canvasHost`). -- **Remote use**: SSH tunnel or tailnet/VPN; see `docs/remote.md` and `docs/discovery.md`. +- **Canvas host**: HTTP file server on `canvasHost.port` (default `18793`), serving `/__clawdbot__/canvas/` for node WebViews; see [`docs/configuration.md`](https://docs.clawd.bot/configuration) (`canvasHost`). +- **Remote use**: SSH tunnel or tailnet/VPN; see [`docs/remote.md`](https://docs.clawd.bot/remote) and [`docs/discovery.md`](https://docs.clawd.bot/discovery). ## Features (high level) @@ -64,6 +70,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long - 🎮 **Discord Bot** — DMs + guild channels via discord.js - 💬 **iMessage** — Local imsg CLI integration (macOS) - 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming +- 🧠 **Multi-agent routing** — Route provider accounts/peers to isolated agents (workspace + per-agent sessions) - 🔐 **Subscription auth** — Anthropic (Claude Pro/Max) + OpenAI (ChatGPT/Codex) via OAuth - 💬 **Sessions** — Direct chats collapse into shared `main` (default); groups are isolated - 👥 **Group Chat Support** — Mention-based by default; owner can toggle `/activation always|mention` @@ -128,41 +135,45 @@ Example: ## Docs - Start here: - - [Docs hubs (all pages linked)](./hubs.md) - - [FAQ](./faq.md) ← *common questions answered* - - [Configuration](./configuration.md) - - [Nix mode](./nix.md) - - [Clawd personal assistant setup](./clawd.md) - - [Skills](./skills.md) - - [Skills config](./skills-config.md) - - [Workspace templates](./templates/AGENTS.md) - - [RPC adapters](./rpc.md) - - [Gateway runbook](./gateway.md) - - [Nodes (iOS/Android)](./nodes.md) - - [Web surfaces (Control UI)](./web.md) - - [Discovery + transports](./discovery.md) - - [Remote access](./remote.md) + - [Docs hubs (all pages linked)](https://docs.clawd.bot/hubs) + - [FAQ](https://docs.clawd.bot/faq) ← *common questions answered* + - [Configuration](https://docs.clawd.bot/configuration) + - [Slash commands](https://docs.clawd.bot/slash-commands) + - [Multi-agent routing](https://docs.clawd.bot/multi-agent) + - [Updating / rollback](https://docs.clawd.bot/updating) + - [Pairing (DM + nodes)](https://docs.clawd.bot/pairing) + - [Nix mode](https://docs.clawd.bot/nix) + - [Clawd personal assistant setup](https://docs.clawd.bot/clawd) + - [Skills](https://docs.clawd.bot/skills) + - [Skills config](https://docs.clawd.bot/skills-config) + - [Workspace templates](https://docs.clawd.bot/templates/AGENTS) + - [RPC adapters](https://docs.clawd.bot/rpc) + - [Gateway runbook](https://docs.clawd.bot/gateway) + - [Nodes (iOS/Android)](https://docs.clawd.bot/nodes) + - [Web surfaces (Control UI)](https://docs.clawd.bot/web) + - [Discovery + transports](https://docs.clawd.bot/discovery) + - [Remote access](https://docs.clawd.bot/remote) - Providers and UX: - - [WebChat](./webchat.md) - - [Control UI (browser)](./control-ui.md) - - [Telegram](./telegram.md) - - [Discord](./discord.md) - - [iMessage](./imessage.md) - - [Groups](./groups.md) - - [WhatsApp group messages](./group-messages.md) - - [Media: images](./images.md) - - [Media: audio](./audio.md) + - [WebChat](https://docs.clawd.bot/webchat) + - [Control UI (browser)](https://docs.clawd.bot/control-ui) + - [Telegram](https://docs.clawd.bot/telegram) + - [Discord](https://docs.clawd.bot/discord) + - [iMessage](https://docs.clawd.bot/imessage) + - [Groups](https://docs.clawd.bot/groups) + - [WhatsApp group messages](https://docs.clawd.bot/group-messages) + - [Media: images](https://docs.clawd.bot/images) + - [Media: audio](https://docs.clawd.bot/audio) - Companion apps: - - [macOS app](./macos.md) - - [iOS app](./ios.md) - - [Android app](./android.md) - - [Windows app](./windows.md) - - [Linux app](./linux.md) + - [macOS app](https://docs.clawd.bot/macos) + - [iOS app](https://docs.clawd.bot/ios) + - [Android app](https://docs.clawd.bot/android) + - [Windows app](https://docs.clawd.bot/windows) + - [Linux app](https://docs.clawd.bot/linux) - Ops and safety: - - [Sessions](./session.md) - - [Cron + wakeups](./cron.md) - - [Security](./security.md) - - [Troubleshooting](./troubleshooting.md) + - [Sessions](https://docs.clawd.bot/session) + - [Cron + wakeups](https://docs.clawd.bot/cron) + - [Security](https://docs.clawd.bot/security) + - [Troubleshooting](https://docs.clawd.bot/troubleshooting) ## The name @@ -181,6 +192,7 @@ Example: ## Core Contributors - **Maxim Vovshin** (@Hyaxia, 36747317+Hyaxia@users.noreply.github.com) — Blogwatcher skill +- **Nacho Iacovino** (@nachoiacovino, nacho.iacovino@gmail.com) — Location parsing (Telegram + WhatsApp) ## License diff --git a/docs/ios.md b/docs/ios.md index ecdf3db01..bd00a11a6 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -61,7 +61,7 @@ If browse works, but the iOS node can’t connect, try resolving one instance: dns-sd -L "" _clawdbot-bridge._tcp local. ``` -More debugging notes: `docs/bonjour.md`. +More debugging notes: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour). #### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD @@ -70,7 +70,7 @@ If the iOS node and the gateway are on different networks but connected via Tail 1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-bridge._tcp` records. 2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server. -Details and example CoreDNS config: `docs/bonjour.md`. +Details and example CoreDNS config: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour). ### 3) Connect from the iOS node app @@ -102,7 +102,7 @@ clawdbot nodes approve After approval, the iOS node receives/stores the token and reconnects authenticated. -Pairing details: `docs/gateway/pairing.md`. +Pairing details: [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing). ### 5) Verify the node is connected @@ -169,7 +169,7 @@ The response includes `{ format, base64 }` image data (default `format="jpeg"`; - **iOS in background:** all `canvas.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring the iOS node app to foreground). - **Return to default scaffold:** `canvas.navigate` with `{"url":""}` or `{"url":"/"}` returns to the built-in scaffold page. -- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`). +- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see [`docs/discovery.md`](https://docs.clawd.bot/discovery)). - **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you. - **Stale pairing / Keychain cleared:** if the pairing token is missing (or iOS Keychain was wiped), the node must pair again; approve a new pending request. - **App reinstall but no reconnect:** the node restores `instanceId` + last bridge preference from Keychain; if it still comes up “unpaired”, verify Keychain persistence on your device/simulator and re-pair once. @@ -194,9 +194,9 @@ Non-goals (v1): - Perfect App Store compliance; this is **internal-only** initially. ### Current repo reality (constraints we respect) -- The Gateway WebSocket server binds to `127.0.0.1:18789` (`src/gateway/server.ts`) with an optional `CLAWDBOT_GATEWAY_TOKEN`. -- The Gateway exposes a Canvas file server (`canvasHost`) on `canvasHost.port` (default `18793`), so nodes can `canvas.navigate` to `http://:18793/__clawdbot__/canvas/` and auto-reload on file changes (`docs/configuration.md`). -- macOS “Canvas” is controlled via the Gateway node protocol (`canvas.*`), matching iOS/Android (`docs/mac/canvas.md`). +- The Gateway WebSocket server binds to `127.0.0.1:18789` ([`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts)) with an optional `CLAWDBOT_GATEWAY_TOKEN`. +- The Gateway exposes a Canvas file server (`canvasHost`) on `canvasHost.port` (default `18793`), so nodes can `canvas.navigate` to `http://:18793/__clawdbot__/canvas/` and auto-reload on file changes ([`docs/configuration.md`](https://docs.clawd.bot/configuration)). +- macOS “Canvas” is controlled via the Gateway node protocol (`canvas.*`), matching iOS/Android ([`docs/mac/canvas.md`](https://docs.clawd.bot/mac/canvas)). - Voice wake forwards via `GatewayChannel` to Gateway `agent` (mac app: `VoiceWakeForwarder` → `GatewayConnection.sendAgent`). ### Recommended topology (B): Gateway-owned Bridge + loopback Gateway @@ -237,7 +237,7 @@ Desired behavior: - If the Swift UI is present: show alert with Approve/Reject/Later. - If the Swift UI is not present: `clawdbot` CLI can list pending requests and approve/reject. -See `docs/gateway/pairing.md` for the API/events and storage. +See [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing) for the API/events and storage. CLI (headless approvals): - `clawdbot nodes pending` @@ -267,7 +267,7 @@ Unify mac Canvas + iOS Canvas under a single conceptual surface: - remote iOS node via the bridge #### Minimal protocol additions (v1) -Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models): +Add to [`src/gateway/protocol/schema.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/protocol/schema.ts) (and regenerate Swift models): **Identity** - Node identity comes from `connect.params.client.instanceId` (stable), and `connect.params.client.mode = "node"` (or `"ios-node"`). @@ -366,7 +366,7 @@ open Clawdbot.xcodeproj ## Related docs -- `docs/gateway.md` (gateway runbook) -- `docs/gateway/pairing.md` (approval + storage) -- `docs/bonjour.md` (discovery debugging) -- `docs/discovery.md` (LAN vs tailnet vs SSH) +- [`docs/gateway.md`](https://docs.clawd.bot/gateway) (gateway runbook) +- [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing) (approval + storage) +- [`docs/bonjour.md`](https://docs.clawd.bot/bonjour) (discovery debugging) +- [`docs/discovery.md`](https://docs.clawd.bot/discovery) (LAN vs tailnet vs SSH) diff --git a/docs/location.md b/docs/location.md new file mode 100644 index 000000000..7d610e7ff --- /dev/null +++ b/docs/location.md @@ -0,0 +1,46 @@ +--- +summary: "Inbound provider location parsing (Telegram + WhatsApp) and context fields" +read_when: + - Adding or modifying provider location parsing + - Using location context fields in agent prompts or tools +--- + +# Provider location parsing + +Clawdbot normalizes shared locations from chat providers into: +- human-readable text appended to the inbound body, and +- structured fields in the auto-reply context payload. + +Currently supported: +- **Telegram** (location pins + venues + live locations) +- **WhatsApp** (locationMessage + liveLocationMessage) + +## Text formatting +Locations are rendered as friendly lines without brackets: + +- Pin: + - `📍 48.858844, 2.294351 ±12m` +- Named place: + - `📍 Eiffel Tower — Champ de Mars, Paris (48.858844, 2.294351 ±12m)` +- Live share: + - `🛰 Live location: 48.858844, 2.294351 ±12m` + +If the provider includes a caption/comment, it is appended on the next line: +``` +📍 48.858844, 2.294351 ±12m +Meet here +``` + +## Context fields +When a location is present, these fields are added to `ctx`: +- `LocationLat` (number) +- `LocationLon` (number) +- `LocationAccuracy` (number, meters; optional) +- `LocationName` (string; optional) +- `LocationAddress` (string; optional) +- `LocationSource` (`pin | place | live`) +- `LocationIsLive` (boolean) + +## Provider notes +- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`. +- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line. diff --git a/docs/logging.md b/docs/logging.md index 89ffab3a7..a67d18500 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -14,7 +14,7 @@ Clawdbot has two log “surfaces”: ## File-based logger -Clawdbot uses a file logger backed by `tslog` (`src/logging.ts`). +Clawdbot uses a file logger backed by `tslog` ([`src/logging.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/logging.ts)). - Default rolling log file is under `/tmp/clawdbot/` (one file per day): `clawdbot-YYYY-MM-DD.log` - The log file path and level can be configured via `~/.clawdbot/clawdbot.json`: @@ -33,7 +33,7 @@ The file format is one JSON object per line. ## Console capture -The CLI entrypoint enables console capture (`src/index.ts` calls `enableConsoleCapture()`). +The CLI entrypoint enables console capture ([`src/index.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/index.ts) calls `enableConsoleCapture()`). That means every `console.log/info/warn/error/debug/trace` is also written into the file logs, while still behaving normally on stdout/stderr. @@ -89,8 +89,8 @@ clawdbot gateway --verbose --ws-log full Clawdbot formats console logs via a small wrapper on top of the existing stack: -- **tslog** for structured file logs (`src/logging.ts`) -- **chalk** for colors (`src/globals.ts`) +- **tslog** for structured file logs ([`src/logging.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/logging.ts)) +- **chalk** for colors ([`src/globals.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/globals.ts)) The console formatter is **TTY-aware** and prints consistent, prefixed lines. Subsystem loggers are created via `createSubsystemLogger("gateway")`. diff --git a/docs/mac/bun.md b/docs/mac/bun.md index d6608e537..92910ca7a 100644 --- a/docs/mac/bun.md +++ b/docs/mac/bun.md @@ -15,7 +15,7 @@ Goal: ship **Clawdbot.app** with a self-contained relay binary that can run both App bundle layout: - `Clawdbot.app/Contents/Resources/Relay/clawdbot` - - bun `--compile` relay executable built from `dist/macos/relay.js` + - bun `--compile` relay executable built from [`dist/macos/relay.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/macos/relay.js) - Supports: - `clawdbot …` (CLI) - `clawdbot gateway-daemon …` (LaunchAgent daemon) @@ -31,7 +31,7 @@ Why the sidecar files matter: ## Build pipeline Packaging script: -- `scripts/package-mac-app.sh` +- [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) It builds: - TS: `pnpm exec tsc` @@ -47,7 +47,7 @@ Important bundler flags: Version injection: - `--define "__CLAWDBOT_VERSION__=\"\""` -- `src/version.ts` also supports `__CLAWDBOT_VERSION__` (and `CLAWDBOT_BUNDLED_VERSION`) so `--version` doesn’t depend on reading `package.json` at runtime. +- [`src/version.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/version.ts) also supports `__CLAWDBOT_VERSION__` (and `CLAWDBOT_BUNDLED_VERSION`) so `--version` doesn’t depend on reading `package.json` at runtime. ## Launchd (Gateway as LaunchAgent) @@ -58,7 +58,7 @@ Plist location (per-user): - `~/Library/LaunchAgents/com.clawdbot.gateway.plist` Manager: -- `apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift` +- [`apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift) Behavior: - “Clawdbot Active” enables/disables the LaunchAgent. @@ -77,7 +77,7 @@ Symptom (when mis-signed): Fix: - The bun executable needs JIT-ish permissions under hardened runtime. -- `scripts/codesign-mac-app.sh` signs `Relay/clawdbot` with: +- [`scripts/codesign-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/codesign-mac-app.sh) signs `Relay/clawdbot` with: - `com.apple.security.cs.allow-jit` - `com.apple.security.cs.allow-unsigned-executable-memory` @@ -87,17 +87,17 @@ Problem: - bun can’t load some native Node addons like `sharp` (and we don’t want to ship native addon trees for the gateway). Solution: -- Central helper `src/media/image-ops.ts` +- Central helper [`src/media/image-ops.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/media/image-ops.ts) - Prefers `/usr/bin/sips` on macOS (esp. when running under bun) - Falls back to `sharp` when available (Node/dev) - Used by: - - `src/web/media.ts` (optimize inbound/outbound images) - - `src/browser/screenshot.ts` - - `src/agents/pi-tools.ts` (image sanitization) + - [`src/web/media.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/media.ts) (optimize inbound/outbound images) + - [`src/browser/screenshot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/browser/screenshot.ts) + - [`src/agents/pi-tools.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-tools.ts) (image sanitization) ## Browser control server -The Gateway starts the browser control server (loopback only) from `src/gateway/server.ts`. +The Gateway starts the browser control server (loopback only) from [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts). It’s started from the relay daemon process, so the relay binary includes Playwright deps. ## Tests / smoke checks @@ -125,7 +125,7 @@ Bun may leave dotfiles like `*.bun-build` in the repo root or subfolders. ## DMG styling (human installer) -`scripts/create-dmg.sh` styles the DMG via Finder AppleScript. +[`scripts/create-dmg.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/create-dmg.sh) styles the DMG via Finder AppleScript. Rules of thumb: - Use a **72dpi** background image that matches the Finder window size in points. diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md index 3dc2458aa..c7e75e63d 100644 --- a/docs/mac/canvas.md +++ b/docs/mac/canvas.md @@ -10,7 +10,7 @@ read_when: Status: draft spec · Date: 2025-12-12 -Note: for iOS/Android nodes that should render agent-edited HTML/CSS/JS over the network, prefer the Gateway `canvasHost` (serves `~/clawd/canvas` over LAN/tailnet with live reload). A2UI is also **hosted by the Gateway** over HTTP. This doc focuses on the macOS in-app canvas panel. See `docs/configuration.md`. +Note: for iOS/Android nodes that should render agent-edited HTML/CSS/JS over the network, prefer the Gateway `canvasHost` (serves `~/clawd/canvas` over LAN/tailnet with live reload). A2UI is also **hosted by the Gateway** over HTTP. This doc focuses on the macOS in-app canvas panel. See [`docs/configuration.md`](https://docs.clawd.bot/configuration). Clawdbot can embed an agent-controlled “visual workspace” panel (“Canvas”) inside the macOS app using `WKWebView`, served via a **custom URL scheme** (no loopback HTTP port required). @@ -81,7 +81,7 @@ Canvas is exposed via the Gateway **node bridge**, so the agent can: This should be modeled after `WebChatManager`/`WebChatSwiftUIWindowController` but targeting `clawdbot-canvas://…` URLs. Related: -- For “invoke the agent again from UI” flows, prefer the macOS deep link scheme (`clawdbot://agent?...`) so *any* UI surface (Canvas, WebChat, native views) can trigger a new agent run. See `docs/macos.md`. +- For “invoke the agent again from UI” flows, prefer the macOS deep link scheme (`clawdbot://agent?...`) so *any* UI surface (Canvas, WebChat, native views) can trigger a new agent run. See [`docs/macos.md`](https://docs.clawd.bot/macos). ## Agent commands (current) diff --git a/docs/mac/dev-setup.md b/docs/mac/dev-setup.md index 866d8e321..a208e2195 100644 --- a/docs/mac/dev-setup.md +++ b/docs/mac/dev-setup.md @@ -57,11 +57,26 @@ The macOS app requires a symlink named `clawdbot` in `/usr/local/bin` or `/opt/h Alternatively, you can manually link it from your Admin account: ```bash -sudo ln -sf "/Users/$(whoami)/clawdbot/dist/Clawdbot.app/Contents/Resources/Relay/clawdbot" /usr/local/bin/clawdbot +sudo ln -sf "/Users/$(whoami)/Projects/clawdbot/dist/Clawdbot.app/Contents/Resources/Relay/clawdbot" /usr/local/bin/clawdbot ``` ## Troubleshooting +### Build Fails: Toolchain or SDK Mismatch +The macOS app build expects the latest macOS SDK and Swift 6.2 toolchain. + +**System dependencies (required):** +- **Latest macOS version available in Software Update** (required by Xcode 26.2 SDKs) +- **Xcode 26.2** (Swift 6.2 toolchain) + +**Checks:** +```bash +xcodebuild -version +xcrun swift --version +``` + +If versions don’t match, update macOS/Xcode and re-run the build. + ### App Crashes on Permission Grant If the app crashes when you try to allow **Speech Recognition** or **Microphone** access, it may be due to a corrupted TCC cache or signature mismatch. @@ -70,7 +85,7 @@ If the app crashes when you try to allow **Speech Recognition** or **Microphone* ```bash tccutil reset All com.clawdbot.mac.debug ``` -2. If that fails, change the `BUNDLE_ID` temporarily in `scripts/package-mac-app.sh` to force a "clean slate" from macOS. +2. If that fails, change the `BUNDLE_ID` temporarily in [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) to force a "clean slate" from macOS. ### Gateway "Starting..." indefinitely If the gateway status stays on "Starting...", check if a zombie process is holding the port: diff --git a/docs/mac/health.md b/docs/mac/health.md index 1514e4c9f..dc081f8b4 100644 --- a/docs/mac/health.md +++ b/docs/mac/health.md @@ -25,4 +25,4 @@ How to see whether the WhatsApp Web/Baileys bridge is healthy from the menu bar - Cache the last good snapshot and the last error separately to avoid flicker; show the timestamp of each. ## When in doubt -- You can still use the CLI flow in `docs/health.md` (`clawdbot status`, `clawdbot status --deep`, `clawdbot health --json`) and tail `/tmp/clawdbot/clawdbot-*.log` for `web-heartbeat` / `web-reconnect`. +- You can still use the CLI flow in [`docs/health.md`](https://docs.clawd.bot/health) (`clawdbot status`, `clawdbot status --deep`, `clawdbot health --json`) and tail `/tmp/clawdbot/clawdbot-*.log` for `web-heartbeat` / `web-reconnect`. diff --git a/docs/mac/release.md b/docs/mac/release.md index 7ac225cc7..fd31c6cfc 100644 --- a/docs/mac/release.md +++ b/docs/mac/release.md @@ -62,7 +62,7 @@ Use the release note generator so Sparkle renders formatted HTML notes: ```bash SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-0.1.0.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml ``` -Generates HTML release notes from `CHANGELOG.md` (via `scripts/changelog-to-html.sh`) and embeds them in the appcast entry. +Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. ## Publish & verify diff --git a/docs/mac/signing.md b/docs/mac/signing.md index 60b462c36..913b66e80 100644 --- a/docs/mac/signing.md +++ b/docs/mac/signing.md @@ -5,11 +5,11 @@ read_when: --- # mac signing (debug builds) -This app is usually built from `scripts/package-mac-app.sh`, which now: +This app is usually built from [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh), which now: - sets a stable debug bundle identifier: `com.clawdbot.mac.debug` - writes the Info.plist with that bundle id (override via `BUNDLE_ID=...`) -- calls `scripts/codesign-mac-app.sh` to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see `docs/mac/permissions.md`). +- calls [`scripts/codesign-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/codesign-mac-app.sh) to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [`docs/mac/permissions.md`](https://docs.clawd.bot/mac/permissions)). - uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds). - inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel. - **Packaging requires Bun**: The embedded gateway relay is compiled using `bun`. Ensure it is installed (`curl -fsSL https://bun.sh/install | bash`). @@ -26,7 +26,7 @@ SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same cave ``` ### Ad-hoc Signing Note -When signing with `SIGN_IDENTITY="-"` (ad-hoc), the script automatically disables the **Hardened Runtime** (`--options runtime`). This is necessary to prevent crashes when the app attempts to load embedded frameworks (like Sparkle) that do not share the same Team ID. Ad-hoc signatures also break TCC permission persistence; see `docs/mac/permissions.md` for recovery steps. +When signing with `SIGN_IDENTITY="-"` (ad-hoc), the script automatically disables the **Hardened Runtime** (`--options runtime`). This is necessary to prevent crashes when the app attempts to load embedded frameworks (like Sparkle) that do not share the same Team ID. Ad-hoc signatures also break TCC permission persistence; see [`docs/mac/permissions.md`](https://docs.clawd.bot/mac/permissions) for recovery steps. ## Build metadata for About diff --git a/docs/mac/voicewake.md b/docs/mac/voicewake.md index 36afae944..898903928 100644 --- a/docs/mac/voicewake.md +++ b/docs/mac/voicewake.md @@ -46,7 +46,7 @@ Hardening: ## Forwarding behavior - When Voice Wake is enabled, transcripts are forwarded to the active gateway/agent (the same local vs remote mode used by the rest of the mac app). -- Replies are delivered to the **last-used main surface** (WhatsApp/Telegram/Discord/WebChat). If delivery fails, the error is logged and the run is still visible via WebChat/session logs. +- Replies are delivered to the **last-used main provider** (WhatsApp/Telegram/Discord/WebChat). If delivery fails, the error is logged and the run is still visible via WebChat/session logs. ## Forwarding payload - `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths. diff --git a/docs/mac/webchat.md b/docs/mac/webchat.md index 66c43a4e3..a9e0abe95 100644 --- a/docs/mac/webchat.md +++ b/docs/mac/webchat.md @@ -13,10 +13,10 @@ The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses ## Launch & debugging - Manual: Lobster menu → “Open Chat”. - Auto-open for testing: run `dist/Clawdbot.app/Contents/MacOS/Clawdbot --webchat` (or pass `--webchat` to the binary launched by launchd). The window opens on startup. -- Logs: see `./scripts/clawlog.sh` (subsystem `com.clawdbot`, category `WebChatSwiftUI`). +- Logs: see [`./scripts/clawlog.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/clawlog.sh) (subsystem `com.clawdbot`, category `WebChatSwiftUI`). ## How it’s wired -- Implementation: `apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift` hosts `ClawdbotChatUI` and speaks to the Gateway over `GatewayConnection`. +- Implementation: [`apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift) hosts `ClawdbotChatUI` and speaks to the Gateway over `GatewayConnection`. - Data plane: Gateway WebSocket methods `chat.history`, `chat.send`, `chat.abort`; events `chat`, `agent`, `presence`, `tick`, `health`. - Session: usually primary (`main`); multiple transports (WhatsApp/Telegram/Discord/Desktop) share the same key. The onboarding flow uses a dedicated `onboarding` session to keep first-run setup separate. diff --git a/docs/mac/xpc.md b/docs/mac/xpc.md index a7c6de28b..01b76e2f4 100644 --- a/docs/mac/xpc.md +++ b/docs/mac/xpc.md @@ -21,7 +21,7 @@ read_when: - UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol. - Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution. - Security: bridge hosts require TeamID `Y5PE65HELJ`; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention). -- See: `docs/mac/peekaboo.md` for the Clawdbot plan and naming. +- See: [`docs/mac/peekaboo.md`](https://docs.clawd.bot/mac/peekaboo) for the Clawdbot plan and naming. ### Mach/XPC (future direction) - Still optional for internal app services, but **not required** for automation now that node.invoke is the surface. @@ -37,4 +37,4 @@ read_when: - Prefer requiring a TeamID match for all privileged surfaces. - PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development. - All communication remains local-only; no network sockets are exposed. -- TCC prompts originate only from the GUI app bundle; run `scripts/package-mac-app.sh` so the signed bundle ID stays stable. +- TCC prompts originate only from the GUI app bundle; run [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) so the signed bundle ID stays stable. diff --git a/docs/macos.md b/docs/macos.md index 7412a2fc8..5317e4e14 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -13,7 +13,7 @@ Author: steipete · Status: draft spec · Date: 2025-12-20 - Shows native notifications for Clawdbot/clawdbot events. - Owns TCC prompts (Notifications, Accessibility, Screen Recording, Automation/AppleScript, Microphone, Speech Recognition). - Runs (or connects to) the **Gateway** and exposes itself as a **node** so agents can reach macOS‑only features. - - Hosts **PeekabooBridge** for UI automation (consumed by `peekaboo`; see `docs/mac/peekaboo.md`). + - Hosts **PeekabooBridge** for UI automation (consumed by `peekaboo`; see [`docs/mac/peekaboo.md`](https://docs.clawd.bot/mac/peekaboo)). - Installs a single CLI (`clawdbot`) by symlinking the bundled binary. ## High-level design @@ -79,7 +79,7 @@ Query parameters: - `sessionKey` (optional): explicit session key to use. - `thinking` (optional): thinking hint (e.g. `low`; omit for default). - `deliver` (optional): `true|false` (default: false). -- `to` / `channel` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`). +- `to` / `provider` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`). - `timeoutSeconds` (optional): timeout hint forwarded to the Gateway. - `key` (optional): unattended mode key (see below). @@ -96,7 +96,7 @@ Notes: ## Build & dev workflow (native) - `cd apps/macos && swift build` (debug) / `swift build -c release`. - Run app for dev: `swift run Clawdbot` (or Xcode scheme). -- Package app + CLI: `scripts/package-mac-app.sh` (builds bun CLI + gateway). +- Package app + CLI: [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) (builds bun CLI + gateway). - Tests: add Swift Testing suites under `apps/macos/Tests`. ## Open questions / decisions diff --git a/docs/model-failover.md b/docs/model-failover.md new file mode 100644 index 000000000..11c571e27 --- /dev/null +++ b/docs/model-failover.md @@ -0,0 +1,93 @@ +--- +summary: "How Clawdbot rotates auth profiles and falls back across models" +read_when: + - Diagnosing auth profile rotation, cooldowns, or model fallback behavior + - Updating failover rules for auth profiles or models +--- + +# Model failover + +Clawdbot handles failures in two stages: +1) **Auth profile rotation** within the current provider. +2) **Model fallback** to the next model in `agent.model.fallbacks`. + +This doc explains the runtime rules and the data that backs them. + +## Auth storage (keys + OAuth) + +Clawdbot uses **auth profiles** for both API keys and OAuth tokens. + +- Secrets live in `~/.clawdbot/agents//agent/auth-profiles.json` (legacy: `~/.clawdbot/agent/auth-profiles.json`). +- Config `auth.profiles` / `auth.order` are **metadata + routing only** (no secrets). +- Legacy import-only OAuth file: `~/.clawdbot/credentials/oauth.json` (imported into `auth-profiles.json` on first use). + +Credential types: +- `type: "api_key"` → `{ provider, key }` +- `type: "oauth"` → `{ provider, access, refresh, expires, email? }` (+ `projectId`/`enterpriseUrl` for some providers) + +## Profile IDs + +OAuth logins create distinct profiles so multiple accounts can coexist. +- Default: `provider:default` when no email is available. +- OAuth with email: `provider:` (for example `google-antigravity:user@gmail.com`). + +Profiles live in `~/.clawdbot/agents//agent/auth-profiles.json` under `profiles`. + +## Rotation order + +When a provider has multiple profiles, Clawdbot chooses an order like this: + +1) **Explicit config**: `auth.order[provider]` (if set). +2) **Configured profiles**: `auth.profiles` filtered by provider. +3) **Stored profiles**: entries in `auth-profiles.json` for the provider. + +If no explicit order is configured, Clawdbot uses a round‑robin order: +- **Primary key:** profile type (**OAuth before API keys**). +- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type). +- **Cooldown profiles** are moved to the end, ordered by soonest cooldown expiry. + +### Why OAuth can “look lost” + +If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile: +- Pin with `auth.order[provider] = ["provider:profileId"]`, or +- Use a per-session override via `/model …` with a profile override (when supported by your UI/chat surface). + +## Cooldowns + +When a profile fails due to auth/rate‑limit errors (or a timeout that looks +like rate limiting), Clawdbot marks it in cooldown and moves to the next profile. + +Cooldowns use exponential backoff: +- 1 minute +- 5 minutes +- 25 minutes +- 1 hour (cap) + +State is stored in `auth-profiles.json` under `usageStats`: + +```json +{ + "usageStats": { + "provider:profile": { + "lastUsed": 1736160000000, + "cooldownUntil": 1736160600000, + "errorCount": 2 + } + } +} +``` + +## Model fallback + +If all profiles for a provider fail, Clawdbot moves to the next model in +`agent.model.fallbacks`. This applies to auth failures, rate limits, and +timeouts that exhausted profile rotation. + +## Related config + +See [`docs/configuration.md`](https://docs.clawd.bot/configuration) for: +- `auth.profiles` / `auth.order` +- `agent.model.primary` / `agent.model.fallbacks` +- `agent.imageModel` routing + +See [`docs/models.md`](https://docs.clawd.bot/models) for the broader model selection and fallback overview. diff --git a/docs/models.md b/docs/models.md index cf88065a5..0b8d6206a 100644 --- a/docs/models.md +++ b/docs/models.md @@ -7,6 +7,8 @@ read_when: --- # Models CLI plan +See [`docs/model-failover.md`](https://docs.clawd.bot/model-failover) for how auth profiles rotate (OAuth vs API keys), cooldowns, and how that interacts with model fallbacks. + Goal: give clear model visibility + control (configured vs available), plus scan tooling that prefers tool-call + image-capable models and maintains ordered fallbacks. @@ -77,6 +79,7 @@ Output - Image routing uses `agent.imageModel` **only when configured** and the primary model lacks image input. - Persist last successful provider/model to session entry; auth profile success is global. +- See [`docs/model-failover.md`](https://docs.clawd.bot/model-failover) for auth profile rotation, cooldowns, and timeout handling. ## Tests @@ -86,5 +89,5 @@ Output ## Docs -- Update `docs/configuration.md` with `agent.models` + `agent.model` + `agent.imageModel`. +- Update [`docs/configuration.md`](https://docs.clawd.bot/configuration) with `agent.models` + `agent.model` + `agent.imageModel`. - Keep this doc current when CLI surface or scan logic changes. diff --git a/docs/multi-agent.md b/docs/multi-agent.md new file mode 100644 index 000000000..004fd1bf4 --- /dev/null +++ b/docs/multi-agent.md @@ -0,0 +1,121 @@ +--- +summary: "Multi-agent routing: isolated agents, provider accounts, and bindings" +title: Multi-Agent Routing +read_when: "You want multiple isolated agents (workspaces + auth) in one gateway process." +status: active +--- + +# Multi-Agent Routing + +Goal: multiple *isolated* agents (separate workspace + `agentDir` + sessions), plus multiple provider accounts (e.g. two WhatsApps) in one running Gateway. Inbound is routed to an agent via bindings. + +## What is “one agent”? + +An **agent** is a fully scoped brain with its own: + +- **Workspace** (files, AGENTS.md/SOUL.md/USER.md, local notes, persona rules). +- **State directory** (`agentDir`) for auth profiles, model registry, and per-agent config. +- **Session store** (chat history + routing state) under `~/.clawdbot/agents//sessions`. + +The Gateway can host **one agent** (default) or **many agents** side-by-side. + +### Single-agent mode (default) + +If you do nothing, Clawdbot runs a single agent: + +- `agentId` defaults to **`main`**. +- Sessions are keyed as `agent:main:`. +- Workspace defaults to `~/clawd` (or `~/clawd-` when `CLAWDBOT_PROFILE` is set). +- State defaults to `~/.clawdbot/agents/main/agent`. + +## Multiple agents = multiple people, multiple personalities + +With **multiple agents**, each `agentId` becomes a **fully isolated persona**: + +- **Different phone numbers/accounts** (per provider `accountId`). +- **Different personalities** (per-agent workspace files like `AGENTS.md` and `SOUL.md`). +- **Separate auth + sessions** (no cross-talk unless explicitly enabled). + +This lets **multiple people** share one Gateway server while keeping their AI “brains” and data isolated. + +## Routing rules (how messages pick an agent) + +Bindings are **deterministic** and **most-specific wins**: + +1. `peer` match (exact DM/group/channel id) +2. `guildId` (Discord) +3. `teamId` (Slack) +4. `accountId` match for a provider +5. provider-level match (`accountId: "*"`) +6. fallback to `routing.defaultAgentId` (default: `main`) + +## Multiple accounts / phone numbers + +Providers that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify +each login. Each `accountId` can be routed to a different agent, so one server can host +multiple phone numbers without mixing sessions. + +## Concepts + +- `agentId`: one “brain” (workspace, per-agent auth, per-agent session store). +- `accountId`: one provider account instance (e.g. WhatsApp account `"personal"` vs `"biz"`). +- `binding`: routes inbound messages to an `agentId` by `(provider, accountId, peer)` and optionally guild/team ids. +- Direct chats collapse to `agent::` (per-agent “main”; `session.mainKey`). + +## Example: two WhatsApps → two agents + +`~/.clawdbot/clawdbot.json` (JSON5): + +```js +{ + routing: { + defaultAgentId: "home", + + agents: { + home: { + workspace: "~/clawd-home", + agentDir: "~/.clawdbot/agents/home/agent", + }, + work: { + workspace: "~/clawd-work", + agentDir: "~/.clawdbot/agents/work/agent", + }, + }, + + // Deterministic routing: first match wins (most-specific first). + bindings: [ + { agentId: "home", match: { provider: "whatsapp", accountId: "personal" } }, + { agentId: "work", match: { provider: "whatsapp", accountId: "biz" } }, + + // Optional per-peer override (example: send a specific group to work agent). + { + agentId: "work", + match: { + provider: "whatsapp", + accountId: "personal", + peer: { kind: "group", id: "1203630...@g.us" }, + }, + }, + ], + + // Off by default: agent-to-agent messaging must be explicitly enabled + allowlisted. + agentToAgent: { + enabled: false, + allow: ["home", "work"], + }, + }, + + whatsapp: { + accounts: { + personal: { + // Optional override. Default: ~/.clawdbot/credentials/whatsapp/personal + // authDir: "~/.clawdbot/credentials/whatsapp/personal", + }, + biz: { + // Optional override. Default: ~/.clawdbot/credentials/whatsapp/biz + // authDir: "~/.clawdbot/credentials/whatsapp/biz", + }, + }, + }, +} +``` diff --git a/docs/nix.md b/docs/nix.md index aa6cbc588..468871770 100644 --- a/docs/nix.md +++ b/docs/nix.md @@ -84,12 +84,12 @@ The macOS packaging flow expects a stable Info.plist template at: apps/macos/Sources/Clawdbot/Resources/Info.plist ``` -`scripts/package-mac-app.sh` copies this template into the app bundle and patches dynamic fields +[`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) copies this template into the app bundle and patches dynamic fields (bundle ID, version/build, Git SHA, Sparkle keys). This keeps the plist deterministic for SwiftPM packaging and Nix builds (which do not rely on a full Xcode toolchain). ## Related - [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) — full setup guide -- [Wizard](./wizard.md) — non-Nix CLI setup -- [Docker](./docker.md) — containerized setup +- [Wizard](https://docs.clawd.bot/wizard) — non-Nix CLI setup +- [Docker](https://docs.clawd.bot/docker) — containerized setup diff --git a/docs/nodes.md b/docs/nodes.md index 28044af7c..e034f8f6d 100644 --- a/docs/nodes.md +++ b/docs/nodes.md @@ -14,7 +14,7 @@ macOS can also run in **node mode**: the menubar app connects to the Gateway’s ## Pairing + status -Pairing is gateway-owned and approval-based. See `docs/gateway/pairing.md` for the full flow. +Pairing is gateway-owned and approval-based. See [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing) for the full flow. Quick CLI: @@ -150,8 +150,8 @@ Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by ## Where to look in code -- CLI wiring: `src/cli/nodes-cli.ts` -- Canvas snapshot decoding/temp paths: `src/cli/nodes-canvas.ts` -- Duration parsing for CLI: `src/cli/parse-duration.ts` -- iOS node commands: `apps/ios/Sources/Model/NodeAppModel.swift` +- CLI wiring: [`src/cli/nodes-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/nodes-cli.ts) +- Canvas snapshot decoding/temp paths: [`src/cli/nodes-canvas.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/nodes-canvas.ts) +- Duration parsing for CLI: [`src/cli/parse-duration.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/parse-duration.ts) +- iOS node commands: [`apps/ios/Sources/Model/NodeAppModel.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/ios/Sources/Model/NodeAppModel.swift) - Android node commands: `apps/android/app/src/main/java/com/clawdbot/android/node/*` diff --git a/docs/onboarding-config-protocol.md b/docs/onboarding-config-protocol.md index 58eb2fa1e..9b593ba01 100644 --- a/docs/onboarding-config-protocol.md +++ b/docs/onboarding-config-protocol.md @@ -9,7 +9,7 @@ Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI. ## Components - Wizard engine: `src/wizard` (session + prompts + onboarding state). -- CLI: `src/commands/onboard-*.ts` uses the wizard with the CLI prompter. +- CLI: [`src/commands/onboard-*.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/onboard-*.ts) uses the wizard with the CLI prompter. - Gateway RPC: wizard + config schema endpoints serve UI clients. - macOS: SwiftUI onboarding uses the wizard step model. - Web UI: config form renders from JSON Schema + hints. diff --git a/docs/onboarding.md b/docs/onboarding.md index bc897e5e3..77da0c341 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -41,7 +41,7 @@ The macOS app should: - `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`) Why this location matters: it’s the Clawdbot-owned OAuth store. -Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on first use. +Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agents//agent/auth-profiles.json`) on first use. ### Recommended: OAuth (OpenAI Codex) @@ -149,7 +149,7 @@ If the Gateway runs on another machine, OAuth credentials must be created/stored For now, remote onboarding should: - explain why OAuth isn't shown -- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on the gateway host +- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the auth profile store (`~/.clawdbot/agents//agent/auth-profiles.json`) on the gateway host - mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files) ### Manual credential setup diff --git a/docs/pairing.md b/docs/pairing.md new file mode 100644 index 000000000..3db05b841 --- /dev/null +++ b/docs/pairing.md @@ -0,0 +1,85 @@ +--- +summary: "Pairing overview: approve who can DM you + which nodes can join" +read_when: + - Setting up DM access control + - Pairing a new iOS/Android node + - Reviewing Clawdbot security posture +--- + +# Pairing + +“Pairing” is Clawdbot’s explicit **owner approval** step. +It is used in two places: + +1) **DM pairing** (who is allowed to talk to the bot) +2) **Node pairing** (which devices/nodes are allowed to join the gateway network) + +Security context: https://docs.clawd.bot/security + +## 1) DM pairing (inbound chat access) + +When a provider is configured with DM policy `pairing`, unknown senders get a short code and their message is **not processed** until you approve. + +Default DM policies are documented in: https://docs.clawd.bot/security + +### Approve a sender + +```bash +clawdbot pairing list --provider telegram +clawdbot pairing approve --provider telegram +``` + +Supported providers: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `slack`. + +### Where the state lives + +Stored under `~/.clawdbot/credentials/`: +- Pending requests: `-pairing.json` +- Approved allowlist store: `-allowFrom.json` + +Treat these as sensitive (they gate access to your assistant). + +### Source of truth (code) + +- DM pairing storage + code generation: [`src/pairing/pairing-store.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/pairing/pairing-store.ts) +- CLI commands: [`src/cli/pairing-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/pairing-cli.ts) + +## 2) Node pairing (iOS/Android nodes joining the gateway) + +Nodes (iOS/Android, future hardware, etc.) connect to the Gateway and request to join. +The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject. + +### Approve a node + +```bash +clawdbot nodes pending +clawdbot nodes approve +``` + +### Where the state lives + +Stored under `~/.clawdbot/nodes/`: +- `pending.json` (short-lived; pending requests expire) +- `paired.json` (paired nodes + tokens) + +### Details + +Full protocol + design notes: https://docs.clawd.bot/gateway/pairing + +### Source of truth (code) + +- Node pairing store (pending/paired + token issuance): [`src/infra/node-pairing.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/node-pairing.ts) +- Gateway methods/events (`node.pair.*`): [`src/gateway/server-methods/nodes.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/nodes.ts) +- CLI: [`src/cli/nodes-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/nodes-cli.ts) + +## Related docs + +- Security model + prompt injection: https://docs.clawd.bot/security +- Updating safely (run doctor): https://docs.clawd.bot/updating +- Provider configs: + - Telegram: https://docs.clawd.bot/telegram + - WhatsApp: https://docs.clawd.bot/whatsapp + - Signal: https://docs.clawd.bot/signal + - iMessage: https://docs.clawd.bot/imessage + - Discord: https://docs.clawd.bot/discord + - Slack: https://docs.clawd.bot/slack diff --git a/docs/plans/cron-add-hardening.md b/docs/plans/cron-add-hardening.md index f1c6fa6ea..2ba67ea66 100644 --- a/docs/plans/cron-add-hardening.md +++ b/docs/plans/cron-add-hardening.md @@ -8,11 +8,11 @@ last_updated: "2026-01-05" # Cron Add Hardening & Schema Alignment ## Context -Recent gateway logs show repeated `cron.add` failures with invalid parameters (missing `sessionTarget`, `wakeMode`, `payload`, and malformed `schedule`). This indicates that at least one client (likely the agent tool call path) is sending wrapped or partially specified job payloads. Separately, there is drift between cron channel enums in TypeScript, gateway schema, CLI flags, and UI form types, plus a UI mismatch for `cron.status` (expects `jobCount` while gateway returns `jobs`). +Recent gateway logs show repeated `cron.add` failures with invalid parameters (missing `sessionTarget`, `wakeMode`, `payload`, and malformed `schedule`). This indicates that at least one client (likely the agent tool call path) is sending wrapped or partially specified job payloads. Separately, there is drift between cron provider enums in TypeScript, gateway schema, CLI flags, and UI form types, plus a UI mismatch for `cron.status` (expects `jobCount` while gateway returns `jobs`). ## Goals - Stop `cron.add` INVALID_REQUEST spam by normalizing common wrapper payloads and inferring missing `kind` fields. -- Align cron channel lists across gateway schema, cron types, CLI docs, and UI forms. +- Align cron provider lists across gateway schema, cron types, CLI docs, and UI forms. - Make agent cron tool schema explicit so the LLM produces correct job payloads. - Fix the Control UI cron status job count display. - Add tests to cover normalization and tool behavior. @@ -31,19 +31,19 @@ Recent gateway logs show repeated `cron.add` failures with invalid parameters (m ## Proposed Approach 1. **Normalize** incoming `cron.add` payloads (unwrap `data`/`job`, infer `schedule.kind` and `payload.kind`, default `wakeMode` + `sessionTarget` when safe). 2. **Harden** the agent cron tool schema using the canonical gateway `CronAddParamsSchema` and normalize before sending to the gateway. -3. **Align** channel enums and cron status fields across gateway schema, TS types, CLI descriptions, and UI form controls. +3. **Align** provider enums and cron status fields across gateway schema, TS types, CLI descriptions, and UI form controls. 4. **Test** normalization in gateway tests and tool behavior in agent tests. ## Multi-phase Execution Plan ### Phase 1 — Schema + type alignment -- [x] Expand gateway `CronPayloadSchema` channel enum to include `signal` and `imessage`. -- [x] Update CLI `--channel` descriptions to include `slack` (already supported by gateway). -- [x] Update UI Cron payload/channel union types to include all supported channels. +- [x] Expand gateway `CronPayloadSchema` provider enum to include `signal` and `imessage`. +- [x] Update CLI `--provider` descriptions to include `slack` (already supported by gateway). +- [x] Update UI Cron payload/provider union types to include all supported providers. - [x] Fix UI CronStatus type to match gateway (`jobs` instead of `jobCount`). -- [x] Update cron UI channel select to include Discord/Slack/Signal/iMessage. -- [x] Update macOS CronJobEditor channel picker + enum to include Slack/Signal/iMessage. -- [x] Document cron compatibility normalization policy in `docs/cron.md`. +- [x] Update cron UI provider select to include Discord/Slack/Signal/iMessage. +- [x] Update macOS CronJobEditor provider picker + enum to include Slack/Signal/iMessage. +- [x] Document cron compatibility normalization policy in [`docs/cron.md`](https://docs.clawd.bot/cron). ### Phase 2 — Input normalization + tooling hardening - [x] Add shared cron input normalization helpers (`normalizeCronJobCreate`/`normalizeCronJobPatch`). @@ -65,8 +65,8 @@ Recent gateway logs show repeated `cron.add` failures with invalid parameters (m - If errors persist, extend normalization for additional common shapes (e.g., `schedule.at`, `payload.message` without `kind`). ## Optional Follow-ups -- Manual Control UI smoke: add cron job per channel + verify status job count. +- Manual Control UI smoke: add cron job per provider + verify status job count. ## Open Questions - Should `cron.add` accept explicit `state` from clients (currently disallowed by schema)? -- Should we allow `webchat` as an explicit delivery channel (currently filtered in delivery resolution)? +- Should we allow `webchat` as an explicit delivery provider (currently filtered in delivery resolution)? diff --git a/docs/plans/group-policy-hardening.md b/docs/plans/group-policy-hardening.md new file mode 100644 index 000000000..208b95beb --- /dev/null +++ b/docs/plans/group-policy-hardening.md @@ -0,0 +1,126 @@ +--- +summary: "Spec: groupPolicy hardening for Telegram allowlist parity" +read_when: + - Reviewing historical Telegram allowlist normalization changes +--- +# Engineering Execution Spec: groupPolicy Hardening (Telegram Allowlist Parity) + +**Date**: 2026-01-05 +**Status**: Complete +**PR**: #216 (feat/whatsapp-group-policy) + +--- + +## Executive Summary + +Follow-up hardening work ensures Telegram allowlists behave consistently across inbound group/DM filtering and outbound send normalization. The focus is on prefix parity (`telegram:` / `tg:`), case-insensitive matching for prefixes, and resilience to accidental whitespace in config entries. Documentation and tests were updated to reflect and lock in this behavior. + +--- + +## Findings Analysis + +### [MED] F1: Telegram Allowlist Prefix Handling Is Case-Sensitive and Excludes `tg:` + +**Location**: [`src/telegram/bot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.ts) + +**Problem**: Inbound allowlist normalization only stripped a lowercase `telegram:` prefix. This rejected `TG:123` / `Telegram:123` and did not accept the `tg:` shorthand even though outbound send normalization already accepts `tg:` and case-insensitive prefixes. + +**Impact**: +- DMs and group allowlists fail when users copy/paste prefixed IDs from logs or existing send format. +- Behavior is inconsistent between inbound filtering and outbound send normalization. + +**Fix**: Normalize allowlist entries by trimming whitespace and stripping `telegram:` / `tg:` prefixes case-insensitively at pre-compute time. + +--- + +### [LOW] F2: Allowlist Entries Are Not Trimmed + +**Location**: [`src/telegram/bot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.ts) + +**Problem**: Allowlist entries are not trimmed; accidental whitespace causes mismatches. + +**Fix**: Trim and drop empty entries while normalizing allowlist inputs. + +--- + +## Implementation Phases + +### Phase 1: Normalize Telegram Allowlist Inputs + +**File**: [`src/telegram/bot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.ts) + +**Changes**: +1. Trim allowlist entries and drop empty values. +2. Strip `telegram:` / `tg:` prefixes case-insensitively. +3. Simplify DM allowlist check to rely on normalized values. + +--- + +### Phase 2: Add Coverage for Prefix + Whitespace + +**File**: [`src/telegram/bot.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.test.ts) + +**Add Tests**: +- DM allowlist accepts `TG:` prefix with surrounding whitespace. +- Group allowlist accepts `TG:` prefix case-insensitively. + +--- + +### Phase 3: Documentation Updates + +**Files**: +- [`docs/groups.md`](https://docs.clawd.bot/groups) +- [`docs/telegram.md`](https://docs.clawd.bot/telegram) + +**Changes**: +- Document `tg:` alias and case-insensitive prefixes for Telegram allowlists. + +--- + +### Phase 4: Verification + +1. Run targeted Telegram tests (`pnpm test -- src/telegram/bot.test.ts`). +2. If time allows, run full suite (`pnpm test`). + +--- + +## Files Modified + +| File | Change Type | Description | +|------|-------------|-------------| +| [`src/telegram/bot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.ts) | Fix | Trim allowlist values; strip `telegram:` / `tg:` prefixes case-insensitively | +| [`src/telegram/bot.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.test.ts) | Test | Add DM + group allowlist coverage for `TG:` prefix + whitespace | +| [`docs/groups.md`](https://docs.clawd.bot/groups) | Docs | Mention `tg:` alias + case-insensitive prefixes | +| [`docs/telegram.md`](https://docs.clawd.bot/telegram) | Docs | Mention `tg:` alias + case-insensitive prefixes | + +--- + +## Success Criteria + +- [x] Telegram allowlist accepts `telegram:` / `tg:` prefixes case-insensitively. +- [x] Telegram allowlist tolerates whitespace in config entries. +- [x] DM and group allowlist tests cover prefixed cases. +- [x] Docs updated to reflect allowlist formats. +- [x] Targeted tests pass. +- [x] Full test suite passes. + +--- + +## Risk Assessment + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Behavior change for malformed entries | Low | Normalization is additive and trims only whitespace | +| Test fragility | Low | Isolated unit tests; no external dependencies | +| Doc drift | Low | Updated docs alongside code | + +--- + +## Estimated Complexity + +- **Phase 1**: Low (normalization helpers) +- **Phase 2**: Low (2 new tests) +- **Phase 3**: Low (doc edits) +- **Phase 4**: Low (verification) + +**Total**: ~20 minutes diff --git a/docs/poll.md b/docs/poll.md new file mode 100644 index 000000000..f00269512 --- /dev/null +++ b/docs/poll.md @@ -0,0 +1,52 @@ +--- +summary: "Poll sending via gateway + CLI" +read_when: + - Adding or modifying poll support + - Debugging poll sends from the CLI or gateway +--- +# Polls + +Updated: 2026-01-06 + +## Supported providers +- WhatsApp (web provider) +- Discord + +## CLI + +```bash +# WhatsApp +clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe" +clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2 + +# Discord +clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord +clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48 +``` + +Options: +- `--provider`: `whatsapp` (default) or `discord` +- `--max-selections`: how many choices a voter can select (default: 1) +- `--duration-hours`: Discord-only (defaults to 24 when omitted) + +## Gateway RPC + +Method: `poll` + +Params: +- `to` (string, required) +- `question` (string, required) +- `options` (string[], required) +- `maxSelections` (number, optional) +- `durationHours` (number, optional) +- `provider` (string, optional, default: `whatsapp`) +- `idempotencyKey` (string, required) + +## Provider differences +- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`. +- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count. + +## Agent tool (Discord) +The Discord tool action `poll` still uses `question`, `answers`, optional `allowMultiselect`, `durationHours`, and `content`. The gateway/CLI poll model maps `allowMultiselect` to `maxSelections > 1`. + +Note: Discord has no “pick exactly N” mode; `maxSelections` is treated as a boolean (`> 1` = multiselect). diff --git a/docs/presence.md b/docs/presence.md index 94348ced5..86153aa7b 100644 --- a/docs/presence.md +++ b/docs/presence.md @@ -36,7 +36,7 @@ Presence entries are produced by multiple sources and then **merged**. The Gateway seeds a “self” entry at startup so UIs always show at least the current gateway host. -Implementation: `src/infra/system-presence.ts` (`initSelfPresence()`). +Implementation: [`src/infra/system-presence.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/system-presence.ts) (`initSelfPresence()`). ### 2) WebSocket connect (connection-derived presence) @@ -44,7 +44,7 @@ Every WS client must begin with a `connect` request. On successful handshake, th This is meant to answer: “Which clients are currently connected?” -Implementation: `src/gateway/server.ts` (connect handling uses `connect.params.client.instanceId` when provided; otherwise falls back to `connId`). +Implementation: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) (connect handling uses `connect.params.client.instanceId` when provided; otherwise falls back to `connId`). #### Why one-off CLI commands do not show up @@ -58,8 +58,8 @@ Clients can publish richer periodic beacons via the `system-event` method. The m - `lastInputSeconds` Implementation: -- Gateway: `src/gateway/server.ts` handles method `system-event` by calling `updateSystemPresence(...)`. -- mac app beaconing: `apps/macos/Sources/Clawdbot/PresenceReporter.swift`. +- Gateway: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) handles method `system-event` by calling `updateSystemPresence(...)`. +- mac app beaconing: [`apps/macos/Sources/Clawdbot/PresenceReporter.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/PresenceReporter.swift). ### 4) Node bridge beacons (gateway-owned presence) @@ -69,7 +69,7 @@ for that node and starts periodic refresh beacons so it does not expire. - Connect/disconnect markers: `node-connected`, `node-disconnected` - Periodic heartbeat: every 3 minutes (`reason: periodic`) -Implementation: `src/gateway/server.ts` (node bridge handlers + timer beacons). +Implementation: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) (node bridge handlers + timer beacons). ## Merge + dedupe rules (why `instanceId` matters) @@ -80,7 +80,7 @@ Key points: - The best key is a stable, opaque `instanceId` that does not change across restarts. - Keys are treated case-insensitively. -Implementation: `src/infra/system-presence.ts` (`normalizePresenceKey()`). +Implementation: [`src/infra/system-presence.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/system-presence.ts) (`normalizePresenceKey()`). ### mac app identity (stable UUID) @@ -89,7 +89,7 @@ The mac app uses a persisted UUID as `instanceId` so: - renaming the Mac does not create a new “instance” - debug/release builds can share the same identity -Implementation: `apps/macos/Sources/Clawdbot/InstanceIdentity.swift`. +Implementation: [`apps/macos/Sources/Clawdbot/InstanceIdentity.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/InstanceIdentity.swift). `displayName` (machine name) is used for UI, while `instanceId` is used for dedupe. @@ -99,7 +99,7 @@ Presence entries are not permanent: - TTL: entries older than 5 minutes are pruned - Max: map is capped at 200 entries (LRU by `ts`) -Implementation: `src/infra/system-presence.ts` (`TTL_MS`, `MAX_ENTRIES`, pruning in `listSystemPresence()`). +Implementation: [`src/infra/system-presence.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/system-presence.ts) (`TTL_MS`, `MAX_ENTRIES`, pruning in `listSystemPresence()`). ## Remote/tunnel caveat (loopback IPs) @@ -107,7 +107,7 @@ When a client connects over an SSH tunnel / local port forward, the Gateway may To avoid degrading an otherwise-correct client beacon IP, the Gateway avoids writing loopback remote addresses into presence entries. -Implementation: `src/gateway/server.ts` (`isLoopbackAddress()`). +Implementation: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) (`isLoopbackAddress()`). ## Consumers (who reads presence) @@ -116,8 +116,8 @@ Implementation: `src/gateway/server.ts` (`isLoopbackAddress()`). The mac app’s Instances tab renders the result of `system-presence`. Implementation: -- View: `apps/macos/Sources/Clawdbot/InstancesSettings.swift` -- Store: `apps/macos/Sources/Clawdbot/InstancesStore.swift` +- View: [`apps/macos/Sources/Clawdbot/InstancesSettings.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/InstancesSettings.swift) +- Store: [`apps/macos/Sources/Clawdbot/InstancesStore.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/InstancesStore.swift) The Instances rows show a small presence indicator (Active/Idle/Stale) based on the last beacon age. The label is derived from the entry timestamp (`ts`). diff --git a/docs/provider-routing.md b/docs/provider-routing.md new file mode 100644 index 000000000..d0d7f76a3 --- /dev/null +++ b/docs/provider-routing.md @@ -0,0 +1,25 @@ +--- +summary: "Routing rules per provider (WhatsApp, Telegram, Discord, web) and shared context" +read_when: + - Changing provider routing or inbox behavior +--- +# Providers & Routing + +Updated: 2026-01-06 + +Goal: deterministic replies per provider, while supporting multi-agent + multi-account routing. + +- **Provider**: provider label (`whatsapp`, `webchat`, `telegram`, `discord`, `signal`, `imessage`, …). Routing is fixed: replies go back to the origin provider; the model doesn’t choose. +- **AccountId**: provider account instance (e.g. WhatsApp account `"default"` vs `"work"`). Not every provider supports multi-account yet. +- **AgentId**: one isolated “brain” (workspace + per-agent agentDir + per-agent session store). +- **Reply context:** inbound replies include `ReplyToId`, `ReplyToBody`, and `ReplyToSender`, and the quoted context is appended to `Body` as a `[Replying to ...]` block. +- **Canonical direct session (per agent):** direct chats collapse to `agent::` (default `main`). Groups/channels stay isolated per agent: + - group: `agent:::group:` + - channel/room: `agent:::channel:` +- **Session store:** per-agent store lives under `~/.clawdbot/agents//sessions/sessions.json` (override via `session.store` with `{agentId}` templating). JSONL transcripts live next to it. +- **WebChat:** attaches to the selected agent’s main session (so desktop reflects cross-provider history for that agent). +- **Implementation hints:** + - Set `Provider` + `AccountId` in each ingress. + - Route inbound to an agent via `routing.bindings` (match on `provider`, `accountId`, plus optional peer/guild/team). + - Keep routing deterministic: originate → same provider. Use the gateway WebSocket for sends; avoid side channels. + - Do not let the agent emit “send to X” decisions; keep that policy in the host code. diff --git a/docs/queue.md b/docs/queue.md index aafda0ceb..063585102 100644 --- a/docs/queue.md +++ b/docs/queue.md @@ -12,13 +12,13 @@ We now serialize command-based auto-replies (WhatsApp Web listener) through a ti - Serializing avoids competing for terminal/stdin, keeps logs readable, and reduces the chance of rate limits from upstream tools. ## How it works -- `src/process/command-queue.ts` holds a lane-aware FIFO queue and drains each lane synchronously. +- [`src/process/command-queue.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/process/command-queue.ts) holds a lane-aware FIFO queue and drains each lane synchronously. - `runEmbeddedPiAgent` enqueues by **session key** (lane `session:`) to guarantee only one active run per session. - Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agent.maxConcurrent`. - When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting. - Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn. -## Queue modes (per surface) +## Queue modes (per provider) Inbound messages can steer the current run, wait for a followup turn, or do both: - `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup. - `followup`: enqueue for the next agent turn after the current run ends. @@ -30,12 +30,12 @@ Inbound messages can steer the current run, wait for a followup turn, or do both Steer-backlog means you can get a followup response after the steered run, so streaming surfaces can look like duplicates. Prefer `collect`/`steer` if you want one response per inbound message. -Inline fix: `/queue collect` (per-session) or set `routing.queue.bySurface.discord: "collect"`. +Send `/queue collect` as a standalone command (per-session) or set `routing.queue.byProvider.discord: "collect"`. Defaults (when unset in config): - All surfaces → `collect` -Configure globally or per surface via `routing.queue`: +Configure globally or per provider via `routing.queue`: ```json5 { @@ -45,7 +45,7 @@ Configure globally or per surface via `routing.queue`: debounceMs: 1000, cap: 20, drop: "summarize", - bySurface: { discord: "collect" } + byProvider: { discord: "collect" } } } } @@ -61,8 +61,7 @@ Summarize keeps a short bullet list of dropped messages and injects it as a synt Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`. ## Per-session overrides -- `/queue ` as a standalone command stores the mode for the current session. -- `/queue ` embedded in a message applies **once** (no persistence). +- Send `/queue ` as a standalone command to store the mode for the current session. - Options can be combined: `/queue collect debounce:2s cap:25 drop:summarize` - `/queue default` or `/queue reset` clears the session override. diff --git a/docs/remote-gateway-readme.md b/docs/remote-gateway-readme.md index 039955a6d..f0b3f8ba4 100644 --- a/docs/remote-gateway-readme.md +++ b/docs/remote-gateway-readme.md @@ -108,7 +108,7 @@ Save this as `~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist`: ### Load the Launch Agent ```bash -launchctl load ~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist ``` The tunnel will now: @@ -130,13 +130,13 @@ lsof -i :18789 **Restart the tunnel:** ```bash -launchctl restart com.clawdbot.ssh-tunnel +launchctl kickstart -k gui/$UID/com.clawdbot.ssh-tunnel ``` **Stop the tunnel:** ```bash -launchctl unload ~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist +launchctl bootout gui/$UID/com.clawdbot.ssh-tunnel ``` --- diff --git a/docs/remote.md b/docs/remote.md index 9239d229d..fd43e6355 100644 --- a/docs/remote.md +++ b/docs/remote.md @@ -8,7 +8,7 @@ read_when: This repo supports “remote over SSH” by keeping a single Gateway (the master) running on a host (e.g., your Mac Studio) and connecting clients to it. - For **operators (you / the macOS app)**: SSH tunneling is the universal fallback. -- For **nodes (iOS/Android and future devices)**: prefer the Gateway **Bridge** when on the same LAN/tailnet (see `docs/discovery.md`). +- For **nodes (iOS/Android and future devices)**: prefer the Gateway **Bridge** when on the same LAN/tailnet (see [`docs/discovery.md`](https://docs.clawd.bot/discovery)). ## The core idea @@ -58,9 +58,4 @@ WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects direct The macOS menu bar app can drive the same setup end-to-end (remote status checks, WebChat, and Voice Wake forwarding). -Runbook: `docs/mac/remote.md`. - -## Legacy control channel - -Older builds experimented with a newline-delimited TCP control channel on the same port. -That API is deprecated and should not be relied on. (Historical reference: `docs/control-api.md`.) +Runbook: [`docs/mac/remote.md`](https://docs.clawd.bot/mac/remote). diff --git a/docs/research/memory.md b/docs/research/memory.md index 5376f85dc..7df735e53 100644 --- a/docs/research/memory.md +++ b/docs/research/memory.md @@ -172,7 +172,7 @@ Recommendation: **deep integration in Clawdbot**, but keep a separable core libr Shape: - `src/memory/*` (library-ish core; pure functions + sqlite adapter) -- `src/commands/memory/*.ts` (CLI glue) +- [`src/commands/memory/*.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/memory/*.ts) (CLI glue) ## “S-Collide” / SuCo: when to use it (research) diff --git a/docs/rpc.md b/docs/rpc.md index 7a484758b..e44369e33 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -14,7 +14,7 @@ Clawdbot integrates external CLIs via JSON-RPC. Two patterns are used today. - Health probe: `/api/v1/check`. - Clawdbot owns lifecycle when `signal.autoStart=true`. -See `docs/signal.md` for setup and endpoints. +See [`docs/signal.md`](https://docs.clawd.bot/signal) for setup and endpoints. ## Pattern B: stdio child process (imsg) - Clawdbot spawns `imsg rpc` as a child process. @@ -27,7 +27,7 @@ Core methods used: - `send` - `chats.list` (probe/diagnostics) -See `docs/imessage.md` for setup and addressing (`chat_id` preferred). +See [`docs/imessage.md`](https://docs.clawd.bot/imessage) for setup and addressing (`chat_id` preferred). ## Adapter guidelines - Gateway owns the process (start/stop tied to provider lifecycle). diff --git a/docs/security.md b/docs/security.md index edd624f20..a30d211f6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -5,7 +5,12 @@ read_when: --- # Security 🔒 -Running an AI agent with shell access on your machine is... *spicy*. Here's how to not get pwned. +Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned. + +Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about: +- who can talk to your bot +- where the bot is allowed to act +- what the bot can touch ## The Threat Model @@ -20,6 +25,57 @@ People who message you can: - Social engineer access to your data - Probe for infrastructure details +## Core concept: access control before intelligence + +Most failures here are not fancy exploits — they’re “someone messaged the bot and the bot did what they asked.” + +Clawdbot’s stance: +- **Identity first:** decide who can talk to the bot (DM pairing / allowlists / explicit “open”). +- **Scope next:** decide where the bot is allowed to act (group allowlists + mention gating, tools, sandboxing, device permissions). +- **Model last:** assume the model can be manipulated; design so manipulation has limited blast radius. + +## DM access model (pairing / allowlist / open / disabled) + +All current DM-capable providers support a DM policy (`dmPolicy` or `*.dm.policy`) that gates inbound DMs **before** the message is processed: + +- `pairing` (default): unknown senders receive a short pairing code and the bot ignores their message until approved. +- `allowlist`: unknown senders are blocked (no pairing handshake). +- `open`: allow anyone to DM (public). **Requires** the provider allowlist to include `"*"` (explicit opt-in). +- `disabled`: ignore inbound DMs entirely. + +Approve via CLI: + +```bash +clawdbot pairing list --provider +clawdbot pairing approve --provider +``` + +Details + files on disk: https://docs.clawd.bot/pairing + +## Allowlists (DM + groups) — terminology + +Clawdbot has two separate “who can trigger me?” layers: + +- **DM allowlist** (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages. + - When `dmPolicy="pairing"`, approvals are written to `~/.clawdbot/credentials/-allowFrom.json` (merged with config allowlists). +- **Group allowlist** (provider-specific): which groups/channels/guilds the bot will accept messages from at all. + - Common patterns: + - `whatsapp.groups`, `telegram.groups`, `imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior). + - `groupPolicy="allowlist"` + `groupAllowFrom`: restrict who can trigger the bot *inside* a group session (WhatsApp/Telegram/Signal/iMessage). + - `discord.guilds` / `slack.channels`: per-surface allowlists + mention defaults. + +Details: https://docs.clawd.bot/configuration and https://docs.clawd.bot/groups + +## Prompt injection (what it is, why it matters) + +Prompt injection is when an attacker crafts a message that manipulates the model into doing something unsafe (“ignore your instructions”, “dump your filesystem”, “follow this link and run commands”, etc.). + +Even with strong system prompts, **prompt injection is not solved**. What helps in practice: +- Keep inbound DMs locked down (pairing/allowlists). +- Prefer mention gating in groups; avoid “always-on” bots in public rooms. +- Treat links and pasted instructions as hostile by default. +- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem. + ## Lessons Learned (The Hard Way) ### The `find ~` Incident 🦞 @@ -36,21 +92,17 @@ This is social engineering 101. Create distrust, encourage snooping. **Lesson:** Don't let strangers (or friends!) manipulate your AI into exploring the filesystem. -## Configuration Hardening +## Configuration Hardening (examples) -### 1. Allowlist Senders +### 1) DMs: pairing by default -```json +```json5 { - "whatsapp": { - "allowFrom": ["+15555550123"] - } + whatsapp: { dmPolicy: "pairing" } } ``` -Only allow specific phone numbers to trigger your AI. Never use `["*"]` in production. - -### 2. Group Chat Mentions +### 2) Groups: require mention everywhere ```json { @@ -82,34 +134,14 @@ We're considering a `readOnlyMode` flag that prevents the AI from: - Executing shell commands - Sending messages -## Container Isolation (Recommended) +## Sandboxing (recommended) -For maximum security, run CLAWDBOT in a container with limited access: +Two complementary approaches: -```yaml -# docker-compose.yml -services: - clawdbot: - build: . - volumes: - - ./clawd-sandbox:/home/clawd # Limited filesystem - - /tmp/clawdbot:/tmp/clawdbot # Logs - environment: - - CLAWDBOT_SANDBOX=true - network_mode: bridge # Limited network -``` +- **Run the full Gateway in Docker** (container boundary): https://docs.clawd.bot/docker +- **Per-session tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): https://docs.clawd.bot/configuration -### Per-session sandbox (Clawdbot-native) - -Clawdbot can also run **non-main sessions** inside per-session Docker containers -(`agent.sandbox`). This keeps the gateway on your host while isolating agent -tools in a hard wall container. See `docs/configuration.md` for the full config. - -Expose only the services your AI needs: -- ✅ GoWA API (for WhatsApp) -- ✅ Specific HTTP APIs -- ❌ Raw shell access to host -- ❌ Full filesystem +Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers. ## What to Tell Your AI @@ -130,7 +162,7 @@ If your AI does something bad: 1. **Stop it:** stop the macOS app (if it’s supervising the Gateway) or terminate your `clawdbot gateway` process 2. **Check logs:** `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or your configured `logging.file`) -3. **Review session:** Check `~/.clawdbot/sessions/` for what happened +3. **Review session:** Check `~/.clawdbot/agents//sessions/` for what happened 4. **Rotate secrets:** If credentials were exposed 5. **Update rules:** Add to your security prompt @@ -157,7 +189,7 @@ Mario asking for find ~ Found a vulnerability in CLAWDBOT? Please report responsibly: -1. Email: security@[redacted].com +1. Email: security@clawd.bot 2. Don't post publicly until fixed 3. We'll credit you (unless you prefer anonymity) diff --git a/docs/session-tool.md b/docs/session-tool.md index 69dd0d5e1..b5a5238e1 100644 --- a/docs/session-tool.md +++ b/docs/session-tool.md @@ -6,16 +6,17 @@ read_when: # Session Tools -Goal: small, hard-to-misuse tool surface so agents can list sessions, fetch history, and send to another session. +Goal: small, hard-to-misuse tool set so agents can list sessions, fetch history, and send to another session. ## Tool Names - `sessions_list` - `sessions_history` - `sessions_send` +- `sessions_spawn` ## Key Model - Main direct chat bucket is always the literal key `"main"`. -- Group chats use `surface:group:` or `surface:channel:`. +- Group chats use `:group:` or `:channel:`. - Cron jobs use `cron:`. - Hooks use `hook:` unless explicitly set. - Node bridge uses `node-` unless explicitly set. @@ -34,6 +35,7 @@ Parameters: Behavior: - `messageLimit > 0` fetches `chat.history` per session and includes the last N messages. - Tool results are filtered out in list output; use `sessions_history` for tool messages. +- When running in a **sandboxed** agent session, session tools default to **spawned-only visibility** (see below). Row shape (JSON): - `key`: session key (string) @@ -45,7 +47,7 @@ Row shape (JSON): - `model`, `contextTokens`, `totalTokens` - `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun` - `sendPolicy` (session override if set) -- `lastChannel`, `lastTo` +- `lastProvider`, `lastTo` - `transcriptPath` (best-effort path derived from store dir + sessionId) - `messages?` (only when `messageLimit > 0`) @@ -82,17 +84,17 @@ Behavior: - Max turns is `session.agentToAgent.maxPingPongTurns` (0–5, default 5). - Once the loop ends, Clawdbot runs the **agent‑to‑agent announce step** (target agent only): - Reply exactly `ANNOUNCE_SKIP` to stay silent. - - Any other reply is sent to the target channel. + - Any other reply is sent to the target provider. - Announce step includes the original request + round‑1 reply + latest ping‑pong reply. ## Provider Field -- For groups, `provider` is the `surface` recorded on the session entry. -- For direct chats, `provider` maps from `lastChannel`. +- For groups, `provider` is the provider recorded on the session entry. +- For direct chats, `provider` maps from `lastProvider`. - For cron/hook/node, `provider` is `internal`. - If missing, `provider` is `unknown`. ## Security / Send Policy -Policy-based blocking by surface/chat type (not per session id). +Policy-based blocking by provider/chat type (not per session id). ```json { @@ -100,7 +102,7 @@ Policy-based blocking by surface/chat type (not per session id). "sendPolicy": { "rules": [ { - "match": { "surface": "discord", "chatType": "group" }, + "match": { "provider": "discord", "chatType": "group" }, "action": "deny" } ], @@ -112,8 +114,41 @@ Policy-based blocking by surface/chat type (not per session id). Runtime override (per session entry): - `sendPolicy: "allow" | "deny"` (unset = inherit config) -- Settable via `sessions.patch` or owner-only `/send on|off|inherit`. +- Settable via `sessions.patch` or owner-only `/send on|off|inherit` (standalone message). Enforcement points: - `chat.send` / `agent` (gateway) - auto-reply delivery logic + +## sessions_spawn +Spawn a sub-agent run in an isolated session and announce the result back to the requester chat provider. + +Parameters: +- `task` (required) +- `label?` (optional; used for logs/UI) +- `timeoutSeconds?` (default 0; 0 = fire-and-forget) +- `cleanup?` (`delete|keep`, default `delete`) + +Behavior: +- Starts a new `subagent:` session with `deliver: false`. +- Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`). +- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning). +- After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat provider. +- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent. + +## Sandbox Session Visibility + +Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`. + +Config: + +```json5 +{ + agent: { + sandbox: { + // default: "spawned" + sessionToolsVisibility: "spawned" // or "all" + } + } +} +``` diff --git a/docs/session.md b/docs/session.md index 6cc7a3396..b72222cec 100644 --- a/docs/session.md +++ b/docs/session.md @@ -5,7 +5,7 @@ read_when: --- # Session Management -Clawdbot treats **one session as primary**. The canonical key is fixed to `main` for direct chats (or `global` when scope is global); no configuration is required. `session.mainKey` is ignored. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls. +Clawdbot treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent::` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored. ## Gateway is the source of truth All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files. @@ -15,17 +15,17 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl ## Where state lives - On the **gateway host**: - - Store file: `~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`). - - Transcripts: `~/.clawdbot/sessions/.jsonl` (one file per session id). + - Store file: `~/.clawdbot/agents//sessions/sessions.json` (per agent). + - Transcripts: `~/.clawdbot/agents//sessions/.jsonl` (one file per session id). - The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand. -- Group entries may include `displayName`, `surface`, `subject`, `room`, and `space` to label sessions in UIs. +- Group entries may include `displayName`, `provider`, `subject`, `room`, and `space` to label sessions in UIs. - Clawdbot does **not** read legacy Pi/Tau session folders. ## Mapping transports → session keys -- Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context. -- Multiple phone numbers can map to that same key; they act as transports into the same conversation. -- Group chats isolate state with `surface:group:` keys (rooms/channels use `surface:channel:`); do not reuse the primary key for groups. (Discord display names show `discord:#`.) - - Legacy `group::` and `group:` keys are still recognized. +- Direct chats collapse to the per-agent primary key: `agent::`. + - Multiple phone numbers and providers can map to the same agent main key; they act as transports into one conversation. +- Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`). + - Legacy `group:` keys are still recognized for migration. - Other sources: - Cron jobs: `cron:` - Webhooks: `hook:` (unless explicitly set by the hook) @@ -44,7 +44,7 @@ Block delivery for specific session types without listing individual ids. session: { sendPolicy: { rules: [ - { action: "deny", match: { surface: "discord", chatType: "group" } }, + { action: "deny", match: { provider: "discord", chatType: "group" } }, { action: "deny", match: { keyPrefix: "cron:" } } ], default: "allow" @@ -57,6 +57,7 @@ Runtime override (owner only): - `/send on` → allow for this session - `/send off` → deny for this session - `/send inherit` → clear override and use config rules +Send these as standalone messages so they register. ## Configuration (optional rename example) ```json5 @@ -66,8 +67,8 @@ Runtime override (owner only): scope: "per-sender", // keep group keys separate idleMinutes: 120, resetTriggers: ["/new", "/reset"], - store: "~/.clawdbot/sessions/sessions.json", - // mainKey is ignored; primary key is fixed to "main" + store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", + mainKey: "main", } } ``` @@ -76,8 +77,8 @@ Runtime override (owner only): - `pnpm clawdbot status` — shows store path and recent sessions. - `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active `). - `pnpm clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). -- Send `/status` in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). -- Send `/compact` (optional instructions) to summarize older context and free up window space. +- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). +- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. - JSONL transcripts can be opened directly to review full turns. ## Tips diff --git a/docs/sessions.md b/docs/sessions.md index 56627b95a..2e048a902 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -5,4 +5,4 @@ read_when: --- # Sessions -Canonical session management docs live in `docs/session.md`. +Canonical session management docs live in [`docs/session.md`](https://docs.clawd.bot/session). diff --git a/docs/setup.md b/docs/setup.md index d053da7e3..e331fc47e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -17,7 +17,7 @@ Last updated: 2026-01-01 ## Prereqs (from source) - Node `>=22` - `pnpm` -- Docker (optional; only for containerized setup/e2e — see `docs/docker.md`) +- Docker (optional; only for containerized setup/e2e — see [`docs/docker.md`](https://docs.clawd.bot/docker)) ## Tailoring strategy (so updates don’t hurt) @@ -77,7 +77,7 @@ pnpm install pnpm gateway:watch ``` -`gateway:watch` runs `src/index.ts gateway --force` and reloads on `src/**/*.ts` changes. +`gateway:watch` runs `src/entry.ts gateway --force` and reloads on [`src/**/*.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/**/*.ts) changes. ### 2) Point the macOS app at your running Gateway @@ -102,7 +102,8 @@ pnpm clawdbot health - **Wrong port:** Gateway WS defaults to `ws://127.0.0.1:18789`; keep app + CLI on the same port. - **Where state lives:** - Credentials: `~/.clawdbot/credentials/` - - Sessions/logs: `~/.clawdbot/sessions/` + - Sessions: `~/.clawdbot/agents//sessions/` + - Logs: `/tmp/clawdbot/` ## Updating (without wrecking your setup) @@ -120,12 +121,12 @@ sudo loginctl enable-linger $USER ``` For always-on or multi-user servers, consider a **system** service instead of a -user service (no lingering needed). See `docs/gateway.md` for the systemd notes. +user service (no lingering needed). See [`docs/gateway.md`](https://docs.clawd.bot/gateway) for the systemd notes. ## Related docs -- `docs/gateway.md` (Gateway runbook; flags, supervision, ports) -- `docs/configuration.md` (config schema + examples) -- `docs/discord.md` and `docs/telegram.md` (reply tags + replyToMode settings) -- `docs/clawd.md` (personal assistant setup) -- `docs/macos.md` (macOS app behavior; gateway lifecycle + “Attach only”) +- [`docs/gateway.md`](https://docs.clawd.bot/gateway) (Gateway runbook; flags, supervision, ports) +- [`docs/configuration.md`](https://docs.clawd.bot/configuration) (config schema + examples) +- [`docs/discord.md`](https://docs.clawd.bot/discord) and [`docs/telegram.md`](https://docs.clawd.bot/telegram) (reply tags + replyToMode settings) +- [`docs/clawd.md`](https://docs.clawd.bot/clawd) (personal assistant setup) +- [`docs/macos.md`](https://docs.clawd.bot/macos) (macOS app behavior; gateway lifecycle + “Attach only”) diff --git a/docs/signal.md b/docs/signal.md index 7dd4843ac..b2970747d 100644 --- a/docs/signal.md +++ b/docs/signal.md @@ -50,8 +50,13 @@ You can still run Clawdbot on your own Signal account if your goal is “respond httpHost: "127.0.0.1", httpPort: 8080, - // Who is allowed to talk to the bot - allowFrom: ["+15557654321"] // your personal number (or "*") + // Who is allowed to talk to the bot (DMs) + dmPolicy: "pairing", // pairing | allowlist | open | disabled + allowFrom: ["+15557654321"], // your personal number ("open" requires ["*"]) + + // Group policy + allowlist + groupPolicy: "open", + groupAllowFrom: ["+15557654321"] } } ``` @@ -60,6 +65,10 @@ You can still run Clawdbot on your own Signal account if your goal is “respond - Expect `signal.probe.ok=true` and `signal.probe.version`. 5) DM the bot number from your phone; Clawdbot replies. +## DM pairing +- Default: `signal.dmPolicy="pairing"` — unknown DM senders get a pairing code. +- Approve via: `clawdbot pairing approve --provider signal `. + ## “Do I need a separate number?” - If you want “I text her and she texts me back”, yes: **use a separate Signal account/number for the bot**. - Your personal account can run `signal-cli`, but you can’t self-chat (Signal loop protection; Clawdbot ignores sender==account). @@ -99,7 +108,7 @@ If you have a second phone: 2) Launch daemon (HTTP preferred), store PID. 3) Poll `/api/v1/check` until ready. 4) Open SSE stream; parse `event: receive`. -5) Translate receive payload into Clawdbot surface model. +5) Translate receive payload into Clawdbot provider model. 6) On SSE disconnect, backoff + reconnect. ## Storage diff --git a/docs/skills.md b/docs/skills.md index 0dadb6a10..424a5b833 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -142,6 +142,6 @@ copy). Workspace skills are user-owned and override both on name conflicts. ## Config reference -See `docs/skills-config.md` for the full configuration schema. +See [`docs/skills-config.md`](https://docs.clawd.bot/skills-config) for the full configuration schema. --- diff --git a/docs/slack.md b/docs/slack.md index d68d4e1a7..a70822116 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -17,7 +17,7 @@ read_when: "Setting up Slack or debugging Slack socket mode" - `channel_rename` - `pin_added`, `pin_removed` 5) Invite the bot to channels you want it to read. -6) Slash Commands → create the `/clawd` command (or your preferred name). +6) Slash Commands → create `/clawd` if you use `slack.slashCommand`. If you enable `commands.native`, add slash commands for the built-in chat commands (same names as `/help`). 7) App Home → enable the **Messages Tab** so users can DM the bot. Use the manifest below so scopes and events stay in sync. @@ -98,6 +98,8 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i } ``` +If you enable `commands.native`, add one `slash_commands` entry per command you want to expose (matching the `/help` list). + ## Scopes (current vs optional) Slack's Conversations API is type-scoped: you only need the scopes for the conversation types you actually touch (channels, groups, im, mpim). See @@ -109,12 +111,12 @@ https://api.slack.com/docs/conversations-api for the overview. - `im:write` (open DMs via `conversations.open` for user DMs) https://api.slack.com/methods/conversations.open - `channels:history`, `groups:history`, `im:history`, `mpim:history` - (`conversations.history` in `src/slack/actions.ts`) + (`conversations.history` in [`src/slack/actions.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/actions.ts)) https://api.slack.com/methods/conversations.history - `channels:read`, `groups:read`, `im:read`, `mpim:read` - (`conversations.info` in `src/slack/monitor.ts`) + (`conversations.info` in [`src/slack/monitor.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/monitor.ts)) https://api.slack.com/methods/conversations.info -- `users:read` (`users.info` in `src/slack/monitor.ts` + `src/slack/actions.ts`) +- `users:read` (`users.info` in [`src/slack/monitor.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/monitor.ts) + [`src/slack/actions.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/actions.ts)) https://api.slack.com/methods/users.info - `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`) https://api.slack.com/methods/reactions.get @@ -145,8 +147,10 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: "enabled": true, "botToken": "xoxb-...", "appToken": "xapp-...", + "groupPolicy": "open", "dm": { "enabled": true, + "policy": "pairing", "allowFrom": ["U123", "U456", "*"], "groupEnabled": false, "groupChannels": ["G123"] @@ -180,10 +184,24 @@ Tokens can also be supplied via env vars: - `SLACK_BOT_TOKEN` - `SLACK_APP_TOKEN` +Ack reactions are controlled globally via `messages.ackReaction` + +`messages.ackReactionScope`. + ## Sessions + routing - DMs share the `main` session (like WhatsApp/Telegram). - Channels map to `slack:channel:` sessions. - Slash commands use `slack:slash:` sessions. +- Native command registration is controlled by `commands.native`; text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands. +- Full command list + config: https://docs.clawd.bot/slash-commands + +## DM security (pairing) +- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code. +- Approve via: `clawdbot pairing approve --provider slack `. +- To allow anyone: set `slack.dm.policy="open"` and `slack.dm.allowFrom=["*"]`. + +## Group policy +- `slack.groupPolicy` controls channel handling (`open|disabled|allowlist`). +- `allowlist` requires channels to be listed in `slack.channels`. ## Delivery targets Use these with cron/CLI sends: diff --git a/docs/slash-commands.md b/docs/slash-commands.md new file mode 100644 index 000000000..81cdd12cf --- /dev/null +++ b/docs/slash-commands.md @@ -0,0 +1,53 @@ +--- +summary: "Slash commands: text vs native, config, and supported commands" +read_when: + - Using or configuring chat commands + - Debugging command routing or permissions +--- +# Slash commands + +Commands are handled by the Gateway. Send them as a **standalone** message that starts with `/`. +Inline text like `hello /status` is ignored. + +## Config + +```json5 +{ + commands: { + native: false, + text: true, + useAccessGroups: true + } +} +``` + +- `commands.text` (default `true`) enables parsing `/...` in chat messages. + - On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage), text commands still work even if you set this to `false`. +- `commands.native` (default `false`) registers native commands on Discord/Slack/Telegram. + - `false` clears previously registered commands on Discord/Telegram at startup. + - Slack commands are managed in the Slack app and are not removed automatically. +- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands. + +## Command list + +Text + native (when enabled): +- `/help` +- `/status` +- `/restart` +- `/activation mention|always` (groups only) +- `/send on|off|inherit` (owner-only) +- `/reset` or `/new` +- `/think ` (aliases: `/thinking`, `/t`) +- `/verbose on|off` (alias: `/v`) +- `/elevated on|off` (alias: `/elev`) +- `/model ` +- `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`) + +Text-only: +- `/compact [instructions]` + +## Surface notes + +- **Text commands** run in the normal chat session (DMs share `main`, groups have their own session). +- **Native commands** use isolated sessions: `discord:slash:`, `slack:slash:`, `telegram:slash:`. +- **Slack:** `slack.slashCommand` is still supported for a single `/clawd`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). diff --git a/docs/subagents.md b/docs/subagents.md new file mode 100644 index 000000000..71b805831 --- /dev/null +++ b/docs/subagents.md @@ -0,0 +1,72 @@ +--- +summary: "Sub-agents: spawning isolated agent runs that announce results back to the requester chat" +read_when: + - You want background/parallel work via the agent + - You are changing sessions_spawn or sub-agent tool policy +--- + +# Sub-agents + +Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`subagent:`) and, when finished, **announce** their result back to the requester chat provider. + +Primary goals: +- Parallelize “research / long task / slow tool” work without blocking the main run. +- Keep sub-agents isolated by default (session separation + optional sandboxing). +- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default. +- Avoid nested fan-out: sub-agents cannot spawn sub-agents. + +## Tool + +Use `sessions_spawn`: +- Starts a sub-agent run (`deliver: false`, global lane: `subagent`) +- Then runs an announce step and posts the announce reply to the requester chat provider + +Tool params: +- `task` (required) +- `label?` (optional) +- `timeoutSeconds?` (default `0`; `0` = fire-and-forget) +- `cleanup?` (`delete|keep`, default `delete`) + +## Announce + +Sub-agents report back via an announce step: +- The announce step runs inside the sub-agent session (not the requester session). +- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted. +- Otherwise the announce reply is posted to the requester chat provider via the gateway `send` method. + +## Tool Policy (sub-agent tools) + +By default, sub-agents get **all tools except session tools**: +- `sessions_list` +- `sessions_history` +- `sessions_send` +- `sessions_spawn` + +Override via config: + +```json5 +{ + agent: { + subagents: { + maxConcurrent: 1, + tools: { + // deny wins + deny: ["gateway", "cron"], + // if allow is set, it becomes allow-only (deny still wins) + // allow: ["read", "bash", "process"] + } + } + } +} +``` + +## Concurrency + +Sub-agents use a dedicated in-process queue lane: +- Lane name: `subagent` +- Concurrency: `agent.subagents.maxConcurrent` (default `1`) + +## Limitations + +- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost. +- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve. diff --git a/docs/surface.md b/docs/surface.md deleted file mode 100644 index fdcaf8871..000000000 --- a/docs/surface.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -summary: "Routing rules per surface (WhatsApp, Telegram, Discord, web) and shared context" -read_when: - - Changing surface routing or inbox behavior ---- -# Surfaces & Routing - -Updated: 2025-12-07 - -Goal: make replies deterministic per channel while keeping one shared context for direct chats. - -- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `discord`, `imessage`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose. -- **Reply context:** inbound replies include `ReplyToId`, `ReplyToBody`, and `ReplyToSender`, and the quoted context is appended to `Body` as a `[Replying to ...]` block. -- **Canonical direct session:** All direct chats collapse into the single `main` session by default (no config needed). Groups stay `surface:group:` (rooms: `surface:channel:`), so they remain isolated. -- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdbot/sessions/.jsonl`. -- **WebChat:** Always attaches to `main`, loads the full session transcript so desktop reflects cross-surface history, and writes new turns back to the same session. -- **Implementation hints:** - - Set `Surface` in each ingress (WhatsApp gateway, WebChat bridge, Telegram, Discord, iMessage). - - Keep routing deterministic: originate → same surface. Use the gateway WebSocket for sends; avoid side channels. - - Do not let the agent emit “send to X” decisions; keep that policy in the host code. diff --git a/docs/telegram.md b/docs/telegram.md index 058a5c36e..5f682d457 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -12,7 +12,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup ## Goals - Let you talk to Clawdbot via a Telegram bot in DMs and groups. - Share the same `main` session used by WhatsApp/WebChat; groups stay isolated as `telegram:group:`. -- Keep transport routing deterministic: replies always go back to the surface they arrived on. +- Keep transport routing deterministic: replies always go back to the provider they arrived on. ## How it will work (Bot API) 1) Create a bot with @BotFather and grab the token. @@ -23,9 +23,13 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - **Webhook mode** is enabled by setting `telegram.webhookUrl` (optionally `telegram.webhookSecret` / `telegram.webhookPath`). - The webhook listener currently binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default. - If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`. -4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config). -5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:` and require mention/command by default (override via `telegram.groups`). -6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). +4) Direct chats: secure by default — unknown senders are gated by `telegram.dmPolicy` (default: `"pairing"`). The bot responds with a pairing code that the owner must approve before messages are processed. If you really want public inbound DMs: set `telegram.dmPolicy="open"` and `telegram.allowFrom=["*"]`. +5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:`. When `telegram.groups` is set, it becomes a group allowlist (use `"*"` to allow all). Mention/command gating defaults come from `telegram.groups`. +6) Allowlist + pairing: + - Direct chats: `telegram.allowFrom` (chat ids) or pairing approvals via `clawdbot pairing approve --provider telegram ` (alias: `clawdbot telegram pairing approve `). + - Groups: set `telegram.groupPolicy = "allowlist"` and list senders in `telegram.groupAllowFrom` (fallback: explicit `telegram.allowFrom`). + - Commands respect group allowlists/policies by default; set `commands.useAccessGroups: false` to bypass. +7) Native commands: set `commands.native: true` to register `/` commands; set `commands.native: false` to clear previously registered commands. ## Capabilities & limits (Bot API) - Sees only messages sent after it’s added to a chat; no pre-history access. @@ -35,9 +39,10 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup ## Planned implementation details - Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits. -- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config). +- Inbound normalization: maps Bot API updates to `MsgContext` with `Provider: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config). - Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort. -- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. +- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.dmPolicy`, `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. + - Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`. - Mention gating precedence (most specific wins): `telegram.groups..requireMention` → `telegram.groups."*".requireMention` → default `true`. Example config: @@ -46,12 +51,15 @@ Example config: telegram: { enabled: true, botToken: "123:abc", + dmPolicy: "pairing", // pairing | allowlist | open | disabled replyToMode: "off", groups: { - "*": { requireMention: true }, + "*": { requireMention: true }, // allow all groups "123456789": { requireMention: false } // group chat id }, - allowFrom: ["123456789"], // direct chat ids allowed (or "*") + allowFrom: ["123456789"], // direct chat ids allowed ("open" requires ["*"]) + groupPolicy: "allowlist", + groupAllowFrom: ["tg:123456789", "@alice"], mediaMaxMb: 5, proxy: "socks5://localhost:9050", webhookSecret: "mysecret", @@ -60,12 +68,12 @@ Example config: } } ``` -- Tests: grammY-based paths in `src/telegram/*.test.ts` cover DM + group gating; add more media and webhook cases as needed. +- Tests: grammY-based paths in [`src/telegram/*.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/*.test.ts) cover DM + group gating; add more media and webhook cases as needed. ## Group etiquette - Keep privacy mode off if you expect the bot to read all messages; with privacy on, it only sees commands/mentions. - Make the bot an admin if you need it to send in restricted groups or channels. -- Mention the bot (`@yourbot`) or use a `routing.groupChat.mentionPatterns` trigger; per-group overrides live in `telegram.groups` if you want always-on behavior. +- Mention the bot (`@yourbot`), use a `routing.groupChat.mentionPatterns` trigger, or send a standalone `/...` command. Per-group overrides live in `telegram.groups` if you want always-on behavior; if `telegram.groups` is set, add `"*"` to keep existing allow-all behavior. ## Reply tags To request a threaded reply, the model can include one tag in its output: diff --git a/docs/templates/AGENTS.md b/docs/templates/AGENTS.md index be4345fd7..051f19c00 100644 --- a/docs/templates/AGENTS.md +++ b/docs/templates/AGENTS.md @@ -16,18 +16,28 @@ If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out w Before doing anything else: 1. Read `SOUL.md` — this is who you are 2. Read `USER.md` — this is who you're helping -3. Read `memory.md` + today's and yesterday's files in `memory/` +3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context +4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md` Don't ask permission. Just do it. ## Memory You wake up fresh each session. These files are your continuity: -- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) -- **Long-term:** `memory.md` for durable facts, preferences, open loops +- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened +- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them. +### 🧠 MEMORY.md - Your Long-Term Memory +- **ONLY load in main session** (direct chats with your human) +- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people) +- This is for **security** — contains personal context that shouldn't leak to strangers +- You can **read, edit, and update** MEMORY.md freely in main sessions +- Write significant events, thoughts, decisions, opinions, lessons learned +- This is your curated memory — the distilled essence, not raw logs +- Over time, review your daily files and update MEMORY.md with what's worth keeping + ### 📝 Write It Down - No "Mental Notes"! - **Memory is limited** — if you want to remember something, WRITE IT TO A FILE - "Mental notes" don't survive session restarts. Files do. @@ -105,7 +115,12 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n ## 💓 Heartbeats - Be Proactive! -When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively! +When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively! + +Default heartbeat prompt: +`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` + +You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small. **Things to check (rotate through these, 2-4 times per day):** - **Emails** - Any urgent unread messages? @@ -141,6 +156,16 @@ When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every ti - Check on projects (git status, etc.) - Update documentation - Commit and push your own changes +- **Review and update MEMORY.md** (see below) + +### 🔄 Memory Maintenance (During Heartbeats) +Periodically (every few days), use a heartbeat to: +1. Read through recent `memory/YYYY-MM-DD.md` files +2. Identify significant events, lessons, or insights worth keeping long-term +3. Update `MEMORY.md` with distilled learnings +4. Remove outdated info from MEMORY.md that's no longer relevant + +Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom. The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time. diff --git a/docs/templates/HEARTBEAT.md b/docs/templates/HEARTBEAT.md new file mode 100644 index 000000000..4d300f421 --- /dev/null +++ b/docs/templates/HEARTBEAT.md @@ -0,0 +1,8 @@ +--- +summary: "Workspace template for HEARTBEAT.md" +read_when: + - Bootstrapping a workspace manually +--- +# HEARTBEAT.md + +Keep this file empty unless you want a tiny checklist for heartbeat runs. Keep it small. diff --git a/docs/test.md b/docs/test.md index b31a57fbb..c04b7b62e 100644 --- a/docs/test.md +++ b/docs/test.md @@ -11,10 +11,10 @@ read_when: ## Model latency bench (local keys) -Script: `scripts/bench-model.ts` +Script: [`scripts/bench-model.ts`](https://github.com/clawdbot/clawdbot/blob/main/scripts/bench-model.ts) Usage: -- `source ~/.profile && pnpm tsx scripts/bench-model.ts --runs 10` +- `source ~/.profile && bun scripts/bench-model.ts --runs 10` - Optional env: `MINIMAX_API_KEY`, `MINIMAX_BASE_URL`, `MINIMAX_MODEL`, `ANTHROPIC_API_KEY` - Default prompt: “Reply with a single word: ok. No punctuation or extra text.” diff --git a/docs/thinking.md b/docs/thinking.md index 3ed9a9aa1..86f697581 100644 --- a/docs/thinking.md +++ b/docs/thinking.md @@ -35,10 +35,10 @@ read_when: - When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with ` : ` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting. ## Related -- Elevated mode docs live in `docs/elevated.md`. +- Elevated mode docs live in [`docs/elevated.md`](https://docs.clawd.bot/elevated). ## Heartbeats -- Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats). +- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats). ## Web chat UI - The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads. diff --git a/docs/timezone.md b/docs/timezone.md index 8a9d0ca6a..3269c610e 100644 --- a/docs/timezone.md +++ b/docs/timezone.md @@ -14,7 +14,7 @@ Clawdbot standardizes timestamps so the model sees a **single reference time**. Inbound messages are wrapped in an envelope like: ``` -[Surface ... 2026-01-05T21:26Z] message text +[Provider ... 2026-01-05T21:26Z] message text ``` The timestamp in the envelope is **always UTC**, with minutes precision. diff --git a/docs/tools.md b/docs/tools.md index 47815a386..f19c4d028 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -203,7 +203,7 @@ Notes: - `reactions` returns per-emoji user lists (limited to 100 per reaction). - `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`. - `searchMessages` follows the Discord preview spec (limit max 25, channel/author filters accept arrays). -- The tool is only exposed when the current surface is Discord. +- The tool is only exposed when the current provider is Discord. ## Parameters (common) @@ -247,17 +247,17 @@ Tools are exposed to the model in **two parallel channels**: 2) **Provider tool schema**: the actual function/tool declarations sent to the model API. In pi-mono: -- System prompt builder: `packages/coding-agent/src/core/system-prompt.ts` +- System prompt builder: [`packages/coding-agent/src/core/system-prompt.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/system-prompt.ts) - Builds the `Available tools:` list from `toolDescriptions`. - Appends skills and project context. - Tool schemas passed to providers: - - OpenAI: `packages/ai/src/providers/openai-responses.ts` (`convertTools`) - - Anthropic: `packages/ai/src/providers/anthropic.ts` (`convertTools`) - - Gemini: `packages/ai/src/providers/google-shared.ts` (`convertTools`) + - OpenAI: [`packages/ai/src/providers/openai-responses.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/openai-responses.ts) (`convertTools`) + - Anthropic: [`packages/ai/src/providers/anthropic.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts) (`convertTools`) + - Gemini: [`packages/ai/src/providers/google-shared.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/google-shared.ts) (`convertTools`) - Tool execution loop: - - Agent loop: `packages/ai/src/agent/agent-loop.ts` + - Agent loop: [`packages/ai/src/agent/agent-loop.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/agent/agent-loop.ts) - Validates tool arguments and executes tools, then appends `toolResult` messages. In Clawdbot: -- System prompt append: `src/agents/system-prompt.ts` -- Tool list injected via `createClawdbotCodingTools()` in `src/agents/pi-tools.ts` +- System prompt append: [`src/agents/system-prompt.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/system-prompt.ts) +- Tool list injected via `createClawdbotCodingTools()` in [`src/agents/pi-tools.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-tools.ts) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c8cbcab88..f119b5029 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,7 +14,7 @@ When your CLAWDBOT misbehaves, here's how to fix it. The agent was interrupted mid-response. **Causes:** -- User sent `stop`, `abort`, `esc`, or `exit` +- User sent `stop`, `abort`, `esc`, `wait`, or `exit` - Timeout exceeded - Process crashed @@ -50,7 +50,7 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp **Check 1:** Is the session file there? ```bash -ls -la ~/.clawdbot/sessions/ +ls -la ~/.clawdbot/agents//sessions/ ``` **Check 2:** Is `idleMinutes` too short? @@ -146,7 +146,7 @@ tccutil reset All com.clawdbot.mac.debug ``` **Fix 2: Force New Bundle ID** -If resetting doesn't work, change the `BUNDLE_ID` in `scripts/package-mac-app.sh` (e.g., add a `.test` suffix) and rebuild. This forces macOS to treat it as a new app. +If resetting doesn't work, change the `BUNDLE_ID` in [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) (e.g., add a `.test` suffix) and rebuild. This forces macOS to treat it as a new app. ### Gateway stuck on "Starting..." @@ -168,7 +168,7 @@ clawdbot gateway stop ``` **Fix 2: Check embedded gateway** -Ensure the gateway relay was properly bundled. Run `./scripts/package-mac-app.sh` and ensure `bun` is installed. +Ensure the gateway relay was properly bundled. Run [`./scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) and ensure `bun` is installed. ## Debug Mode @@ -188,7 +188,7 @@ clawdbot login --verbose | Log | Location | |-----|----------| | Main logs (default) | `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` | -| Session files | `~/.clawdbot/sessions/` | +| Session files | `~/.clawdbot/agents//sessions/` | | Media cache | `~/.clawdbot/media/` | | Credentials | `~/.clawdbot/credentials/` | @@ -254,4 +254,4 @@ Then set in config: } ``` -**Full guide:** See [browser-linux-troubleshooting.md](./browser-linux-troubleshooting.md) +**Full guide:** See [browser-linux-troubleshooting](https://docs.clawd.bot/browser-linux-troubleshooting) diff --git a/docs/tui.md b/docs/tui.md index de0479788..2a668160e 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -52,6 +52,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `/think ` - `/verbose ` - `/elevated ` +- `/elev ` - `/activation ` - `/deliver ` - `/new` or `/reset` @@ -65,6 +66,6 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - It registers as a Gateway client with `mode: "tui"` for presence and debugging. ## Files -- CLI: `src/cli/tui-cli.ts` -- Runner: `src/tui/tui.ts` -- Gateway client: `src/tui/gateway-chat.ts` +- CLI: [`src/cli/tui-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/tui-cli.ts) +- Runner: [`src/tui/tui.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/tui/tui.ts) +- Gateway client: [`src/tui/gateway-chat.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/tui/gateway-chat.ts) diff --git a/docs/typebox.md b/docs/typebox.md index d02db5435..cc192d271 100644 --- a/docs/typebox.md +++ b/docs/typebox.md @@ -7,12 +7,12 @@ read_when: Last updated: 2025-12-09 -We use TypeBox schemas in `src/gateway/protocol/schema.ts` as the single source of truth for the Gateway control plane (connect/req/res/event frames and payloads). All derived artifacts should be generated from these schemas, not edited by hand. +We use TypeBox schemas in [`src/gateway/protocol/schema.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/protocol/schema.ts) as the single source of truth for the Gateway control plane (connect/req/res/event frames and payloads). All derived artifacts should be generated from these schemas, not edited by hand. ## Current pipeline -- **TypeBox → JSON Schema**: `pnpm protocol:gen` writes `dist/protocol.schema.json` (draft-07) and runs AJV in the server tests. -- **TypeBox → Swift**: `pnpm protocol:gen:swift` generates `apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`. +- **TypeBox → JSON Schema**: `pnpm protocol:gen` writes [`dist/protocol.schema.json`](https://github.com/clawdbot/clawdbot/blob/main/dist/protocol.schema.json) (draft-07) and runs AJV in the server tests. +- **TypeBox → Swift**: `pnpm protocol:gen:swift` generates [`apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift). ## Problem diff --git a/docs/updating.md b/docs/updating.md new file mode 100644 index 000000000..8c31091c3 --- /dev/null +++ b/docs/updating.md @@ -0,0 +1,138 @@ +--- +summary: "Updating Clawdbot safely (npm or source), plus rollback strategy" +read_when: + - Updating Clawdbot + - Something breaks after an update +--- + +# Updating + +Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify. + +## Before you update + +- Know how you installed: **npm** (global) vs **from source** (git clone). +- Know how your Gateway is running: **foreground terminal** vs **supervised service** (launchd/systemd/Scheduled Task). +- Snapshot your tailoring: + - Config: `~/.clawdbot/clawdbot.json` + - Credentials: `~/.clawdbot/credentials/` + - Workspace: `~/clawd` + +## Update (npm install) + +Global install (pick one): + +```bash +npm i -g clawdbot@latest +``` + +```bash +pnpm add -g clawdbot@latest +``` + +Then: + +```bash +clawdbot doctor +clawdbot gateway restart +clawdbot health +``` + +Notes: +- If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs. +- If you’re pinned to a specific version, see “Rollback / pinning” below. + +## Update (from source) + +From the repo checkout: + +```bash +git pull +pnpm install +pnpm build +pnpm ui:install +pnpm ui:build +pnpm clawdbot doctor +pnpm clawdbot health +``` + +Notes: +- `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`. +- If you run directly from TypeScript (`pnpm clawdbot ...` / `bun run clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor. + +## Always run: `clawdbot doctor` + +Doctor is the “safe update” command. It’s intentionally boring: repair + migrate + warn. + +Typical things it does: +- Migrate deprecated config keys / legacy config file locations. +- Audit DM policies and warn on risky “open” settings. +- Check Gateway health and can offer to restart. +- Detect and migrate older gateway services (launchd/systemd/schtasks) to current Clawdbot services. +- On Linux, ensure systemd user lingering (so the Gateway survives logout). + +Details: https://docs.clawd.bot/doctor + +## Start / stop / restart the Gateway + +CLI (works regardless of OS): + +```bash +clawdbot gateway stop +clawdbot gateway restart +clawdbot gateway --port 18789 +``` + +If you’re supervised: +- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway` +- Linux systemd user service: `systemctl --user restart clawdbot-gateway.service` +- Windows: restart the `Clawdbot Gateway` Scheduled Task (Task Scheduler) + +Runbook + exact service labels: https://docs.clawd.bot/gateway + +## Rollback / pinning (when something breaks) + +### Pin (npm) + +Install a known-good version: + +```bash +npm i -g clawdbot@2026.1.5-3 +``` + +Then restart + re-run doctor: + +```bash +clawdbot doctor +clawdbot gateway restart +``` + +### Pin (source) by date + +Pick a commit from a date (example: “state of main as of 2026-01-01”): + +```bash +git fetch origin +git checkout "$(git rev-list -n 1 --before=\"2026-01-01\" origin/main)" +``` + +Then reinstall deps + restart: + +```bash +pnpm install +pnpm build +pnpm clawdbot gateway restart +``` + +If you want to go back to latest later: + +```bash +git checkout main +git pull +``` + +## If you’re stuck + +- Run `clawdbot doctor` again and read the output carefully (it often tells you the fix). +- Check: https://docs.clawd.bot/troubleshooting +- Ask in Discord: https://discord.gg/clawd diff --git a/docs/web.md b/docs/web.md index aeb0967f1..aecc44f47 100644 --- a/docs/web.md +++ b/docs/web.md @@ -25,8 +25,8 @@ The UI talks directly to the Gateway WS and supports: ## Webhooks -When `hooks.enabled=true`, the Gateway also exposes a small webhook surface on the same HTTP server. -See `docs/configuration.md` → `hooks` for auth + payloads. +When `hooks.enabled=true`, the Gateway also exposes a small webhook endpoint on the same HTTP server. +See [`docs/configuration.md`](https://docs.clawd.bot/configuration) → `hooks` for auth + payloads. ## Config (default-on) @@ -110,6 +110,6 @@ Open: The Gateway serves static files from `dist/control-ui`. Build them with: ```bash -pnpm ui:install -pnpm ui:build +bun run ui:install +bun run ui:build ``` diff --git a/docs/webchat.md b/docs/webchat.md index e36f08da1..46db1b999 100644 --- a/docs/webchat.md +++ b/docs/webchat.md @@ -30,5 +30,5 @@ Updated: 2025-12-17 - No fallback transport; the Gateway WS is required. ## Dev notes -- macOS glue: `apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift` + `apps/macos/Sources/Clawdbot/WebChatManager.swift`. -- Remote tunnel helper: `apps/macos/Sources/Clawdbot/RemotePortTunnel.swift`. +- macOS glue: [`apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift) + [`apps/macos/Sources/Clawdbot/WebChatManager.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/WebChatManager.swift). +- Remote tunnel helper: [`apps/macos/Sources/Clawdbot/RemotePortTunnel.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift). diff --git a/docs/webhook.md b/docs/webhook.md index ae17f8925..d591892f2 100644 --- a/docs/webhook.md +++ b/docs/webhook.md @@ -7,7 +7,7 @@ read_when: # Webhooks -Gateway can expose a small HTTP webhook surface for external triggers. +Gateway can expose a small HTTP webhook endpoint for external triggers. ## Enable @@ -58,7 +58,7 @@ Payload: "sessionKey": "hook:email:msg-123", "wakeMode": "now", "deliver": false, - "channel": "last", + "provider": "last", "to": "+15551234567", "thinking": "low", "timeoutSeconds": 120 @@ -70,8 +70,8 @@ Payload: - `sessionKey` optional (default random `hook:`) - `wakeMode` optional: `now` | `next-heartbeat` (default `now`) - `deliver` optional (default `false`) -- `channel` optional: `last` | `whatsapp` | `telegram` -- `to` optional (channel-specific target) +- `provider` optional: `last` | `whatsapp` | `telegram` +- `to` optional (provider-specific target) - `thinking` optional (override) - `timeoutSeconds` optional @@ -91,7 +91,7 @@ Mapping options (summary): - `hooks.mappings` lets you define `match`, `action`, and templates in config. - `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic. - Use `match.source` to keep a generic ingest endpoint (payload-driven routing). -- TS transforms require a TS loader (e.g. `tsx`) or precompiled `.js` at runtime. +- TS transforms require a TS loader (e.g. `bun`) or precompiled `.js` at runtime. - `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`. ## Responses diff --git a/docs/whatsapp.md b/docs/whatsapp.md index 0594fb583..e23c597bc 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -7,10 +7,10 @@ read_when: Updated: 2025-12-23 -Status: WhatsApp Web via Baileys only. Gateway owns the single session. +Status: WhatsApp Web via Baileys only. Gateway owns the session(s). ## Goals -- One WhatsApp identity, one gateway session. +- Multiple WhatsApp accounts (multi-account) in one Gateway process. - Deterministic routing: replies return to WhatsApp, no model routing. - Model sees enough context to understand quoted replies. @@ -37,9 +37,12 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Login + credentials - Login command: `clawdbot login` (QR via Linked Devices). -- Credentials stored in `~/.clawdbot/credentials/creds.json`. +- Multi-account login: `clawdbot login --account ` (`` = `accountId`). +- Default account (when `--account` is omitted): `default` if present, otherwise the first configured account id (sorted). +- Credentials stored in `~/.clawdbot/credentials/whatsapp//creds.json`. - Backup copy at `creds.json.bak` (restored on corruption). -- Logout: `clawdbot logout` deletes creds and session store. +- Legacy compatibility: older installs stored Baileys files directly in `~/.clawdbot/credentials/`. +- Logout: `clawdbot logout` (or `--account `) deletes WhatsApp auth state (but keeps shared `oauth.json`). - Logged-out socket => error instructs re-link. ## Inbound flow (DM + group) @@ -47,8 +50,12 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts. - Status/broadcast chats are ignored. - Direct chats use E.164; groups use group JID. -- **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only. - - If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode). +- **DM policy**: `whatsapp.dmPolicy` controls direct chat access (default: `pairing`). + - Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp `). + - Open: requires `whatsapp.allowFrom` to include `"*"`. + - Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number. +- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`). + - `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`). - **Self-chat mode**: avoids auto read receipts and ignores mention JIDs. - Read receipts sent for non-self-chat DMs. @@ -68,11 +75,12 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - `` ## Groups -- Groups map to `whatsapp:group:` sessions. +- Groups map to `agent::whatsapp:group:` sessions. +- Group policy: `whatsapp.groupPolicy = open|disabled|allowlist` (default `open`). - Activation modes: - `mention` (default): requires @mention or regex match. - `always`: always triggers. -- `/activation mention|always` is owner-only. +- `/activation mention|always` is owner-only and must be sent as a standalone message. - Owner = `whatsapp.allowFrom` (or self E.164 if unset). - **History injection**: - Recent messages (default 50) inserted under: @@ -84,7 +92,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Reply delivery (threading) - WhatsApp Web sends standard messages (no quoted reply threading in the current gateway). -- Reply tags are ignored on this surface. +- Reply tags are ignored on this provider. ## Outbound send (text + media) - Uses active web listener; error if gateway not running. @@ -107,8 +115,8 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Heartbeats - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). - **Agent heartbeat** is global (`agent.heartbeat.*`) and runs in the main session. - - Uses `HEARTBEAT` prompt + `HEARTBEAT_OK` skip behavior. - - Delivery defaults to the last used channel (or configured target). + - Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior. + - Delivery defaults to the last used provider (or configured target). ## Reconnect behavior - Backoff policy: `web.reconnect`: @@ -117,8 +125,12 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - Logged-out => stop and require re-link. ## Config quick map +- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled). - `whatsapp.allowFrom` (DM allowlist). -- `whatsapp.groups` (group mention gating defaults/overrides) +- `whatsapp.accounts..*` (per-account settings + optional `authDir`). +- `whatsapp.groupAllowFrom` (group sender allowlist). +- `whatsapp.groupPolicy` (group policy). +- `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `routing.groupChat.mentionPatterns` - `routing.groupChat.historyLimit` - `messages.messagePrefix` (inbound prefix) @@ -128,7 +140,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - `agent.heartbeat.model` (optional override) - `agent.heartbeat.target` - `agent.heartbeat.to` -- `session.*` (scope, idle, store; `mainKey` is ignored) +- `session.*` (scope, idle, store, mainKey) - `web.enabled` (disable provider startup when false) - `web.heartbeatSeconds` - `web.reconnect.*` @@ -136,9 +148,9 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Logs + troubleshooting - Subsystems: `whatsapp/inbound`, `whatsapp/outbound`, `web-heartbeat`, `web-reconnect`. - Log file: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (configurable). -- Troubleshooting guide: `docs/troubleshooting.md`. +- Troubleshooting guide: [`docs/troubleshooting.md`](https://docs.clawd.bot/troubleshooting). ## Tests -- `src/web/auto-reply.test.ts` (mention gating, history injection, reply flow) -- `src/web/monitor-inbox.test.ts` (inbound parsing + reply context) -- `src/web/outbound.test.ts` (send mapping + media) +- [`src/web/auto-reply.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/auto-reply.test.ts) (mention gating, history injection, reply flow) +- [`src/web/monitor-inbox.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/monitor-inbox.test.ts) (inbound parsing + reply context) +- [`src/web/outbound.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/outbound.test.ts) (send mapping + media) diff --git a/docs/wizard.md b/docs/wizard.md index 883f4e003..76ac67534 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -54,7 +54,7 @@ It does **not** install or change anything on the remote host. - **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint. - **Skip**: no auth configured yet. - Wizard runs a model check and warns if the configured model is unknown or missing auth. - - OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agent/auth-profiles.json` (API keys + OAuth). + - OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agents//agent/auth-profiles.json` (API keys + OAuth). 3) **Workspace** - Default `~/clawd` (configurable). @@ -71,6 +71,7 @@ It does **not** install or change anything on the remote host. - Discord: bot token. - Signal: optional `signal-cli` install + account config. - iMessage: local `imsg` CLI path + DB access. + - DM security: default is pairing (unknown DMs get a pairing code). Approve via `clawdbot pairing approve --provider `. 6) **Daemon install** - macOS: LaunchAgent @@ -155,12 +156,12 @@ Typical fields in `~/.clawdbot/clawdbot.json`: - `wizard.lastRunCommand` - `wizard.lastRunMode` -WhatsApp credentials go to `~/.clawdbot/credentials/`. -Sessions are stored under `~/.clawdbot/sessions/`. +WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp//`. +Sessions are stored under `~/.clawdbot/agents//sessions/`. ## Related docs -- macOS app onboarding: `docs/onboarding.md` -- Config reference: `docs/configuration.md` -- Providers: `docs/whatsapp.md`, `docs/telegram.md`, `docs/discord.md`, `docs/signal.md`, `docs/imessage.md` -- Skills: `docs/skills.md`, `docs/skills-config.md` +- macOS app onboarding: [`docs/onboarding.md`](https://docs.clawd.bot/onboarding) +- Config reference: [`docs/configuration.md`](https://docs.clawd.bot/configuration) +- Providers: [`docs/whatsapp.md`](https://docs.clawd.bot/whatsapp), [`docs/telegram.md`](https://docs.clawd.bot/telegram), [`docs/discord.md`](https://docs.clawd.bot/discord), [`docs/signal.md`](https://docs.clawd.bot/signal), [`docs/imessage.md`](https://docs.clawd.bot/imessage) +- Skills: [`docs/skills.md`](https://docs.clawd.bot/skills), [`docs/skills-config.md`](https://docs.clawd.bot/skills-config) diff --git a/package.json b/package.json index 5ddc0c3a7..8dd1f0b7c 100644 --- a/package.json +++ b/package.json @@ -44,19 +44,20 @@ "LICENSE" ], "scripts": { - "dev": "tsx src/entry.ts", - "docs:list": "tsx scripts/docs-list.ts", + "dev": "bun src/entry.ts", + "postinstall": "node scripts/postinstall.js", + "docs:list": "bun scripts/docs-list.ts", "docs:dev": "cd docs && mint dev", "docs:build": "cd docs && pnpm dlx mint broken-links", - "build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts", - "release:check": "tsx scripts/release-check.ts", - "ui:install": "pnpm -C ui install", - "ui:dev": "pnpm -C ui dev", - "ui:build": "pnpm -C ui build", - "start": "tsx src/entry.ts", - "clawdbot": "tsx src/entry.ts", - "gateway:watch": "tsx watch --clear-screen=false --include 'src/**/*.ts' src/entry.ts gateway --force", - "clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json", + "build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts", + "release:check": "bun scripts/release-check.ts", + "ui:install": "node scripts/ui.js install", + "ui:dev": "node scripts/ui.js dev", + "ui:build": "node scripts/ui.js build", + "start": "bun src/entry.ts", + "clawdbot": "bun src/entry.ts", + "gateway:watch": "bun --watch src/entry.ts gateway --force", + "clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json", "lint": "biome check src test && oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", "lint:fix": "biome check --write --unsafe src && biome format --write src", @@ -64,12 +65,12 @@ "format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources", "format:fix": "biome format src --write", "test": "vitest", - "test:force": "tsx scripts/test-force.ts", + "test:force": "bun scripts/test-force.ts", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", - "protocol:gen": "tsx scripts/protocol-gen.ts", - "protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts", + "protocol:gen": "bun scripts/protocol-gen.ts", + "protocol:gen:swift": "bun scripts/protocol-gen-swift.ts", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh" }, @@ -81,13 +82,14 @@ }, "packageManager": "pnpm@10.23.0", "dependencies": { + "@buape/carbon": "^0.13.0", "@clack/prompts": "^0.11.0", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", - "@mariozechner/pi-agent-core": "^0.36.0", - "@mariozechner/pi-ai": "^0.36.0", - "@mariozechner/pi-coding-agent": "^0.36.0", - "@mariozechner/pi-tui": "^0.36.0", + "@mariozechner/pi-agent-core": "^0.37.2", + "@mariozechner/pi-ai": "^0.37.2", + "@mariozechner/pi-coding-agent": "^0.37.2", + "@mariozechner/pi-tui": "^0.37.2", "@sinclair/typebox": "0.34.46", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.13.0", @@ -101,7 +103,6 @@ "croner": "^9.1.0", "detect-libc": "^2.1.2", "discord-api-types": "^0.38.37", - "discord.js": "^14.25.1", "dotenv": "^17.2.3", "express": "^5.2.1", "file-type": "^21.3.0", @@ -109,6 +110,7 @@ "json5": "^2.2.3", "long": "5.3.2", "playwright-core": "1.57.0", + "proper-lockfile": "^4.1.2", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", "tslog": "^4.10.2", @@ -125,6 +127,7 @@ "@types/express": "^5.0.6", "@types/markdown-it": "^14.1.2", "@types/node": "^25.0.3", + "@types/proper-lockfile": "^4.1.4", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", @@ -139,7 +142,6 @@ "quicktype-core": "^23.2.6", "rolldown": "1.0.0-beta.58", "signal-utils": "^0.21.1", - "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.16", "wireit": "^0.14.12" @@ -150,7 +152,8 @@ }, "patchedDependencies": { "@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch", - "qrcode-terminal": "patches/qrcode-terminal.patch" + "qrcode-terminal": "patches/qrcode-terminal.patch", + "playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch" } }, "vitest": { @@ -184,5 +187,10 @@ "apps/macos/.build/**", "dist/Clawdbot.app/**" ] + }, + "patchedDependencies": { + "@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch", + "qrcode-terminal": "patches/qrcode-terminal.patch", + "playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch" } } diff --git a/patches/@mariozechner__pi-ai.patch b/patches/@mariozechner__pi-ai.patch index b4cdf8e51..aa03fc55a 100644 --- a/patches/@mariozechner__pi-ai.patch +++ b/patches/@mariozechner__pi-ai.patch @@ -1,8 +1,52 @@ diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js -index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a6dd394ec 100644 +index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..56866774e47444b5d333961c9b20fce582363124 100644 --- a/dist/providers/google-shared.js +++ b/dist/providers/google-shared.js -@@ -51,9 +51,19 @@ export function convertMessages(model, context) { +@@ -10,13 +10,27 @@ import { transformMessages } from "./transorm-messages.js"; + export function convertMessages(model, context) { + const contents = []; + const transformedMessages = transformMessages(context.messages, model); ++ ++ /** ++ * Helper to add content while merging consecutive messages of the same role. ++ * Gemini/Cloud Code Assist requires strict role alternation (user/model/user/model). ++ * Consecutive messages of the same role cause "function call turn" errors. ++ */ ++ function addContent(role, parts) { ++ if (parts.length === 0) return; ++ const lastContent = contents[contents.length - 1]; ++ if (lastContent?.role === role) { ++ // Merge into existing message of same role ++ lastContent.parts.push(...parts); ++ } else { ++ contents.push({ role, parts }); ++ } ++ } ++ + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { +- contents.push({ +- role: "user", +- parts: [{ text: sanitizeSurrogates(msg.content) }], +- }); ++ addContent("user", [{ text: sanitizeSurrogates(msg.content) }]); + } + else { + const parts = msg.content.map((item) => { +@@ -35,10 +49,7 @@ export function convertMessages(model, context) { + const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts; + if (filteredParts.length === 0) + continue; +- contents.push({ +- role: "user", +- parts: filteredParts, +- }); ++ addContent("user", filteredParts); + } + } + else if (msg.role === "assistant") { +@@ -51,9 +62,19 @@ export function convertMessages(model, context) { parts.push({ text: sanitizeSurrogates(block.text) }); } else if (block.type === "thinking") { @@ -25,7 +69,7 @@ index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a parts.push({ thought: true, text: sanitizeSurrogates(block.thinking), -@@ -61,6 +71,7 @@ export function convertMessages(model, context) { +@@ -61,6 +82,7 @@ export function convertMessages(model, context) { }); } else { @@ -33,7 +77,44 @@ index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a parts.push({ text: `\n${sanitizeSurrogates(block.thinking)}\n`, }); -@@ -146,6 +157,77 @@ export function convertMessages(model, context) { +@@ -85,10 +107,7 @@ export function convertMessages(model, context) { + } + if (parts.length === 0) + continue; +- contents.push({ +- role: "model", +- parts, +- }); ++ addContent("model", parts); + } + else if (msg.role === "toolResult") { + // Extract text and image content +@@ -125,27 +144,94 @@ export function convertMessages(model, context) { + } + // Cloud Code Assist API requires all function responses to be in a single user turn. + // Check if the last content is already a user turn with function responses and merge. ++ // Use addContent for proper role alternation handling. + const lastContent = contents[contents.length - 1]; + if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) { + lastContent.parts.push(functionResponsePart); + } + else { +- contents.push({ +- role: "user", +- parts: [functionResponsePart], +- }); ++ addContent("user", [functionResponsePart]); + } + // For older models, add images in a separate user message ++ // Note: This may create consecutive user messages, but addContent will merge them + if (hasImages && !supportsMultimodalFunctionResponse) { +- contents.push({ +- role: "user", +- parts: [{ text: "Tool result image:" }, ...imageParts], +- }); ++ addContent("user", [{ text: "Tool result image:" }, ...imageParts]); + } + } } return contents; } @@ -111,7 +192,7 @@ index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a /** * Convert tools to Gemini function declarations format. */ -@@ -157,7 +239,7 @@ export function convertTools(tools) { +@@ -157,7 +243,7 @@ export function convertTools(tools) { functionDeclarations: tools.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/patches/@mariozechner__pi-coding-agent@0.32.3.patch b/patches/@mariozechner__pi-coding-agent@0.32.3.patch deleted file mode 100644 index a56be808a..000000000 --- a/patches/@mariozechner__pi-coding-agent@0.32.3.patch +++ /dev/null @@ -1,17 +0,0 @@ -diff --git a/dist/config.js b/dist/config.js -index 7caa66d2676933b102431ec8d92c571eb9d6d82c..77103b9d9573e56c26014c8c7c918e1f853afcdc 100644 ---- a/dist/config.js -+++ b/dist/config.js -@@ -10,8 +10,11 @@ const __dirname = dirname(__filename); - /** - * Detect if we're running as a Bun compiled binary. - * Bun binaries have import.meta.url containing "$bunfs", "~BUN", or "%7EBUN" (Bun's virtual filesystem path) -+ * Some packaging workflows keep import.meta.url as a file:// path, so fall back to execPath next to package.json. - */ --export const isBunBinary = import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN"); -+const bunBinaryByUrl = import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN"); -+const bunBinaryByExecPath = existsSync(join(dirname(process.execPath), "package.json")); -+export const isBunBinary = bunBinaryByUrl || bunBinaryByExecPath; - // ============================================================================= - // Package Asset Paths (shipped with executable) - // ============================================================================= diff --git a/patches/playwright-core@1.57.0.patch b/patches/playwright-core@1.57.0.patch new file mode 100644 index 000000000..97e5ad3ae --- /dev/null +++ b/patches/playwright-core@1.57.0.patch @@ -0,0 +1,13 @@ +diff --git a/lib/utilsBundle.js b/lib/utilsBundle.js +index 7dd8831f29c19f2e20468508b77b0a3f9d204ae6..c50a1ac2b3439a5b2fbf8afa61c369360710071f 100644 +--- a/lib/utilsBundle.js ++++ b/lib/utilsBundle.js +@@ -59,7 +59,7 @@ const program = require("./utilsBundleImpl").program; + const ProgramOption = require("./utilsBundleImpl").ProgramOption; + const progress = require("./utilsBundleImpl").progress; + const SocksProxyAgent = require("./utilsBundleImpl").SocksProxyAgent; +-const ws = require("./utilsBundleImpl").ws; ++const ws = "Bun" in globalThis ? require("ws") : require("./utilsBundleImpl").ws; + const wsServer = require("./utilsBundleImpl").wsServer; + const wsReceiver = require("./utilsBundleImpl").wsReceiver; + const wsSender = require("./utilsBundleImpl").wsSender; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce9e10913..cdaa899e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,11 @@ overrides: patchedDependencies: '@mariozechner/pi-ai': - hash: 628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5 + hash: b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a path: patches/@mariozechner__pi-ai.patch + playwright-core@1.57.0: + hash: 66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02 + path: patches/playwright-core@1.57.0.patch qrcode-terminal: hash: ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12 path: patches/qrcode-terminal.patch @@ -19,6 +22,9 @@ importers: .: dependencies: + '@buape/carbon': + specifier: ^0.13.0 + version: 0.13.0(@types/react@19.2.7)(hono@4.11.3) '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 @@ -29,17 +35,17 @@ importers: specifier: ^1.3.4 version: 1.3.4 '@mariozechner/pi-agent-core': - specifier: ^0.36.0 - version: 0.36.0(ws@8.19.0)(zod@4.3.5) + specifier: ^0.37.2 + version: 0.37.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': - specifier: ^0.36.0 - version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) + specifier: ^0.37.2 + version: 0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-coding-agent': - specifier: ^0.36.0 - version: 0.36.0(ws@8.19.0)(zod@4.3.5) + specifier: ^0.37.2 + version: 0.37.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': - specifier: ^0.36.0 - version: 0.36.0 + specifier: ^0.37.2 + version: 0.37.2 '@sinclair/typebox': specifier: 0.34.46 version: 0.34.46 @@ -79,9 +85,6 @@ importers: discord-api-types: specifier: ^0.38.37 version: 0.38.37 - discord.js: - specifier: ^14.25.1 - version: 14.25.1 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -102,7 +105,10 @@ importers: version: 5.3.2 playwright-core: specifier: 1.57.0 - version: 1.57.0 + version: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02) + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 qrcode-terminal: specifier: ^0.12.0 version: 0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12) @@ -146,6 +152,9 @@ importers: '@types/node': specifier: ^25.0.3 version: 25.0.3 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 @@ -188,9 +197,6 @@ importers: signal-utils: specifier: ^0.21.1 version: 0.21.1(signal-polyfill@0.2.2) - tsx: - specifier: ^4.21.0 - version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -325,6 +331,9 @@ packages: '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@buape/carbon@0.13.0': + resolution: {integrity: sha512-N52sGIJj832IezL+JmekC4gE7cCORj8r8mCJ1vsHOZiyr3O2pvsUA930E1j+rjStkd67TLxURPRMrpyqAFveIg==} + '@cacheable/memory@2.0.7': resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==} @@ -341,6 +350,9 @@ packages: '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@cloudflare/workers-types@4.20250513.0': + resolution: {integrity: sha512-TXaQyWLqhxEmi/DHx+VSaHZ4DHF/uJCPVv/hRyC7M/eWBo/I7mBtAkUEsrhqcKKO9oCeeRUHUHoeRLh5Gd96Gg==} + '@crosscopy/clipboard-darwin-arm64@0.2.8': resolution: {integrity: sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==} engines: {node: '>= 10'} @@ -392,34 +404,6 @@ packages: resolution: {integrity: sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==} engines: {node: '>= 10'} - '@discordjs/builders@1.13.1': - resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==} - engines: {node: '>=16.11.0'} - - '@discordjs/collection@1.5.3': - resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} - engines: {node: '>=16.11.0'} - - '@discordjs/collection@2.1.1': - resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} - engines: {node: '>=18'} - - '@discordjs/formatters@0.6.2': - resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} - engines: {node: '>=16.11.0'} - - '@discordjs/rest@2.6.0': - resolution: {integrity: sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==} - engines: {node: '>=18'} - - '@discordjs/util@1.2.0': - resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} - engines: {node: '>=18'} - - '@discordjs/ws@1.2.3': - resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} - engines: {node: '>=16.11.0'} - '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -619,6 +603,12 @@ packages: resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==} hasBin: true + '@hono/node-server@1.18.2': + resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} @@ -804,22 +794,22 @@ packages: peerDependencies: lit: ^3.3.1 - '@mariozechner/pi-agent-core@0.36.0': - resolution: {integrity: sha512-86BI1/j/MLxQHSWRXVLz8+NuSmDvLQebNb40+lFDI9XI9YBh8+r5fkYgU43u4j2TvANZ7iW6SFFnhWhzy8y6dg==} + '@mariozechner/pi-agent-core@0.37.2': + resolution: {integrity: sha512-GAN1lDVmlY1yH/FCfvpH29f2WBoqqMQkda7zKthOJO9l8tagxnlCWtq078CjzUGYlTDhKSf388XlOuDByBGYLA==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.36.0': - resolution: {integrity: sha512-xkzTgvdMzAZ/L/TgMH8z9Zi+aH0EWc54l5ygiafwvCgDk7xvfbylQG6pa9yn5zEn9T4NF9byJNk+nMHnycZvMQ==} + '@mariozechner/pi-ai@0.37.2': + resolution: {integrity: sha512-IhhvlPrgkdrlbS7QnV+qJPmlzKyae/aI1kenclG18/dXCypxUU50OuzGoVwrXvXw/RIHRwodhd7w4IH38Z7W4Q==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.36.0': - resolution: {integrity: sha512-lKdpuGE0yVs/96GnDhrPLEEFhRteHRtnkfX04KIBpcsEXXg2vyAlpxtjtZ9nlhYqLLIY7qJRkeyjbhcFFfbAAA==} + '@mariozechner/pi-coding-agent@0.37.2': + resolution: {integrity: sha512-wRFqcyY76h4mONO1si2oAn9WVKnhmVV28dPHjQXVPrl7uSwMCLn+Fcde/nmbL29pYfiU1il4GmUR+iSyoxBUVQ==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-tui@0.36.0': - resolution: {integrity: sha512-4n+nmTd36q0AVCbqWmjtTHTjIEwlGayKKhc+4QbpN9U3Z9jyQQa8Za1P2OHRmi6Jeu+ISuf4VBDvgmgCaxPZYg==} + '@mariozechner/pi-tui@0.37.2': + resolution: {integrity: sha512-XNV+jEeWJxQ8U3r5njRotVs6DnEIunkLHSA4nnF4OaRRgrcsafD8M4Pm/3RywSucclVK8P7+KoGiBB2Lokkmuw==} engines: {node: '>=20.0.0'} '@mistralai/mistralai@1.10.0': @@ -1146,18 +1136,6 @@ packages: cpu: [x64] os: [win32] - '@sapphire/async-queue@1.5.5': - resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - - '@sapphire/shapeshift@4.0.0': - resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} - engines: {node: '>=v16'} - - '@sapphire/snowflake@3.5.3': - resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - '@sinclair/typebox@0.34.46': resolution: {integrity: sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==} @@ -1224,6 +1202,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/bun@1.2.23': + resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1269,9 +1250,15 @@ packages: '@types/node@10.17.60': resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + '@types/node@22.19.3': + resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qrcode-terminal@0.12.2': resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} @@ -1281,9 +1268,15 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -1350,10 +1343,6 @@ packages: '@vitest/utils@4.0.16': resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} - '@vladfrangu/async_event_emitter@2.4.7': - resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - '@wasm-audio-decoders/common@9.0.7': resolution: {integrity: sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag==} @@ -1525,6 +1514,11 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-types@1.2.23: + resolution: {integrity: sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw==} + peerDependencies: + '@types/react': ^19 + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1635,6 +1629,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + curve25519-js@0.0.4: resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==} @@ -1674,13 +1671,12 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + discord-api-types@0.38.29: + resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==} + discord-api-types@0.38.37: resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==} - discord.js@14.25.1: - resolution: {integrity: sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==} - engines: {node: '>=18'} - docx-preview@0.3.7: resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==} @@ -1954,6 +1950,10 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hono@4.11.3: + resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} + engines: {node: '>=16.9.0'} + hookified@1.15.0: resolution: {integrity: sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==} @@ -2207,9 +2207,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash.snakecase@4.1.1: - resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} - lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -2236,9 +2233,6 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true - magic-bytes.js@1.12.1: - resolution: {integrity: sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2891,9 +2885,6 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} - ts-mixer@6.0.4: - resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2929,13 +2920,12 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@6.21.3: - resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} - engines: {node: '>=18.17'} - undici@7.18.0: resolution: {integrity: sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ==} engines: {node: '>=20.18.1'} @@ -3076,6 +3066,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -3185,6 +3187,22 @@ snapshots: '@borewit/text-codec@0.2.1': {} + '@buape/carbon@0.13.0(@types/react@19.2.7)(hono@4.11.3)': + dependencies: + '@types/node': 22.19.3 + discord-api-types: 0.38.29 + optionalDependencies: + '@cloudflare/workers-types': 4.20250513.0 + '@hono/node-server': 1.18.2(hono@4.11.3) + '@types/bun': 1.2.23(@types/react@19.2.7) + '@types/ws': 8.18.1 + ws: 8.18.3 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - hono + - utf-8-validate + '@cacheable/memory@2.0.7': dependencies: '@cacheable/utils': 2.3.3 @@ -3214,6 +3232,9 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@cloudflare/workers-types@4.20250513.0': + optional: true + '@crosscopy/clipboard-darwin-arm64@0.2.8': optional: true @@ -3249,55 +3270,6 @@ snapshots: '@crosscopy/clipboard-win32-arm64-msvc': 0.2.8 '@crosscopy/clipboard-win32-x64-msvc': 0.2.8 - '@discordjs/builders@1.13.1': - dependencies: - '@discordjs/formatters': 0.6.2 - '@discordjs/util': 1.2.0 - '@sapphire/shapeshift': 4.0.0 - discord-api-types: 0.38.37 - fast-deep-equal: 3.1.3 - ts-mixer: 6.0.4 - tslib: 2.8.1 - - '@discordjs/collection@1.5.3': {} - - '@discordjs/collection@2.1.1': {} - - '@discordjs/formatters@0.6.2': - dependencies: - discord-api-types: 0.38.37 - - '@discordjs/rest@2.6.0': - dependencies: - '@discordjs/collection': 2.1.1 - '@discordjs/util': 1.2.0 - '@sapphire/async-queue': 1.5.5 - '@sapphire/snowflake': 3.5.3 - '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.37 - magic-bytes.js: 1.12.1 - tslib: 2.8.1 - undici: 6.21.3 - - '@discordjs/util@1.2.0': - dependencies: - discord-api-types: 0.38.37 - - '@discordjs/ws@1.2.3': - dependencies: - '@discordjs/collection': 2.1.1 - '@discordjs/rest': 2.6.0 - '@discordjs/util': 1.2.0 - '@sapphire/async-queue': 1.5.5 - '@types/ws': 8.18.1 - '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.37 - tslib: 2.8.1 - ws: 8.19.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -3428,6 +3400,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@hono/node-server@1.18.2(hono@4.11.3)': + dependencies: + hono: 4.11.3 + optional: true + '@img/colour@1.0.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -3588,10 +3565,10 @@ snapshots: transitivePeerDependencies: - tailwindcss - '@mariozechner/pi-agent-core@0.36.0(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-agent-core@0.37.2(ws@8.19.0)(zod@4.3.5)': dependencies: - '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-tui': 0.36.0 + '@mariozechner/pi-ai': 0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-tui': 0.37.2 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -3600,7 +3577,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-ai@0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@google/genai': 1.34.0 @@ -3620,12 +3597,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.36.0(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-coding-agent@0.37.2(ws@8.19.0)(zod@4.3.5)': dependencies: '@crosscopy/clipboard': 0.2.8 - '@mariozechner/pi-agent-core': 0.36.0(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-tui': 0.36.0 + '@mariozechner/pi-agent-core': 0.37.2(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-tui': 0.37.2 chalk: 5.6.2 cli-highlight: 2.1.11 diff: 8.0.2 @@ -3633,6 +3610,7 @@ snapshots: glob: 11.1.0 jiti: 2.6.1 marked: 15.0.12 + proper-lockfile: 4.1.2 sharp: 0.34.5 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -3642,7 +3620,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.36.0': + '@mariozechner/pi-tui@0.37.2': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -3859,15 +3837,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.54.0': optional: true - '@sapphire/async-queue@1.5.5': {} - - '@sapphire/shapeshift@4.0.0': - dependencies: - fast-deep-equal: 3.1.3 - lodash: 4.17.21 - - '@sapphire/snowflake@3.5.3': {} - '@sinclair/typebox@0.34.46': {} '@slack/bolt@4.6.0(@types/express@5.0.6)': @@ -3984,6 +3953,13 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.0.3 + '@types/bun@1.2.23(@types/react@19.2.7)': + dependencies: + bun-types: 1.2.23(@types/react@19.2.7) + transitivePeerDependencies: + - '@types/react' + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -4034,18 +4010,33 @@ snapshots: '@types/node@10.17.60': {} + '@types/node@22.19.3': + dependencies: + undici-types: 6.21.0 + '@types/node@25.0.3': dependencies: undici-types: 7.16.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/qrcode-terminal@0.12.2': {} '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + optional: true + '@types/retry@0.12.0': {} + '@types/retry@0.12.5': {} + '@types/send@1.2.1': dependencies: '@types/node': 25.0.3 @@ -4162,8 +4153,6 @@ snapshots: '@vitest/pretty-format': 4.0.16 tinyrainbow: 3.0.3 - '@vladfrangu/async_event_emitter@2.4.7': {} - '@wasm-audio-decoders/common@9.0.7': dependencies: '@eshaz/web-worker': 1.2.2 @@ -4358,6 +4347,12 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bun-types@1.2.23(@types/react@19.2.7): + dependencies: + '@types/node': 25.0.3 + '@types/react': 19.2.7 + optional: true + bytes@3.1.2: {} cacheable@2.3.1: @@ -4473,6 +4468,9 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: + optional: true + curve25519-js@0.0.4: {} data-uri-to-buffer@4.0.1: {} @@ -4494,26 +4492,9 @@ snapshots: diff@8.0.2: {} - discord-api-types@0.38.37: {} + discord-api-types@0.38.29: {} - discord.js@14.25.1: - dependencies: - '@discordjs/builders': 1.13.1 - '@discordjs/collection': 1.5.3 - '@discordjs/formatters': 0.6.2 - '@discordjs/rest': 2.6.0 - '@discordjs/util': 1.2.0 - '@discordjs/ws': 1.2.3 - '@sapphire/snowflake': 3.5.3 - discord-api-types: 0.38.37 - fast-deep-equal: 3.1.3 - lodash.snakecase: 4.1.1 - magic-bytes.js: 1.12.1 - tslib: 2.8.1 - undici: 6.21.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + discord-api-types@0.38.37: {} docx-preview@0.3.7: dependencies: @@ -4773,6 +4754,7 @@ snapshots: get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 + optional: true glob-parent@5.1.2: dependencies: @@ -4851,6 +4833,9 @@ snapshots: highlight.js@11.11.1: {} + hono@4.11.3: + optional: true + hookified@1.15.0: {} html-escaper@2.0.2: {} @@ -5092,8 +5077,6 @@ snapshots: lodash.once@4.1.1: {} - lodash.snakecase@4.1.1: {} - lodash@4.17.21: {} long@4.0.0: {} @@ -5111,8 +5094,6 @@ snapshots: lz-string@1.5.0: optional: true - magic-bytes.js@1.12.1: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5377,11 +5358,11 @@ snapshots: dependencies: pngjs: 7.0.0 - playwright-core@1.57.0: {} + playwright-core@1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02): {} playwright@1.57.0: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02) optionalDependencies: fsevents: 2.3.2 @@ -5534,7 +5515,8 @@ snapshots: require-from-string@2.0.2: {} - resolve-pkg-maps@1.0.0: {} + resolve-pkg-maps@1.0.0: + optional: true retry@0.12.0: {} @@ -5844,8 +5826,6 @@ snapshots: ts-algebra@2.0.0: {} - ts-mixer@6.0.4: {} - tslib@2.8.1: {} tslog@4.10.2: {} @@ -5858,6 +5838,7 @@ snapshots: get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3 + optional: true type-is@2.0.1: dependencies: @@ -5875,9 +5856,9 @@ snapshots: uint8array-extras@1.5.0: {} - undici-types@7.16.0: {} + undici-types@6.21.0: {} - undici@6.21.3: {} + undici-types@7.16.0: {} undici@7.18.0: {} @@ -5998,6 +5979,9 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.3: + optional: true + ws@8.19.0: {} y18n@5.0.8: {} diff --git a/scripts/docs-list.ts b/scripts/docs-list.ts old mode 100644 new mode 100755 index a631726cf..7fad2594a --- a/scripts/docs-list.ts +++ b/scripts/docs-list.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env tsx +#!/usr/bin/env bun import { readdirSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 7c7fe1b1f..b60a6cb75 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -146,8 +146,8 @@ else fi if [[ "${SKIP_UI_BUILD:-0}" != "1" ]]; then - echo "🖥 Building Control UI (pnpm ui:build)" - (cd "$ROOT_DIR" && pnpm ui:build) + echo "🖥 Building Control UI (ui:build)" + (cd "$ROOT_DIR" && node scripts/ui.js build) else echo "🖥 Skipping Control UI build (SKIP_UI_BUILD=1)" fi diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 000000000..f849c02fd --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,110 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function isBunInstall() { + const ua = process.env.npm_config_user_agent ?? ""; + return ua.includes("bun/"); +} + +function getRepoRoot() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, ".."); +} + +function run(cmd, args, opts = {}) { + const res = spawnSync(cmd, args, { stdio: "inherit", ...opts }); + if (typeof res.status === "number") return res.status; + return 1; +} + +function applyPatchIfNeeded(opts) { + const patchPath = path.resolve(opts.patchPath); + if (!fs.existsSync(patchPath)) { + throw new Error(`missing patch: ${patchPath}`); + } + + let targetDir = path.resolve(opts.targetDir); + if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) { + console.warn(`[postinstall] skip missing target: ${targetDir}`); + return; + } + + // Resolve symlinks to avoid "beyond a symbolic link" errors from git apply + // (bun/pnpm use symlinks in node_modules) + targetDir = fs.realpathSync(targetDir); + + const gitArgsBase = ["apply", "--unsafe-paths", "--whitespace=nowarn"]; + const reverseCheck = [ + ...gitArgsBase, + "--reverse", + "--check", + "--directory", + targetDir, + patchPath, + ]; + const forwardCheck = [ + ...gitArgsBase, + "--check", + "--directory", + targetDir, + patchPath, + ]; + const apply = [...gitArgsBase, "--directory", targetDir, patchPath]; + + // Already applied? + if (run("git", reverseCheck, { stdio: "ignore" }) === 0) { + return; + } + + if (run("git", forwardCheck, { stdio: "ignore" }) !== 0) { + throw new Error(`patch does not apply cleanly: ${path.basename(patchPath)}`); + } + + const status = run("git", apply); + if (status !== 0) { + throw new Error(`failed applying patch: ${path.basename(patchPath)}`); + } +} + +function extractPackageName(key) { + if (key.startsWith("@")) { + const idx = key.indexOf("@", 1); + if (idx === -1) return key; + return key.slice(0, idx); + } + const idx = key.lastIndexOf("@"); + if (idx <= 0) return key; + return key.slice(0, idx); +} + +function main() { + if (!isBunInstall()) return; + + const repoRoot = getRepoRoot(); + process.chdir(repoRoot); + + const pkgPath = path.join(repoRoot, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + const patched = pkg?.pnpm?.patchedDependencies ?? {}; + + // Bun does not support pnpm.patchedDependencies. Apply these patch files to + // node_modules packages as a best-effort compatibility layer. + for (const [key, relPatchPath] of Object.entries(patched)) { + if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue; + const pkgName = extractPackageName(String(key)); + if (!pkgName) continue; + applyPatchIfNeeded({ + targetDir: path.join("node_modules", ...pkgName.split("/")), + patchPath: relPatchPath, + }); + } +} + +try { + main(); +} catch (err) { + console.error(String(err)); + process.exit(1); +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts old mode 100644 new mode 100755 index d9e0b43a3..3863a9d11 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env tsx +#!/usr/bin/env bun import { execSync } from "node:child_process"; diff --git a/scripts/test-force.ts b/scripts/test-force.ts old mode 100644 new mode 100755 index 9845fbd69..4e6e3bf9e --- a/scripts/test-force.ts +++ b/scripts/test-force.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env tsx +#!/usr/bin/env bun import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; diff --git a/scripts/ui.js b/scripts/ui.js new file mode 100644 index 000000000..8296491b7 --- /dev/null +++ b/scripts/ui.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const uiDir = path.join(repoRoot, "ui"); + +function usage() { + // keep this tiny; it's invoked from npm scripts too + process.stderr.write( + "Usage: node scripts/ui.js [...args]\n", + ); +} + +function which(cmd) { + try { + const key = process.platform === "win32" ? "Path" : "PATH"; + const paths = (process.env[key] ?? process.env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean); + const extensions = + process.platform === "win32" + ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM") + .split(";") + .filter(Boolean) + : [""]; + for (const entry of paths) { + for (const ext of extensions) { + const candidate = path.join(entry, process.platform === "win32" ? `${cmd}${ext}` : cmd); + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + // ignore + } + } + } + } catch { + // ignore + } + return null; +} + +function resolveRunner() { + const bun = which("bun"); + if (bun) return { cmd: bun, kind: "bun" }; + const pnpm = which("pnpm"); + if (pnpm) return { cmd: pnpm, kind: "pnpm" }; + return null; +} + +function run(cmd, args) { + const child = spawn(cmd, args, { + cwd: uiDir, + stdio: "inherit", + env: process.env, + }); + child.on("exit", (code, signal) => { + if (signal) process.exit(1); + process.exit(code ?? 1); + }); +} + +function runSync(cmd, args) { + const result = spawnSync(cmd, args, { + cwd: uiDir, + stdio: "inherit", + env: process.env, + }); + if (result.signal) process.exit(1); + if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1); +} + +function depsInstalled() { + try { + const require = createRequire(path.join(uiDir, "package.json")); + require.resolve("vite"); + require.resolve("dompurify"); + return true; + } catch { + return false; + } +} + +const [, , action, ...rest] = process.argv; +if (!action) { + usage(); + process.exit(2); +} + +const runner = resolveRunner(); +if (!runner) { + process.stderr.write( + "Missing UI runner: install bun or pnpm, then retry.\n", + ); + process.exit(1); +} + +const script = + action === "install" + ? null + : action === "dev" + ? "dev" + : action === "build" + ? "build" + : action === "test" + ? "test" + : null; + +if (action !== "install" && !script) { + usage(); + process.exit(2); +} + +if (runner.kind === "bun") { + if (action === "install") run(runner.cmd, ["install", ...rest]); + else { + if (!depsInstalled()) runSync(runner.cmd, ["install"]); + run(runner.cmd, ["run", script, ...rest]); + } +} else { + if (action === "install") run(runner.cmd, ["install", ...rest]); + else { + if (!depsInstalled()) runSync(runner.cmd, ["install"]); + run(runner.cmd, ["run", script, ...rest]); + } +} diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md index 814ce326b..a36c21f64 100644 --- a/skills/nano-banana-pro/SKILL.md +++ b/skills/nano-banana-pro/SKILL.md @@ -26,4 +26,5 @@ API key Notes - Resolutions: `1K` (default), `2K`, `4K`. - Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`. +- The script prints a `MEDIA:` line for Clawdbot to auto-attach on supported chat providers. - Do not read the image back; report the saved path only. diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py index b3dbf30ba..48dd9e9e5 100755 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -154,6 +154,8 @@ def main(): if image_saved: full_path = output_path.resolve() print(f"\nImage saved: {full_path}") + # Clawdbot parses MEDIA tokens and will attach the file on supported providers. + print(f"MEDIA: {full_path}") else: print("Error: No image was generated in the response.", file=sys.stderr) sys.exit(1) diff --git a/src/agents/agent-paths.test.ts b/src/agents/agent-paths.test.ts new file mode 100644 index 000000000..e70f93717 --- /dev/null +++ b/src/agents/agent-paths.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { resolveClawdbotAgentDir } from "./agent-paths.js"; + +describe("resolveClawdbotAgentDir", () => { + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + let tempStateDir: string | null = null; + + afterEach(async () => { + if (tempStateDir) { + await fs.rm(tempStateDir, { recursive: true, force: true }); + tempStateDir = null; + } + if (previousStateDir === undefined) { + delete process.env.CLAWDBOT_STATE_DIR; + } else { + process.env.CLAWDBOT_STATE_DIR = previousStateDir; + } + if (previousAgentDir === undefined) { + delete process.env.CLAWDBOT_AGENT_DIR; + } else { + process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; + } + if (previousPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; + } + }); + + it("defaults to the multi-agent path when no overrides are set", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + delete process.env.CLAWDBOT_AGENT_DIR; + delete process.env.PI_CODING_AGENT_DIR; + + const resolved = resolveClawdbotAgentDir(); + + expect(resolved).toBe(path.join(tempStateDir, "agents", "main", "agent")); + }); + + it("honors CLAWDBOT_AGENT_DIR overrides", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); + const override = path.join(tempStateDir, "agent"); + process.env.CLAWDBOT_AGENT_DIR = override; + delete process.env.PI_CODING_AGENT_DIR; + + const resolved = resolveClawdbotAgentDir(); + + expect(resolved).toBe(path.resolve(override)); + }); +}); diff --git a/src/agents/agent-paths.ts b/src/agents/agent-paths.ts index 2fe019e75..1dd54ea81 100644 --- a/src/agents/agent-paths.ts +++ b/src/agents/agent-paths.ts @@ -1,14 +1,21 @@ import path from "node:path"; -import { resolveConfigDir, resolveUserPath } from "../utils.js"; +import { resolveStateDir } from "../config/paths.js"; +import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; +import { resolveUserPath } from "../utils.js"; export function resolveClawdbotAgentDir(): string { - const defaultAgentDir = path.join(resolveConfigDir(), "agent"); const override = process.env.CLAWDBOT_AGENT_DIR?.trim() || - process.env.PI_CODING_AGENT_DIR?.trim() || - defaultAgentDir; - return resolveUserPath(override); + process.env.PI_CODING_AGENT_DIR?.trim(); + if (override) return resolveUserPath(override); + const defaultAgentDir = path.join( + resolveStateDir(), + "agents", + DEFAULT_AGENT_ID, + "agent", + ); + return resolveUserPath(defaultAgentDir); } export function ensureClawdbotAgentEnv(): string { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts new file mode 100644 index 000000000..e462abbcf --- /dev/null +++ b/src/agents/agent-scope.ts @@ -0,0 +1,64 @@ +import os from "node:os"; +import path from "node:path"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { + DEFAULT_AGENT_ID, + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; +import { resolveUserPath } from "../utils.js"; +import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js"; + +export function resolveAgentIdFromSessionKey( + sessionKey?: string | null, +): string { + const parsed = parseAgentSessionKey(sessionKey); + return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); +} + +export function resolveAgentConfig( + cfg: ClawdbotConfig, + agentId: string, +): { workspace?: string; agentDir?: string } | undefined { + const id = normalizeAgentId(agentId); + const agents = cfg.routing?.agents; + if (!agents || typeof agents !== "object") return undefined; + const entry = agents[id]; + if (!entry || typeof entry !== "object") return undefined; + return { + workspace: + typeof entry.workspace === "string" ? entry.workspace : undefined, + agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, + }; +} + +export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) { + const id = normalizeAgentId(agentId); + const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); + if (configured) return resolveUserPath(configured); + if (id === DEFAULT_AGENT_ID) { + const legacy = cfg.agent?.workspace?.trim(); + if (legacy) return resolveUserPath(legacy); + return DEFAULT_AGENT_WORKSPACE_DIR; + } + return path.join(os.homedir(), `clawd-${id}`); +} + +export function resolveAgentDir(cfg: ClawdbotConfig, agentId: string) { + const id = normalizeAgentId(agentId); + const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim(); + if (configured) return resolveUserPath(configured); + const root = resolveStateDir(process.env, os.homedir); + return path.join(root, "agents", id, "agent"); +} + +/** + * Resolve the agent directory for the default agent without requiring config. + * Used by onboarding when writing auth profiles before config is fully set up. + */ +export function resolveDefaultAgentDir(): string { + const root = resolveStateDir(process.env, os.homedir); + return path.join(root, "agents", DEFAULT_AGENT_ID, "agent"); +} diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts index 493f2c09d..f7fecba89 100644 --- a/src/agents/auth-profiles.test.ts +++ b/src/agents/auth-profiles.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { type AuthProfileStore, + calculateAuthProfileCooldownMs, resolveAuthProfileOrder, } from "./auth-profiles.js"; @@ -49,13 +50,20 @@ describe("resolveAuthProfileOrder", () => { expect(order).toContain("anthropic:default"); }); - it("prioritizes last-good profile when no preferred override", () => { + it("does not prioritize lastGood over round-robin ordering", () => { const order = resolveAuthProfileOrder({ cfg, - store: { ...store, lastGood: { anthropic: "anthropic:work" } }, + store: { + ...store, + lastGood: { anthropic: "anthropic:work" }, + usageStats: { + "anthropic:default": { lastUsed: 100 }, + "anthropic:work": { lastUsed: 200 }, + }, + }, provider: "anthropic", }); - expect(order[0]).toBe("anthropic:work"); + expect(order[0]).toBe("anthropic:default"); }); it("uses explicit profiles when order is missing", () => { @@ -105,4 +113,87 @@ describe("resolveAuthProfileOrder", () => { }); expect(order).toEqual(["anthropic:oauth", "anthropic:default"]); }); + + it("orders by lastUsed when no explicit order exists", () => { + const order = resolveAuthProfileOrder({ + store: { + version: 1, + profiles: { + "anthropic:a": { + type: "oauth", + provider: "anthropic", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + "anthropic:b": { + type: "api_key", + provider: "anthropic", + key: "sk-b", + }, + "anthropic:c": { + type: "api_key", + provider: "anthropic", + key: "sk-c", + }, + }, + usageStats: { + "anthropic:a": { lastUsed: 200 }, + "anthropic:b": { lastUsed: 100 }, + "anthropic:c": { lastUsed: 300 }, + }, + }, + provider: "anthropic", + }); + expect(order).toEqual(["anthropic:a", "anthropic:b", "anthropic:c"]); + }); + + it("pushes cooldown profiles to the end, ordered by cooldown expiry", () => { + const now = Date.now(); + const order = resolveAuthProfileOrder({ + store: { + version: 1, + profiles: { + "anthropic:ready": { + type: "api_key", + provider: "anthropic", + key: "sk-ready", + }, + "anthropic:cool1": { + type: "oauth", + provider: "anthropic", + access: "access-token", + refresh: "refresh-token", + expires: now + 60_000, + }, + "anthropic:cool2": { + type: "api_key", + provider: "anthropic", + key: "sk-cool", + }, + }, + usageStats: { + "anthropic:ready": { lastUsed: 50 }, + "anthropic:cool1": { cooldownUntil: now + 5_000 }, + "anthropic:cool2": { cooldownUntil: now + 1_000 }, + }, + }, + provider: "anthropic", + }); + expect(order).toEqual([ + "anthropic:ready", + "anthropic:cool2", + "anthropic:cool1", + ]); + }); +}); + +describe("auth profile cooldowns", () => { + it("applies exponential backoff with a 1h cap", () => { + expect(calculateAuthProfileCooldownMs(1)).toBe(60_000); + expect(calculateAuthProfileCooldownMs(2)).toBe(5 * 60_000); + expect(calculateAuthProfileCooldownMs(3)).toBe(25 * 60_000); + expect(calculateAuthProfileCooldownMs(4)).toBe(60 * 60_000); + expect(calculateAuthProfileCooldownMs(5)).toBe(60 * 60_000); + }); }); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index f9999f17e..8b0b31866 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -6,6 +6,7 @@ import { type OAuthCredentials, type OAuthProvider, } from "@mariozechner/pi-ai"; +import lockfile from "proper-lockfile"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveOAuthPath } from "../config/paths.js"; @@ -31,22 +32,31 @@ export type OAuthCredential = OAuthCredentials & { export type AuthProfileCredential = ApiKeyCredential | OAuthCredential; +/** Per-profile usage statistics for round-robin and cooldown tracking */ +export type ProfileUsageStats = { + lastUsed?: number; + cooldownUntil?: number; + errorCount?: number; +}; + export type AuthProfileStore = { version: number; profiles: Record; lastGood?: Record; + /** Usage statistics per profile for round-robin rotation */ + usageStats?: Record; }; type LegacyAuthStore = Record; -function resolveAuthStorePath(): string { - const agentDir = resolveClawdbotAgentDir(); - return path.join(agentDir, AUTH_PROFILE_FILENAME); +function resolveAuthStorePath(agentDir?: string): string { + const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir()); + return path.join(resolved, AUTH_PROFILE_FILENAME); } -function resolveLegacyAuthStorePath(): string { - const agentDir = resolveClawdbotAgentDir(); - return path.join(agentDir, LEGACY_AUTH_FILENAME); +function resolveLegacyAuthStorePath(agentDir?: string): string { + const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir()); + return path.join(resolved, LEGACY_AUTH_FILENAME); } function loadJsonFile(pathname: string): unknown { @@ -68,6 +78,84 @@ function saveJsonFile(pathname: string, data: unknown) { fs.chmodSync(pathname, 0o600); } +function ensureAuthStoreFile(pathname: string) { + if (fs.existsSync(pathname)) return; + const payload: AuthProfileStore = { + version: AUTH_STORE_VERSION, + profiles: {}, + }; + saveJsonFile(pathname, payload); +} + +function buildOAuthApiKey( + provider: OAuthProvider, + credentials: OAuthCredentials, +): string { + const needsProjectId = + provider === "google-gemini-cli" || provider === "google-antigravity"; + return needsProjectId + ? JSON.stringify({ + token: credentials.access, + projectId: credentials.projectId, + }) + : credentials.access; +} + +async function refreshOAuthTokenWithLock(params: { + profileId: string; + provider: OAuthProvider; + agentDir?: string; +}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { + const authPath = resolveAuthStorePath(params.agentDir); + ensureAuthStoreFile(authPath); + + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(authPath, { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 10_000, + randomize: true, + }, + stale: 30_000, + }); + + const store = ensureAuthProfileStore(params.agentDir); + const cred = store.profiles[params.profileId]; + if (!cred || cred.type !== "oauth") return null; + + if (Date.now() < cred.expires) { + return { + apiKey: buildOAuthApiKey(cred.provider, cred), + newCredentials: cred, + }; + } + + const oauthCreds: Record = { + [cred.provider]: cred, + }; + const result = await getOAuthApiKey(cred.provider, oauthCreds); + if (!result) return null; + store.profiles[params.profileId] = { + ...cred, + ...result.newCredentials, + type: "oauth", + }; + saveAuthProfileStore(store, params.agentDir); + return result; + } finally { + if (release) { + try { + await release(); + } catch { + // ignore unlock errors + } + } + } +} + function coerceLegacyStore(raw: unknown): LegacyAuthStore | null { if (!raw || typeof raw !== "object") return null; const record = raw as Record; @@ -105,6 +193,10 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null { record.lastGood && typeof record.lastGood === "object" ? (record.lastGood as Record) : undefined, + usageStats: + record.usageStats && typeof record.usageStats === "object" + ? (record.usageStats as Record) + : undefined, }; } @@ -170,13 +262,13 @@ export function loadAuthProfileStore(): AuthProfileStore { return { version: AUTH_STORE_VERSION, profiles: {} }; } -export function ensureAuthProfileStore(): AuthProfileStore { - const authPath = resolveAuthStorePath(); +export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore { + const authPath = resolveAuthStorePath(agentDir); const raw = loadJsonFile(authPath); const asStore = coerceAuthStore(raw); if (asStore) return asStore; - const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath()); + const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir)); const legacy = coerceLegacyStore(legacyRaw); const store: AuthProfileStore = { version: AUTH_STORE_VERSION, @@ -216,12 +308,16 @@ export function ensureAuthProfileStore(): AuthProfileStore { return store; } -export function saveAuthProfileStore(store: AuthProfileStore): void { - const authPath = resolveAuthStorePath(); +export function saveAuthProfileStore( + store: AuthProfileStore, + agentDir?: string, +): void { + const authPath = resolveAuthStorePath(agentDir); const payload = { version: AUTH_STORE_VERSION, profiles: store.profiles, lastGood: store.lastGood ?? undefined, + usageStats: store.usageStats ?? undefined, } satisfies AuthProfileStore; saveJsonFile(authPath, payload); } @@ -229,10 +325,11 @@ export function saveAuthProfileStore(store: AuthProfileStore): void { export function upsertAuthProfile(params: { profileId: string; credential: AuthProfileCredential; + agentDir?: string; }): void { - const store = ensureAuthProfileStore(); + const store = ensureAuthProfileStore(params.agentDir); store.profiles[params.profileId] = params.credential; - saveAuthProfileStore(store); + saveAuthProfileStore(store, params.agentDir); } export function listProfilesForProvider( @@ -244,6 +341,93 @@ export function listProfilesForProvider( .map(([id]) => id); } +/** + * Check if a profile is currently in cooldown (due to rate limiting or errors). + */ +export function isProfileInCooldown( + store: AuthProfileStore, + profileId: string, +): boolean { + const stats = store.usageStats?.[profileId]; + if (!stats?.cooldownUntil) return false; + return Date.now() < stats.cooldownUntil; +} + +/** + * Mark a profile as successfully used. Resets error count and updates lastUsed. + */ +export function markAuthProfileUsed(params: { + store: AuthProfileStore; + profileId: string; + agentDir?: string; +}): void { + const { store, profileId, agentDir } = params; + if (!store.profiles[profileId]) return; + + store.usageStats = store.usageStats ?? {}; + store.usageStats[profileId] = { + ...store.usageStats[profileId], + lastUsed: Date.now(), + errorCount: 0, + cooldownUntil: undefined, + }; + saveAuthProfileStore(store, agentDir); +} + +export function calculateAuthProfileCooldownMs(errorCount: number): number { + const normalized = Math.max(1, errorCount); + return Math.min( + 60 * 60 * 1000, // 1 hour max + 60 * 1000 * 5 ** Math.min(normalized - 1, 3), + ); +} + +/** + * Mark a profile as failed/rate-limited. Applies exponential backoff cooldown. + * Cooldown times: 1min, 5min, 25min, max 1 hour. + */ +export function markAuthProfileCooldown(params: { + store: AuthProfileStore; + profileId: string; + agentDir?: string; +}): void { + const { store, profileId, agentDir } = params; + if (!store.profiles[profileId]) return; + + store.usageStats = store.usageStats ?? {}; + const existing = store.usageStats[profileId] ?? {}; + const errorCount = (existing.errorCount ?? 0) + 1; + + // Exponential backoff: 1min, 5min, 25min, capped at 1h + const backoffMs = calculateAuthProfileCooldownMs(errorCount); + + store.usageStats[profileId] = { + ...existing, + errorCount, + cooldownUntil: Date.now() + backoffMs, + }; + saveAuthProfileStore(store, agentDir); +} + +/** + * Clear cooldown for a profile (e.g., manual reset). + */ +export function clearAuthProfileCooldown(params: { + store: AuthProfileStore; + profileId: string; + agentDir?: string; +}): void { + const { store, profileId, agentDir } = params; + if (!store.usageStats?.[profileId]) return; + + store.usageStats[profileId] = { + ...store.usageStats[profileId], + errorCount: 0, + cooldownUntil: undefined, + }; + saveAuthProfileStore(store, agentDir); +} + export function resolveAuthProfileOrder(params: { cfg?: ClawdbotConfig; store: AuthProfileStore; @@ -257,19 +441,14 @@ export function resolveAuthProfileOrder(params: { .filter(([, profile]) => profile.provider === provider) .map(([profileId]) => profileId) : []; - const lastGood = store.lastGood?.[provider]; const baseOrder = configuredOrder ?? (explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, provider)); if (baseOrder.length === 0) return []; - const order = - configuredOrder && configuredOrder.length > 0 - ? baseOrder - : orderProfilesByMode(baseOrder, store); - const filtered = order.filter((profileId) => { + const filtered = baseOrder.filter((profileId) => { const cred = store.profiles[profileId]; return cred ? cred.provider === provider : true; }); @@ -277,41 +456,86 @@ export function resolveAuthProfileOrder(params: { for (const entry of filtered) { if (!deduped.includes(entry)) deduped.push(entry); } - if (preferredProfile && deduped.includes(preferredProfile)) { - const rest = deduped.filter((entry) => entry !== preferredProfile); - if (lastGood && rest.includes(lastGood)) { + + // If user specified explicit order in config, respect it exactly + if (configuredOrder && configuredOrder.length > 0) { + // Still put preferredProfile first if specified + if (preferredProfile && deduped.includes(preferredProfile)) { return [ preferredProfile, - lastGood, - ...rest.filter((entry) => entry !== lastGood), + ...deduped.filter((e) => e !== preferredProfile), ]; } - return [preferredProfile, ...rest]; + return deduped; } - if (lastGood && deduped.includes(lastGood)) { - return [lastGood, ...deduped.filter((entry) => entry !== lastGood)]; + + // Otherwise, use round-robin: sort by lastUsed (oldest first) + // preferredProfile goes first if specified (for explicit user choice) + // lastGood is NOT prioritized - that would defeat round-robin + const sorted = orderProfilesByMode(deduped, store); + + if (preferredProfile && sorted.includes(preferredProfile)) { + return [preferredProfile, ...sorted.filter((e) => e !== preferredProfile)]; } - return deduped; + + return sorted; } function orderProfilesByMode( order: string[], store: AuthProfileStore, ): string[] { - const scored = order.map((profileId) => { + const now = Date.now(); + + // Partition into available and in-cooldown + const available: string[] = []; + const inCooldown: string[] = []; + + for (const profileId of order) { + if (isProfileInCooldown(store, profileId)) { + inCooldown.push(profileId); + } else { + available.push(profileId); + } + } + + // Sort available profiles by lastUsed (oldest first = round-robin) + // Then by lastUsed (oldest first = round-robin within type) + const scored = available.map((profileId) => { const type = store.profiles[profileId]?.type; - const score = type === "oauth" ? 0 : type === "api_key" ? 1 : 2; - return { profileId, score }; + const typeScore = type === "oauth" ? 0 : type === "api_key" ? 1 : 2; + const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0; + return { profileId, typeScore, lastUsed }; }); - return scored - .sort((a, b) => a.score - b.score) + + // Primary sort: type preference (oauth > api_key). + // Secondary sort: lastUsed (oldest first for round-robin within type). + const sorted = scored + .sort((a, b) => { + // First by type (oauth > api_key) + if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore; + // Then by lastUsed (oldest first) + return a.lastUsed - b.lastUsed; + }) .map((entry) => entry.profileId); + + // Append cooldown profiles at the end (sorted by cooldown expiry, soonest first) + const cooldownSorted = inCooldown + .map((profileId) => ({ + profileId, + cooldownUntil: store.usageStats?.[profileId]?.cooldownUntil ?? now, + })) + .sort((a, b) => a.cooldownUntil - b.cooldownUntil) + .map((entry) => entry.profileId); + + return [...sorted, ...cooldownSorted]; } export async function resolveApiKeyForProfile(params: { cfg?: ClawdbotConfig; store: AuthProfileStore; profileId: string; + agentDir?: string; }): Promise<{ apiKey: string; provider: string; email?: string } | null> { const { cfg, store, profileId } = params; const cred = store.profiles[profileId]; @@ -323,35 +547,55 @@ export async function resolveApiKeyForProfile(params: { if (cred.type === "api_key") { return { apiKey: cred.key, provider: cred.provider, email: cred.email }; } + if (Date.now() < cred.expires) { + return { + apiKey: buildOAuthApiKey(cred.provider, cred), + provider: cred.provider, + email: cred.email, + }; + } - const oauthCreds: Record = { - [cred.provider]: cred, - }; - const result = await getOAuthApiKey(cred.provider, oauthCreds); - if (!result) return null; - store.profiles[profileId] = { - ...cred, - ...result.newCredentials, - type: "oauth", - }; - saveAuthProfileStore(store); - return { - apiKey: result.apiKey, - provider: cred.provider, - email: cred.email, - }; + try { + const result = await refreshOAuthTokenWithLock({ + profileId, + provider: cred.provider, + agentDir: params.agentDir, + }); + if (!result) return null; + return { + apiKey: result.apiKey, + provider: cred.provider, + email: cred.email, + }; + } catch (error) { + const refreshedStore = ensureAuthProfileStore(params.agentDir); + const refreshed = refreshedStore.profiles[profileId]; + if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) { + return { + apiKey: buildOAuthApiKey(refreshed.provider, refreshed), + provider: refreshed.provider, + email: refreshed.email ?? cred.email, + }; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `OAuth token refresh failed for ${cred.provider}: ${message}. ` + + "Please try again or re-authenticate.", + ); + } } export function markAuthProfileGood(params: { store: AuthProfileStore; provider: string; profileId: string; + agentDir?: string; }): void { - const { store, provider, profileId } = params; + const { store, provider, profileId, agentDir } = params; const profile = store.profiles[profileId]; if (!profile || profile.provider !== provider) return; store.lastGood = { ...store.lastGood, [provider]: profileId }; - saveAuthProfileStore(store); + saveAuthProfileStore(store, agentDir); } export function resolveAuthStorePathForDisplay(): string { diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index f92057046..b8985756d 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -36,6 +36,7 @@ const DEFAULT_MAX_OUTPUT = clampNumber( 150_000, ); const DEFAULT_PATH = + process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const stringEnum = ( diff --git a/src/agents/clawdbot-tools.sessions.test.ts b/src/agents/clawdbot-tools.sessions.test.ts index 10b6b5b34..c7df1cf79 100644 --- a/src/agents/clawdbot-tools.sessions.test.ts +++ b/src/agents/clawdbot-tools.sessions.test.ts @@ -36,14 +36,14 @@ describe("sessions tools", () => { kind: "direct", sessionId: "s-main", updatedAt: 10, - lastChannel: "whatsapp", + lastProvider: "whatsapp", }, { key: "discord:group:dev", kind: "group", sessionId: "s-group", updatedAt: 11, - surface: "discord", + provider: "discord", displayName: "discord:g-dev", }, { @@ -196,7 +196,7 @@ describe("sessions tools", () => { const tool = createClawdbotTools({ agentSessionKey: requesterKey, - agentSurface: "discord", + agentProvider: "discord", }).find((candidate) => candidate.name === "sessions_send"); expect(tool).toBeDefined(); if (!tool) throw new Error("missing sessions_send tool"); @@ -340,7 +340,7 @@ describe("sessions tools", () => { const tool = createClawdbotTools({ agentSessionKey: requesterKey, - agentSurface: "discord", + agentProvider: "discord", }).find((candidate) => candidate.name === "sessions_send"); expect(tool).toBeDefined(); if (!tool) throw new Error("missing sessions_send tool"); diff --git a/src/agents/clawdbot-tools.subagents.test.ts b/src/agents/clawdbot-tools.subagents.test.ts new file mode 100644 index 000000000..0d1d61f0a --- /dev/null +++ b/src/agents/clawdbot-tools.subagents.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({ + session: { + mainKey: "main", + scope: "per-sender", + }, + }), + resolveGatewayPort: () => 18789, + }; +}); + +import { createClawdbotTools } from "./clawdbot-tools.js"; + +describe("subagents", () => { + it("sessions_spawn announces back to the requester group provider", async () => { + callGatewayMock.mockReset(); + const calls: Array<{ method?: string; params?: unknown }> = []; + let agentCallCount = 0; + let lastWaitedRunId: string | undefined; + const replyByRunId = new Map(); + let sendParams: { to?: string; provider?: string; message?: string } = {}; + let deletedKey: string | undefined; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as + | { message?: string; sessionKey?: string } + | undefined; + const message = params?.message ?? ""; + const reply = + message === "Sub-agent announce step." ? "announce now" : "result"; + replyByRunId.set(runId, reply); + return { + runId, + status: "accepted", + acceptedAt: 1000 + agentCallCount, + }; + } + if (request.method === "agent.wait") { + const params = request.params as { runId?: string } | undefined; + lastWaitedRunId = params?.runId; + return { runId: params?.runId ?? "run-1", status: "ok" }; + } + if (request.method === "chat.history") { + const text = + (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; + return { + messages: [{ role: "assistant", content: [{ type: "text", text }] }], + }; + } + if (request.method === "send") { + const params = request.params as + | { to?: string; provider?: string; message?: string } + | undefined; + sendParams = { + to: params?.to, + provider: params?.provider, + message: params?.message, + }; + return { messageId: "m-announce" }; + } + if (request.method === "sessions.delete") { + const params = request.params as { key?: string } | undefined; + deletedKey = params?.key; + return { ok: true }; + } + return {}; + }); + + const tool = createClawdbotTools({ + agentSessionKey: "discord:group:req", + agentProvider: "discord", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) throw new Error("missing sessions_spawn tool"); + + const result = await tool.execute("call1", { + task: "do thing", + timeoutSeconds: 1, + }); + expect(result.details).toMatchObject({ status: "ok", reply: "result" }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const agentCalls = calls.filter((call) => call.method === "agent"); + expect(agentCalls).toHaveLength(2); + const first = agentCalls[0]?.params as + | { lane?: string; deliver?: boolean; sessionKey?: string } + | undefined; + expect(first?.lane).toBe("subagent"); + expect(first?.deliver).toBe(false); + expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); + + expect(sendParams).toMatchObject({ + provider: "discord", + to: "channel:req", + message: "announce now", + }); + expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); + }); + + it("sessions_spawn resolves main announce target from sessions.list", async () => { + callGatewayMock.mockReset(); + const calls: Array<{ method?: string; params?: unknown }> = []; + let agentCallCount = 0; + let lastWaitedRunId: string | undefined; + const replyByRunId = new Map(); + let sendParams: { to?: string; provider?: string; message?: string } = {}; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "sessions.list") { + return { + sessions: [ + { + key: "main", + lastProvider: "whatsapp", + lastTo: "+123", + }, + ], + }; + } + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as + | { message?: string; sessionKey?: string } + | undefined; + const message = params?.message ?? ""; + const reply = + message === "Sub-agent announce step." ? "hello from sub" : "done"; + replyByRunId.set(runId, reply); + return { + runId, + status: "accepted", + acceptedAt: 2000 + agentCallCount, + }; + } + if (request.method === "agent.wait") { + const params = request.params as { runId?: string } | undefined; + lastWaitedRunId = params?.runId; + return { runId: params?.runId ?? "run-1", status: "ok" }; + } + if (request.method === "chat.history") { + const text = + (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; + return { + messages: [{ role: "assistant", content: [{ type: "text", text }] }], + }; + } + if (request.method === "send") { + const params = request.params as + | { to?: string; provider?: string; message?: string } + | undefined; + sendParams = { + to: params?.to, + provider: params?.provider, + message: params?.message, + }; + return { messageId: "m1" }; + } + if (request.method === "sessions.delete") { + return { ok: true }; + } + return {}; + }); + + const tool = createClawdbotTools({ + agentSessionKey: "main", + agentProvider: "whatsapp", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) throw new Error("missing sessions_spawn tool"); + + const result = await tool.execute("call2", { + task: "do thing", + timeoutSeconds: 1, + }); + expect(result.details).toMatchObject({ status: "ok", reply: "done" }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(sendParams).toMatchObject({ + provider: "whatsapp", + to: "+123", + message: "hello from sub", + }); + }); +}); diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index cd655ea36..447a098b0 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -10,15 +10,21 @@ import { createNodesTool } from "./tools/nodes-tool.js"; import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; import { createSessionsListTool } from "./tools/sessions-list-tool.js"; import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; import { createSlackTool } from "./tools/slack-tool.js"; export function createClawdbotTools(options?: { browserControlUrl?: string; agentSessionKey?: string; - agentSurface?: string; + agentProvider?: string; + agentDir?: string; + sandboxed?: boolean; config?: ClawdbotConfig; }): AnyAgentTool[] { - const imageTool = createImageTool({ config: options?.config }); + const imageTool = createImageTool({ + config: options?.config, + agentDir: options?.agentDir, + }); return [ createBrowserTool({ defaultControlUrl: options?.browserControlUrl }), createCanvasTool(), @@ -27,11 +33,23 @@ export function createClawdbotTools(options?: { createDiscordTool(), createSlackTool(), createGatewayTool(), - createSessionsListTool(), - createSessionsHistoryTool(), + createSessionsListTool({ + agentSessionKey: options?.agentSessionKey, + sandboxed: options?.sandboxed, + }), + createSessionsHistoryTool({ + agentSessionKey: options?.agentSessionKey, + sandboxed: options?.sandboxed, + }), createSessionsSendTool({ agentSessionKey: options?.agentSessionKey, - agentSurface: options?.agentSurface, + agentProvider: options?.agentProvider, + sandboxed: options?.sandboxed, + }), + createSessionsSpawnTool({ + agentSessionKey: options?.agentSessionKey, + agentProvider: options?.agentProvider, + sandboxed: options?.sandboxed, }), ...(imageTool ? [imageTool] : []), ]; diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 0564381e4..1716f7800 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -31,15 +31,17 @@ export async function resolveApiKeyForProvider(params: { profileId?: string; preferredProfile?: string; store?: AuthProfileStore; + agentDir?: string; }): Promise<{ apiKey: string; profileId?: string; source: string }> { const { provider, cfg, profileId, preferredProfile } = params; - const store = params.store ?? ensureAuthProfileStore(); + const store = params.store ?? ensureAuthProfileStore(params.agentDir); if (profileId) { const resolved = await resolveApiKeyForProfile({ cfg, store, profileId, + agentDir: params.agentDir, }); if (!resolved) { throw new Error(`No credentials found for profile "${profileId}".`); @@ -63,6 +65,7 @@ export async function resolveApiKeyForProvider(params: { cfg, store, profileId: candidate, + agentDir: params.agentDir, }); if (resolved) { return { @@ -146,6 +149,7 @@ export async function getApiKeyForModel(params: { profileId?: string; preferredProfile?: string; store?: AuthProfileStore; + agentDir?: string; }): Promise<{ apiKey: string; profileId?: string; source: string }> { return resolveApiKeyForProvider({ provider: params.model.provider, @@ -153,5 +157,6 @@ export async function getApiKeyForModel(params: { profileId: params.profileId, preferredProfile: params.preferredProfile, store: params.store, + agentDir: params.agentDir, }); } diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index b3fa0dae7..2887554f5 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -2,10 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; -import { - ensureClawdbotAgentEnv, - resolveClawdbotAgentDir, -} from "./agent-paths.js"; +import { resolveClawdbotAgentDir } from "./agent-paths.js"; type ModelsConfig = NonNullable; @@ -26,15 +23,21 @@ async function readJson(pathname: string): Promise { export async function ensureClawdbotModelsJson( config?: ClawdbotConfig, + agentDirOverride?: string, ): Promise<{ agentDir: string; wrote: boolean }> { const cfg = config ?? loadConfig(); const providers = cfg.models?.providers; if (!providers || Object.keys(providers).length === 0) { - return { agentDir: resolveClawdbotAgentDir(), wrote: false }; + const agentDir = agentDirOverride?.trim() + ? agentDirOverride.trim() + : resolveClawdbotAgentDir(); + return { agentDir, wrote: false }; } const mode = cfg.models?.mode ?? DEFAULT_MODE; - const agentDir = ensureClawdbotAgentEnv(); + const agentDir = agentDirOverride?.trim() + ? agentDirOverride.trim() + : resolveClawdbotAgentDir(); const targetPath = path.join(agentDir, "models.json"); let mergedProviders = providers; diff --git a/src/agents/pi-embedded-block-chunker.ts b/src/agents/pi-embedded-block-chunker.ts index 9aa97afad..f5473c182 100644 --- a/src/agents/pi-embedded-block-chunker.ts +++ b/src/agents/pi-embedded-block-chunker.ts @@ -1,17 +1,15 @@ +import { + findFenceSpanAt, + isSafeFenceBreak, + parseFenceSpans, +} from "../markdown/fences.js"; + export type BlockReplyChunking = { minChars: number; maxChars: number; breakPreference?: "paragraph" | "newline" | "sentence"; }; -type FenceSpan = { - start: number; - end: number; - openLine: string; - marker: string; - indent: string; -}; - type FenceSplit = { closeFenceLine: string; reopenFenceLine: string; @@ -123,7 +121,10 @@ export class EmbeddedBlockChunker { if (preference === "paragraph") { let paragraphIdx = buffer.indexOf("\n\n"); while (paragraphIdx !== -1) { - if (paragraphIdx >= minChars && isSafeBreak(fenceSpans, paragraphIdx)) { + if ( + paragraphIdx >= minChars && + isSafeFenceBreak(fenceSpans, paragraphIdx) + ) { return { index: paragraphIdx }; } paragraphIdx = buffer.indexOf("\n\n", paragraphIdx + 2); @@ -133,7 +134,10 @@ export class EmbeddedBlockChunker { if (preference === "paragraph" || preference === "newline") { let newlineIdx = buffer.indexOf("\n"); while (newlineIdx !== -1) { - if (newlineIdx >= minChars && isSafeBreak(fenceSpans, newlineIdx)) { + if ( + newlineIdx >= minChars && + isSafeFenceBreak(fenceSpans, newlineIdx) + ) { return { index: newlineIdx }; } newlineIdx = buffer.indexOf("\n", newlineIdx + 1); @@ -147,7 +151,7 @@ export class EmbeddedBlockChunker { const at = match.index ?? -1; if (at < minChars) continue; const candidate = at + 1; - if (isSafeBreak(fenceSpans, candidate)) { + if (isSafeFenceBreak(fenceSpans, candidate)) { sentenceIdx = candidate; } } @@ -168,7 +172,7 @@ export class EmbeddedBlockChunker { if (preference === "paragraph") { let paragraphIdx = window.lastIndexOf("\n\n"); while (paragraphIdx >= minChars) { - if (isSafeBreak(fenceSpans, paragraphIdx)) { + if (isSafeFenceBreak(fenceSpans, paragraphIdx)) { return { index: paragraphIdx }; } paragraphIdx = window.lastIndexOf("\n\n", paragraphIdx - 1); @@ -178,7 +182,7 @@ export class EmbeddedBlockChunker { if (preference === "paragraph" || preference === "newline") { let newlineIdx = window.lastIndexOf("\n"); while (newlineIdx >= minChars) { - if (isSafeBreak(fenceSpans, newlineIdx)) { + if (isSafeFenceBreak(fenceSpans, newlineIdx)) { return { index: newlineIdx }; } newlineIdx = window.lastIndexOf("\n", newlineIdx - 1); @@ -192,7 +196,7 @@ export class EmbeddedBlockChunker { const at = match.index ?? -1; if (at < minChars) continue; const candidate = at + 1; - if (isSafeBreak(fenceSpans, candidate)) { + if (isSafeFenceBreak(fenceSpans, candidate)) { sentenceIdx = candidate; } } @@ -200,13 +204,13 @@ export class EmbeddedBlockChunker { } for (let i = window.length - 1; i >= minChars; i--) { - if (/\s/.test(window[i]) && isSafeBreak(fenceSpans, i)) { + if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, i)) { return { index: i }; } } if (buffer.length >= maxChars) { - if (isSafeBreak(fenceSpans, maxChars)) return { index: maxChars }; + if (isSafeFenceBreak(fenceSpans, maxChars)) return { index: maxChars }; const fence = findFenceSpanAt(fenceSpans, maxChars); if (fence) { return { @@ -229,76 +233,3 @@ function stripLeadingNewlines(value: string): string { while (i < value.length && value[i] === "\n") i++; return i > 0 ? value.slice(i) : value; } - -function parseFenceSpans(buffer: string): FenceSpan[] { - const spans: FenceSpan[] = []; - let open: - | { - start: number; - markerChar: string; - markerLen: number; - openLine: string; - marker: string; - indent: string; - } - | undefined; - let offset = 0; - while (offset <= buffer.length) { - const nextNewline = buffer.indexOf("\n", offset); - const lineEnd = nextNewline === -1 ? buffer.length : nextNewline; - const line = buffer.slice(offset, lineEnd); - const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); - if (match) { - const indent = match[1]; - const marker = match[2]; - const markerChar = marker[0]; - const markerLen = marker.length; - if (!open) { - open = { - start: offset, - markerChar, - markerLen, - openLine: line, - marker, - indent, - }; - } else if ( - open.markerChar === markerChar && - markerLen >= open.markerLen - ) { - const end = nextNewline === -1 ? buffer.length : nextNewline + 1; - spans.push({ - start: open.start, - end, - openLine: open.openLine, - marker: open.marker, - indent: open.indent, - }); - open = undefined; - } - } - if (nextNewline === -1) break; - offset = nextNewline + 1; - } - if (open) { - spans.push({ - start: open.start, - end: buffer.length, - openLine: open.openLine, - marker: open.marker, - indent: open.indent, - }); - } - return spans; -} - -function findFenceSpanAt( - spans: FenceSpan[], - index: number, -): FenceSpan | undefined { - return spans.find((span) => index > span.start && index < span.end); -} - -function isSafeBreak(spans: FenceSpan[], index: number): boolean { - return !findFenceSpanAt(spans, index); -} diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index cc27f95ef..a80b6a982 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -12,6 +12,7 @@ import { SettingsManager, type Skill, } from "@mariozechner/pi-coding-agent"; +import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js"; import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import type { ClawdbotConfig } from "../config/config.js"; @@ -24,7 +25,11 @@ import { } from "../process/command-queue.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; -import { markAuthProfileGood } from "./auth-profiles.js"; +import { + markAuthProfileCooldown, + markAuthProfileGood, + markAuthProfileUsed, +} from "./auth-profiles.js"; import type { BashElevatedDefaults } from "./bash-tools.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { @@ -61,6 +66,7 @@ import { type SkillSnapshot, } from "./skills.js"; import { buildAgentSystemPromptAppend } from "./system-prompt.js"; +import { normalizeUsage, type UsageLike } from "./usage.js"; import { loadWorkspaceBootstrapFiles } from "./workspace.js"; export type EmbeddedPiAgentMeta = { @@ -94,6 +100,7 @@ export type EmbeddedPiRunResult = { mediaUrl?: string; mediaUrls?: string[]; replyToId?: string; + isError?: boolean; }>; meta: EmbeddedPiRunMeta; }; @@ -113,6 +120,7 @@ export type EmbeddedPiCompactResult = { type EmbeddedPiQueueHandle = { queueMessage: (text: string) => Promise; isStreaming: () => boolean; + isCompacting: () => boolean; abort: () => void; }; @@ -212,6 +220,7 @@ export function queueEmbeddedPiMessage( const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); if (!handle) return false; if (!handle.isStreaming()) return false; + if (handle.isCompacting()) return false; void handle.queueMessage(text); return true; } @@ -329,9 +338,10 @@ function resolvePromptSkills( export async function compactEmbeddedPiSession(params: { sessionId: string; sessionKey?: string; - surface?: string; + messageProvider?: string; sessionFile: string; workspaceDir: string; + agentDir?: string; config?: ClawdbotConfig; skillsSnapshot?: SkillSnapshot; provider?: string; @@ -360,7 +370,7 @@ export async function compactEmbeddedPiSession(params: { (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; await ensureClawdbotModelsJson(params.config); - const agentDir = resolveClawdbotAgentDir(); + const agentDir = params.agentDir ?? resolveClawdbotAgentDir(); const { model, error, authStorage, modelRegistry } = resolveModel( provider, modelId, @@ -434,8 +444,9 @@ export async function compactEmbeddedPiSession(params: { elevated: params.bashElevated, }, sandbox, - surface: params.surface, + messageProvider: params.messageProvider, sessionKey: params.sessionKey ?? params.sessionId, + agentDir, config: params.config, }); const machineName = await getMachineDisplayName(); @@ -459,6 +470,9 @@ export async function compactEmbeddedPiSession(params: { extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, reasoningTagHint, + heartbeatPrompt: resolveHeartbeatPrompt( + params.config?.agent?.heartbeat?.prompt, + ), runtimeInfo, sandboxInfo, toolNames: tools.map((tool) => tool.name), @@ -538,9 +552,10 @@ export async function compactEmbeddedPiSession(params: { export async function runEmbeddedPiAgent(params: { sessionId: string; sessionKey?: string; - surface?: string; + messageProvider?: string; sessionFile: string; workspaceDir: string; + agentDir?: string; config?: ClawdbotConfig; skillsSnapshot?: SkillSnapshot; prompt: string; @@ -595,7 +610,7 @@ export async function runEmbeddedPiAgent(params: { (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; await ensureClawdbotModelsJson(params.config); - const agentDir = resolveClawdbotAgentDir(); + const agentDir = params.agentDir ?? resolveClawdbotAgentDir(); const { model, error, authStorage, modelRegistry } = resolveModel( provider, modelId, @@ -604,7 +619,7 @@ export async function runEmbeddedPiAgent(params: { if (!model) { throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); } - const authStore = ensureAuthProfileStore(); + const authStore = ensureAuthProfileStore(agentDir); const explicitProfileId = params.authProfileId?.trim(); const profileOrder = resolveAuthProfileOrder({ cfg: params.config, @@ -672,7 +687,7 @@ export async function runEmbeddedPiAgent(params: { attemptedThinking.add(thinkLevel); log.debug( - `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} surface=${params.surface ?? "unknown"}`, + `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} messageProvider=${params.messageProvider ?? "unknown"}`, ); await fs.mkdir(resolvedWorkspace, { recursive: true }); @@ -728,8 +743,9 @@ export async function runEmbeddedPiAgent(params: { elevated: params.bashElevated, }, sandbox, - surface: params.surface, + messageProvider: params.messageProvider, sessionKey: params.sessionKey ?? params.sessionId, + agentDir, config: params.config, }); const machineName = await getMachineDisplayName(); @@ -753,6 +769,9 @@ export async function runEmbeddedPiAgent(params: { extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, reasoningTagHint, + heartbeatPrompt: resolveHeartbeatPrompt( + params.config?.agent?.heartbeat?.prompt, + ), runtimeInfo, sandboxInfo, toolNames: tools.map((tool) => tool.name), @@ -806,25 +825,13 @@ export async function runEmbeddedPiAgent(params: { session.agent.replaceMessages(prior); } let aborted = Boolean(params.abortSignal?.aborted); - const abortRun = () => { + let timedOut = false; + const abortRun = (isTimeout = false) => { aborted = true; + if (isTimeout) timedOut = true; void session.abort(); }; - const queueHandle: EmbeddedPiQueueHandle = { - queueMessage: async (text: string) => { - await session.steer(text); - }, - isStreaming: () => session.isStreaming, - abort: abortRun, - }; - ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle); - - const { - assistantTexts, - toolMetas, - unsubscribe, - waitForCompactionRetry, - } = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedPiSession({ session, runId: params.runId, verboseLevel: params.verboseLevel, @@ -837,6 +844,22 @@ export async function runEmbeddedPiAgent(params: { onAgentEvent: params.onAgentEvent, enforceFinalTag: params.enforceFinalTag, }); + const { + assistantTexts, + toolMetas, + unsubscribe, + waitForCompactionRetry, + } = subscription; + + const queueHandle: EmbeddedPiQueueHandle = { + queueMessage: async (text: string) => { + await session.steer(text); + }, + isStreaming: () => session.isStreaming, + isCompacting: () => subscription.isCompacting(), + abort: abortRun, + }; + ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle); let abortWarnTimer: NodeJS.Timeout | undefined; const abortTimer = setTimeout( @@ -844,7 +867,7 @@ export async function runEmbeddedPiAgent(params: { log.warn( `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, ); - abortRun(); + abortRun(true); if (!abortWarnTimer) { abortWarnTimer = setTimeout(() => { if (!session.isStreaming) return; @@ -949,7 +972,24 @@ export async function runEmbeddedPiAgent(params: { (params.config?.agent?.model?.fallbacks?.length ?? 0) > 0; const authFailure = isAuthAssistantError(lastAssistant); const rateLimitFailure = isRateLimitAssistantError(lastAssistant); - if (!aborted && (authFailure || rateLimitFailure)) { + + // Treat timeout as potential rate limit (Antigravity hangs on rate limit) + const shouldRotate = + (!aborted && (authFailure || rateLimitFailure)) || timedOut; + + if (shouldRotate) { + // Mark current profile for cooldown before rotating + if (lastProfileId) { + markAuthProfileCooldown({ + store: authStore, + profileId: lastProfileId, + }); + if (timedOut) { + log.warn( + `Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`, + ); + } + } const rotated = await advanceAuthProfile(); if (rotated) { continue; @@ -960,35 +1000,34 @@ export async function runEmbeddedPiAgent(params: { (lastAssistant ? formatAssistantErrorText(lastAssistant) : "") || - (rateLimitFailure - ? "LLM request rate limited." - : "LLM request unauthorized."); + (timedOut + ? "LLM request timed out." + : rateLimitFailure + ? "LLM request rate limited." + : "LLM request unauthorized."); throw new Error(message); } } - const usage = lastAssistant?.usage; + const usage = normalizeUsage(lastAssistant?.usage as UsageLike); const agentMeta: EmbeddedPiAgentMeta = { sessionId: sessionIdUsed, provider: lastAssistant?.provider ?? provider, model: lastAssistant?.model ?? model.id, - usage: usage - ? { - input: usage.input, - output: usage.output, - cacheRead: usage.cacheRead, - cacheWrite: usage.cacheWrite, - total: usage.totalTokens, - } - : undefined, + usage, }; - const replyItems: Array<{ text: string; media?: string[] }> = []; + const replyItems: Array<{ + text: string; + media?: string[]; + isError?: boolean; + }> = []; const errorText = lastAssistant ? formatAssistantErrorText(lastAssistant) : undefined; - if (errorText) replyItems.push({ text: errorText }); + + if (errorText) replyItems.push({ text: errorText, isError: true }); const inlineToolResults = params.verboseLevel === "on" && @@ -1021,6 +1060,7 @@ export async function runEmbeddedPiAgent(params: { text: item.text?.trim() ? item.text.trim() : undefined, mediaUrls: item.media?.length ? item.media : undefined, mediaUrl: item.media?.[0], + isError: item.isError, })) .filter( (p) => @@ -1036,6 +1076,8 @@ export async function runEmbeddedPiAgent(params: { provider, profileId: lastProfileId, }); + // Track usage for round-robin rotation + markAuthProfileUsed({ store: authStore, profileId: lastProfileId }); } return { payloads: payloads.length ? payloads : undefined, diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index 8c7751c51..c22316357 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -968,6 +968,7 @@ describe("subscribeEmbeddedPiSession", () => { }); } + expect(subscription.isCompacting()).toBe(true); expect(subscription.assistantTexts.length).toBe(0); let resolved = false; @@ -1004,6 +1005,8 @@ describe("subscribeEmbeddedPiSession", () => { listener({ type: "auto_compaction_start" }); } + expect(subscription.isCompacting()).toBe(true); + let resolved = false; const waitPromise = subscription.waitForCompactionRetry().then(() => { resolved = true; @@ -1018,6 +1021,7 @@ describe("subscribeEmbeddedPiSession", () => { await waitPromise; expect(resolved).toBe(true); + expect(subscription.isCompacting()).toBe(false); }); it("waits for multiple compaction retries before resolving", async () => { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 330422efd..e87ff74e8 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -604,6 +604,7 @@ export function subscribeEmbeddedPiSession(params: { assistantTexts, toolMetas, unsubscribe, + isCompacting: () => compactionInFlight || pendingCompactionRetry > 0, waitForCompactionRetry: () => { if (compactionInFlight || pendingCompactionRetry > 0) { ensureCompactionPromise(); diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index a9cb42d44..4dd83fdbd 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -100,22 +100,54 @@ describe("createClawdbotCodingTools", () => { expect(offenders).toEqual([]); }); - it("scopes discord tool to discord surface", () => { - const other = createClawdbotCodingTools({ surface: "whatsapp" }); + it("scopes discord tool to discord provider", () => { + const other = createClawdbotCodingTools({ messageProvider: "whatsapp" }); expect(other.some((tool) => tool.name === "discord")).toBe(false); - const discord = createClawdbotCodingTools({ surface: "discord" }); + const discord = createClawdbotCodingTools({ messageProvider: "discord" }); expect(discord.some((tool) => tool.name === "discord")).toBe(true); }); - it("scopes slack tool to slack surface", () => { - const other = createClawdbotCodingTools({ surface: "whatsapp" }); + it("scopes slack tool to slack provider", () => { + const other = createClawdbotCodingTools({ messageProvider: "whatsapp" }); expect(other.some((tool) => tool.name === "slack")).toBe(false); - const slack = createClawdbotCodingTools({ surface: "slack" }); + const slack = createClawdbotCodingTools({ messageProvider: "slack" }); expect(slack.some((tool) => tool.name === "slack")).toBe(true); }); + it("filters session tools for sub-agent sessions by default", () => { + const tools = createClawdbotCodingTools({ + sessionKey: "agent:main:subagent:test", + }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("sessions_list")).toBe(false); + expect(names.has("sessions_history")).toBe(false); + expect(names.has("sessions_send")).toBe(false); + expect(names.has("sessions_spawn")).toBe(false); + + expect(names.has("read")).toBe(true); + expect(names.has("bash")).toBe(true); + expect(names.has("process")).toBe(true); + }); + + it("supports allow-only sub-agent tool policy", () => { + const tools = createClawdbotCodingTools({ + sessionKey: "agent:main:subagent:test", + // Intentionally partial config; only fields used by pi-tools are provided. + config: { + agent: { + subagents: { + tools: { + allow: ["read"], + }, + }, + }, + }, + }); + expect(tools.map((tool) => tool.name)).toEqual(["read"]); + }); + it("keeps read tool image metadata intact", async () => { const tools = createClawdbotCodingTools(); const readTool = tools.find((tool) => tool.name === "read"); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index e25afe3d4..2dbfb6452 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -9,6 +9,7 @@ import { import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../config/config.js"; import { detectMime } from "../media/mime.js"; +import { isSubagentSessionKey } from "../routing/session-key.js"; import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; import { type BashToolDefaults, @@ -333,6 +334,23 @@ function normalizeToolNames(list?: string[]) { return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean); } +const DEFAULT_SUBAGENT_TOOL_DENY = [ + "sessions_list", + "sessions_history", + "sessions_send", + "sessions_spawn", +]; + +function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy { + const configured = cfg?.agent?.subagents?.tools; + const deny = [ + ...DEFAULT_SUBAGENT_TOOL_DENY, + ...(Array.isArray(configured?.deny) ? configured.deny : []), + ]; + const allow = Array.isArray(configured?.allow) ? configured.allow : undefined; + return { allow, deny }; +} + function filterToolsByPolicy( tools: AnyAgentTool[], policy?: SandboxToolPolicy, @@ -466,28 +484,31 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool { }; } -function normalizeSurface(surface?: string): string | undefined { - const trimmed = surface?.trim().toLowerCase(); +function normalizeMessageProvider( + messageProvider?: string, +): string | undefined { + const trimmed = messageProvider?.trim().toLowerCase(); return trimmed ? trimmed : undefined; } -function shouldIncludeDiscordTool(surface?: string): boolean { - const normalized = normalizeSurface(surface); +function shouldIncludeDiscordTool(messageProvider?: string): boolean { + const normalized = normalizeMessageProvider(messageProvider); if (!normalized) return false; return normalized === "discord" || normalized.startsWith("discord:"); } -function shouldIncludeSlackTool(surface?: string): boolean { - const normalized = normalizeSurface(surface); +function shouldIncludeSlackTool(messageProvider?: string): boolean { + const normalized = normalizeMessageProvider(messageProvider); if (!normalized) return false; return normalized === "slack" || normalized.startsWith("slack:"); } export function createClawdbotCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; - surface?: string; + messageProvider?: string; sandbox?: SandboxContext | null; sessionKey?: string; + agentDir?: string; config?: ClawdbotConfig; }): AnyAgentTool[] { const bashToolName = "bash"; @@ -533,12 +554,14 @@ export function createClawdbotCodingTools(options?: { ...createClawdbotTools({ browserControlUrl: sandbox?.browser?.controlUrl, agentSessionKey: options?.sessionKey, - agentSurface: options?.surface, + agentProvider: options?.messageProvider, + agentDir: options?.agentDir, + sandboxed: !!sandbox, config: options?.config, }), ]; - const allowDiscord = shouldIncludeDiscordTool(options?.surface); - const allowSlack = shouldIncludeSlackTool(options?.surface); + const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider); + const allowSlack = shouldIncludeSlackTool(options?.messageProvider); const filtered = tools.filter((tool) => { if (tool.name === "discord") return allowDiscord; if (tool.name === "slack") return allowSlack; @@ -553,7 +576,14 @@ export function createClawdbotCodingTools(options?: { const sandboxed = sandbox ? filterToolsByPolicy(globallyFiltered, sandbox.tools) : globallyFiltered; + const subagentFiltered = + isSubagentSessionKey(options?.sessionKey) && options?.sessionKey + ? filterToolsByPolicy( + sandboxed, + resolveSubagentToolPolicy(options.config), + ) + : sandboxed; // Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai. // Without this, some providers (notably OpenAI) will reject root-level union schemas. - return sandboxed.map(normalizeToolParameters); + return subagentFiltered.map(normalizeToolParameters); } diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 6166f3349..15ef13840 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -114,7 +114,17 @@ const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdbot-sbx-"; const DEFAULT_SANDBOX_WORKDIR = "/workspace"; const DEFAULT_SANDBOX_IDLE_HOURS = 24; const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7; -const DEFAULT_TOOL_ALLOW = ["bash", "process", "read", "write", "edit"]; +const DEFAULT_TOOL_ALLOW = [ + "bash", + "process", + "read", + "write", + "edit", + "sessions_list", + "sessions_history", + "sessions_send", + "sessions_spawn", +]; const DEFAULT_TOOL_DENY = [ "browser", "canvas", @@ -424,7 +434,11 @@ async function dockerContainerState(name: string) { return { exists: true, running: result.stdout.trim() === "true" }; } -async function ensureSandboxWorkspace(workspaceDir: string, seedFrom?: string) { +async function ensureSandboxWorkspace( + workspaceDir: string, + seedFrom?: string, + skipBootstrap?: boolean, +) { await fs.mkdir(workspaceDir, { recursive: true }); if (seedFrom) { const seed = resolveUserPath(seedFrom); @@ -451,7 +465,10 @@ async function ensureSandboxWorkspace(workspaceDir: string, seedFrom?: string) { } } } - await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: true }); + await ensureAgentWorkspace({ + dir: workspaceDir, + ensureBootstrapFiles: !skipBootstrap, + }); } function normalizeDockerLimit(value?: string | number) { @@ -846,7 +863,11 @@ export async function resolveSandboxContext(params: { : workspaceRoot; const seedWorkspace = params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; - await ensureSandboxWorkspace(workspaceDir, seedWorkspace); + await ensureSandboxWorkspace( + workspaceDir, + seedWorkspace, + params.config?.agent?.skipBootstrap, + ); const containerName = await ensureSandboxContainer({ sessionKey: rawSessionKey, @@ -889,7 +910,11 @@ export async function ensureSandboxWorkspaceForSession(params: { : workspaceRoot; const seedWorkspace = params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; - await ensureSandboxWorkspace(workspaceDir, seedWorkspace); + await ensureSandboxWorkspace( + workspaceDir, + seedWorkspace, + params.config?.agent?.skipBootstrap, + ); return { workspaceDir, diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 4528d372d..d5c40f51b 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -9,6 +9,7 @@ export function buildAgentSystemPromptAppend(params: { toolNames?: string[]; userTimezone?: string; userTime?: string; + heartbeatPrompt?: string; runtimeInfo?: { host?: string; os?: string; @@ -113,6 +114,10 @@ export function buildAgentSystemPromptAppend(params: { : undefined; const userTimezone = params.userTimezone?.trim(); const userTime = params.userTime?.trim(); + const heartbeatPrompt = params.heartbeatPrompt?.trim(); + const heartbeatPromptLine = heartbeatPrompt + ? `Heartbeat prompt: ${heartbeatPrompt}` + : "Heartbeat prompt: (configured)"; const runtimeInfo = params.runtimeInfo; const runtimeLines: string[] = []; if (runtimeInfo?.host) runtimeLines.push(`Host: ${runtimeInfo.host}`); @@ -207,7 +212,8 @@ export function buildAgentSystemPromptAppend(params: { lines.push( "## Heartbeats", - 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:', + heartbeatPromptLine, + "If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:", "HEARTBEAT_OK", 'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).', 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', diff --git a/src/agents/timeout.ts b/src/agents/timeout.ts new file mode 100644 index 000000000..65d0eeb9c --- /dev/null +++ b/src/agents/timeout.ts @@ -0,0 +1,35 @@ +import type { ClawdbotConfig } from "../config/config.js"; + +const DEFAULT_AGENT_TIMEOUT_SECONDS = 600; + +const normalizeNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) + ? Math.floor(value) + : undefined; + +export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number { + const raw = normalizeNumber(cfg?.agent?.timeoutSeconds); + const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS; + return Math.max(seconds, 1); +} + +export function resolveAgentTimeoutMs(opts: { + cfg?: ClawdbotConfig; + overrideMs?: number | null; + overrideSeconds?: number | null; + minMs?: number; +}): number { + const minMs = Math.max(normalizeNumber(opts.minMs) ?? 1, 1); + const defaultMs = resolveAgentTimeoutSeconds(opts.cfg) * 1000; + const overrideMs = normalizeNumber(opts.overrideMs); + if (overrideMs !== undefined) { + if (overrideMs <= 0) return defaultMs; + return Math.max(overrideMs, minMs); + } + const overrideSeconds = normalizeNumber(opts.overrideSeconds); + if (overrideSeconds !== undefined) { + if (overrideSeconds <= 0) return defaultMs; + return Math.max(overrideSeconds * 1000, minMs); + } + return Math.max(defaultMs, minMs); +} diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 6de42b775..ce3ba7b66 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -165,6 +165,11 @@ "title": "Session Send", "detailKeys": ["sessionKey", "timeoutSeconds"] }, + "sessions_spawn": { + "emoji": "🧑‍🔧", + "title": "Sub-agent", + "detailKeys": ["label", "timeoutSeconds", "cleanup"] + }, "whatsapp_login": { "emoji": "🟢", "title": "WhatsApp Login", diff --git a/src/agents/tool-display.ts b/src/agents/tool-display.ts index 1d531e8c5..9e9a5a9e0 100644 --- a/src/agents/tool-display.ts +++ b/src/agents/tool-display.ts @@ -1,6 +1,6 @@ -import fs from "node:fs"; import { redactToolDetail } from "../logging/redact.js"; import { shortenHomeInString } from "../utils.js"; +import TOOL_DISPLAY_JSON from "./tool-display.json" with { type: "json" }; type ToolDisplayActionSpec = { label?: string; @@ -30,17 +30,7 @@ export type ToolDisplay = { detail?: string; }; -const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = (() => { - try { - const raw = fs.readFileSync( - new URL("./tool-display.json", import.meta.url), - "utf8", - ); - return JSON.parse(raw) as ToolDisplayConfig; - } catch { - return {}; - } -})(); +const TOOL_DISPLAY_CONFIG = TOOL_DISPLAY_JSON as ToolDisplayConfig; const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { emoji: "🧩" }; const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {}; diff --git a/src/agents/tools/agent-step.ts b/src/agents/tools/agent-step.ts new file mode 100644 index 000000000..84d5fdff8 --- /dev/null +++ b/src/agents/tools/agent-step.ts @@ -0,0 +1,56 @@ +import crypto from "node:crypto"; + +import { callGateway } from "../../gateway/call.js"; +import { extractAssistantText, stripToolMessages } from "./sessions-helpers.js"; + +export async function readLatestAssistantReply(params: { + sessionKey: string; + limit?: number; +}): Promise { + const history = (await callGateway({ + method: "chat.history", + params: { sessionKey: params.sessionKey, limit: params.limit ?? 50 }, + })) as { messages?: unknown[] }; + const filtered = stripToolMessages( + Array.isArray(history?.messages) ? history.messages : [], + ); + const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined; + return last ? extractAssistantText(last) : undefined; +} + +export async function runAgentStep(params: { + sessionKey: string; + message: string; + extraSystemPrompt: string; + timeoutMs: number; + lane?: string; +}): Promise { + const stepIdem = crypto.randomUUID(); + const response = (await callGateway({ + method: "agent", + params: { + message: params.message, + sessionKey: params.sessionKey, + idempotencyKey: stepIdem, + deliver: false, + lane: params.lane ?? "nested", + extraSystemPrompt: params.extraSystemPrompt, + }, + timeoutMs: 10_000, + })) as { runId?: string; acceptedAt?: number }; + + const stepRunId = + typeof response?.runId === "string" && response.runId ? response.runId : ""; + const resolvedRunId = stepRunId || stepIdem; + const stepWaitMs = Math.min(params.timeoutMs, 60_000); + const wait = (await callGateway({ + method: "agent.wait", + params: { + runId: resolvedRunId, + timeoutMs: stepWaitMs, + }, + timeoutMs: stepWaitMs + 2000, + })) as { status?: string }; + if (wait?.status !== "ok") return undefined; + return await readLatestAssistantReply({ sessionKey: params.sessionKey }); +} diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 882c65a33..8681e3abb 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -118,6 +118,7 @@ const BrowserToolSchema = Type.Object({ Type.Literal("dialog"), Type.Literal("act"), ]), + profile: Type.Optional(Type.String()), controlUrl: Type.Optional(Type.String()), targetUrl: Type.Optional(Type.String()), targetId: Type.Optional(Type.String()), @@ -161,38 +162,41 @@ export function createBrowserTool(opts?: { const params = args as Record; const action = readStringParam(params, "action", { required: true }); const controlUrl = readStringParam(params, "controlUrl"); + const profile = readStringParam(params, "profile"); const baseUrl = resolveBrowserBaseUrl( controlUrl ?? opts?.defaultControlUrl, ); switch (action) { case "status": - return jsonResult(await browserStatus(baseUrl)); + return jsonResult(await browserStatus(baseUrl, { profile })); case "start": - await browserStart(baseUrl); - return jsonResult(await browserStatus(baseUrl)); + await browserStart(baseUrl, { profile }); + return jsonResult(await browserStatus(baseUrl, { profile })); case "stop": - await browserStop(baseUrl); - return jsonResult(await browserStatus(baseUrl)); + await browserStop(baseUrl, { profile }); + return jsonResult(await browserStatus(baseUrl, { profile })); case "tabs": - return jsonResult({ tabs: await browserTabs(baseUrl) }); + return jsonResult({ tabs: await browserTabs(baseUrl, { profile }) }); case "open": { const targetUrl = readStringParam(params, "targetUrl", { required: true, }); - return jsonResult(await browserOpenTab(baseUrl, targetUrl)); + return jsonResult( + await browserOpenTab(baseUrl, targetUrl, { profile }), + ); } case "focus": { const targetId = readStringParam(params, "targetId", { required: true, }); - await browserFocusTab(baseUrl, targetId); + await browserFocusTab(baseUrl, targetId, { profile }); return jsonResult({ ok: true }); } case "close": { const targetId = readStringParam(params, "targetId"); - if (targetId) await browserCloseTab(baseUrl, targetId); - else await browserAct(baseUrl, { kind: "close" }); + if (targetId) await browserCloseTab(baseUrl, targetId, { profile }); + else await browserAct(baseUrl, { kind: "close" }, { profile }); return jsonResult({ ok: true }); } case "snapshot": { @@ -212,6 +216,7 @@ export function createBrowserTool(opts?: { format, targetId, limit, + profile, }); if (snapshot.format === "ai") { return { @@ -233,6 +238,7 @@ export function createBrowserTool(opts?: { ref, element, type, + profile, }); return await imageResultFromFile({ label: "browser:screenshot", @@ -246,7 +252,11 @@ export function createBrowserTool(opts?: { }); const targetId = readStringParam(params, "targetId"); return jsonResult( - await browserNavigate(baseUrl, { url: targetUrl, targetId }), + await browserNavigate(baseUrl, { + url: targetUrl, + targetId, + profile, + }), ); } case "console": { @@ -257,7 +267,7 @@ export function createBrowserTool(opts?: { ? params.targetId.trim() : undefined; return jsonResult( - await browserConsoleMessages(baseUrl, { level, targetId }), + await browserConsoleMessages(baseUrl, { level, targetId, profile }), ); } case "pdf": { @@ -265,7 +275,7 @@ export function createBrowserTool(opts?: { typeof params.targetId === "string" ? params.targetId.trim() : undefined; - const result = await browserPdfSave(baseUrl, { targetId }); + const result = await browserPdfSave(baseUrl, { targetId, profile }); return { content: [{ type: "text", text: `FILE:${result.path}` }], details: result, @@ -296,6 +306,7 @@ export function createBrowserTool(opts?: { element, targetId, timeoutMs, + profile, }), ); } @@ -320,6 +331,7 @@ export function createBrowserTool(opts?: { promptText, targetId, timeoutMs, + profile, }), ); } @@ -331,6 +343,7 @@ export function createBrowserTool(opts?: { const result = await browserAct( baseUrl, request as Parameters[1], + { profile }, ); return jsonResult(result); } diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 38e7ee9e9..95f7a68ba 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -3,11 +3,23 @@ import { normalizeCronJobCreate, normalizeCronJobPatch, } from "../../cron/normalize.js"; -import { CronAddParamsSchema } from "../../gateway/protocol/schema.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; -const CronJobPatchSchema = Type.Partial(CronAddParamsSchema); +// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch +// instead of CronAddParamsSchema/CronJobPatchSchema because: +// +// 1. CronAddParamsSchema contains nested Type.Union (for schedule, payload, etc.) +// 2. TypeBox compiles Type.Union to JSON Schema `anyOf` +// 3. pi-ai's sanitizeSchemaForGoogle() strips `anyOf` from nested properties +// 4. This leaves empty schemas `{}` which Claude rejects as invalid +// +// The actual validation happens at runtime via normalizeCronJobCreate/Patch +// and the gateway's validateCronAddParams. This schema just needs to accept +// any object so the AI can pass through the job definition. +// +// See: https://github.com/anthropics/anthropic-cookbook/blob/main/misc/tool_use_best_practices.md +// Claude requires valid JSON Schema 2020-12 with explicit types. const CronToolSchema = Type.Union([ Type.Object({ @@ -28,7 +40,7 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - job: CronAddParamsSchema, + job: Type.Object({}, { additionalProperties: true }), }), Type.Object({ action: Type.Literal("update"), @@ -36,7 +48,7 @@ const CronToolSchema = Type.Union([ gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), id: Type.String(), - patch: CronJobPatchSchema, + patch: Type.Object({}, { additionalProperties: true }), }), Type.Object({ action: Type.Literal("remove"), diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 63fbe491a..855a72d8f 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -126,9 +126,10 @@ export async function handleDiscordMessagingAction( typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined; + const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1; await sendPollDiscord( to, - { question, answers, allowMultiselect, durationHours }, + { question, options: answers, maxSelections, durationHours }, { content }, ); return jsonResult({ ok: true }); diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts new file mode 100644 index 000000000..7827a7947 --- /dev/null +++ b/src/agents/tools/gateway.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { callGatewayTool, resolveGatewayOptions } from "./gateway.js"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (...args: unknown[]) => callGatewayMock(...args), +})); + +describe("gateway tool defaults", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("leaves url undefined so callGateway can use config", () => { + const opts = resolveGatewayOptions(); + expect(opts.url).toBeUndefined(); + }); + + it("passes through explicit overrides", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + await callGatewayTool( + "health", + { gatewayUrl: "ws://example", gatewayToken: "t", timeoutMs: 5000 }, + {}, + ); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://example", + token: "t", + timeoutMs: 5000, + }), + ); + }); +}); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index ae2ca7744..c0ca1b36b 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -9,10 +9,11 @@ export type GatewayCallOptions = { }; export function resolveGatewayOptions(opts?: GatewayCallOptions) { + // Prefer an explicit override; otherwise let callGateway choose based on config. const url = typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim() ? opts.gatewayUrl.trim() - : DEFAULT_GATEWAY_URL; + : undefined; const token = typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim() ? opts.gatewayToken.trim() diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index e39841972..b1a7574e8 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -14,7 +14,6 @@ import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; import { resolveUserPath } from "../../utils.js"; import { loadWebMedia } from "../../web/media.js"; -import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { getApiKeyForModel } from "../model-auth.js"; import { runWithImageModelFallback } from "../model-fallback.js"; import { ensureClawdbotModelsJson } from "../models-config.js"; @@ -78,15 +77,15 @@ function buildImageContext( async function runImagePrompt(params: { cfg?: ClawdbotConfig; + agentDir: string; modelOverride?: string; prompt: string; base64: string; mimeType: string; }): Promise<{ text: string; provider: string; model: string }> { - const agentDir = resolveClawdbotAgentDir(); - await ensureClawdbotModelsJson(params.cfg); - const authStorage = discoverAuthStorage(agentDir); - const modelRegistry = discoverModels(authStorage, agentDir); + await ensureClawdbotModelsJson(params.cfg, params.agentDir); + const authStorage = discoverAuthStorage(params.agentDir); + const modelRegistry = discoverModels(authStorage, params.agentDir); const result = await runWithImageModelFallback({ cfg: params.cfg, @@ -104,6 +103,7 @@ async function runImagePrompt(params: { const apiKeyInfo = await getApiKeyForModel({ model, cfg: params.cfg, + agentDir: params.agentDir, }); authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); const context = buildImageContext( @@ -130,8 +130,13 @@ async function runImagePrompt(params: { export function createImageTool(options?: { config?: ClawdbotConfig; + agentDir?: string; }): AnyAgentTool | null { if (!ensureImageToolConfigured(options?.config)) return null; + const agentDir = options?.agentDir; + if (!agentDir?.trim()) { + throw new Error("createImageTool requires agentDir when enabled"); + } return { label: "Image", name: "image", @@ -175,6 +180,7 @@ export function createImageTool(options?: { const base64 = media.buffer.toString("base64"); const result = await runImagePrompt({ cfg: options?.config, + agentDir, modelOverride, prompt: promptRaw, base64, diff --git a/src/agents/tools/sessions-announce-target.test.ts b/src/agents/tools/sessions-announce-target.test.ts new file mode 100644 index 000000000..490a8c4d2 --- /dev/null +++ b/src/agents/tools/sessions-announce-target.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +import { resolveAnnounceTarget } from "./sessions-announce-target.js"; + +describe("resolveAnnounceTarget", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("derives non-WhatsApp announce targets from the session key", async () => { + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:discord:group:dev", + displayKey: "agent:main:discord:group:dev", + }); + expect(target).toEqual({ provider: "discord", to: "channel:dev" }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("hydrates WhatsApp accountId from sessions.list when available", async () => { + callGatewayMock.mockResolvedValueOnce({ + sessions: [ + { + key: "agent:main:whatsapp:group:123@g.us", + lastProvider: "whatsapp", + lastTo: "123@g.us", + lastAccountId: "work", + }, + ], + }); + + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:whatsapp:group:123@g.us", + displayKey: "agent:main:whatsapp:group:123@g.us", + }); + expect(target).toEqual({ + provider: "whatsapp", + to: "123@g.us", + accountId: "work", + }); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + const first = callGatewayMock.mock.calls[0]?.[0] as + | { method?: string } + | undefined; + expect(first).toBeDefined(); + expect(first?.method).toBe("sessions.list"); + }); +}); diff --git a/src/agents/tools/sessions-announce-target.ts b/src/agents/tools/sessions-announce-target.ts new file mode 100644 index 000000000..4a0b66dc9 --- /dev/null +++ b/src/agents/tools/sessions-announce-target.ts @@ -0,0 +1,43 @@ +import { callGateway } from "../../gateway/call.js"; +import type { AnnounceTarget } from "./sessions-send-helpers.js"; +import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js"; + +export async function resolveAnnounceTarget(params: { + sessionKey: string; + displayKey: string; +}): Promise { + const parsed = resolveAnnounceTargetFromKey(params.sessionKey); + const parsedDisplay = resolveAnnounceTargetFromKey(params.displayKey); + const fallback = parsed ?? parsedDisplay ?? null; + + // Most providers can derive (provider,to) from the session key directly. + // WhatsApp is special: we may need lastAccountId from the session store. + if (fallback && fallback.provider !== "whatsapp") return fallback; + + try { + const list = (await callGateway({ + method: "sessions.list", + params: { + includeGlobal: true, + includeUnknown: true, + limit: 200, + }, + })) as { sessions?: Array> }; + const sessions = Array.isArray(list?.sessions) ? list.sessions : []; + const match = + sessions.find((entry) => entry?.key === params.sessionKey) ?? + sessions.find((entry) => entry?.key === params.displayKey); + const provider = + typeof match?.lastProvider === "string" ? match.lastProvider : undefined; + const to = typeof match?.lastTo === "string" ? match.lastTo : undefined; + const accountId = + typeof match?.lastAccountId === "string" + ? match.lastAccountId + : undefined; + if (provider && to) return { provider, to, accountId }; + } catch { + // ignore + } + + return fallback; +} diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 76fe061c1..c1dbc6f95 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -58,8 +58,8 @@ export function classifySessionKind(params: { export function deriveProvider(params: { key: string; kind: SessionKind; - surface?: string | null; - lastChannel?: string | null; + provider?: string | null; + lastProvider?: string | null; }): string { if ( params.kind === "cron" || @@ -67,10 +67,10 @@ export function deriveProvider(params: { params.kind === "node" ) return "internal"; - const surface = normalizeKey(params.surface ?? undefined); - if (surface) return surface; - const lastChannel = normalizeKey(params.lastChannel ?? undefined); - if (lastChannel) return lastChannel; + const provider = normalizeKey(params.provider ?? undefined); + if (provider) return provider; + const lastProvider = normalizeKey(params.lastProvider ?? undefined); + if (lastProvider) return lastProvider; const parts = params.key.split(":").filter(Boolean); if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { return parts[0]; diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index d3ddd5534..f35806fe6 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -2,6 +2,11 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; import { @@ -17,7 +22,37 @@ const SessionsHistoryToolSchema = Type.Object({ includeTools: Type.Optional(Type.Boolean()), }); -export function createSessionsHistoryTool(): AnyAgentTool { +function resolveSandboxSessionToolsVisibility( + cfg: ReturnType, +) { + return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; +} + +async function isSpawnedSessionAllowed(params: { + requesterSessionKey: string; + targetSessionKey: string; +}): Promise { + try { + const list = (await callGateway({ + method: "sessions.list", + params: { + includeGlobal: false, + includeUnknown: false, + limit: 500, + spawnedBy: params.requesterSessionKey, + }, + })) as { sessions?: Array> }; + const sessions = Array.isArray(list?.sessions) ? list.sessions : []; + return sessions.some((entry) => entry?.key === params.targetSessionKey); + } catch { + return false; + } +} + +export function createSessionsHistoryTool(opts?: { + agentSessionKey?: string; + sandboxed?: boolean; +}): AnyAgentTool { return { label: "Session History", name: "sessions_history", @@ -30,11 +65,79 @@ export function createSessionsHistoryTool(): AnyAgentTool { }); const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); + const visibility = resolveSandboxSessionToolsVisibility(cfg); + const requesterInternalKey = + typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() + ? resolveInternalSessionKey({ + key: opts.agentSessionKey, + alias, + mainKey, + }) + : undefined; const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey, }); + const restrictToSpawned = + opts?.sandboxed === true && + visibility === "spawned" && + requesterInternalKey && + !isSubagentSessionKey(requesterInternalKey); + if (restrictToSpawned) { + const ok = await isSpawnedSessionAllowed({ + requesterSessionKey: requesterInternalKey, + targetSessionKey: resolvedKey, + }); + if (!ok) { + return jsonResult({ + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${sessionKey}`, + }); + } + } + + const routingA2A = cfg.routing?.agentToAgent; + const a2aEnabled = routingA2A?.enabled === true; + const allowPatterns = Array.isArray(routingA2A?.allow) + ? routingA2A.allow + : []; + const matchesAllow = (agentId: string) => { + if (allowPatterns.length === 0) return true; + return allowPatterns.some((pattern) => { + const raw = String(pattern ?? "").trim(); + if (!raw) return false; + if (raw === "*") return true; + if (!raw.includes("*")) return raw === agentId; + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); + return re.test(agentId); + }); + }; + const requesterAgentId = normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ); + const targetAgentId = normalizeAgentId( + parseAgentSessionKey(resolvedKey)?.agentId, + ); + const isCrossAgent = requesterAgentId !== targetAgentId; + if (isCrossAgent) { + if (!a2aEnabled) { + return jsonResult({ + status: "forbidden", + error: + "Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.", + }); + } + if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) { + return jsonResult({ + status: "forbidden", + error: + "Agent-to-agent history denied by routing.agentToAgent.allow.", + }); + } + } + const limit = typeof params.limit === "number" && Number.isFinite(params.limit) ? Math.max(1, Math.floor(params.limit)) diff --git a/src/agents/tools/sessions-list-tool.gating.test.ts b/src/agents/tools/sessions-list-tool.gating.test.ts new file mode 100644 index 000000000..e375a766f --- /dev/null +++ b/src/agents/tools/sessions-list-tool.gating.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadConfig: () => + ({ + session: { scope: "per-sender", mainKey: "main" }, + routing: { agentToAgent: { enabled: false } }, + }) as never, + }; +}); + +import { createSessionsListTool } from "./sessions-list-tool.js"; + +describe("sessions_list gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + callGatewayMock.mockResolvedValue({ + path: "/tmp/sessions.json", + sessions: [ + { key: "agent:main:main", kind: "direct" }, + { key: "agent:other:main", kind: "direct" }, + ], + }); + }); + + it("filters out other agents when routing.agentToAgent.enabled is false", async () => { + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + expect(result.details).toMatchObject({ + count: 1, + sessions: [{ key: "agent:main:main" }], + }); + }); +}); diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 0209813f2..0163f3b04 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringArrayParam } from "./common.js"; import { @@ -31,8 +36,9 @@ type SessionListRow = { systemSent?: boolean; abortedLastRun?: boolean; sendPolicy?: string; - lastChannel?: string; + lastProvider?: string; lastTo?: string; + lastAccountId?: string; transcriptPath?: string; messages?: unknown[]; }; @@ -44,7 +50,16 @@ const SessionsListToolSchema = Type.Object({ messageLimit: Type.Optional(Type.Integer({ minimum: 0 })), }); -export function createSessionsListTool(): AnyAgentTool { +function resolveSandboxSessionToolsVisibility( + cfg: ReturnType, +) { + return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; +} + +export function createSessionsListTool(opts?: { + agentSessionKey?: string; + sandboxed?: boolean; +}): AnyAgentTool { return { label: "Sessions", name: "sessions_list", @@ -54,6 +69,20 @@ export function createSessionsListTool(): AnyAgentTool { const params = args as Record; const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); + const visibility = resolveSandboxSessionToolsVisibility(cfg); + const requesterInternalKey = + typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() + ? resolveInternalSessionKey({ + key: opts.agentSessionKey, + alias, + mainKey, + }) + : undefined; + const restrictToSpawned = + opts?.sandboxed === true && + visibility === "spawned" && + requesterInternalKey && + !isSubagentSessionKey(requesterInternalKey); const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) => value.trim().toLowerCase(), @@ -86,8 +115,9 @@ export function createSessionsListTool(): AnyAgentTool { params: { limit, activeMinutes, - includeGlobal: true, - includeUnknown: true, + includeGlobal: !restrictToSpawned, + includeUnknown: !restrictToSpawned, + spawnedBy: restrictToSpawned ? requesterInternalKey : undefined, }, })) as { path?: string; @@ -96,12 +126,43 @@ export function createSessionsListTool(): AnyAgentTool { const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const storePath = typeof list?.path === "string" ? list.path : undefined; + const routingA2A = cfg.routing?.agentToAgent; + const a2aEnabled = routingA2A?.enabled === true; + const allowPatterns = Array.isArray(routingA2A?.allow) + ? routingA2A.allow + : []; + const matchesAllow = (agentId: string) => { + if (allowPatterns.length === 0) return true; + return allowPatterns.some((pattern) => { + const raw = String(pattern ?? "").trim(); + if (!raw) return false; + if (raw === "*") return true; + if (!raw.includes("*")) return raw === agentId; + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); + return re.test(agentId); + }); + }; + const requesterAgentId = normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ); const rows: SessionListRow[] = []; for (const entry of sessions) { if (!entry || typeof entry !== "object") continue; const key = typeof entry.key === "string" ? entry.key : ""; if (!key) continue; + + const entryAgentId = normalizeAgentId( + parseAgentSessionKey(key)?.agentId, + ); + const crossAgent = entryAgentId !== requesterAgentId; + if (crossAgent) { + if (!a2aEnabled) continue; + if (!matchesAllow(requesterAgentId) || !matchesAllow(entryAgentId)) + continue; + } + if (key === "unknown") continue; if (key === "global" && alias !== "global") continue; @@ -116,15 +177,21 @@ export function createSessionsListTool(): AnyAgentTool { mainKey, }); - const surface = - typeof entry.surface === "string" ? entry.surface : undefined; - const lastChannel = - typeof entry.lastChannel === "string" ? entry.lastChannel : undefined; - const provider = deriveProvider({ + const entryProvider = + typeof entry.provider === "string" ? entry.provider : undefined; + const lastProvider = + typeof entry.lastProvider === "string" + ? entry.lastProvider + : undefined; + const lastAccountId = + typeof entry.lastAccountId === "string" + ? entry.lastAccountId + : undefined; + const derivedProvider = deriveProvider({ key, kind, - surface, - lastChannel, + provider: entryProvider, + lastProvider, }); const sessionId = @@ -137,7 +204,7 @@ export function createSessionsListTool(): AnyAgentTool { const row: SessionListRow = { key: displayKey, kind, - provider, + provider: derivedProvider, displayName: typeof entry.displayName === "string" ? entry.displayName @@ -172,8 +239,9 @@ export function createSessionsListTool(): AnyAgentTool { : undefined, sendPolicy: typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined, - lastChannel, + lastProvider, lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined, + lastAccountId, transcriptPath, }; diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index ad47f127a..cc2b995d1 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -6,33 +6,38 @@ const DEFAULT_PING_PONG_TURNS = 5; const MAX_PING_PONG_TURNS = 5; export type AnnounceTarget = { - channel: string; + provider: string; to: string; + accountId?: string; }; export function resolveAnnounceTargetFromKey( sessionKey: string, ): AnnounceTarget | null { - const parts = sessionKey.split(":").filter(Boolean); + const rawParts = sessionKey.split(":").filter(Boolean); + const parts = + rawParts.length >= 3 && rawParts[0] === "agent" + ? rawParts.slice(2) + : rawParts; if (parts.length < 3) return null; - const [surface, kind, ...rest] = parts; + const [providerRaw, kind, ...rest] = parts; if (kind !== "group" && kind !== "channel") return null; const id = rest.join(":").trim(); if (!id) return null; - if (!surface) return null; - const channel = surface.toLowerCase(); - if (channel === "discord") { - return { channel, to: `channel:${id}` }; + if (!providerRaw) return null; + const provider = providerRaw.toLowerCase(); + if (provider === "discord") { + return { provider, to: `channel:${id}` }; } - if (channel === "signal") { - return { channel, to: `group:${id}` }; + if (provider === "signal") { + return { provider, to: `group:${id}` }; } - return { channel, to: id }; + return { provider, to: id }; } export function buildAgentToAgentMessageContext(params: { requesterSessionKey?: string; - requesterSurface?: string; + requesterProvider?: string; targetSessionKey: string; }) { const lines = [ @@ -40,8 +45,8 @@ export function buildAgentToAgentMessageContext(params: { params.requesterSessionKey ? `Agent 1 (requester) session: ${params.requesterSessionKey}.` : undefined, - params.requesterSurface - ? `Agent 1 (requester) surface: ${params.requesterSurface}.` + params.requesterProvider + ? `Agent 1 (requester) provider: ${params.requesterProvider}.` : undefined, `Agent 2 (target) session: ${params.targetSessionKey}.`, ].filter(Boolean); @@ -50,9 +55,9 @@ export function buildAgentToAgentMessageContext(params: { export function buildAgentToAgentReplyContext(params: { requesterSessionKey?: string; - requesterSurface?: string; + requesterProvider?: string; targetSessionKey: string; - targetChannel?: string; + targetProvider?: string; currentRole: "requester" | "target"; turn: number; maxTurns: number; @@ -68,12 +73,12 @@ export function buildAgentToAgentReplyContext(params: { params.requesterSessionKey ? `Agent 1 (requester) session: ${params.requesterSessionKey}.` : undefined, - params.requesterSurface - ? `Agent 1 (requester) surface: ${params.requesterSurface}.` + params.requesterProvider + ? `Agent 1 (requester) provider: ${params.requesterProvider}.` : undefined, `Agent 2 (target) session: ${params.targetSessionKey}.`, - params.targetChannel - ? `Agent 2 (target) surface: ${params.targetChannel}.` + params.targetProvider + ? `Agent 2 (target) provider: ${params.targetProvider}.` : undefined, `If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`, ].filter(Boolean); @@ -82,9 +87,9 @@ export function buildAgentToAgentReplyContext(params: { export function buildAgentToAgentAnnounceContext(params: { requesterSessionKey?: string; - requesterSurface?: string; + requesterProvider?: string; targetSessionKey: string; - targetChannel?: string; + targetProvider?: string; originalMessage: string; roundOneReply?: string; latestReply?: string; @@ -94,12 +99,12 @@ export function buildAgentToAgentAnnounceContext(params: { params.requesterSessionKey ? `Agent 1 (requester) session: ${params.requesterSessionKey}.` : undefined, - params.requesterSurface - ? `Agent 1 (requester) surface: ${params.requesterSurface}.` + params.requesterProvider + ? `Agent 1 (requester) provider: ${params.requesterProvider}.` : undefined, `Agent 2 (target) session: ${params.targetSessionKey}.`, - params.targetChannel - ? `Agent 2 (target) surface: ${params.targetChannel}.` + params.targetProvider + ? `Agent 2 (target) provider: ${params.targetProvider}.` : undefined, `Original request: ${params.originalMessage}`, params.roundOneReply @@ -109,7 +114,7 @@ export function buildAgentToAgentAnnounceContext(params: { ? `Latest reply: ${params.latestReply}` : "Latest reply: (not available).", `If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`, - "Any other reply will be posted to the target channel.", + "Any other reply will be posted to the target provider.", "After this reply, the agent-to-agent conversation is over.", ].filter(Boolean); return lines.join("\n"); diff --git a/src/agents/tools/sessions-send-tool.gating.test.ts b/src/agents/tools/sessions-send-tool.gating.test.ts new file mode 100644 index 000000000..5137eea71 --- /dev/null +++ b/src/agents/tools/sessions-send-tool.gating.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadConfig: () => + ({ + session: { scope: "per-sender", mainKey: "main" }, + routing: { agentToAgent: { enabled: false } }, + }) as never, + }; +}); + +import { createSessionsSendTool } from "./sessions-send-tool.js"; + +describe("sessions_send gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => { + const tool = createSessionsSendTool({ + agentSessionKey: "agent:main:main", + agentProvider: "whatsapp", + }); + + const result = await tool.execute("call1", { + sessionKey: "agent:other:main", + message: "hi", + timeoutSeconds: 0, + }); + + expect(callGatewayMock).not.toHaveBeenCalled(); + expect(result.details).toMatchObject({ status: "forbidden" }); + }); +}); diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 3edda725c..8c8a4cdec 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -4,8 +4,15 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; +import { readLatestAssistantReply, runAgentStep } from "./agent-step.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; +import { resolveAnnounceTarget } from "./sessions-announce-target.js"; import { extractAssistantText, resolveDisplaySessionKey, @@ -14,13 +21,11 @@ import { stripToolMessages, } from "./sessions-helpers.js"; import { - type AnnounceTarget, buildAgentToAgentAnnounceContext, buildAgentToAgentMessageContext, buildAgentToAgentReplyContext, isAnnounceSkip, isReplySkip, - resolveAnnounceTargetFromKey, resolvePingPongTurns, } from "./sessions-send-helpers.js"; @@ -32,7 +37,8 @@ const SessionsSendToolSchema = Type.Object({ export function createSessionsSendTool(opts?: { agentSessionKey?: string; - agentSurface?: string; + agentProvider?: string; + sandboxed?: boolean; }): AnyAgentTool { return { label: "Session Send", @@ -47,11 +53,64 @@ export function createSessionsSendTool(opts?: { const message = readStringParam(params, "message", { required: true }); const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); + const visibility = + cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; + const requesterInternalKey = + typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() + ? resolveInternalSessionKey({ + key: opts.agentSessionKey, + alias, + mainKey, + }) + : undefined; const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey, }); + const restrictToSpawned = + opts?.sandboxed === true && + visibility === "spawned" && + requesterInternalKey && + !isSubagentSessionKey(requesterInternalKey); + if (restrictToSpawned) { + try { + const list = (await callGateway({ + method: "sessions.list", + params: { + includeGlobal: false, + includeUnknown: false, + limit: 500, + spawnedBy: requesterInternalKey, + }, + })) as { sessions?: Array> }; + const sessions = Array.isArray(list?.sessions) ? list.sessions : []; + const ok = sessions.some((entry) => entry?.key === resolvedKey); + if (!ok) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${sessionKey}`, + sessionKey: resolveDisplaySessionKey({ + key: sessionKey, + alias, + mainKey, + }), + }); + } + } catch { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${sessionKey}`, + sessionKey: resolveDisplaySessionKey({ + key: sessionKey, + alias, + mainKey, + }), + }); + } + } const timeoutSeconds = typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds) @@ -66,9 +125,55 @@ export function createSessionsSendTool(opts?: { alias, mainKey, }); + + const routingA2A = cfg.routing?.agentToAgent; + const a2aEnabled = routingA2A?.enabled === true; + const allowPatterns = Array.isArray(routingA2A?.allow) + ? routingA2A.allow + : []; + const matchesAllow = (agentId: string) => { + if (allowPatterns.length === 0) return true; + return allowPatterns.some((pattern) => { + const raw = String(pattern ?? "").trim(); + if (!raw) return false; + if (raw === "*") return true; + if (!raw.includes("*")) return raw === agentId; + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); + return re.test(agentId); + }); + }; + const requesterAgentId = normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ); + const targetAgentId = normalizeAgentId( + parseAgentSessionKey(resolvedKey)?.agentId, + ); + const isCrossAgent = requesterAgentId !== targetAgentId; + if (isCrossAgent) { + if (!a2aEnabled) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: + "Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.", + sessionKey: displayKey, + }); + } + if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: + "Agent-to-agent messaging denied by routing.agentToAgent.allow.", + sessionKey: displayKey, + }); + } + } + const agentMessageContext = buildAgentToAgentMessageContext({ requesterSessionKey: opts?.agentSessionKey, - requesterSurface: opts?.agentSurface, + requesterProvider: opts?.agentProvider, targetSessionKey: displayKey, }); const sendParams = { @@ -80,90 +185,9 @@ export function createSessionsSendTool(opts?: { extraSystemPrompt: agentMessageContext, }; const requesterSessionKey = opts?.agentSessionKey; - const requesterSurface = opts?.agentSurface; + const requesterProvider = opts?.agentProvider; const maxPingPongTurns = resolvePingPongTurns(cfg); - const resolveAnnounceTarget = - async (): Promise => { - const parsed = resolveAnnounceTargetFromKey(resolvedKey); - if (parsed) return parsed; - try { - const list = (await callGateway({ - method: "sessions.list", - params: { - includeGlobal: true, - includeUnknown: true, - limit: 200, - }, - })) as { sessions?: Array> }; - const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - const match = - sessions.find((entry) => entry?.key === resolvedKey) ?? - sessions.find((entry) => entry?.key === displayKey); - const channel = - typeof match?.lastChannel === "string" - ? match.lastChannel - : undefined; - const to = - typeof match?.lastTo === "string" ? match.lastTo : undefined; - if (channel && to) return { channel, to }; - } catch { - // ignore; fall through to null - } - return null; - }; - - const readLatestAssistantReply = async ( - sessionKeyToRead: string, - ): Promise => { - const history = (await callGateway({ - method: "chat.history", - params: { sessionKey: sessionKeyToRead, limit: 50 }, - })) as { messages?: unknown[] }; - const filtered = stripToolMessages( - Array.isArray(history?.messages) ? history.messages : [], - ); - const last = - filtered.length > 0 ? filtered[filtered.length - 1] : undefined; - return last ? extractAssistantText(last) : undefined; - }; - - const runAgentStep = async (step: { - sessionKey: string; - message: string; - extraSystemPrompt: string; - timeoutMs: number; - }): Promise => { - const stepIdem = crypto.randomUUID(); - const response = (await callGateway({ - method: "agent", - params: { - message: step.message, - sessionKey: step.sessionKey, - idempotencyKey: stepIdem, - deliver: false, - lane: "nested", - extraSystemPrompt: step.extraSystemPrompt, - }, - timeoutMs: 10_000, - })) as { runId?: string; acceptedAt?: number }; - const stepRunId = - typeof response?.runId === "string" && response.runId - ? response.runId - : stepIdem; - const stepWaitMs = Math.min(step.timeoutMs, 60_000); - const wait = (await callGateway({ - method: "agent.wait", - params: { - runId: stepRunId, - timeoutMs: stepWaitMs, - }, - timeoutMs: stepWaitMs + 2000, - })) as { status?: string }; - if (wait?.status !== "ok") return undefined; - return readLatestAssistantReply(step.sessionKey); - }; - const runAgentToAgentFlow = async ( roundOneReply?: string, runInfo?: { runId: string }, @@ -182,13 +206,18 @@ export function createSessionsSendTool(opts?: { timeoutMs: waitMs + 2000, })) as { status?: string }; if (wait?.status === "ok") { - primaryReply = await readLatestAssistantReply(resolvedKey); + primaryReply = await readLatestAssistantReply({ + sessionKey: resolvedKey, + }); latestReply = primaryReply; } } if (!latestReply) return; - const announceTarget = await resolveAnnounceTarget(); - const targetChannel = announceTarget?.channel ?? "unknown"; + const announceTarget = await resolveAnnounceTarget({ + sessionKey: resolvedKey, + displayKey, + }); + const targetProvider = announceTarget?.provider ?? "unknown"; if ( maxPingPongTurns > 0 && requesterSessionKey && @@ -204,9 +233,9 @@ export function createSessionsSendTool(opts?: { : "target"; const replyPrompt = buildAgentToAgentReplyContext({ requesterSessionKey, - requesterSurface, + requesterProvider, targetSessionKey: displayKey, - targetChannel, + targetProvider, currentRole, turn, maxTurns: maxPingPongTurns, @@ -216,6 +245,7 @@ export function createSessionsSendTool(opts?: { message: incomingMessage, extraSystemPrompt: replyPrompt, timeoutMs: announceTimeoutMs, + lane: "nested", }); if (!replyText || isReplySkip(replyText)) { break; @@ -229,9 +259,9 @@ export function createSessionsSendTool(opts?: { } const announcePrompt = buildAgentToAgentAnnounceContext({ requesterSessionKey, - requesterSurface, + requesterProvider, targetSessionKey: displayKey, - targetChannel, + targetProvider, originalMessage: message, roundOneReply: primaryReply, latestReply, @@ -241,6 +271,7 @@ export function createSessionsSendTool(opts?: { message: "Agent-to-agent announce step.", extraSystemPrompt: announcePrompt, timeoutMs: announceTimeoutMs, + lane: "nested", }); if ( announceTarget && @@ -253,7 +284,8 @@ export function createSessionsSendTool(opts?: { params: { to: announceTarget.to, message: announceReply.trim(), - provider: announceTarget.channel, + provider: announceTarget.provider, + accountId: announceTarget.accountId, idempotencyKey: crypto.randomUUID(), }, timeoutMs: 10_000, diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts new file mode 100644 index 000000000..f29569f47 --- /dev/null +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -0,0 +1,378 @@ +import crypto from "node:crypto"; + +import { Type } from "@sinclair/typebox"; + +import { loadConfig } from "../../config/config.js"; +import { callGateway } from "../../gateway/call.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; +import { readLatestAssistantReply, runAgentStep } from "./agent-step.js"; +import type { AnyAgentTool } from "./common.js"; +import { jsonResult, readStringParam } from "./common.js"; +import { resolveAnnounceTarget } from "./sessions-announce-target.js"; +import { + resolveDisplaySessionKey, + resolveInternalSessionKey, + resolveMainSessionAlias, +} from "./sessions-helpers.js"; +import { isAnnounceSkip } from "./sessions-send-helpers.js"; + +const SessionsSpawnToolSchema = Type.Object({ + task: Type.String(), + label: Type.Optional(Type.String()), + timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), + cleanup: Type.Optional( + Type.Union([Type.Literal("delete"), Type.Literal("keep")]), + ), +}); + +function buildSubagentSystemPrompt(params: { + requesterSessionKey?: string; + requesterProvider?: string; + childSessionKey: string; + label?: string; +}) { + const lines = [ + "Sub-agent context:", + params.label ? `Label: ${params.label}` : undefined, + params.requesterSessionKey + ? `Requester session: ${params.requesterSessionKey}.` + : undefined, + params.requesterProvider + ? `Requester provider: ${params.requesterProvider}.` + : undefined, + `Your session: ${params.childSessionKey}.`, + "Run the task. Provide a clear final answer (plain text).", + 'After you finish, you may be asked to produce an "announce" message to post back to the requester chat.', + ].filter(Boolean); + return lines.join("\n"); +} + +function buildSubagentAnnouncePrompt(params: { + requesterSessionKey?: string; + requesterProvider?: string; + announceChannel: string; + task: string; + subagentReply?: string; +}) { + const lines = [ + "Sub-agent announce step:", + params.requesterSessionKey + ? `Requester session: ${params.requesterSessionKey}.` + : undefined, + params.requesterProvider + ? `Requester provider: ${params.requesterProvider}.` + : undefined, + `Post target provider: ${params.announceChannel}.`, + `Original task: ${params.task}`, + params.subagentReply + ? `Sub-agent result: ${params.subagentReply}` + : "Sub-agent result: (not available).", + 'Reply exactly "ANNOUNCE_SKIP" to stay silent.', + "Any other reply will be posted to the requester chat provider.", + ].filter(Boolean); + return lines.join("\n"); +} + +async function runSubagentAnnounceFlow(params: { + childSessionKey: string; + childRunId: string; + requesterSessionKey: string; + requesterProvider?: string; + requesterDisplayKey: string; + task: string; + timeoutMs: number; + cleanup: "delete" | "keep"; + roundOneReply?: string; +}) { + try { + let reply = params.roundOneReply; + if (!reply) { + const waitMs = Math.min(params.timeoutMs, 60_000); + const wait = (await callGateway({ + method: "agent.wait", + params: { + runId: params.childRunId, + timeoutMs: waitMs, + }, + timeoutMs: waitMs + 2000, + })) as { status?: string }; + if (wait?.status !== "ok") return; + reply = await readLatestAssistantReply({ + sessionKey: params.childSessionKey, + }); + } + + const announceTarget = await resolveAnnounceTarget({ + sessionKey: params.requesterSessionKey, + displayKey: params.requesterDisplayKey, + }); + if (!announceTarget) return; + + const announcePrompt = buildSubagentAnnouncePrompt({ + requesterSessionKey: params.requesterSessionKey, + requesterProvider: params.requesterProvider, + announceChannel: announceTarget.provider, + task: params.task, + subagentReply: reply, + }); + + const announceReply = await runAgentStep({ + sessionKey: params.childSessionKey, + message: "Sub-agent announce step.", + extraSystemPrompt: announcePrompt, + timeoutMs: params.timeoutMs, + lane: "nested", + }); + + if ( + !announceReply || + !announceReply.trim() || + isAnnounceSkip(announceReply) + ) + return; + + await callGateway({ + method: "send", + params: { + to: announceTarget.to, + message: announceReply.trim(), + provider: announceTarget.provider, + accountId: announceTarget.accountId, + idempotencyKey: crypto.randomUUID(), + }, + timeoutMs: 10_000, + }); + } catch { + // Best-effort follow-ups; ignore failures to avoid breaking the caller response. + } finally { + if (params.cleanup === "delete") { + try { + await callGateway({ + method: "sessions.delete", + params: { key: params.childSessionKey, deleteTranscript: true }, + timeoutMs: 10_000, + }); + } catch { + // ignore + } + } + } +} + +export function createSessionsSpawnTool(opts?: { + agentSessionKey?: string; + agentProvider?: string; + sandboxed?: boolean; +}): AnyAgentTool { + return { + label: "Sessions", + name: "sessions_spawn", + description: + "Spawn a background sub-agent run in an isolated session and announce the result back to the requester chat.", + parameters: SessionsSpawnToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const task = readStringParam(params, "task", { required: true }); + const label = typeof params.label === "string" ? params.label.trim() : ""; + const cleanup = + params.cleanup === "keep" || params.cleanup === "delete" + ? (params.cleanup as "keep" | "delete") + : "delete"; + const timeoutSeconds = + typeof params.timeoutSeconds === "number" && + Number.isFinite(params.timeoutSeconds) + ? Math.max(0, Math.floor(params.timeoutSeconds)) + : 0; + const timeoutMs = timeoutSeconds * 1000; + + const cfg = loadConfig(); + const { mainKey, alias } = resolveMainSessionAlias(cfg); + const requesterSessionKey = opts?.agentSessionKey; + if ( + typeof requesterSessionKey === "string" && + isSubagentSessionKey(requesterSessionKey) + ) { + return jsonResult({ + status: "forbidden", + error: "sessions_spawn is not allowed from sub-agent sessions", + }); + } + const requesterInternalKey = requesterSessionKey + ? resolveInternalSessionKey({ + key: requesterSessionKey, + alias, + mainKey, + }) + : alias; + const requesterDisplayKey = resolveDisplaySessionKey({ + key: requesterInternalKey, + alias, + mainKey, + }); + + const requesterAgentId = normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ); + const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`; + if (opts?.sandboxed === true) { + try { + await callGateway({ + method: "sessions.patch", + params: { key: childSessionKey, spawnedBy: requesterInternalKey }, + timeoutMs: 10_000, + }); + } catch { + // best-effort; scoping relies on this metadata but spawning still works without it + } + } + const childSystemPrompt = buildSubagentSystemPrompt({ + requesterSessionKey, + requesterProvider: opts?.agentProvider, + childSessionKey, + label: label || undefined, + }); + + const childIdem = crypto.randomUUID(); + let childRunId: string = childIdem; + try { + const response = (await callGateway({ + method: "agent", + params: { + message: task, + sessionKey: childSessionKey, + idempotencyKey: childIdem, + deliver: false, + lane: "subagent", + extraSystemPrompt: childSystemPrompt, + }, + timeoutMs: 10_000, + })) as { runId?: string }; + if (typeof response?.runId === "string" && response.runId) { + childRunId = response.runId; + } + } catch (err) { + const messageText = + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : "error"; + return jsonResult({ + status: "error", + error: messageText, + childSessionKey, + runId: childRunId, + }); + } + + if (timeoutSeconds === 0) { + void runSubagentAnnounceFlow({ + childSessionKey, + childRunId, + requesterSessionKey: requesterInternalKey, + requesterProvider: opts?.agentProvider, + requesterDisplayKey, + task, + timeoutMs: 30_000, + cleanup, + }); + return jsonResult({ + status: "accepted", + childSessionKey, + runId: childRunId, + }); + } + + let waitStatus: string | undefined; + let waitError: string | undefined; + try { + const wait = (await callGateway({ + method: "agent.wait", + params: { + runId: childRunId, + timeoutMs, + }, + timeoutMs: timeoutMs + 2000, + })) as { status?: string; error?: string }; + waitStatus = typeof wait?.status === "string" ? wait.status : undefined; + waitError = typeof wait?.error === "string" ? wait.error : undefined; + } catch (err) { + const messageText = + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : "error"; + return jsonResult({ + status: messageText.includes("gateway timeout") ? "timeout" : "error", + error: messageText, + childSessionKey, + runId: childRunId, + }); + } + + if (waitStatus === "timeout") { + void runSubagentAnnounceFlow({ + childSessionKey, + childRunId, + requesterSessionKey: requesterInternalKey, + requesterProvider: opts?.agentProvider, + requesterDisplayKey, + task, + timeoutMs: 30_000, + cleanup, + }); + return jsonResult({ + status: "timeout", + error: waitError, + childSessionKey, + runId: childRunId, + }); + } + if (waitStatus === "error") { + void runSubagentAnnounceFlow({ + childSessionKey, + childRunId, + requesterSessionKey: requesterInternalKey, + requesterProvider: opts?.agentProvider, + requesterDisplayKey, + task, + timeoutMs: 30_000, + cleanup, + }); + return jsonResult({ + status: "error", + error: waitError ?? "agent error", + childSessionKey, + runId: childRunId, + }); + } + + const replyText = await readLatestAssistantReply({ + sessionKey: childSessionKey, + }); + void runSubagentAnnounceFlow({ + childSessionKey, + childRunId, + requesterSessionKey: requesterInternalKey, + requesterProvider: opts?.agentProvider, + requesterDisplayKey, + task, + timeoutMs: 30_000, + cleanup, + roundOneReply: replyText, + }); + + return jsonResult({ + status: "ok", + childSessionKey, + runId: childRunId, + reply: replyText, + }); + }, + }; +} diff --git a/src/agents/usage.test.ts b/src/agents/usage.test.ts new file mode 100644 index 000000000..f0b0d53b4 --- /dev/null +++ b/src/agents/usage.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { hasNonzeroUsage, normalizeUsage } from "./usage.js"; + +describe("normalizeUsage", () => { + it("normalizes Anthropic-style snake_case usage", () => { + const usage = normalizeUsage({ + input_tokens: 1200, + output_tokens: 340, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 50, + total_tokens: 1790, + }); + expect(usage).toEqual({ + input: 1200, + output: 340, + cacheRead: 50, + cacheWrite: 200, + total: 1790, + }); + }); + + it("normalizes OpenAI-style prompt/completion usage", () => { + const usage = normalizeUsage({ + prompt_tokens: 987, + completion_tokens: 123, + total_tokens: 1110, + }); + expect(usage).toEqual({ + input: 987, + output: 123, + cacheRead: undefined, + cacheWrite: undefined, + total: 1110, + }); + }); + + it("returns undefined for empty usage objects", () => { + expect(normalizeUsage({})).toBeUndefined(); + }); + + it("guards against empty/zero usage overwrites", () => { + expect(hasNonzeroUsage(undefined)).toBe(false); + expect(hasNonzeroUsage(null)).toBe(false); + expect(hasNonzeroUsage({})).toBe(false); + expect(hasNonzeroUsage({ input: 0, output: 0 })).toBe(false); + expect(hasNonzeroUsage({ input: 1 })).toBe(true); + expect(hasNonzeroUsage({ total: 1 })).toBe(true); + }); +}); diff --git a/src/agents/usage.ts b/src/agents/usage.ts index bc33a942b..76697bcda 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -4,6 +4,17 @@ export type UsageLike = { cacheRead?: number; cacheWrite?: number; total?: number; + // Common alternates across providers/SDKs. + inputTokens?: number; + outputTokens?: number; + promptTokens?: number; + completionTokens?: number; + input_tokens?: number; + output_tokens?: number; + prompt_tokens?: number; + completion_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; // Some agents/logs emit alternate naming. totalTokens?: number; total_tokens?: number; @@ -11,27 +22,58 @@ export type UsageLike = { cache_write?: number; }; +export type NormalizedUsage = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; +}; + const asFiniteNumber = (value: unknown): number | undefined => { if (typeof value !== "number") return undefined; if (!Number.isFinite(value)) return undefined; return value; }; -export function normalizeUsage(raw?: UsageLike | null): - | { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - } - | undefined { +export function hasNonzeroUsage( + usage?: NormalizedUsage | null, +): usage is NormalizedUsage { + if (!usage) return false; + return [ + usage.input, + usage.output, + usage.cacheRead, + usage.cacheWrite, + usage.total, + ].some((v) => typeof v === "number" && Number.isFinite(v) && v > 0); +} + +export function normalizeUsage( + raw?: UsageLike | null, +): NormalizedUsage | undefined { if (!raw) return undefined; - const input = asFiniteNumber(raw.input); - const output = asFiniteNumber(raw.output); - const cacheRead = asFiniteNumber(raw.cacheRead ?? raw.cache_read); - const cacheWrite = asFiniteNumber(raw.cacheWrite ?? raw.cache_write); + const input = asFiniteNumber( + raw.input ?? + raw.inputTokens ?? + raw.input_tokens ?? + raw.promptTokens ?? + raw.prompt_tokens, + ); + const output = asFiniteNumber( + raw.output ?? + raw.outputTokens ?? + raw.output_tokens ?? + raw.completionTokens ?? + raw.completion_tokens, + ); + const cacheRead = asFiniteNumber( + raw.cacheRead ?? raw.cache_read ?? raw.cache_read_input_tokens, + ); + const cacheWrite = asFiniteNumber( + raw.cacheWrite ?? raw.cache_write ?? raw.cache_creation_input_tokens, + ); const total = asFiniteNumber( raw.total ?? raw.totalTokens ?? raw.total_tokens, ); diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index e03a559f9..89e8c5a25 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -23,9 +23,11 @@ describe("ensureAgentWorkspace", () => { const identity = path.join(path.resolve(nested), "IDENTITY.md"); const user = path.join(path.resolve(nested), "USER.md"); + const heartbeat = path.join(path.resolve(nested), "HEARTBEAT.md"); const bootstrap = path.join(path.resolve(nested), "BOOTSTRAP.md"); await expect(fs.stat(identity)).resolves.toBeDefined(); await expect(fs.stat(user)).resolves.toBeDefined(); + await expect(fs.stat(heartbeat)).resolves.toBeDefined(); await expect(fs.stat(bootstrap)).resolves.toBeDefined(); }); @@ -36,4 +38,17 @@ describe("ensureAgentWorkspace", () => { await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true }); expect(await fs.readFile(agentsPath, "utf-8")).toBe("custom"); }); + + it("does not recreate BOOTSTRAP.md once workspace exists", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-")); + const agentsPath = path.join(dir, "AGENTS.md"); + const bootstrapPath = path.join(dir, "BOOTSTRAP.md"); + + await fs.writeFile(agentsPath, "custom", "utf-8"); + await fs.rm(bootstrapPath, { force: true }); + + await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true }); + + await expect(fs.stat(bootstrapPath)).rejects.toBeDefined(); + }); }); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index ca9ecfe72..a27dc7e5f 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -22,6 +22,7 @@ export const DEFAULT_SOUL_FILENAME = "SOUL.md"; export const DEFAULT_TOOLS_FILENAME = "TOOLS.md"; export const DEFAULT_IDENTITY_FILENAME = "IDENTITY.md"; export const DEFAULT_USER_FILENAME = "USER.md"; +export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md"; export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md"; const DEFAULT_AGENTS_TEMPLATE = `# AGENTS.md - Clawdbot Workspace @@ -53,6 +54,9 @@ git commit -m "Add agent workspace" - On session start, read today + yesterday if present. - Capture durable facts, preferences, and decisions; avoid secrets. +## Heartbeats (optional) +- HEARTBEAT.md can hold a tiny checklist for heartbeat runs; keep it small. + ## Customize - Add your preferred style, rules, and "memory" here. `; @@ -83,6 +87,12 @@ It does not define which tools exist; Clawdbot provides built-in tools internall Add whatever else you want the assistant to know about your local toolchain. `; +const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md - Optional heartbeat notes + +Keep this file small. Leave it empty unless you want a short checklist or reminders +to follow during heartbeat runs. +`; + const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after) Hello. I was just born. @@ -174,6 +184,7 @@ export type WorkspaceBootstrapFileName = | typeof DEFAULT_TOOLS_FILENAME | typeof DEFAULT_IDENTITY_FILENAME | typeof DEFAULT_USER_FILENAME + | typeof DEFAULT_HEARTBEAT_FILENAME | typeof DEFAULT_BOOTSTRAP_FILENAME; export type WorkspaceBootstrapFile = { @@ -205,6 +216,7 @@ export async function ensureAgentWorkspace(params?: { toolsPath?: string; identityPath?: string; userPath?: string; + heartbeatPath?: string; bootstrapPath?: string; }> { const rawDir = params?.dir?.trim() @@ -220,8 +232,31 @@ export async function ensureAgentWorkspace(params?: { const toolsPath = path.join(dir, DEFAULT_TOOLS_FILENAME); const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME); const userPath = path.join(dir, DEFAULT_USER_FILENAME); + const heartbeatPath = path.join(dir, DEFAULT_HEARTBEAT_FILENAME); const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME); + const isBrandNewWorkspace = await (async () => { + const paths = [ + agentsPath, + soulPath, + toolsPath, + identityPath, + userPath, + heartbeatPath, + ]; + const existing = await Promise.all( + paths.map(async (p) => { + try { + await fs.access(p); + return true; + } catch { + return false; + } + }), + ); + return existing.every((v) => !v); + })(); + const agentsTemplate = await loadTemplate( DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_TEMPLATE, @@ -242,6 +277,10 @@ export async function ensureAgentWorkspace(params?: { DEFAULT_USER_FILENAME, DEFAULT_USER_TEMPLATE, ); + const heartbeatTemplate = await loadTemplate( + DEFAULT_HEARTBEAT_FILENAME, + DEFAULT_HEARTBEAT_TEMPLATE, + ); const bootstrapTemplate = await loadTemplate( DEFAULT_BOOTSTRAP_FILENAME, DEFAULT_BOOTSTRAP_TEMPLATE, @@ -252,7 +291,10 @@ export async function ensureAgentWorkspace(params?: { await writeFileIfMissing(toolsPath, toolsTemplate); await writeFileIfMissing(identityPath, identityTemplate); await writeFileIfMissing(userPath, userTemplate); - await writeFileIfMissing(bootstrapPath, bootstrapTemplate); + await writeFileIfMissing(heartbeatPath, heartbeatTemplate); + if (isBrandNewWorkspace) { + await writeFileIfMissing(bootstrapPath, bootstrapTemplate); + } return { dir, @@ -261,6 +303,7 @@ export async function ensureAgentWorkspace(params?: { toolsPath, identityPath, userPath, + heartbeatPath, bootstrapPath, }; } @@ -294,6 +337,10 @@ export async function loadWorkspaceBootstrapFiles( name: DEFAULT_USER_FILENAME, filePath: path.join(resolvedDir, DEFAULT_USER_FILENAME), }, + { + name: DEFAULT_HEARTBEAT_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_HEARTBEAT_FILENAME), + }, { name: DEFAULT_BOOTSTRAP_FILENAME, filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME), diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 8bef8ad71..a6218fbfa 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -1,6 +1,29 @@ import { describe, expect, it } from "vitest"; -import { chunkText, resolveTextChunkLimit } from "./chunk.js"; +import { + chunkMarkdownText, + chunkText, + resolveTextChunkLimit, +} from "./chunk.js"; + +function expectFencesBalanced(chunks: string[]) { + for (const chunk of chunks) { + let open: { markerChar: string; markerLen: number } | null = null; + for (const line of chunk.split("\n")) { + const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); + if (!match) continue; + const marker = match[2]; + if (!open) { + open = { markerChar: marker[0], markerLen: marker.length }; + continue; + } + if (open.markerChar === marker[0] && marker.length >= open.markerLen) { + open = null; + } + } + expect(open).toBe(null); + } +} describe("chunkText", () => { it("keeps multi-line text in one chunk when under limit", () => { @@ -47,7 +70,7 @@ describe("chunkText", () => { }); describe("resolveTextChunkLimit", () => { - it("uses per-surface defaults", () => { + it("uses per-provider defaults", () => { expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000); expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000); expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000); @@ -72,3 +95,79 @@ describe("resolveTextChunkLimit", () => { expect(resolveTextChunkLimit(cfg, "telegram")).toBe(4000); }); }); + +describe("chunkMarkdownText", () => { + it("keeps fenced blocks intact when a safe break exists", () => { + const prefix = "p".repeat(60); + const fence = "```bash\nline1\nline2\n```"; + const suffix = "s".repeat(60); + const text = `${prefix}\n\n${fence}\n\n${suffix}`; + + const chunks = chunkMarkdownText(text, 40); + expect(chunks.some((chunk) => chunk.trimEnd() === fence)).toBe(true); + expectFencesBalanced(chunks); + }); + + it("reopens fenced blocks when forced to split inside them", () => { + const text = `\`\`\`txt\n${"a".repeat(500)}\n\`\`\``; + const limit = 120; + const chunks = chunkMarkdownText(text, limit); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(limit); + expect(chunk.startsWith("```txt\n")).toBe(true); + expect(chunk.trimEnd().endsWith("```")).toBe(true); + } + expectFencesBalanced(chunks); + }); + + it("supports tilde fences", () => { + const text = `~~~sh\n${"x".repeat(600)}\n~~~`; + const limit = 140; + const chunks = chunkMarkdownText(text, limit); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(limit); + expect(chunk.startsWith("~~~sh\n")).toBe(true); + expect(chunk.trimEnd().endsWith("~~~")).toBe(true); + } + expectFencesBalanced(chunks); + }); + + it("supports longer fence markers for close", () => { + const text = `\`\`\`\`md\n${"y".repeat(600)}\n\`\`\`\``; + const limit = 140; + const chunks = chunkMarkdownText(text, limit); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(limit); + expect(chunk.startsWith("````md\n")).toBe(true); + expect(chunk.trimEnd().endsWith("````")).toBe(true); + } + expectFencesBalanced(chunks); + }); + + it("preserves indentation for indented fences", () => { + const text = ` \`\`\`js\n ${"z".repeat(600)}\n \`\`\``; + const limit = 160; + const chunks = chunkMarkdownText(text, limit); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(limit); + expect(chunk.startsWith(" ```js\n")).toBe(true); + expect(chunk.trimEnd().endsWith(" ```")).toBe(true); + } + expectFencesBalanced(chunks); + }); + + it("never produces an empty fenced chunk when splitting", () => { + const text = `\`\`\`txt\n${"a".repeat(300)}\n\`\`\``; + const chunks = chunkMarkdownText(text, 60); + for (const chunk of chunks) { + const nonFenceLines = chunk + .split("\n") + .filter((line) => !/^( {0,3})(`{3,}|~{3,})(.*)$/.test(line)); + expect(nonFenceLines.join("\n").trim()).not.toBe(""); + } + }); +}); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 6dd175f29..fb2174d1d 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -3,8 +3,13 @@ // the chunk so messages are only split when they truly exceed the limit. import type { ClawdbotConfig } from "../config/config.js"; +import { + findFenceSpanAt, + isSafeFenceBreak, + parseFenceSpans, +} from "../markdown/fences.js"; -export type TextChunkSurface = +export type TextChunkProvider = | "whatsapp" | "telegram" | "discord" @@ -13,7 +18,7 @@ export type TextChunkSurface = | "imessage" | "webchat"; -const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record = { +const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record = { whatsapp: 4000, telegram: 4000, discord: 2000, @@ -25,22 +30,22 @@ const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record = { export function resolveTextChunkLimit( cfg: ClawdbotConfig | undefined, - surface?: TextChunkSurface, + provider?: TextChunkProvider, ): number { - const surfaceOverride = (() => { - if (!surface) return undefined; - if (surface === "whatsapp") return cfg?.whatsapp?.textChunkLimit; - if (surface === "telegram") return cfg?.telegram?.textChunkLimit; - if (surface === "discord") return cfg?.discord?.textChunkLimit; - if (surface === "slack") return cfg?.slack?.textChunkLimit; - if (surface === "signal") return cfg?.signal?.textChunkLimit; - if (surface === "imessage") return cfg?.imessage?.textChunkLimit; + const providerOverride = (() => { + if (!provider) return undefined; + if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit; + if (provider === "telegram") return cfg?.telegram?.textChunkLimit; + if (provider === "discord") return cfg?.discord?.textChunkLimit; + if (provider === "slack") return cfg?.slack?.textChunkLimit; + if (provider === "signal") return cfg?.signal?.textChunkLimit; + if (provider === "imessage") return cfg?.imessage?.textChunkLimit; return undefined; })(); - if (typeof surfaceOverride === "number" && surfaceOverride > 0) { - return surfaceOverride; + if (typeof providerOverride === "number" && providerOverride > 0) { + return providerOverride; } - if (surface) return DEFAULT_CHUNK_LIMIT_BY_SURFACE[surface]; + if (provider) return DEFAULT_CHUNK_LIMIT_BY_PROVIDER[provider]; return 4000; } @@ -91,3 +96,123 @@ export function chunkText(text: string, limit: number): string[] { return chunks; } + +export function chunkMarkdownText(text: string, limit: number): string[] { + if (!text) return []; + if (limit <= 0) return [text]; + if (text.length <= limit) return [text]; + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > limit) { + const spans = parseFenceSpans(remaining); + const window = remaining.slice(0, limit); + + const softBreak = pickSafeBreakIndex(window, spans); + let breakIdx = softBreak > 0 ? softBreak : limit; + + const initialFence = isSafeFenceBreak(spans, breakIdx) + ? undefined + : findFenceSpanAt(spans, breakIdx); + + let fenceToSplit = initialFence; + if (initialFence) { + const closeLine = `${initialFence.indent}${initialFence.marker}`; + const maxIdxIfNeedNewline = limit - (closeLine.length + 1); + + if (maxIdxIfNeedNewline <= 0) { + fenceToSplit = undefined; + breakIdx = limit; + } else { + const minProgressIdx = Math.min( + remaining.length, + initialFence.start + initialFence.openLine.length + 2, + ); + const maxIdxIfAlreadyNewline = limit - closeLine.length; + + let pickedNewline = false; + let lastNewline = remaining.lastIndexOf( + "\n", + Math.max(0, maxIdxIfAlreadyNewline - 1), + ); + while (lastNewline !== -1) { + const candidateBreak = lastNewline + 1; + if (candidateBreak < minProgressIdx) break; + const candidateFence = findFenceSpanAt(spans, candidateBreak); + if (candidateFence && candidateFence.start === initialFence.start) { + breakIdx = Math.max(1, candidateBreak); + pickedNewline = true; + break; + } + lastNewline = remaining.lastIndexOf("\n", lastNewline - 1); + } + + if (!pickedNewline) { + if (minProgressIdx > maxIdxIfAlreadyNewline) { + fenceToSplit = undefined; + breakIdx = limit; + } else { + breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline); + } + } + } + + const fenceAtBreak = findFenceSpanAt(spans, breakIdx); + fenceToSplit = + fenceAtBreak && fenceAtBreak.start === initialFence.start + ? fenceAtBreak + : undefined; + } + + let rawChunk = remaining.slice(0, breakIdx); + if (!rawChunk) break; + + const brokeOnSeparator = + breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); + const nextStart = Math.min( + remaining.length, + breakIdx + (brokeOnSeparator ? 1 : 0), + ); + let next = remaining.slice(nextStart); + + if (fenceToSplit) { + const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`; + rawChunk = rawChunk.endsWith("\n") + ? `${rawChunk}${closeLine}` + : `${rawChunk}\n${closeLine}`; + next = `${fenceToSplit.openLine}\n${next}`; + } else { + next = stripLeadingNewlines(next); + } + + chunks.push(rawChunk); + remaining = next; + } + + if (remaining.length) chunks.push(remaining); + return chunks; +} + +function stripLeadingNewlines(value: string): string { + let i = 0; + while (i < value.length && value[i] === "\n") i++; + return i > 0 ? value.slice(i) : value; +} + +function pickSafeBreakIndex( + window: string, + spans: ReturnType, +): number { + let newlineIdx = window.lastIndexOf("\n"); + while (newlineIdx > 0) { + if (isSafeFenceBreak(spans, newlineIdx)) return newlineIdx; + newlineIdx = window.lastIndexOf("\n", newlineIdx - 1); + } + + for (let i = window.length - 1; i > 0; i--) { + if (/\s/.test(window[i]) && isSafeFenceBreak(spans, i)) return i; + } + + return -1; +} diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index 7599c7390..744971951 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -3,7 +3,7 @@ import { normalizeE164 } from "../utils.js"; import type { MsgContext } from "./templating.js"; export type CommandAuthorization = { - isWhatsAppSurface: boolean; + isWhatsAppProvider: boolean; ownerList: string[]; senderE164?: string; isAuthorizedSender: boolean; @@ -17,7 +17,7 @@ export function resolveCommandAuthorization(params: { commandAuthorized: boolean; }): CommandAuthorization { const { ctx, cfg, commandAuthorized } = params; - const surface = (ctx.Surface ?? "").trim().toLowerCase(); + const provider = (ctx.Provider ?? "").trim().toLowerCase(); const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); const hasWhatsappPrefix = @@ -26,30 +26,30 @@ export function resolveCommandAuthorization(params: { const looksLikeE164 = (value: string) => Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, ""))); const inferWhatsApp = - !surface && + !provider && Boolean(cfg.whatsapp?.allowFrom?.length) && (looksLikeE164(from) || looksLikeE164(to)); - const isWhatsAppSurface = - surface === "whatsapp" || hasWhatsappPrefix || inferWhatsApp; + const isWhatsAppProvider = + provider === "whatsapp" || hasWhatsappPrefix || inferWhatsApp; - const configuredAllowFrom = isWhatsAppSurface + const configuredAllowFrom = isWhatsAppProvider ? cfg.whatsapp?.allowFrom : undefined; const allowFromList = configuredAllowFrom?.filter((entry) => entry?.trim()) ?? []; const allowAll = - !isWhatsAppSurface || + !isWhatsAppProvider || allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*"); const senderE164 = normalizeE164( - ctx.SenderE164 ?? (isWhatsAppSurface ? from : ""), + ctx.SenderE164 ?? (isWhatsAppProvider ? from : ""), ); const ownerCandidates = - isWhatsAppSurface && !allowAll + isWhatsAppProvider && !allowAll ? allowFromList.filter((entry) => entry !== "*") : []; - if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) { + if (isWhatsAppProvider && !allowAll && ownerCandidates.length === 0 && to) { ownerCandidates.push(to); } const ownerList = ownerCandidates @@ -57,14 +57,14 @@ export function resolveCommandAuthorization(params: { .filter((entry): entry is string => Boolean(entry)); const isOwner = - !isWhatsAppSurface || + !isWhatsAppProvider || allowAll || ownerList.length === 0 || (senderE164 ? ownerList.includes(senderE164) : false); const isAuthorizedSender = commandAuthorized && isOwner; return { - isWhatsAppSurface, + isWhatsAppProvider, ownerList, senderE164: senderE164 || undefined, isAuthorizedSender, diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts new file mode 100644 index 000000000..755da8b12 --- /dev/null +++ b/src/auto-reply/command-detection.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { hasControlCommand } from "./command-detection.js"; +import { parseActivationCommand } from "./group-activation.js"; +import { parseSendPolicyCommand } from "./send-policy.js"; + +describe("control command parsing", () => { + it("requires slash for send policy", () => { + expect(parseSendPolicyCommand("/send on")).toEqual({ + hasCommand: true, + mode: "allow", + }); + expect(parseSendPolicyCommand("/send")).toEqual({ hasCommand: true }); + expect(parseSendPolicyCommand("send on")).toEqual({ hasCommand: false }); + expect(parseSendPolicyCommand("send")).toEqual({ hasCommand: false }); + }); + + it("requires slash for activation", () => { + expect(parseActivationCommand("/activation mention")).toEqual({ + hasCommand: true, + mode: "mention", + }); + expect(parseActivationCommand("activation mention")).toEqual({ + hasCommand: false, + }); + }); + + it("treats bare commands as non-control", () => { + expect(hasControlCommand("/send")).toBe(true); + expect(hasControlCommand("send")).toBe(false); + expect(hasControlCommand("/help")).toBe(true); + expect(hasControlCommand("help")).toBe(false); + expect(hasControlCommand("/status")).toBe(true); + expect(hasControlCommand("status")).toBe(false); + }); + + it("requires commands to be the full message", () => { + expect(hasControlCommand("hello /status")).toBe(false); + expect(hasControlCommand("/status please")).toBe(false); + expect(hasControlCommand("prefix /send on")).toBe(false); + expect(hasControlCommand("/send on")).toBe(true); + }); +}); diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts index 1782f66f9..ae6279459 100644 --- a/src/auto-reply/command-detection.ts +++ b/src/auto-reply/command-detection.ts @@ -1,30 +1,20 @@ -const CONTROL_COMMAND_RE = - /(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)(?=$|\s|:)\b/i; - -const CONTROL_COMMAND_EXACT = new Set([ - "help", - "/help", - "status", - "/status", - "restart", - "/restart", - "activation", - "/activation", - "send", - "/send", - "reset", - "/reset", - "new", - "/new", - "compact", - "/compact", -]); +import { listChatCommands } from "./commands-registry.js"; export function hasControlCommand(text?: string): boolean { if (!text) return false; const trimmed = text.trim(); if (!trimmed) return false; const lowered = trimmed.toLowerCase(); - if (CONTROL_COMMAND_EXACT.has(lowered)) return true; - return CONTROL_COMMAND_RE.test(text); + for (const command of listChatCommands()) { + for (const alias of command.textAliases) { + const normalized = alias.trim().toLowerCase(); + if (!normalized) continue; + if (lowered === normalized) return true; + if (command.acceptsArgs && lowered.startsWith(normalized)) { + const nextChar = trimmed.charAt(normalized.length); + if (nextChar && /\s/.test(nextChar)) return true; + } + } + } + return false; } diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts new file mode 100644 index 000000000..7e07e9b81 --- /dev/null +++ b/src/auto-reply/commands-registry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + buildCommandText, + getCommandDetection, + listNativeCommandSpecs, + shouldHandleTextCommands, +} from "./commands-registry.js"; + +describe("commands registry", () => { + it("builds command text with args", () => { + expect(buildCommandText("status")).toBe("/status"); + expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5"); + }); + + it("exposes native specs", () => { + const specs = listNativeCommandSpecs(); + expect(specs.find((spec) => spec.name === "help")).toBeTruthy(); + }); + + it("detects known text commands", () => { + const detection = getCommandDetection(); + expect(detection.exact.has("/help")).toBe(true); + expect(detection.regex.test("/status")).toBe(true); + expect(detection.regex.test("try /status")).toBe(false); + }); + + it("respects text command gating", () => { + const cfg = { commands: { text: false } }; + expect( + shouldHandleTextCommands({ + cfg, + surface: "discord", + commandSource: "text", + }), + ).toBe(false); + expect( + shouldHandleTextCommands({ + cfg, + surface: "whatsapp", + commandSource: "text", + }), + ).toBe(true); + expect( + shouldHandleTextCommands({ + cfg, + surface: "discord", + commandSource: "native", + }), + ).toBe(true); + }); +}); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts new file mode 100644 index 000000000..cc90e0be9 --- /dev/null +++ b/src/auto-reply/commands-registry.ts @@ -0,0 +1,178 @@ +import type { ClawdbotConfig } from "../config/types.js"; + +export type ChatCommandDefinition = { + key: string; + nativeName: string; + description: string; + textAliases: string[]; + acceptsArgs?: boolean; +}; + +export type NativeCommandSpec = { + name: string; + description: string; + acceptsArgs: boolean; +}; + +const CHAT_COMMANDS: ChatCommandDefinition[] = [ + { + key: "help", + nativeName: "help", + description: "Show available commands.", + textAliases: ["/help"], + }, + { + key: "status", + nativeName: "status", + description: "Show current status.", + textAliases: ["/status"], + }, + { + key: "restart", + nativeName: "restart", + description: "Restart Clawdbot.", + textAliases: ["/restart"], + }, + { + key: "activation", + nativeName: "activation", + description: "Set group activation mode.", + textAliases: ["/activation"], + acceptsArgs: true, + }, + { + key: "send", + nativeName: "send", + description: "Set send policy.", + textAliases: ["/send"], + acceptsArgs: true, + }, + { + key: "reset", + nativeName: "reset", + description: "Reset the current session.", + textAliases: ["/reset"], + }, + { + key: "new", + nativeName: "new", + description: "Start a new session.", + textAliases: ["/new"], + }, + { + key: "think", + nativeName: "think", + description: "Set thinking level.", + textAliases: ["/thinking", "/think", "/t"], + acceptsArgs: true, + }, + { + key: "verbose", + nativeName: "verbose", + description: "Toggle verbose mode.", + textAliases: ["/verbose", "/v"], + acceptsArgs: true, + }, + { + key: "elevated", + nativeName: "elevated", + description: "Toggle elevated mode.", + textAliases: ["/elevated", "/elev"], + acceptsArgs: true, + }, + { + key: "model", + nativeName: "model", + description: "Show or set the model.", + textAliases: ["/model"], + acceptsArgs: true, + }, + { + key: "queue", + nativeName: "queue", + description: "Adjust queue settings.", + textAliases: ["/queue"], + acceptsArgs: true, + }, +]; + +const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]); + +let cachedDetection: + | { + exact: Set; + regex: RegExp; + } + | undefined; + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function listChatCommands(): ChatCommandDefinition[] { + return [...CHAT_COMMANDS]; +} + +export function listNativeCommandSpecs(): NativeCommandSpec[] { + return CHAT_COMMANDS.map((command) => ({ + name: command.nativeName, + description: command.description, + acceptsArgs: Boolean(command.acceptsArgs), + })); +} + +export function findCommandByNativeName( + name: string, +): ChatCommandDefinition | undefined { + const normalized = name.trim().toLowerCase(); + return CHAT_COMMANDS.find( + (command) => command.nativeName.toLowerCase() === normalized, + ); +} + +export function buildCommandText(commandName: string, args?: string): string { + const trimmedArgs = args?.trim(); + return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`; +} + +export function getCommandDetection(): { exact: Set; regex: RegExp } { + if (cachedDetection) return cachedDetection; + const exact = new Set(); + const patterns: string[] = []; + for (const command of CHAT_COMMANDS) { + for (const alias of command.textAliases) { + const normalized = alias.trim().toLowerCase(); + if (!normalized) continue; + exact.add(normalized); + const escaped = escapeRegExp(normalized); + if (!escaped) continue; + if (command.acceptsArgs) { + patterns.push(`${escaped}(?:\\s+.+)?`); + } else { + patterns.push(escaped); + } + } + } + const regex = patterns.length + ? new RegExp(`^(?:${patterns.join("|")})$`, "i") + : /$^/; + cachedDetection = { exact, regex }; + return cachedDetection; +} + +export function supportsNativeCommands(surface?: string): boolean { + if (!surface) return false; + return NATIVE_COMMAND_SURFACES.has(surface.toLowerCase()); +} + +export function shouldHandleTextCommands(params: { + cfg: ClawdbotConfig; + surface?: string; + commandSource?: "text" | "native"; +}): boolean { + const { cfg, surface, commandSource } = params; + const textEnabled = cfg.commands?.text !== false; + if (commandSource === "native") return true; + if (textEnabled) return true; + return !supportsNativeCommands(surface); +} diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index d5ae06674..d1092b142 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest"; import { formatAgentEnvelope } from "./envelope.js"; describe("formatAgentEnvelope", () => { - it("includes surface, from, ip, host, and timestamp", () => { + it("includes provider, from, ip, host, and timestamp", () => { const originalTz = process.env.TZ; process.env.TZ = "UTC"; const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z const body = formatAgentEnvelope({ - surface: "WebChat", + provider: "WebChat", from: "user1", host: "mac-mini", ip: "10.0.0.5", @@ -30,7 +30,7 @@ describe("formatAgentEnvelope", () => { const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z const body = formatAgentEnvelope({ - surface: "WebChat", + provider: "WebChat", timestamp: ts, body: "hello", }); @@ -41,7 +41,7 @@ describe("formatAgentEnvelope", () => { }); it("handles missing optional fields", () => { - const body = formatAgentEnvelope({ surface: "Telegram", body: "hi" }); + const body = formatAgentEnvelope({ provider: "Telegram", body: "hi" }); expect(body).toBe("[Telegram] hi"); }); }); diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 6238e5c82..628e13e54 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -1,5 +1,5 @@ export type AgentEnvelopeParams = { - surface: string; + provider: string; from?: string; timestamp?: number | Date; host?: string; @@ -24,8 +24,8 @@ function formatTimestamp(ts?: number | Date): string | undefined { } export function formatAgentEnvelope(params: AgentEnvelopeParams): string { - const surface = params.surface?.trim() || "Surface"; - const parts: string[] = [surface]; + const provider = params.provider?.trim() || "Provider"; + const parts: string[] = [provider]; if (params.from?.trim()) parts.push(params.from.trim()); if (params.host?.trim()) parts.push(params.host.trim()); if (params.ip?.trim()) parts.push(params.ip.trim()); diff --git a/src/auto-reply/group-activation.ts b/src/auto-reply/group-activation.ts index 9372da5fa..b60ae0e20 100644 --- a/src/auto-reply/group-activation.ts +++ b/src/auto-reply/group-activation.ts @@ -16,7 +16,7 @@ export function parseActivationCommand(raw?: string): { if (!raw) return { hasCommand: false }; const trimmed = raw.trim(); if (!trimmed) return { hasCommand: false }; - const match = trimmed.match(/^\/?activation\b(?:\s+([a-zA-Z]+))?/i); + const match = trimmed.match(/^\/activation(?:\s+([a-zA-Z]+))?\s*$/i); if (!match) return { hasCommand: false }; const mode = normalizeGroupActivation(match[1]); return { hasCommand: true, mode }; diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index d4b57bfe2..3f7856ed7 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -1,8 +1,15 @@ import { HEARTBEAT_TOKEN } from "./tokens.js"; -export const HEARTBEAT_PROMPT = "HEARTBEAT"; +export const HEARTBEAT_PROMPT = + "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."; +export const DEFAULT_HEARTBEAT_EVERY = "30m"; export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 30; +export function resolveHeartbeatPrompt(raw?: string): string { + const trimmed = typeof raw === "string" ? raw.trim() : ""; + return trimmed || HEARTBEAT_PROMPT; +} + export type StripHeartbeatMode = "heartbeat" | "message"; function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } { diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index 128681746..caeedc120 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -78,7 +78,7 @@ describe("block streaming", () => { From: "+1004", To: "+2000", MessageSid: "msg-123", - Surface: "discord", + Provider: "discord", }, { onReplyStart, @@ -124,7 +124,7 @@ describe("block streaming", () => { From: "+1004", To: "+2000", MessageSid: "msg-124", - Surface: "discord", + Provider: "discord", }, { onBlockReply, diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 062c2099c..55004ce9c 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -321,7 +321,7 @@ describe("directive parsing", () => { Body: "/elevated maybe", From: "+1222", To: "+1222", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1222", }, {}, @@ -512,7 +512,7 @@ describe("directive parsing", () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); const ctx = { - Body: "please do the thing /verbose on", + Body: "please do the thing", From: "+1004", To: "+2000", }; @@ -546,6 +546,21 @@ describe("directive parsing", () => { }; }); + await getReplyFromConfig( + { Body: "/verbose on", From: ctx.From, To: ctx.To }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + whatsapp: { + allowFrom: ["*"], + }, + session: { store: storePath }, + }, + ); + const res = await getReplyFromConfig( ctx, {}, @@ -709,7 +724,7 @@ describe("directive parsing", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Model set to openai/gpt-4.1-mini"); const store = loadSessionStore(storePath); - const entry = store.main; + const entry = store["agent:main:main"]; expect(entry.modelOverride).toBe("gpt-4.1-mini"); expect(entry.providerOverride).toBe("openai"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -741,7 +756,7 @@ describe("directive parsing", () => { expect(text).toContain("Model set to Opus"); expect(text).toContain("anthropic/claude-opus-4-5"); const store = loadSessionStore(storePath); - const entry = store.main; + const entry = store["agent:main:main"]; expect(entry.modelOverride).toBe("claude-opus-4-5"); expect(entry.providerOverride).toBe("anthropic"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -791,7 +806,7 @@ describe("directive parsing", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Auth profile set to anthropic:work"); const store = loadSessionStore(storePath); - const entry = store.main; + const entry = store["agent:main:main"]; expect(entry.authProfileOverride).toBe("anthropic:work"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); @@ -827,7 +842,7 @@ describe("directive parsing", () => { }); }); - it("uses model override for inline /model", async () => { + it("ignores inline /model and uses the default model", async () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ @@ -867,8 +882,8 @@ describe("directive parsing", () => { expect(texts).toContain("done"); expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; - expect(call?.provider).toBe("openai"); - expect(call?.model).toBe("gpt-4.1-mini"); + expect(call?.provider).toBe("anthropic"); + expect(call?.model).toBe("claude-opus-4-5"); }); }); @@ -932,7 +947,7 @@ describe("directive parsing", () => { Body: "hello", From: "+1004", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1004", }, {}, diff --git a/src/auto-reply/reply.heartbeat-typing.test.ts b/src/auto-reply/reply.heartbeat-typing.test.ts index 190b094b8..27cd335f1 100644 --- a/src/auto-reply/reply.heartbeat-typing.test.ts +++ b/src/auto-reply/reply.heartbeat-typing.test.ts @@ -82,7 +82,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => { const onReplyStart = vi.fn(); await getReplyFromConfig( - { Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" }, + { Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" }, { onReplyStart, isHeartbeat: false }, makeCfg(home), ); @@ -100,7 +100,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => { const onReplyStart = vi.fn(); await getReplyFromConfig( - { Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" }, + { Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" }, { onReplyStart, isHeartbeat: true }, makeCfg(home), ); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 006bfdaca..f3efa1368 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -23,6 +23,8 @@ import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import { getReplyFromConfig } from "./reply.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; +const MAIN_SESSION_KEY = "agent:main:main"; + const webMocks = vi.hoisted(() => ({ webAuthExists: vi.fn().mockResolvedValue(true), getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), @@ -113,8 +115,15 @@ describe("trigger handling", () => { }); }); - it("reports status when /status appears inline", async () => { + it("ignores inline /status and runs the agent", async () => { await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); const res = await getReplyFromConfig( { Body: "please /status now", @@ -125,8 +134,8 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Status"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(text).not.toContain("Status"); + expect(runEmbeddedPiAgent).toHaveBeenCalled(); }); }); @@ -166,7 +175,7 @@ describe("trigger handling", () => { Body: "/send off", From: "+1000", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1000", }, {}, @@ -180,7 +189,7 @@ describe("trigger handling", () => { string, { sendPolicy?: string } >; - expect(store.main?.sendPolicy).toBe("deny"); + expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny"); }); }); @@ -205,7 +214,7 @@ describe("trigger handling", () => { Body: "/elevated on", From: "+1000", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1000", }, {}, @@ -219,7 +228,7 @@ describe("trigger handling", () => { string, { elevatedLevel?: string } >; - expect(store.main?.elevatedLevel).toBe("on"); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); }); }); @@ -245,7 +254,7 @@ describe("trigger handling", () => { Body: "/elevated on", From: "+1000", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+1000", }, {}, @@ -259,12 +268,19 @@ describe("trigger handling", () => { string, { elevatedLevel?: string } >; - expect(store.main?.elevatedLevel).toBeUndefined(); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined(); }); }); - it("rejects elevated inline directive for unapproved sender", async () => { + it("ignores inline elevated directive for unapproved sender", async () => { await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); const cfg = { agent: { model: "anthropic/claude-opus-4-5", @@ -284,15 +300,15 @@ describe("trigger handling", () => { Body: "please /elevated on now", From: "+2000", To: "+2000", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+2000", }, {}, cfg, ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("elevated is not available right now."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(text).not.toBe("elevated is not available right now."); + expect(runEmbeddedPiAgent).toHaveBeenCalled(); }); }); @@ -316,7 +332,7 @@ describe("trigger handling", () => { Body: "/elevated on", From: "discord:123", To: "user:123", - Surface: "discord", + Provider: "discord", SenderName: "Peter Steinberger", SenderUsername: "steipete", SenderTag: "steipete", @@ -332,7 +348,7 @@ describe("trigger handling", () => { string, { elevatedLevel?: string } >; - expect(store.main?.elevatedLevel).toBe("on"); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); }); }); @@ -359,7 +375,7 @@ describe("trigger handling", () => { Body: "/elevated on", From: "discord:123", To: "user:123", - Surface: "discord", + Provider: "discord", SenderName: "steipete", }, {}, @@ -510,7 +526,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+2000", }, {}, @@ -521,7 +537,9 @@ describe("trigger handling", () => { const store = JSON.parse( await fs.readFile(cfg.session.store, "utf-8"), ) as Record; - expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always"); + expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe( + "always", + ); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -535,7 +553,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+999", }, {}, @@ -563,7 +581,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", SenderE164: "+2000", GroupSubject: "Test Group", GroupMembers: "Alice (+1), Bob (+2)", @@ -879,7 +897,7 @@ describe("trigger handling", () => { From: "group:whatsapp:demo", To: "+2000", ChatType: "group" as const, - Surface: "whatsapp" as const, + Provider: "whatsapp" as const, MediaPath: mediaPath, MediaType: "image/jpeg", MediaUrl: mediaPath, @@ -942,7 +960,7 @@ describe("group intro prompts", () => { ChatType: "group", GroupSubject: "Release Squad", GroupMembers: "Alice, Bob", - Surface: "discord", + Provider: "discord", }, {}, makeCfg(home), @@ -975,7 +993,7 @@ describe("group intro prompts", () => { To: "+1999", ChatType: "group", GroupSubject: "Ops", - Surface: "whatsapp", + Provider: "whatsapp", }, {}, makeCfg(home), @@ -1008,7 +1026,7 @@ describe("group intro prompts", () => { To: "+1777", ChatType: "group", GroupSubject: "Dev Chat", - Surface: "telegram", + Provider: "telegram", }, {}, makeCfg(home), diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 085563d6c..34c8b0115 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -2,7 +2,11 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; - +import { + resolveAgentDir, + resolveAgentIdFromSessionKey, + resolveAgentWorkspaceDir, +} from "../agents/agent-scope.js"; import { resolveModelRefFromString } from "../agents/model-selection.js"; import { abortEmbeddedPiRun, @@ -11,6 +15,7 @@ import { resolveEmbeddedSessionLane, } from "../agents/pi-embedded.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, @@ -26,6 +31,7 @@ import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; import { defaultRuntime } from "../runtime.js"; import { resolveCommandAuthorization } from "./command-auth.js"; import { hasControlCommand } from "./command-detection.js"; +import { shouldHandleTextCommands } from "./commands-registry.js"; import { getAbortMemory } from "./reply/abort.js"; import { runReplyAgent } from "./reply/agent-runner.js"; import { resolveBlockStreamingChunking } from "./reply/block-streaming.js"; @@ -33,6 +39,7 @@ import { applySessionHints } from "./reply/body.js"; import { buildCommandContext, handleCommands } from "./reply/commands.js"; import { handleDirectiveOnly, + type InlineDirectives, isDirectiveOnly, parseInlineDirectives, persistInlineDirectives, @@ -43,7 +50,7 @@ import { defaultGroupActivation, resolveGroupRequireMention, } from "./reply/groups.js"; -import { stripMentions } from "./reply/mentions.js"; +import { stripMentions, stripStructuralPrefixes } from "./reply/mentions.js"; import { createModelSelectionState, resolveContextTokens, @@ -78,9 +85,6 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js"; const BARE_SESSION_RESET_PROMPT = "A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning."; -const CONTROL_COMMAND_PREFIX_RE = - /^\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)\b/i; - function normalizeAllowToken(value?: string) { if (!value) return ""; return value.trim().toLowerCase(); @@ -107,10 +111,10 @@ function stripSenderPrefix(value?: string) { function resolveElevatedAllowList( allowFrom: AgentElevatedAllowFromConfig | undefined, - surface: string, + provider: string, discordFallback?: Array, ): Array | undefined { - switch (surface) { + switch (provider) { case "whatsapp": return allowFrom?.whatsapp; case "telegram": @@ -134,14 +138,14 @@ function resolveElevatedAllowList( } function isApprovedElevatedSender(params: { - surface: string; + provider: string; ctx: MsgContext; allowFrom?: AgentElevatedAllowFromConfig; discordFallback?: Array; }): boolean { const rawAllow = resolveElevatedAllowList( params.allowFrom, - params.surface, + params.provider, params.discordFallback, ); if (!rawAllow || rawAllow.length === 0) return false; @@ -215,14 +219,16 @@ export async function getReplyFromConfig( } } - const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey); + const workspaceDirRaw = + resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, - ensureBootstrapFiles: true, + ensureBootstrapFiles: !cfg.agent?.skipBootstrap, }); const workspaceDir = workspace.dir; - const timeoutSeconds = Math.max(agentCfg?.timeoutSeconds ?? 600, 1); - const timeoutMs = timeoutSeconds * 1000; + const agentDir = resolveAgentDir(cfg, agentId); + const timeoutMs = resolveAgentTimeoutMs({ cfg }); const configuredTypingSeconds = agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds; const typingIntervalSeconds = @@ -233,6 +239,7 @@ export async function getReplyFromConfig( silentToken: SILENT_REPLY_TOKEN, log: defaultRuntime.log, }); + opts?.onTypingController?.(typing); let transcribedText: string | undefined; if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) { @@ -246,7 +253,7 @@ export async function getReplyFromConfig( } const commandAuthorized = ctx.CommandAuthorized ?? true; - const commandAuth = resolveCommandAuthorization({ + resolveCommandAuthorization({ ctx, cfg, commandAuthorized, @@ -273,7 +280,47 @@ export async function getReplyFromConfig( } = sessionState; const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; - const parsedDirectives = parseInlineDirectives(rawBody); + const clearInlineDirectives = (cleaned: string): InlineDirectives => ({ + cleaned, + hasThinkDirective: false, + thinkLevel: undefined, + rawThinkLevel: undefined, + hasVerboseDirective: false, + verboseLevel: undefined, + rawVerboseLevel: undefined, + hasElevatedDirective: false, + elevatedLevel: undefined, + rawElevatedLevel: undefined, + hasStatusDirective: false, + hasModelDirective: false, + rawModelDirective: undefined, + hasQueueDirective: false, + queueMode: undefined, + queueReset: false, + rawQueueMode: undefined, + debounceMs: undefined, + cap: undefined, + dropPolicy: undefined, + rawDebounce: undefined, + rawCap: undefined, + rawDrop: undefined, + hasQueueOptions: false, + }); + let parsedDirectives = parseInlineDirectives(rawBody); + const hasDirective = + parsedDirectives.hasThinkDirective || + parsedDirectives.hasVerboseDirective || + parsedDirectives.hasElevatedDirective || + parsedDirectives.hasStatusDirective || + parsedDirectives.hasModelDirective || + parsedDirectives.hasQueueDirective; + if (hasDirective) { + const stripped = stripStructuralPrefixes(parsedDirectives.cleaned); + const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped; + if (noMentions.trim().length > 0) { + parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); + } + } const directives = commandAuthorized ? parsedDirectives : { @@ -288,20 +335,20 @@ export async function getReplyFromConfig( sessionCtx.Body = parsedDirectives.cleaned; sessionCtx.BodyStripped = parsedDirectives.cleaned; - const surfaceKey = - sessionCtx.Surface?.trim().toLowerCase() ?? - ctx.Surface?.trim().toLowerCase() ?? + const messageProviderKey = + sessionCtx.Provider?.trim().toLowerCase() ?? + ctx.Provider?.trim().toLowerCase() ?? ""; const elevatedConfig = agentCfg?.elevated; const discordElevatedFallback = - surfaceKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; + messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; const elevatedEnabled = elevatedConfig?.enabled !== false; const elevatedAllowed = elevatedEnabled && Boolean( - surfaceKey && + messageProviderKey && isApprovedElevatedSender({ - surface: surfaceKey, + provider: messageProviderKey, ctx, allowFrom: elevatedConfig?.allowFrom, discordFallback: discordElevatedFallback, @@ -344,7 +391,7 @@ export async function getReplyFromConfig( : "text_end"; const blockStreamingEnabled = resolvedBlockStreaming === "on"; const blockReplyChunking = blockStreamingEnabled - ? resolveBlockStreamingChunking(cfg, sessionCtx.Surface) + ? resolveBlockStreamingChunking(cfg, sessionCtx.Provider) : undefined; const modelState = await createModelSelectionState({ @@ -460,9 +507,14 @@ export async function getReplyFromConfig( triggerBodyNormalized, commandAuthorized, }); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: command.surface, + commandSource: ctx.CommandSource, + }); const isEmptyConfig = Object.keys(cfg).length === 0; if ( - command.isWhatsAppSurface && + command.isWhatsAppProvider && isEmptyConfig && command.from && command.to && @@ -530,20 +582,15 @@ export async function getReplyFromConfig( const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const rawBodyTrimmed = (ctx.Body ?? "").trim(); const baseBodyTrimmedRaw = baseBody.trim(); - const strippedCommandBody = isGroup - ? stripMentions(triggerBodyNormalized, ctx, cfg) - : triggerBodyNormalized; if ( - !commandAuth.isAuthorizedSender && - CONTROL_COMMAND_PREFIX_RE.test(strippedCommandBody.trim()) + allowTextCommands && + !commandAuthorized && + !baseBodyTrimmedRaw && + hasControlCommand(rawBody) ) { typing.cleanup(); return undefined; } - if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) { - typing.cleanup(); - return undefined; - } const isBareSessionReset = isNewSession && baseBodyTrimmedRaw.length === 0 && @@ -637,7 +684,7 @@ export async function getReplyFromConfig( : queueBodyBase; const resolvedQueue = resolveQueueSettings({ cfg, - surface: sessionCtx.Surface, + provider: sessionCtx.Provider, sessionEntry, inlineMode: perMessageQueueMode, inlineOptions: perMessageQueueOptions, @@ -668,9 +715,11 @@ export async function getReplyFromConfig( summaryLine: baseBodyTrimmedRaw, enqueuedAt: Date.now(), run: { + agentId, + agentDir, sessionId: sessionIdFinal, sessionKey, - surface: sessionCtx.Surface?.trim().toLowerCase() || undefined, + messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, sessionFile, workspaceDir, config: cfg, diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts index 31c686366..5ae3b6ec1 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts @@ -51,6 +51,8 @@ function createTyping(): TypingController { startTypingLoop: vi.fn(async () => {}), startTypingOnText: vi.fn(async () => {}), refreshTypingTtl: vi.fn(), + markRunComplete: vi.fn(), + markDispatchIdle: vi.fn(), cleanup: vi.fn(), }; } @@ -70,7 +72,7 @@ function createMinimalRun(params?: { const typing = createTyping(); const opts = params?.opts; const sessionCtx = { - Surface: "whatsapp", + Provider: "whatsapp", MessageSid: "msg", } as unknown as TemplateContext; const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; @@ -82,7 +84,7 @@ function createMinimalRun(params?: { run: { sessionId: "session", sessionKey, - surface: "whatsapp", + messageProvider: "whatsapp", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", config: {}, @@ -208,7 +210,6 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(payloads[0]?.text).toContain("count 1"); expect(sessionStore.main.compactionCount).toBe(1); }); - it("resets corrupted Gemini sessions and deletes transcripts", async () => { const prevStateDir = process.env.CLAWDBOT_STATE_DIR; const stateDir = await fs.mkdtemp( @@ -354,4 +355,26 @@ describe("runReplyAgent typing (heartbeat)", () => { } } }); + + it("rewrites Bun socket errors into friendly text", async () => { + runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [ + { + text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", + isError: true, + }, + ], + meta: {}, + })); + + const { run } = createMinimalRun(); + const res = await run(); + const payloads = Array.isArray(res) ? res : res ? [res] : []; + expect(payloads.length).toBe(1); + expect(payloads[0]?.text).toContain("LLM connection failed"); + expect(payloads[0]?.text).toContain( + "socket connection was closed unexpectedly", + ); + expect(payloads[0]?.text).toContain("```"); + }); }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 7d2ba3d22..490b696cd 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -7,6 +7,7 @@ import { queueEmbeddedPiMessage, runEmbeddedPiAgent, } from "../../agents/pi-embedded.js"; +import { hasNonzeroUsage } from "../../agents/usage.js"; import { loadSessionStore, resolveSessionTranscriptPath, @@ -32,6 +33,21 @@ import { extractReplyToTag } from "./reply-tags.js"; import { incrementCompactionCount } from "./session-updates.js"; import type { TypingController } from "./typing.js"; +const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; + +const isBunFetchSocketError = (message?: string) => + Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message)); + +const formatBunFetchSocketError = (message: string) => { + const trimmed = message.trim(); + return [ + "⚠️ LLM connection failed. This could be due to server issues, network problems, or context length exceeded (e.g., with local LLMs like LM Studio). Original error:", + "```", + trimmed || "Unknown error", + "```", + ].join("\n"); +}; + export async function runReplyAgent(params: { commandBody: string; followupRun: FollowupRun; @@ -107,6 +123,7 @@ export async function runReplyAgent(params: { const streamedPayloadKeys = new Set(); const pendingStreamedPayloadKeys = new Set(); const pendingBlockTasks = new Set>(); + const pendingToolTasks = new Set>(); let didStreamBlockReply = false; const buildPayloadKey = (payload: ReplyPayload) => { const text = payload.text?.trim() ?? ""; @@ -188,9 +205,11 @@ export async function runReplyAgent(params: { runEmbeddedPiAgent({ sessionId: followupRun.run.sessionId, sessionKey, - surface: sessionCtx.Surface?.trim().toLowerCase() || undefined, + messageProvider: + sessionCtx.Provider?.trim().toLowerCase() || undefined, sessionFile: followupRun.run.sessionFile, workspaceDir: followupRun.run.workspaceDir, + agentDir: followupRun.run.agentDir, config: followupRun.run.config, skillsSnapshot: followupRun.run.skillsSnapshot, prompt: commandBody, @@ -239,7 +258,8 @@ export async function runReplyAgent(params: { : undefined, onAgentEvent: (evt) => { if (evt.stream !== "compaction") return; - const phase = String(evt.data.phase ?? ""); + const phase = + typeof evt.data.phase === "string" ? evt.data.phase : ""; const willRetry = Boolean(evt.data.willRetry); if (phase === "end" && !willRetry) { autoCompactionCompleted = true; @@ -310,33 +330,45 @@ export async function runReplyAgent(params: { : undefined, shouldEmitToolResult, onToolResult: opts?.onToolResult - ? async (payload) => { - let text = payload.text; - if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) { - const stripped = stripHeartbeatToken(text, { - mode: "message", + ? (payload) => { + // `subscribeEmbeddedPiSession` may invoke tool callbacks without awaiting them. + // If a tool callback starts typing after the run finalized, we can end up with + // a typing loop that never sees a matching markRunComplete(). Track and drain. + const task = (async () => { + let text = payload.text; + if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) { + const stripped = stripHeartbeatToken(text, { + mode: "message", + }); + if (stripped.didStrip && !didLogHeartbeatStrip) { + didLogHeartbeatStrip = true; + logVerbose( + "Stripped stray HEARTBEAT_OK token from reply", + ); + } + if ( + stripped.shouldSkip && + (payload.mediaUrls?.length ?? 0) === 0 + ) { + return; + } + text = stripped.text; + } + if (!isHeartbeat) { + await typing.startTypingOnText(text); + } + await opts.onToolResult?.({ + text, + mediaUrls: payload.mediaUrls, }); - if (stripped.didStrip && !didLogHeartbeatStrip) { - didLogHeartbeatStrip = true; - logVerbose( - "Stripped stray HEARTBEAT_OK token from reply", - ); - } - if ( - stripped.shouldSkip && - (payload.mediaUrls?.length ?? 0) === 0 - ) { - return; - } - text = stripped.text; - } - if (!isHeartbeat) { - await typing.startTypingOnText(text); - } - await opts.onToolResult?.({ - text, - mediaUrls: payload.mediaUrls, - }); + })() + .catch((err) => { + logVerbose(`tool result delivery failed: ${String(err)}`); + }) + .finally(() => { + pendingToolTasks.delete(task); + }); + pendingToolTasks.add(task); } : undefined, }), @@ -408,16 +440,28 @@ export async function runReplyAgent(params: { } const payloadArray = runResult.payloads ?? []; - if (payloadArray.length === 0) return finalizeWithFollowup(undefined); if (pendingBlockTasks.size > 0) { await Promise.allSettled(pendingBlockTasks); } + if (pendingToolTasks.size > 0) { + await Promise.allSettled(pendingToolTasks); + } + // Drain any late tool/block deliveries before deciding there's "nothing to send". + // Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and + // keep the typing indicator stuck. + if (payloadArray.length === 0) return finalizeWithFollowup(undefined); const sanitizedPayloads = isHeartbeat ? payloadArray : payloadArray.flatMap((payload) => { - const text = payload.text; - if (!text || !text.includes("HEARTBEAT_OK")) return [payload]; + let text = payload.text; + + if (payload.isError && text && isBunFetchSocketError(text)) { + text = formatBunFetchSocketError(text); + } + + if (!text || !text.includes("HEARTBEAT_OK")) + return [{ ...payload, text }]; const stripped = stripHeartbeatToken(text, { mode: "message" }); if (stripped.didStrip && !didLogHeartbeatStrip) { didLogHeartbeatStrip = true; @@ -485,7 +529,7 @@ export async function runReplyAgent(params: { sessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS; - if (usage) { + if (hasNonzeroUsage(usage)) { const entry = sessionEntry ?? sessionStore[sessionKey]; if (entry) { const input = usage.input ?? 0; @@ -552,6 +596,6 @@ export async function runReplyAgent(params: { finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, ); } finally { - typing.cleanup(); + typing.markRunComplete(); } } diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 6388363ee..ea231c04c 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -1,10 +1,10 @@ import type { ClawdbotConfig } from "../../config/config.js"; -import { resolveTextChunkLimit, type TextChunkSurface } from "../chunk.js"; +import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; -const BLOCK_CHUNK_SURFACES = new Set([ +const BLOCK_CHUNK_PROVIDERS = new Set([ "whatsapp", "telegram", "discord", @@ -14,24 +14,26 @@ const BLOCK_CHUNK_SURFACES = new Set([ "webchat", ]); -function normalizeChunkSurface(surface?: string): TextChunkSurface | undefined { - if (!surface) return undefined; - const cleaned = surface.trim().toLowerCase(); - return BLOCK_CHUNK_SURFACES.has(cleaned as TextChunkSurface) - ? (cleaned as TextChunkSurface) +function normalizeChunkProvider( + provider?: string, +): TextChunkProvider | undefined { + if (!provider) return undefined; + const cleaned = provider.trim().toLowerCase(); + return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider) + ? (cleaned as TextChunkProvider) : undefined; } export function resolveBlockStreamingChunking( cfg: ClawdbotConfig | undefined, - surface?: string, + provider?: string, ): { minChars: number; maxChars: number; breakPreference: "paragraph" | "newline" | "sentence"; } { - const surfaceKey = normalizeChunkSurface(surface); - const textLimit = resolveTextChunkLimit(cfg, surfaceKey); + const providerKey = normalizeChunkProvider(provider); + const textLimit = resolveTextChunkLimit(cfg, providerKey); const chunkCfg = cfg?.agent?.blockStreamingChunk; const maxRequested = Math.max( 1, diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 22cc7f7c8..f7b7e7122 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -27,6 +27,7 @@ import { normalizeE164 } from "../../utils.js"; import { resolveHeartbeatSeconds } from "../../web/reconnect.js"; import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js"; import { resolveCommandAuthorization } from "../command-auth.js"; +import { shouldHandleTextCommands } from "../commands-registry.js"; import { normalizeGroupActivation, parseActivationCommand, @@ -48,7 +49,8 @@ import { incrementCompactionCount } from "./session-updates.js"; export type CommandContext = { surface: string; - isWhatsAppSurface: boolean; + provider: string; + isWhatsAppProvider: boolean; ownerList: string[]; isAuthorizedSender: boolean; senderE164?: string; @@ -102,11 +104,7 @@ function extractCompactInstructions(params: { const trimmed = stripped.trim(); if (!trimmed) return undefined; const lowered = trimmed.toLowerCase(); - const prefix = lowered.startsWith("/compact") - ? "/compact" - : lowered.startsWith("compact") - ? "compact" - : null; + const prefix = lowered.startsWith("/compact") ? "/compact" : null; if (!prefix) return undefined; let rest = trimmed.slice(prefix.length).trimStart(); if (rest.startsWith(":")) rest = rest.slice(1).trimStart(); @@ -127,7 +125,8 @@ export function buildCommandContext(params: { cfg, commandAuthorized: params.commandAuthorized, }); - const surface = (ctx.Surface ?? "").trim().toLowerCase(); + const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase(); + const provider = (ctx.Provider ?? surface).trim().toLowerCase(); const abortKey = sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); const rawBodyNormalized = triggerBodyNormalized; @@ -137,7 +136,8 @@ export function buildCommandContext(params: { return { surface, - isWhatsAppSurface: auth.isWhatsAppSurface, + provider, + isWhatsAppProvider: auth.isWhatsAppProvider, ownerList: auth.ownerList, isAuthorizedSender: auth.isAuthorizedSender, senderE164: auth.senderE164, @@ -197,9 +197,7 @@ export async function handleCommands(params: { const resetRequested = command.commandBodyNormalized === "/reset" || - command.commandBodyNormalized === "reset" || - command.commandBodyNormalized === "/new" || - command.commandBodyNormalized === "new"; + command.commandBodyNormalized === "/new"; if (resetRequested && !command.isAuthorizedSender) { logVerbose( `Ignoring /reset from unauthorized sender: ${command.senderE164 || ""}`, @@ -213,8 +211,13 @@ export async function handleCommands(params: { const sendPolicyCommand = parseSendPolicyCommand( command.commandBodyNormalized, ); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: command.surface, + commandSource: ctx.CommandSource, + }); - if (activationCommand.hasCommand) { + if (allowTextCommands && activationCommand.hasCommand) { if (!isGroup) { return { shouldContinue: false, @@ -226,14 +229,14 @@ export async function handleCommands(params: { ? normalizeE164(command.senderE164) : ""; const isActivationOwner = - !command.isWhatsAppSurface || activationOwnerList.length === 0 + !command.isWhatsAppProvider || activationOwnerList.length === 0 ? command.isAuthorizedSender : Boolean(activationSenderE164) && activationOwnerList.includes(activationSenderE164); if ( !command.isAuthorizedSender || - (command.isWhatsAppSurface && !isActivationOwner) + (command.isWhatsAppProvider && !isActivationOwner) ) { logVerbose( `Ignoring /activation from unauthorized sender in group: ${command.senderE164 || ""}`, @@ -261,7 +264,7 @@ export async function handleCommands(params: { }; } - if (sendPolicyCommand.hasCommand) { + if (allowTextCommands && sendPolicyCommand.hasCommand) { if (!command.isAuthorizedSender) { logVerbose( `Ignoring /send from unauthorized sender: ${command.senderE164 || ""}`, @@ -298,11 +301,7 @@ export async function handleCommands(params: { }; } - if ( - command.commandBodyNormalized === "/restart" || - command.commandBodyNormalized === "restart" || - command.commandBodyNormalized.startsWith("/restart ") - ) { + if (allowTextCommands && command.commandBodyNormalized === "/restart") { if (!command.isAuthorizedSender) { logVerbose( `Ignoring /restart from unauthorized sender: ${command.senderE164 || ""}`, @@ -318,11 +317,8 @@ export async function handleCommands(params: { }; } - const helpRequested = - command.commandBodyNormalized === "/help" || - command.commandBodyNormalized === "help" || - /(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized); - if (helpRequested) { + const helpRequested = command.commandBodyNormalized === "/help"; + if (allowTextCommands && helpRequested) { if (!command.isAuthorizedSender) { logVerbose( `Ignoring /help from unauthorized sender: ${command.senderE164 || ""}`, @@ -334,10 +330,8 @@ export async function handleCommands(params: { const statusRequested = directives.hasStatusDirective || - command.commandBodyNormalized === "/status" || - command.commandBodyNormalized === "status" || - command.commandBodyNormalized.startsWith("/status "); - if (statusRequested) { + command.commandBodyNormalized === "/status"; + if (allowTextCommands && statusRequested) { if (!command.isAuthorizedSender) { logVerbose( `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, @@ -383,9 +377,7 @@ export async function handleCommands(params: { const compactRequested = command.commandBodyNormalized === "/compact" || - command.commandBodyNormalized === "compact" || - command.commandBodyNormalized.startsWith("/compact ") || - command.commandBodyNormalized.startsWith("compact "); + command.commandBodyNormalized.startsWith("/compact "); if (compactRequested) { if (!command.isAuthorizedSender) { logVerbose( @@ -413,7 +405,7 @@ export async function handleCommands(params: { const result = await compactEmbeddedPiSession({ sessionId, sessionKey, - surface: command.surface, + messageProvider: command.provider, sessionFile: resolveSessionTranscriptPath(sessionId), workspaceDir, config: cfg, @@ -462,7 +454,7 @@ export async function handleCommands(params: { } const abortRequested = isAbortTrigger(command.rawBodyNormalized); - if (abortRequested) { + if (allowTextCommands && abortRequested) { if (sessionEntry && sessionStore && sessionKey) { sessionEntry.abortedLastRun = true; sessionEntry.updatedAt = Date.now(); @@ -480,7 +472,7 @@ export async function handleCommands(params: { cfg, entry: sessionEntry, sessionKey, - surface: sessionEntry?.surface ?? command.surface, + provider: sessionEntry?.provider ?? command.provider, chatType: sessionEntry?.chatType, }); if (sendPolicy === "deny") { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts new file mode 100644 index 000000000..7eba4cf4b --- /dev/null +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -0,0 +1,46 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { getReplyFromConfig } from "../reply.js"; +import type { MsgContext } from "../templating.js"; +import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; + +type DispatchFromConfigResult = { + queuedFinal: boolean; + counts: Record; +}; + +export async function dispatchReplyFromConfig(params: { + ctx: MsgContext; + cfg: ClawdbotConfig; + dispatcher: ReplyDispatcher; + replyOptions?: Omit; + replyResolver?: typeof getReplyFromConfig; +}): Promise { + const replyResult = await (params.replyResolver ?? getReplyFromConfig)( + params.ctx, + { + ...params.replyOptions, + onToolResult: (payload: ReplyPayload) => { + params.dispatcher.sendToolResult(payload); + }, + onBlockReply: (payload: ReplyPayload) => { + params.dispatcher.sendBlockReply(payload); + }, + }, + params.cfg, + ); + + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + + let queuedFinal = false; + for (const reply of replies) { + queuedFinal = params.dispatcher.sendFinalReply(reply) || queuedFinal; + } + await params.dispatcher.waitForIdle(); + + return { queuedFinal, counts: params.dispatcher.getQueuedCounts() }; +} diff --git a/src/auto-reply/reply/followup-runner.compaction.test.ts b/src/auto-reply/reply/followup-runner.compaction.test.ts index b4ac4c856..6c319a310 100644 --- a/src/auto-reply/reply/followup-runner.compaction.test.ts +++ b/src/auto-reply/reply/followup-runner.compaction.test.ts @@ -37,6 +37,8 @@ function createTyping(): TypingController { startTypingLoop: vi.fn(async () => {}), startTypingOnText: vi.fn(async () => {}), refreshTypingTtl: vi.fn(), + markRunComplete: vi.fn(), + markDispatchIdle: vi.fn(), cleanup: vi.fn(), }; } @@ -88,7 +90,7 @@ describe("createFollowupRunner compaction", () => { run: { sessionId: "session", sessionKey: "main", - surface: "whatsapp", + messageProvider: "whatsapp", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", config: {}, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 528bca679..71e183263 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -3,6 +3,7 @@ import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { hasNonzeroUsage } from "../../agents/usage.js"; import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -58,153 +59,160 @@ export function createFollowupRunner(params: { }; return async (queued: FollowupRun) => { - const runId = crypto.randomUUID(); - if (queued.run.sessionKey) { - registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey }); - } - let autoCompactionCompleted = false; - let runResult: Awaited>; - let fallbackProvider = queued.run.provider; - let fallbackModel = queued.run.model; try { - const fallbackResult = await runWithModelFallback({ - cfg: queued.run.config, - provider: queued.run.provider, - model: queued.run.model, - run: (provider, model) => - runEmbeddedPiAgent({ - sessionId: queued.run.sessionId, - sessionKey: queued.run.sessionKey, - surface: queued.run.surface, - sessionFile: queued.run.sessionFile, - workspaceDir: queued.run.workspaceDir, - config: queued.run.config, - skillsSnapshot: queued.run.skillsSnapshot, - prompt: queued.prompt, - extraSystemPrompt: queued.run.extraSystemPrompt, - ownerNumbers: queued.run.ownerNumbers, - enforceFinalTag: queued.run.enforceFinalTag, - provider, - model, - authProfileId: queued.run.authProfileId, - thinkLevel: queued.run.thinkLevel, - verboseLevel: queued.run.verboseLevel, - bashElevated: queued.run.bashElevated, - timeoutMs: queued.run.timeoutMs, - runId, - blockReplyBreak: queued.run.blockReplyBreak, - onAgentEvent: (evt) => { - if (evt.stream !== "compaction") return; - const phase = String(evt.data.phase ?? ""); - const willRetry = Boolean(evt.data.willRetry); - if (phase === "end" && !willRetry) { - autoCompactionCompleted = true; - } - }, - }), - }); - runResult = fallbackResult.result; - fallbackProvider = fallbackResult.provider; - fallbackModel = fallbackResult.model; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - defaultRuntime.error?.(`Followup agent failed before reply: ${message}`); - return; - } - - const payloadArray = runResult.payloads ?? []; - if (payloadArray.length === 0) return; - const sanitizedPayloads = payloadArray.flatMap((payload) => { - const text = payload.text; - if (!text || !text.includes("HEARTBEAT_OK")) return [payload]; - const stripped = stripHeartbeatToken(text, { mode: "message" }); - const hasMedia = - Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - if (stripped.shouldSkip && !hasMedia) return []; - return [{ ...payload, text: stripped.text }]; - }); - - const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads - .map((payload) => { - const { cleaned, replyToId } = extractReplyToTag(payload.text); - return { - ...payload, - text: cleaned ? cleaned : undefined, - replyToId: replyToId ?? payload.replyToId, - }; - }) - .filter( - (payload) => - payload.text || - payload.mediaUrl || - (payload.mediaUrls && payload.mediaUrls.length > 0), - ); - - if (replyTaggedPayloads.length === 0) return; - - if (autoCompactionCompleted) { - const count = await incrementCompactionCount({ - sessionEntry, - sessionStore, - sessionKey, - storePath, - }); - if (queued.run.verboseLevel === "on") { - const suffix = typeof count === "number" ? ` (count ${count})` : ""; - replyTaggedPayloads.unshift({ - text: `🧹 Auto-compaction complete${suffix}.`, + const runId = crypto.randomUUID(); + if (queued.run.sessionKey) { + registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey }); + } + let autoCompactionCompleted = false; + let runResult: Awaited>; + let fallbackProvider = queued.run.provider; + let fallbackModel = queued.run.model; + try { + const fallbackResult = await runWithModelFallback({ + cfg: queued.run.config, + provider: queued.run.provider, + model: queued.run.model, + run: (provider, model) => + runEmbeddedPiAgent({ + sessionId: queued.run.sessionId, + sessionKey: queued.run.sessionKey, + messageProvider: queued.run.messageProvider, + sessionFile: queued.run.sessionFile, + workspaceDir: queued.run.workspaceDir, + config: queued.run.config, + skillsSnapshot: queued.run.skillsSnapshot, + prompt: queued.prompt, + extraSystemPrompt: queued.run.extraSystemPrompt, + ownerNumbers: queued.run.ownerNumbers, + enforceFinalTag: queued.run.enforceFinalTag, + provider, + model, + authProfileId: queued.run.authProfileId, + thinkLevel: queued.run.thinkLevel, + verboseLevel: queued.run.verboseLevel, + bashElevated: queued.run.bashElevated, + timeoutMs: queued.run.timeoutMs, + runId, + blockReplyBreak: queued.run.blockReplyBreak, + onAgentEvent: (evt) => { + if (evt.stream !== "compaction") return; + const phase = + typeof evt.data.phase === "string" ? evt.data.phase : ""; + const willRetry = Boolean(evt.data.willRetry); + if (phase === "end" && !willRetry) { + autoCompactionCompleted = true; + } + }, + }), }); + runResult = fallbackResult.result; + fallbackProvider = fallbackResult.provider; + fallbackModel = fallbackResult.model; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + defaultRuntime.error?.( + `Followup agent failed before reply: ${message}`, + ); + return; } - } - if (sessionStore && sessionKey) { - const usage = runResult.meta.agentMeta?.usage; - const modelUsed = - runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; - const contextTokensUsed = - agentCfgContextTokens ?? - lookupContextTokens(modelUsed) ?? - sessionEntry?.contextTokens ?? - DEFAULT_CONTEXT_TOKENS; + const payloadArray = runResult.payloads ?? []; + if (payloadArray.length === 0) return; + const sanitizedPayloads = payloadArray.flatMap((payload) => { + const text = payload.text; + if (!text || !text.includes("HEARTBEAT_OK")) return [payload]; + const stripped = stripHeartbeatToken(text, { mode: "message" }); + const hasMedia = + Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + if (stripped.shouldSkip && !hasMedia) return []; + return [{ ...payload, text: stripped.text }]; + }); - if (usage) { - const entry = sessionStore[sessionKey]; - if (entry) { - const input = usage.input ?? 0; - const output = usage.output ?? 0; - const promptTokens = - input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); - sessionStore[sessionKey] = { - ...entry, - inputTokens: input, - outputTokens: output, - totalTokens: - promptTokens > 0 ? promptTokens : (usage.total ?? input), - modelProvider: fallbackProvider ?? entry.modelProvider, - model: modelUsed, - contextTokens: contextTokensUsed ?? entry.contextTokens, - updatedAt: Date.now(), + const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads + .map((payload) => { + const { cleaned, replyToId } = extractReplyToTag(payload.text); + return { + ...payload, + text: cleaned ? cleaned : undefined, + replyToId: replyToId ?? payload.replyToId, }; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } + }) + .filter( + (payload) => + payload.text || + payload.mediaUrl || + (payload.mediaUrls && payload.mediaUrls.length > 0), + ); + + if (replyTaggedPayloads.length === 0) return; + + if (autoCompactionCompleted) { + const count = await incrementCompactionCount({ + sessionEntry, + sessionStore, + sessionKey, + storePath, + }); + if (queued.run.verboseLevel === "on") { + const suffix = typeof count === "number" ? ` (count ${count})` : ""; + replyTaggedPayloads.unshift({ + text: `🧹 Auto-compaction complete${suffix}.`, + }); } - } else if (modelUsed || contextTokensUsed) { - const entry = sessionStore[sessionKey]; - if (entry) { - sessionStore[sessionKey] = { - ...entry, - modelProvider: fallbackProvider ?? entry.modelProvider, - model: modelUsed ?? entry.model, - contextTokens: contextTokensUsed ?? entry.contextTokens, - }; - if (storePath) { - await saveSessionStore(storePath, sessionStore); + } + + if (sessionStore && sessionKey) { + const usage = runResult.meta.agentMeta?.usage; + const modelUsed = + runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; + const contextTokensUsed = + agentCfgContextTokens ?? + lookupContextTokens(modelUsed) ?? + sessionEntry?.contextTokens ?? + DEFAULT_CONTEXT_TOKENS; + + if (hasNonzeroUsage(usage)) { + const entry = sessionStore[sessionKey]; + if (entry) { + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const promptTokens = + input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + sessionStore[sessionKey] = { + ...entry, + inputTokens: input, + outputTokens: output, + totalTokens: + promptTokens > 0 ? promptTokens : (usage.total ?? input), + modelProvider: fallbackProvider ?? entry.modelProvider, + model: modelUsed, + contextTokens: contextTokensUsed ?? entry.contextTokens, + updatedAt: Date.now(), + }; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + } + } else if (modelUsed || contextTokensUsed) { + const entry = sessionStore[sessionKey]; + if (entry) { + sessionStore[sessionKey] = { + ...entry, + modelProvider: fallbackProvider ?? entry.modelProvider, + model: modelUsed ?? entry.model, + contextTokens: contextTokensUsed ?? entry.contextTokens, + }; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } } } } - } - await sendFollowupPayloads(replyTaggedPayloads); + await sendFollowupPayloads(replyTaggedPayloads); + } finally { + typing.markRunComplete(); + } }; } diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts index a7fde10a3..d950731ef 100644 --- a/src/auto-reply/reply/groups.test.ts +++ b/src/auto-reply/reply/groups.test.ts @@ -19,13 +19,13 @@ describe("resolveGroupRequireMention", () => { }, }; const ctx: TemplateContext = { - Surface: "discord", + Provider: "discord", From: "group:123", GroupRoom: "#general", GroupSpace: "145", }; const groupResolution: GroupKeyResolution = { - surface: "discord", + provider: "discord", id: "123", chatType: "group", }; @@ -44,12 +44,12 @@ describe("resolveGroupRequireMention", () => { }, }; const ctx: TemplateContext = { - Surface: "slack", + Provider: "slack", From: "slack:channel:C123", GroupSubject: "#general", }; const groupResolution: GroupKeyResolution = { - surface: "slack", + provider: "slack", id: "C123", chatType: "group", }; diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index c94f0ef73..9569f1f1e 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,4 +1,5 @@ import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveProviderGroupRequireMention } from "../../config/group-policy.js"; import type { GroupKeyResolution, SessionEntry, @@ -49,44 +50,23 @@ export function resolveGroupRequireMention(params: { groupResolution?: GroupKeyResolution; }): boolean { const { cfg, ctx, groupResolution } = params; - const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase(); + const provider = + groupResolution?.provider ?? ctx.Provider?.trim().toLowerCase(); const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim(); const groupSpace = ctx.GroupSpace?.trim(); - if (surface === "telegram") { - if (groupId) { - const groupConfig = cfg.telegram?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; + if ( + provider === "telegram" || + provider === "whatsapp" || + provider === "imessage" + ) { + return resolveProviderGroupRequireMention({ + cfg, + provider, + groupId, + }); } - if (surface === "whatsapp") { - if (groupId) { - const groupConfig = cfg.whatsapp?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; - } - if (surface === "imessage") { - if (groupId) { - const groupConfig = cfg.imessage?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; - } - if (surface === "discord") { + if (provider === "discord") { const guildEntry = resolveDiscordGuildEntry( cfg.discord?.guilds, groupSpace, @@ -111,7 +91,7 @@ export function resolveGroupRequireMention(params: { } return true; } - if (surface === "slack") { + if (provider === "slack") { const channels = cfg.slack?.channels ?? {}; const keys = Object.keys(channels); if (keys.length === 0) return true; @@ -158,18 +138,18 @@ export function buildGroupIntro(params: { params.defaultActivation; const subject = params.sessionCtx.GroupSubject?.trim(); const members = params.sessionCtx.GroupMembers?.trim(); - const surface = params.sessionCtx.Surface?.trim().toLowerCase(); - const surfaceLabel = (() => { - if (!surface) return "chat"; - if (surface === "whatsapp") return "WhatsApp"; - if (surface === "telegram") return "Telegram"; - if (surface === "discord") return "Discord"; - if (surface === "webchat") return "WebChat"; - return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`; + const provider = params.sessionCtx.Provider?.trim().toLowerCase(); + const providerLabel = (() => { + if (!provider) return "chat"; + if (provider === "whatsapp") return "WhatsApp"; + if (provider === "telegram") return "Telegram"; + if (provider === "discord") return "Discord"; + if (provider === "webchat") return "WebChat"; + return `${provider.at(0)?.toUpperCase() ?? ""}${provider.slice(1)}`; })(); const subjectLine = subject - ? `You are replying inside the ${surfaceLabel} group "${subject}".` - : `You are replying inside a ${surfaceLabel} group chat.`; + ? `You are replying inside the ${providerLabel} group "${subject}".` + : `You are replying inside a ${providerLabel} group chat.`; const membersLine = members ? `Group members: ${members}.` : undefined; const activationLine = activation === "always" diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index 47b0dc432..2d9093cc5 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -23,9 +23,11 @@ export type FollowupRun = { summaryLine?: string; enqueuedAt: number; run: { + agentId: string; + agentDir: string; sessionId: string; sessionKey?: string; - surface?: string; + messageProvider?: string; sessionFile: string; workspaceDir: string; config: ClawdbotConfig; @@ -425,8 +427,8 @@ export function scheduleFollowupDrain( } })(); } -function defaultQueueModeForSurface(surface?: string): QueueMode { - const normalized = surface?.trim().toLowerCase(); +function defaultQueueModeForProvider(provider?: string): QueueMode { + const normalized = provider?.trim().toLowerCase(); if (normalized === "discord") return "collect"; if (normalized === "webchat") return "collect"; if (normalized === "whatsapp") return "collect"; @@ -437,23 +439,23 @@ function defaultQueueModeForSurface(surface?: string): QueueMode { } export function resolveQueueSettings(params: { cfg: ClawdbotConfig; - surface?: string; + provider?: string; sessionEntry?: SessionEntry; inlineMode?: QueueMode; inlineOptions?: Partial; }): QueueSettings { - const surfaceKey = params.surface?.trim().toLowerCase(); + const providerKey = params.provider?.trim().toLowerCase(); const queueCfg = params.cfg.routing?.queue; - const surfaceModeRaw = - surfaceKey && queueCfg?.bySurface - ? (queueCfg.bySurface as Record)[surfaceKey] + const providerModeRaw = + providerKey && queueCfg?.byProvider + ? (queueCfg.byProvider as Record)[providerKey] : undefined; const resolvedMode = params.inlineMode ?? normalizeQueueMode(params.sessionEntry?.queueMode) ?? - normalizeQueueMode(surfaceModeRaw) ?? + normalizeQueueMode(providerModeRaw) ?? normalizeQueueMode(queueCfg?.mode) ?? - defaultQueueModeForSurface(surfaceKey); + defaultQueueModeForProvider(providerKey); const debounceRaw = params.inlineOptions?.debounceMs ?? params.sessionEntry?.queueDebounceMs ?? diff --git a/src/auto-reply/reply/reply-dispatcher.test.ts b/src/auto-reply/reply/reply-dispatcher.test.ts index d97822fe3..dee7795d2 100644 --- a/src/auto-reply/reply/reply-dispatcher.test.ts +++ b/src/auto-reply/reply/reply-dispatcher.test.ts @@ -79,4 +79,18 @@ describe("createReplyDispatcher", () => { await dispatcher.waitForIdle(); expect(delivered).toEqual(["tool", "block", "final"]); }); + + it("fires onIdle when the queue drains", async () => { + const deliver = vi.fn( + async () => await new Promise((resolve) => setTimeout(resolve, 5)), + ); + const onIdle = vi.fn(); + const dispatcher = createReplyDispatcher({ deliver, onIdle }); + + dispatcher.sendToolResult({ text: "one" }); + dispatcher.sendFinalReply({ text: "two" }); + + await dispatcher.waitForIdle(); + expect(onIdle).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 070cc7a65..0db0e102c 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,6 +1,7 @@ import { stripHeartbeatToken } from "../heartbeat.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; -import type { ReplyPayload } from "../types.js"; +import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import type { TypingController } from "./typing.js"; export type ReplyDispatchKind = "tool" | "block" | "final"; @@ -18,10 +19,25 @@ export type ReplyDispatcherOptions = { deliver: ReplyDispatchDeliverer; responsePrefix?: string; onHeartbeatStrip?: () => void; + onIdle?: () => void; onError?: ReplyDispatchErrorHandler; }; -type ReplyDispatcher = { +type ReplyDispatcherWithTypingOptions = Omit< + ReplyDispatcherOptions, + "onIdle" +> & { + onReplyStart?: () => Promise | void; + onIdle?: () => void; +}; + +type ReplyDispatcherWithTypingResult = { + dispatcher: ReplyDispatcher; + replyOptions: Pick; + markDispatchIdle: () => void; +}; + +export type ReplyDispatcher = { sendToolResult: (payload: ReplyPayload) => boolean; sendBlockReply: (payload: ReplyPayload) => boolean; sendFinalReply: (payload: ReplyPayload) => boolean; @@ -70,6 +86,8 @@ export function createReplyDispatcher( options: ReplyDispatcherOptions, ): ReplyDispatcher { let sendChain: Promise = Promise.resolve(); + // Track in-flight deliveries so we can emit a reliable "idle" signal. + let pending = 0; // Serialize outbound replies to preserve tool/block/final order. const queuedCounts: Record = { tool: 0, @@ -81,10 +99,17 @@ export function createReplyDispatcher( const normalized = normalizeReplyPayload(payload, options); if (!normalized) return false; queuedCounts[kind] += 1; + pending += 1; sendChain = sendChain .then(() => options.deliver(normalized, { kind })) .catch((err) => { options.onError?.(err, { kind }); + }) + .finally(() => { + pending -= 1; + if (pending === 0) { + options.onIdle?.(); + } }); return true; }; @@ -97,3 +122,31 @@ export function createReplyDispatcher( getQueuedCounts: () => ({ ...queuedCounts }), }; } + +export function createReplyDispatcherWithTyping( + options: ReplyDispatcherWithTypingOptions, +): ReplyDispatcherWithTypingResult { + const { onReplyStart, onIdle, ...dispatcherOptions } = options; + let typingController: TypingController | undefined; + const dispatcher = createReplyDispatcher({ + ...dispatcherOptions, + onIdle: () => { + typingController?.markDispatchIdle(); + onIdle?.(); + }, + }); + + return { + dispatcher, + replyOptions: { + onReplyStart, + onTypingController: (typing) => { + typingController = typing; + }, + }, + markDispatchIdle: () => { + typingController?.markDispatchIdle(); + onIdle?.(); + }, + }; +} diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a6d4f0357..992fb2f61 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -7,6 +7,7 @@ import { DEFAULT_RESET_TRIGGERS, type GroupKeyResolution, loadSessionStore, + resolveAgentIdFromSessionKey, resolveGroupSessionKey, resolveSessionKey, resolveStorePath, @@ -43,6 +44,7 @@ export async function initSessionState(params: { const { ctx, cfg, commandAuthorized } = params; const sessionCfg = cfg.session; const mainKey = sessionCfg?.mainKey ?? "main"; + const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey); const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers : DEFAULT_RESET_TRIGGERS; @@ -51,12 +53,12 @@ export async function initSessionState(params: { 1, ); const sessionScope = sessionCfg?.scope ?? "per-sender"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const sessionStore: Record = loadSessionStore(storePath); let sessionKey: string | undefined; - let sessionEntry: SessionEntry | undefined; + let sessionEntry: SessionEntry; let sessionId: string | undefined; let isNewSession = false; @@ -154,30 +156,30 @@ export async function initSessionState(params: { queueDrop: baseEntry?.queueDrop, displayName: baseEntry?.displayName, chatType: baseEntry?.chatType, - surface: baseEntry?.surface, + provider: baseEntry?.provider, subject: baseEntry?.subject, room: baseEntry?.room, space: baseEntry?.space, }; - if (groupResolution?.surface) { - const surface = groupResolution.surface; + if (groupResolution?.provider) { + const provider = groupResolution.provider; const subject = ctx.GroupSubject?.trim(); const space = ctx.GroupSpace?.trim(); const explicitRoom = ctx.GroupRoom?.trim(); - const isRoomSurface = surface === "discord" || surface === "slack"; + const isRoomProvider = provider === "discord" || provider === "slack"; const nextRoom = explicitRoom ?? - (isRoomSurface && subject && subject.startsWith("#") + (isRoomProvider && subject && subject.startsWith("#") ? subject : undefined); const nextSubject = nextRoom ? undefined : subject; sessionEntry.chatType = groupResolution.chatType ?? "group"; - sessionEntry.surface = surface; + sessionEntry.provider = provider; if (nextSubject) sessionEntry.subject = nextSubject; if (nextRoom) sessionEntry.room = nextRoom; if (space) sessionEntry.space = space; sessionEntry.displayName = buildGroupDisplayName({ - surface: sessionEntry.surface, + provider: sessionEntry.provider, subject: sessionEntry.subject, room: sessionEntry.room, space: sessionEntry.space, diff --git a/src/auto-reply/reply/typing.test.ts b/src/auto-reply/reply/typing.test.ts new file mode 100644 index 000000000..18c3fd322 --- /dev/null +++ b/src/auto-reply/reply/typing.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createTypingController } from "./typing.js"; + +describe("typing controller", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("stops after run completion and dispatcher idle", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(1_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + }); + + it("keeps typing until both idle and run completion are set", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + }); + + it("does not restart typing after it has stopped", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + typing.markRunComplete(); + typing.markDispatchIdle(); + + vi.advanceTimersByTime(5_000); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + // Late callbacks should be ignored and must not restart the interval. + await typing.startTypingOnText("late tool result"); + vi.advanceTimersByTime(5_000); + expect(onReplyStart).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index 6c2004e67..7850ec132 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -3,6 +3,8 @@ export type TypingController = { startTypingLoop: () => Promise; startTypingOnText: (text?: string) => Promise; refreshTypingTtl: () => void; + markRunComplete: () => void; + markDispatchIdle: () => void; cleanup: () => void; }; @@ -21,6 +23,13 @@ export function createTypingController(params: { log, } = params; let started = false; + let active = false; + let runComplete = false; + let dispatchIdle = false; + // Important: callbacks (tool/block streaming) can fire late (after the run completed), + // especially when upstream event emitters don't await async listeners. + // Once we stop typing, we "seal" the controller so late events can't restart typing forever. + let sealed = false; let typingTimer: NodeJS.Timeout | undefined; let typingTtlTimer: NodeJS.Timeout | undefined; const typingIntervalMs = typingIntervalSeconds * 1000; @@ -30,7 +39,15 @@ export function createTypingController(params: { return `${Math.round(ms / 1000)}s`; }; + const resetCycle = () => { + started = false; + active = false; + runComplete = false; + dispatchIdle = false; + }; + const cleanup = () => { + if (sealed) return; if (typingTtlTimer) { clearTimeout(typingTtlTimer); typingTtlTimer = undefined; @@ -39,9 +56,12 @@ export function createTypingController(params: { clearInterval(typingTimer); typingTimer = undefined; } + resetCycle(); + sealed = true; }; const refreshTypingTtl = () => { + if (sealed) return; if (!typingIntervalMs || typingIntervalMs <= 0) return; if (typingTtlMs <= 0) return; if (typingTtlTimer) { @@ -57,16 +77,30 @@ export function createTypingController(params: { }; const triggerTyping = async () => { + if (sealed) return; await onReplyStart?.(); }; const ensureStart = async () => { + if (sealed) return; + // Late callbacks after a run completed should never restart typing. + if (runComplete) return; + if (!active) { + active = true; + } if (started) return; started = true; await triggerTyping(); }; + const maybeStopOnIdle = () => { + if (!active) return; + // Stop only when the model run is done and the dispatcher queue is empty. + if (runComplete && dispatchIdle) cleanup(); + }; + const startTypingLoop = async () => { + if (sealed) return; if (!onReplyStart) return; if (typingIntervalMs <= 0) return; if (typingTimer) return; @@ -78,6 +112,7 @@ export function createTypingController(params: { }; const startTypingOnText = async (text?: string) => { + if (sealed) return; const trimmed = text?.trim(); if (!trimmed) return; if (silentToken && trimmed === silentToken) return; @@ -85,11 +120,23 @@ export function createTypingController(params: { await startTypingLoop(); }; + const markRunComplete = () => { + runComplete = true; + maybeStopOnIdle(); + }; + + const markDispatchIdle = () => { + dispatchIdle = true; + maybeStopOnIdle(); + }; + return { onReplyStart: ensureStart, startTypingLoop, startTypingOnText, refreshTypingTtl, + markRunComplete, + markDispatchIdle, cleanup, }; } diff --git a/src/auto-reply/send-policy.ts b/src/auto-reply/send-policy.ts index 4b4ad6dbe..272720949 100644 --- a/src/auto-reply/send-policy.ts +++ b/src/auto-reply/send-policy.ts @@ -17,7 +17,7 @@ export function parseSendPolicyCommand(raw?: string): { if (!raw) return { hasCommand: false }; const trimmed = raw.trim(); if (!trimmed) return { hasCommand: false }; - const match = trimmed.match(/^\/?send\b(?:\s+([a-zA-Z]+))?/i); + const match = trimmed.match(/^\/send(?:\s+([a-zA-Z]+))?\s*$/i); if (!match) return { hasCommand: false }; const token = match[1]?.trim().toLowerCase(); if (!token) return { hasCommand: true }; diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 3d4bc9587..878065913 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -24,7 +24,7 @@ describe("buildStatusMessage", () => { verboseLevel: "on", compactionCount: 2, }, - sessionKey: "main", + sessionKey: "agent:main:main", sessionScope: "per-sender", storePath: "/tmp/sessions.json", resolvedThink: "medium", @@ -39,7 +39,7 @@ describe("buildStatusMessage", () => { expect(text).toContain("Agent: embedded pi"); expect(text).toContain("Runtime: direct"); expect(text).toContain("Context: 16k/32k (50%)"); - expect(text).toContain("Session: main"); + expect(text).toContain("Session: agent:main:main"); expect(text).toContain("compactions 2"); expect(text).toContain("Web: linked"); expect(text).toContain("heartbeat 45s"); @@ -70,7 +70,7 @@ describe("buildStatusMessage", () => { groupActivation: "always", chatType: "group", }, - sessionKey: "whatsapp:group:123@g.us", + sessionKey: "agent:main:whatsapp:group:123@g.us", sessionScope: "per-sender", webLinked: true, }); @@ -91,6 +91,8 @@ describe("buildStatusMessage", () => { const storePath = path.join( dir, ".clawdbot", + "agents", + "main", "sessions", "sessions.json", ); @@ -98,6 +100,8 @@ describe("buildStatusMessage", () => { const logPath = path.join( dir, ".clawdbot", + "agents", + "main", "sessions", `${sessionId}.jsonl`, ); @@ -135,7 +139,7 @@ describe("buildStatusMessage", () => { totalTokens: 3, // would be wrong if cached prompt tokens exist contextTokens: 32_000, }, - sessionKey: "main", + sessionKey: "agent:main:main", sessionScope: "per-sender", storePath, webLinked: true, diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index be5df6196..5b229c076 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -3,6 +3,8 @@ export type MsgContext = { From?: string; To?: string; SessionKey?: string; + /** Provider account id (multi-account). */ + AccountId?: string; MessageSid?: string; ReplyToId?: string; ReplyToBody?: string; @@ -10,6 +12,9 @@ export type MsgContext = { MediaPath?: string; MediaUrl?: string; MediaType?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; Transcript?: string; ChatType?: string; GroupSubject?: string; @@ -21,9 +26,13 @@ export type MsgContext = { SenderUsername?: string; SenderTag?: string; SenderE164?: string; + /** Provider label (whatsapp|telegram|discord|imessage|...). */ + Provider?: string; + /** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */ Surface?: string; WasMentioned?: boolean; CommandAuthorized?: boolean; + CommandSource?: "text" | "native"; }; export type TemplateContext = MsgContext & { diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 3ab927358..b76a0a5a1 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -1,5 +1,8 @@ +import type { TypingController } from "./reply/typing.js"; + export type GetReplyOptions = { onReplyStart?: () => Promise | void; + onTypingController?: (typing: TypingController) => void; isHeartbeat?: boolean; onPartialReply?: (payload: ReplyPayload) => Promise | void; onBlockReply?: (payload: ReplyPayload) => Promise | void; @@ -11,4 +14,5 @@ export type ReplyPayload = { mediaUrl?: string; mediaUrls?: string[]; replyToId?: string; + isError?: boolean; }; diff --git a/src/browser/routes/tabs.ts b/src/browser/routes/tabs.ts index ef786afa6..509395414 100644 --- a/src/browser/routes/tabs.ts +++ b/src/browser/routes/tabs.ts @@ -1,18 +1,26 @@ import type express from "express"; import type { BrowserRouteContext } from "../server-context.js"; -import { jsonError, toNumber, toStringOrEmpty } from "./utils.js"; +import { + getProfileContext, + jsonError, + toNumber, + toStringOrEmpty, +} from "./utils.js"; export function registerBrowserTabRoutes( app: express.Express, ctx: BrowserRouteContext, ) { - app.get("/tabs", async (_req, res) => { + app.get("/tabs", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); try { - const reachable = await ctx.isReachable(300); + const reachable = await profileCtx.isReachable(300); if (!reachable) return res.json({ running: false, tabs: [] as unknown[] }); - const tabs = await ctx.listTabs(); + const tabs = await profileCtx.listTabs(); res.json({ running: true, tabs }); } catch (err) { jsonError(res, 500, String(err)); @@ -20,11 +28,14 @@ export function registerBrowserTabRoutes( }); app.post("/tabs/open", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); const url = toStringOrEmpty((req.body as { url?: unknown })?.url); if (!url) return jsonError(res, 400, "url is required"); try { - await ctx.ensureBrowserAvailable(); - const tab = await ctx.openTab(url); + await profileCtx.ensureBrowserAvailable(); + const tab = await profileCtx.openTab(url); res.json(tab); } catch (err) { jsonError(res, 500, String(err)); @@ -32,14 +43,17 @@ export function registerBrowserTabRoutes( }); app.post("/tabs/focus", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); const targetId = toStringOrEmpty( (req.body as { targetId?: unknown })?.targetId, ); if (!targetId) return jsonError(res, 400, "targetId is required"); try { - if (!(await ctx.isReachable(300))) + if (!(await profileCtx.isReachable(300))) return jsonError(res, 409, "browser not running"); - await ctx.focusTab(targetId); + await profileCtx.focusTab(targetId); res.json({ ok: true }); } catch (err) { const mapped = ctx.mapTabError(err); @@ -49,12 +63,15 @@ export function registerBrowserTabRoutes( }); app.delete("/tabs/:targetId", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); const targetId = toStringOrEmpty(req.params.targetId); if (!targetId) return jsonError(res, 400, "targetId is required"); try { - if (!(await ctx.isReachable(300))) + if (!(await profileCtx.isReachable(300))) return jsonError(res, 409, "browser not running"); - await ctx.closeTab(targetId); + await profileCtx.closeTab(targetId); res.json({ ok: true }); } catch (err) { const mapped = ctx.mapTabError(err); @@ -64,37 +81,40 @@ export function registerBrowserTabRoutes( }); app.post("/tabs/action", async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) + return jsonError(res, profileCtx.status, profileCtx.error); const action = toStringOrEmpty((req.body as { action?: unknown })?.action); const index = toNumber((req.body as { index?: unknown })?.index); try { if (action === "list") { - const reachable = await ctx.isReachable(300); + const reachable = await profileCtx.isReachable(300); if (!reachable) return res.json({ ok: true, tabs: [] as unknown[] }); - const tabs = await ctx.listTabs(); + const tabs = await profileCtx.listTabs(); return res.json({ ok: true, tabs }); } if (action === "new") { - await ctx.ensureBrowserAvailable(); - const tab = await ctx.openTab("about:blank"); + await profileCtx.ensureBrowserAvailable(); + const tab = await profileCtx.openTab("about:blank"); return res.json({ ok: true, tab }); } if (action === "close") { - const tabs = await ctx.listTabs(); + const tabs = await profileCtx.listTabs(); const target = typeof index === "number" ? tabs[index] : tabs.at(0); if (!target) return jsonError(res, 404, "tab not found"); - await ctx.closeTab(target.targetId); + await profileCtx.closeTab(target.targetId); return res.json({ ok: true, targetId: target.targetId }); } if (action === "select") { if (typeof index !== "number") return jsonError(res, 400, "index is required"); - const tabs = await ctx.listTabs(); + const tabs = await profileCtx.listTabs(); const target = tabs[index]; if (!target) return jsonError(res, 404, "tab not found"); - await ctx.focusTab(target.targetId); + await profileCtx.focusTab(target.targetId); return res.json({ ok: true, targetId: target.targetId }); } diff --git a/src/browser/server.test.ts b/src/browser/server.test.ts index d1318771b..4124c956c 100644 --- a/src/browser/server.test.ts +++ b/src/browser/server.test.ts @@ -894,6 +894,61 @@ describe("backward compatibility (profile parameter)", () => { // Should at least have the default clawd profile expect(result.profiles.some((p) => p.name === "clawd")).toBe(true); }); + + it("GET /tabs?profile=clawd returns tabs for specified profile", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + await realFetch(`${base}/start`, { method: "POST" }); + + const result = (await realFetch(`${base}/tabs?profile=clawd`).then((r) => + r.json(), + )) as { running: boolean; tabs: unknown[] }; + expect(result.running).toBe(true); + expect(Array.isArray(result.tabs)).toBe(true); + }); + + it("POST /tabs/open?profile=clawd opens tab in specified profile", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + await realFetch(`${base}/start`, { method: "POST" }); + + const result = (await realFetch(`${base}/tabs/open?profile=clawd`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }).then((r) => r.json())) as { targetId?: string }; + expect(result.targetId).toBe("newtab1"); + }); + + it("GET /tabs?profile=unknown returns 404", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + const result = await realFetch(`${base}/tabs?profile=unknown`); + expect(result.status).toBe(404); + const body = (await result.json()) as { error: string }; + expect(body.error).toContain("not found"); + }); + + it("POST /tabs/open?profile=unknown returns 404", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + const result = await realFetch(`${base}/tabs/open?profile=unknown`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }); + expect(result.status).toBe(404); + const body = (await result.json()) as { error: string }; + expect(body.error).toContain("not found"); + }); }); describe("profile CRUD endpoints", () => { diff --git a/src/canvas-host/a2ui.ts b/src/canvas-host/a2ui.ts index 0c55c3c54..ff871aec2 100644 --- a/src/canvas-host/a2ui.ts +++ b/src/canvas-host/a2ui.ts @@ -15,7 +15,7 @@ let resolvingA2uiRoot: Promise | null = null; async function resolveA2uiRoot(): Promise { const here = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ - // Running from source (tsx) or dist (tsc + copied assets). + // Running from source (bun) or dist (tsc + copied assets). path.resolve(here, "a2ui"), // Running from dist without copied assets (fallback to source). path.resolve(here, "../../src/canvas-host/a2ui"), diff --git a/src/cli/browser-cli-actions-input.ts b/src/cli/browser-cli-actions-input.ts index a2c412105..ad19b6648 100644 --- a/src/cli/browser-cli-actions-input.ts +++ b/src/cli/browser-cli-actions-input.ts @@ -64,7 +64,7 @@ export function registerBrowserActionInputCommands( .action(async (url: string, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserNavigate(baseUrl, { url, @@ -91,7 +91,7 @@ export function registerBrowserActionInputCommands( .action(async (width: number, height: number, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; if (!Number.isFinite(width) || !Number.isFinite(height)) { defaultRuntime.error(danger("width and height must be numbers")); defaultRuntime.exit(1); @@ -130,7 +130,7 @@ export function registerBrowserActionInputCommands( .action(async (ref: string | undefined, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; const refValue = typeof ref === "string" ? ref.trim() : ""; if (!refValue) { defaultRuntime.error(danger("ref is required")); @@ -179,7 +179,7 @@ export function registerBrowserActionInputCommands( .action(async (ref: string | undefined, text: string, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; const refValue = typeof ref === "string" ? ref.trim() : ""; if (!refValue) { defaultRuntime.error(danger("ref is required")); @@ -218,7 +218,7 @@ export function registerBrowserActionInputCommands( .action(async (key: string, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserAct( baseUrl, @@ -248,7 +248,7 @@ export function registerBrowserActionInputCommands( .action(async (ref: string, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserAct( baseUrl, @@ -279,7 +279,7 @@ export function registerBrowserActionInputCommands( .action(async (startRef: string, endRef: string, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserAct( baseUrl, @@ -311,7 +311,7 @@ export function registerBrowserActionInputCommands( .action(async (ref: string, values: string[], opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserAct( baseUrl, @@ -350,7 +350,7 @@ export function registerBrowserActionInputCommands( .action(async (paths: string[], opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserArmFileChooser(baseUrl, { paths, @@ -383,7 +383,7 @@ export function registerBrowserActionInputCommands( .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const fields = await readFields({ fields: opts.fields, @@ -424,7 +424,7 @@ export function registerBrowserActionInputCommands( .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; const accept = opts.accept ? true : opts.dismiss ? false : undefined; if (accept === undefined) { defaultRuntime.error(danger("Specify --accept or --dismiss")); @@ -462,7 +462,7 @@ export function registerBrowserActionInputCommands( .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserAct( baseUrl, @@ -495,7 +495,7 @@ export function registerBrowserActionInputCommands( .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; if (!opts.fn) { defaultRuntime.error(danger("Missing --fn")); defaultRuntime.exit(1); diff --git a/src/cli/browser-cli-actions-observe.ts b/src/cli/browser-cli-actions-observe.ts index b39a3347e..0eade88a6 100644 --- a/src/cli/browser-cli-actions-observe.ts +++ b/src/cli/browser-cli-actions-observe.ts @@ -20,7 +20,7 @@ export function registerBrowserActionObserveCommands( .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserConsoleMessages(baseUrl, { level: opts.level?.trim() || undefined, @@ -45,7 +45,7 @@ export function registerBrowserActionObserveCommands( .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserPdfSave(baseUrl, { targetId: opts.targetId?.trim() || undefined, diff --git a/src/cli/browser-cli-inspect.ts b/src/cli/browser-cli-inspect.ts index 0a78af641..0bc528bd4 100644 --- a/src/cli/browser-cli-inspect.ts +++ b/src/cli/browser-cli-inspect.ts @@ -24,7 +24,7 @@ export function registerBrowserInspectCommands( .action(async (targetId: string | undefined, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserScreenshotAction(baseUrl, { targetId: targetId?.trim() || undefined, @@ -59,7 +59,7 @@ export function registerBrowserInspectCommands( .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; const format = opts.format === "aria" ? "aria" : "ai"; try { const result = await browserSnapshot(baseUrl, { diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index 725b7f987..6164c8c73 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -31,7 +31,7 @@ export function registerBrowserManageCommands( const baseUrl = resolveBrowserControlUrl(parent?.url); try { const status = await browserStatus(baseUrl, { - profile: parent?.profile, + profile: parent?.browserProfile, }); if (parent?.json) { defaultRuntime.log(JSON.stringify(status, null, 2)); @@ -61,7 +61,7 @@ export function registerBrowserManageCommands( .action(async (_opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { await browserStart(baseUrl, { profile }); const status = await browserStatus(baseUrl, { profile }); @@ -85,7 +85,7 @@ export function registerBrowserManageCommands( .action(async (_opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { await browserStop(baseUrl, { profile }); const status = await browserStatus(baseUrl, { profile }); @@ -109,7 +109,7 @@ export function registerBrowserManageCommands( .action(async (_opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const result = await browserResetProfile(baseUrl, { profile }); if (parent?.json) { @@ -134,7 +134,7 @@ export function registerBrowserManageCommands( .action(async (_opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const tabs = await browserTabs(baseUrl, { profile }); if (parent?.json) { @@ -166,7 +166,7 @@ export function registerBrowserManageCommands( .action(async (url: string, _opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { const tab = await browserOpenTab(baseUrl, url, { profile }); if (parent?.json) { @@ -187,7 +187,7 @@ export function registerBrowserManageCommands( .action(async (targetId: string, _opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { await browserFocusTab(baseUrl, targetId, { profile }); if (parent?.json) { @@ -208,7 +208,7 @@ export function registerBrowserManageCommands( .action(async (targetId: string | undefined, _opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const profile = parent?.profile; + const profile = parent?.browserProfile; try { if (targetId?.trim()) { await browserCloseTab(baseUrl, targetId.trim(), { profile }); diff --git a/src/cli/browser-cli-shared.ts b/src/cli/browser-cli-shared.ts index b280052a2..2e110f186 100644 --- a/src/cli/browser-cli-shared.ts +++ b/src/cli/browser-cli-shared.ts @@ -1,5 +1,5 @@ export type BrowserParentOpts = { url?: string; json?: boolean; - profile?: string; + browserProfile?: string; }; diff --git a/src/cli/browser-cli.test.ts b/src/cli/browser-cli.test.ts new file mode 100644 index 000000000..6dc5a0a48 --- /dev/null +++ b/src/cli/browser-cli.test.ts @@ -0,0 +1,86 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; + +describe("browser CLI --browser-profile flag", () => { + it("parses --browser-profile from parent command options", () => { + const program = new Command(); + program.name("test"); + + const browser = program + .command("browser") + .option("--browser-profile ", "Browser profile name"); + + let capturedProfile: string | undefined; + + browser.command("status").action((_opts, cmd) => { + const parent = cmd.parent?.opts?.() as { browserProfile?: string }; + capturedProfile = parent?.browserProfile; + }); + + program.parse([ + "node", + "test", + "browser", + "--browser-profile", + "onasset", + "status", + ]); + + expect(capturedProfile).toBe("onasset"); + }); + + it("defaults to undefined when --browser-profile not provided", () => { + const program = new Command(); + program.name("test"); + + const browser = program + .command("browser") + .option("--browser-profile ", "Browser profile name"); + + let capturedProfile: string | undefined = "should-be-undefined"; + + browser.command("status").action((_opts, cmd) => { + const parent = cmd.parent?.opts?.() as { browserProfile?: string }; + capturedProfile = parent?.browserProfile; + }); + + program.parse(["node", "test", "browser", "status"]); + + expect(capturedProfile).toBeUndefined(); + }); + + it("does not conflict with global --profile flag", () => { + // The global --profile flag is handled by entry.js before Commander + // This test verifies --browser-profile is a separate option + const program = new Command(); + program.name("test"); + program.option("--profile ", "Global config profile"); + + const browser = program + .command("browser") + .option("--browser-profile ", "Browser profile name"); + + let globalProfile: string | undefined; + let browserProfile: string | undefined; + + browser.command("status").action((_opts, cmd) => { + const parent = cmd.parent?.opts?.() as { browserProfile?: string }; + browserProfile = parent?.browserProfile; + globalProfile = program.opts().profile; + }); + + program.parse([ + "node", + "test", + "--profile", + "dev", + "browser", + "--browser-profile", + "onasset", + "status", + ]); + + expect(globalProfile).toBe("dev"); + expect(browserProfile).toBe("onasset"); + }); +}); diff --git a/src/cli/browser-cli.ts b/src/cli/browser-cli.ts index e6bd5adfc..88d6022f5 100644 --- a/src/cli/browser-cli.ts +++ b/src/cli/browser-cli.ts @@ -20,7 +20,10 @@ export function registerBrowserCli(program: Command) { "--url ", "Override browser control URL (default from ~/.clawdbot/clawdbot.json)", ) - .option("--profile ", "Browser profile name (default from config)") + .option( + "--browser-profile ", + "Browser profile name (default from config)", + ) .option("--json", "Output machine-readable JSON", false) .addHelpText( "after", diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index 94076ea3d..bca2b160f 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -154,8 +154,8 @@ export function registerCronCli(program: Command) { .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--deliver", "Deliver agent output", false) .option( - "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", + "--provider ", + "Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)", "last", ) .option( @@ -255,7 +255,8 @@ export function registerCronCli(program: Command) { ? timeoutSeconds : undefined, deliver: Boolean(opts.deliver), - channel: typeof opts.channel === "string" ? opts.channel : "last", + provider: + typeof opts.provider === "string" ? opts.provider : "last", to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() @@ -413,8 +414,8 @@ export function registerCronCli(program: Command) { .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--deliver", "Deliver agent output", false) .option( - "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", + "--provider ", + "Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)", ) .option( "--to ", @@ -502,8 +503,8 @@ export function registerCronCli(program: Command) { ? timeoutSeconds : undefined, deliver: Boolean(opts.deliver), - channel: - typeof opts.channel === "string" ? opts.channel : undefined, + provider: + typeof opts.provider === "string" ? opts.provider : undefined, to: typeof opts.to === "string" ? opts.to : undefined, bestEffortDeliver: Boolean(opts.bestEffortDeliver), }; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 7daf257ca..6ac33db34 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -51,6 +51,24 @@ function parsePort(raw: unknown): number | null { return parsed; } +function describeUnknownError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + if (typeof err === "number" || typeof err === "bigint") return err.toString(); + if (typeof err === "boolean") return err ? "true" : "false"; + if (err && typeof err === "object") { + if ("message" in err && typeof err.message === "string") { + return err.message; + } + try { + return JSON.stringify(err); + } catch { + return "Unknown error"; + } + } + return "Unknown error"; +} + function renderGatewayServiceStopHints(): string[] { switch (process.platform) { case "darwin": @@ -353,12 +371,7 @@ export function registerGatewayCli(program: Command) { typeof err === "object" && (err as { name?: string }).name === "GatewayLockError") ) { - const errMessage = - err instanceof Error - ? err.message - : typeof err === "object" && err !== null && "message" in err - ? String((err as { message?: unknown }).message ?? "") - : String(err); + const errMessage = describeUnknownError(err); defaultRuntime.error( `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`, ); @@ -568,12 +581,7 @@ export function registerGatewayCli(program: Command) { typeof err === "object" && (err as { name?: string }).name === "GatewayLockError") ) { - const errMessage = - err instanceof Error - ? err.message - : typeof err === "object" && err !== null && "message" in err - ? String((err as { message?: unknown }).message ?? "") - : String(err); + const errMessage = describeUnknownError(err); defaultRuntime.error( `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`, ); diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts index 5d722cf16..533cd06f4 100644 --- a/src/cli/gateway.sigterm.test.ts +++ b/src/cli/gateway.sigterm.test.ts @@ -90,10 +90,8 @@ describe("gateway SIGTERM", () => { const err: string[] = []; child = spawn( - process.execPath, + "bun", [ - "--import", - "tsx", "src/index.ts", "gateway", "--port", diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index cc6adf9c3..9ee9e64f4 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -23,7 +23,13 @@ import { defaultRuntime } from "../runtime.js"; export function registerModelsCli(program: Command) { const models = program .command("models") - .description("Model discovery, scanning, and configuration"); + .description("Model discovery, scanning, and configuration") + .option("--json", "Output JSON (alias for `models status --json`)", false) + .option( + "--plain", + "Plain output (alias for `models status --plain`)", + false, + ); models .command("list") @@ -264,9 +270,9 @@ export function registerModelsCli(program: Command) { } }); - models.action(async () => { + models.action(async (opts) => { try { - await modelsStatusCommand({}, defaultRuntime); + await modelsStatusCommand(opts ?? {}, defaultRuntime); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts new file mode 100644 index 000000000..eee882da6 --- /dev/null +++ b/src/cli/pairing-cli.ts @@ -0,0 +1,122 @@ +import type { Command } from "commander"; + +import { loadConfig } from "../config/config.js"; +import { sendMessageDiscord } from "../discord/send.js"; +import { sendMessageIMessage } from "../imessage/send.js"; +import { + approveProviderPairingCode, + listProviderPairingRequests, + type PairingProvider, +} from "../pairing/pairing-store.js"; +import { sendMessageSignal } from "../signal/send.js"; +import { sendMessageSlack } from "../slack/send.js"; +import { sendMessageTelegram } from "../telegram/send.js"; +import { resolveTelegramToken } from "../telegram/token.js"; + +const PROVIDERS: PairingProvider[] = [ + "telegram", + "signal", + "imessage", + "discord", + "slack", + "whatsapp", +]; + +function parseProvider(raw: unknown): PairingProvider { + const value = String(raw ?? "") + .trim() + .toLowerCase(); + if ((PROVIDERS as string[]).includes(value)) return value as PairingProvider; + throw new Error( + `Invalid provider: ${value || "(empty)"} (expected one of: ${PROVIDERS.join(", ")})`, + ); +} + +async function notifyApproved(provider: PairingProvider, id: string) { + const message = + "✅ Clawdbot access approved. Send a message to start chatting."; + if (provider === "telegram") { + const cfg = loadConfig(); + const { token } = resolveTelegramToken(cfg); + if (!token) throw new Error("telegram token not configured"); + await sendMessageTelegram(id, message, { token }); + return; + } + if (provider === "discord") { + await sendMessageDiscord(`user:${id}`, message); + return; + } + if (provider === "slack") { + await sendMessageSlack(`user:${id}`, message); + return; + } + if (provider === "signal") { + await sendMessageSignal(id, message); + return; + } + if (provider === "imessage") { + await sendMessageIMessage(id, message); + return; + } + // WhatsApp: approval still works (store); notifying requires an active web session. +} + +export function registerPairingCli(program: Command) { + const pairing = program + .command("pairing") + .description("Secure DM pairing (approve inbound requests)"); + + pairing + .command("list") + .description("List pending pairing requests") + .requiredOption( + "--provider ", + `Provider (${PROVIDERS.join(", ")})`, + ) + .option("--json", "Print JSON", false) + .action(async (opts) => { + const provider = parseProvider(opts.provider); + const requests = await listProviderPairingRequests(provider); + if (opts.json) { + console.log(JSON.stringify({ provider, requests }, null, 2)); + return; + } + if (requests.length === 0) { + console.log(`No pending ${provider} pairing requests.`); + return; + } + for (const r of requests) { + const meta = r.meta ? JSON.stringify(r.meta) : ""; + console.log( + `${r.code} id=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`, + ); + } + }); + + pairing + .command("approve") + .description("Approve a pairing code and allow that sender") + .requiredOption( + "--provider ", + `Provider (${PROVIDERS.join(", ")})`, + ) + .argument("", "Pairing code (shown to the requester)") + .option("--notify", "Notify the requester on the same provider", false) + .action(async (code, opts) => { + const provider = parseProvider(opts.provider); + const approved = await approveProviderPairingCode({ + provider, + code: String(code), + }); + if (!approved) { + throw new Error(`No pending pairing request found for code: ${code}`); + } + + console.log(`Approved ${provider} sender ${approved.id}.`); + + if (!opts.notify) return; + await notifyApproved(provider, approved.id).catch((err) => { + console.log(`Failed to notify requester: ${String(err)}`); + }); + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 98bc51352..d66fb0738 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,10 +1,11 @@ import chalk from "chalk"; import { Command } from "commander"; -import { agentCommand } from "../commands/agent.js"; +import { agentCliCommand } from "../commands/agent-via-gateway.js"; import { configureCommand } from "../commands/configure.js"; import { doctorCommand } from "../commands/doctor.js"; import { healthCommand } from "../commands/health.js"; import { onboardCommand } from "../commands/onboard.js"; +import { pollCommand } from "../commands/poll.js"; import { sendCommand } from "../commands/send.js"; import { sessionsCommand } from "../commands/sessions.js"; import { setupCommand } from "../commands/setup.js"; @@ -12,6 +13,7 @@ import { statusCommand } from "../commands/status.js"; import { updateCommand } from "../commands/update.js"; import { isNixMode, + loadConfig, migrateLegacyConfig, readConfigFileSnapshot, writeConfigFile, @@ -20,6 +22,7 @@ import { danger, setVerbose } from "../globals.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; +import { resolveWhatsAppAccount } from "../web/accounts.js"; import { registerBrowserCli } from "./browser-cli.js"; import { registerCanvasCli } from "./canvas-cli.js"; import { registerCronCli } from "./cron-cli.js"; @@ -29,7 +32,9 @@ import { registerGatewayCli } from "./gateway-cli.js"; import { registerHooksCli } from "./hooks-cli.js"; import { registerModelsCli } from "./models-cli.js"; import { registerNodesCli } from "./nodes-cli.js"; +import { registerPairingCli } from "./pairing-cli.js"; import { forceFreePort } from "./ports.js"; +import { registerTelegramCli } from "./telegram-cli.js"; import { registerTuiCli } from "./tui-cli.js"; export { forceFreePort }; @@ -288,9 +293,16 @@ export function buildProgram() { program .command("doctor") .description("Health checks + quick fixes for the gateway and providers") - .action(async () => { + .option( + "--no-workspace-suggestions", + "Disable workspace memory system suggestions", + false, + ) + .action(async (opts) => { try { - await doctorCommand(defaultRuntime); + await doctorCommand(defaultRuntime, { + workspaceSuggestions: opts.workspaceSuggestions, + }); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); @@ -314,11 +326,18 @@ export function buildProgram() { .description("Link your personal WhatsApp via QR (web provider)") .option("--verbose", "Verbose connection logs", false) .option("--provider ", "Provider alias (default: whatsapp)") + .option("--account ", "WhatsApp account id (accountId)") .action(async (opts) => { setVerbose(Boolean(opts.verbose)); try { const provider = opts.provider ?? "whatsapp"; - await loginWeb(Boolean(opts.verbose), provider); + await loginWeb( + Boolean(opts.verbose), + provider, + undefined, + defaultRuntime, + opts.account as string | undefined, + ); } catch (err) { defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); defaultRuntime.exit(1); @@ -329,10 +348,20 @@ export function buildProgram() { .command("logout") .description("Clear cached WhatsApp Web credentials") .option("--provider ", "Provider alias (default: whatsapp)") + .option("--account ", "WhatsApp account id (accountId)") .action(async (opts) => { try { void opts.provider; // placeholder for future multi-provider; currently web only. - await logoutWeb(defaultRuntime); + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ + cfg, + accountId: opts.account as string | undefined, + }); + await logoutWeb({ + runtime: defaultRuntime, + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, + }); } catch (err) { defaultRuntime.error(danger(`Logout failed: ${String(err)}`)); defaultRuntime.exit(1); @@ -362,6 +391,7 @@ export function buildProgram() { "--provider ", "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)", ) + .option("--account ", "WhatsApp account id (accountId)") .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) .option("--verbose", "Verbose logging", false) @@ -378,7 +408,63 @@ Examples: setVerbose(Boolean(opts.verbose)); const deps = createDefaultDeps(); try { - await sendCommand(opts, deps, defaultRuntime); + await sendCommand( + { + ...opts, + account: opts.account as string | undefined, + }, + deps, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + program + .command("poll") + .description("Create a poll via WhatsApp or Discord") + .requiredOption( + "-t, --to ", + "Recipient: WhatsApp JID/number or Discord channel/user", + ) + .requiredOption("-q, --question ", "Poll question") + .requiredOption( + "-o, --option ", + "Poll option (use multiple times, 2-12 required)", + (value: string, previous: string[]) => previous.concat([value]), + [] as string[], + ) + .option( + "-s, --max-selections ", + "How many options can be selected (default: 1)", + ) + .option( + "--duration-hours ", + "Poll duration in hours (Discord only, default: 24)", + ) + .option( + "--provider ", + "Delivery provider: whatsapp|discord (default: whatsapp)", + ) + .option("--dry-run", "Print payload and skip sending", false) + .option("--json", "Output result as JSON", false) + .option("--verbose", "Verbose logging", false) + .addHelpText( + "after", + ` +Examples: + clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe" + clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2 + clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord + clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`, + ) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + const deps = createDefaultDeps(); + try { + await pollCommand(opts, deps, defaultRuntime); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); @@ -387,9 +473,7 @@ Examples: program .command("agent") - .description( - "Talk directly to the configured agent (no chat send; optional delivery)", - ) + .description("Run an agent turn via the Gateway (use --local for embedded)") .requiredOption("-m, --message ", "Message body for the agent") .option( "-t, --to ", @@ -405,6 +489,11 @@ Examples: "--provider ", "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)", ) + .option( + "--local", + "Run the embedded agent locally (requires provider API keys in your shell)", + false, + ) .option( "--deliver", "Send the agent's reply back to the selected provider (requires --to)", @@ -430,9 +519,9 @@ Examples: typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : ""; setVerbose(verboseLevel === "on"); // Build default deps (keeps parity with other commands; future-proofing). - void createDefaultDeps(); + const deps = createDefaultDeps(); try { - await agentCommand(opts, defaultRuntime); + await agentCliCommand(opts, defaultRuntime, deps); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); @@ -447,6 +536,8 @@ Examples: registerCronCli(program); registerDnsCli(program); registerHooksCli(program); + registerPairingCli(program); + registerTelegramCli(program); program .command("status") diff --git a/src/cli/telegram-cli.ts b/src/cli/telegram-cli.ts new file mode 100644 index 000000000..dbe31aec6 --- /dev/null +++ b/src/cli/telegram-cli.ts @@ -0,0 +1,74 @@ +import type { Command } from "commander"; + +import { loadConfig } from "../config/config.js"; +import { + approveTelegramPairingCode, + listTelegramPairingRequests, +} from "../telegram/pairing-store.js"; +import { sendMessageTelegram } from "../telegram/send.js"; +import { resolveTelegramToken } from "../telegram/token.js"; + +export function registerTelegramCli(program: Command) { + const telegram = program + .command("telegram") + .description("Telegram helpers (pairing, allowlists)"); + + const pairing = telegram + .command("pairing") + .description("Secure DM pairing (approve inbound requests)"); + + pairing + .command("list") + .description("List pending Telegram pairing requests") + .option("--json", "Print JSON", false) + .action(async (opts) => { + const requests = await listTelegramPairingRequests(); + if (opts.json) { + console.log(JSON.stringify({ requests }, null, 2)); + return; + } + if (requests.length === 0) { + console.log("No pending Telegram pairing requests."); + return; + } + for (const r of requests) { + const name = [r.firstName, r.lastName].filter(Boolean).join(" ").trim(); + const username = r.username ? `@${r.username}` : ""; + const who = [name, username].filter(Boolean).join(" ").trim(); + console.log( + `${r.code} chatId=${r.chatId}${who ? ` ${who}` : ""} ${r.createdAt}`, + ); + } + }); + + pairing + .command("approve") + .description("Approve a pairing code and allow that chatId") + .argument("", "Pairing code (shown to the requester)") + .option("--no-notify", "Do not notify the requester on Telegram") + .action(async (code, opts) => { + const approved = await approveTelegramPairingCode({ code: String(code) }); + if (!approved) { + throw new Error(`No pending pairing request found for code: ${code}`); + } + + console.log(`Approved Telegram chatId ${approved.chatId}.`); + + if (opts.notify === false) return; + const cfg = loadConfig(); + const { token } = resolveTelegramToken(cfg); + if (!token) { + console.log( + "Telegram token not configured; skipping requester notification.", + ); + return; + } + await sendMessageTelegram( + approved.chatId, + "✅ Clawdbot access approved. Send a message to start chatting.", + { token }, + ).catch((err) => { + console.log(`Failed to notify requester: ${String(err)}`); + }); + }); +} diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts new file mode 100644 index 000000000..cd0867582 --- /dev/null +++ b/src/commands/agent-via-gateway.test.ts @@ -0,0 +1,126 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(), + randomIdempotencyKey: () => "idem-1", +})); +vi.mock("./agent.js", () => ({ + agentCommand: vi.fn(), +})); + +import type { ClawdbotConfig } from "../config/config.js"; +import * as configModule from "../config/config.js"; +import { callGateway } from "../gateway/call.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { agentCommand } from "./agent.js"; +import { agentCliCommand } from "./agent-via-gateway.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +const configSpy = vi.spyOn(configModule, "loadConfig"); + +function mockConfig(storePath: string, overrides?: Partial) { + configSpy.mockReturnValue({ + agent: { + timeoutSeconds: 600, + ...overrides?.agent, + }, + session: { + store: storePath, + mainKey: "main", + ...overrides?.session, + }, + gateway: overrides?.gateway, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("agentCliCommand", () => { + it("uses gateway by default", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-cli-")); + const store = path.join(dir, "sessions.json"); + mockConfig(store); + + vi.mocked(callGateway).mockResolvedValue({ + runId: "idem-1", + status: "ok", + result: { + payloads: [{ text: "hello" }], + meta: { stub: true }, + }, + }); + + try { + await agentCliCommand({ message: "hi", to: "+1555" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(agentCommand).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("hello"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("falls back to embedded agent when gateway fails", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-cli-")); + const store = path.join(dir, "sessions.json"); + mockConfig(store); + + vi.mocked(callGateway).mockRejectedValue( + new Error("gateway not connected"), + ); + vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => { + rt.log?.("local"); + return { payloads: [{ text: "local" }], meta: { stub: true } }; + }); + + try { + await agentCliCommand({ message: "hi", to: "+1555" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(agentCommand).toHaveBeenCalledTimes(1); + expect(runtime.log).toHaveBeenCalledWith("local"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("skips gateway when --local is set", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-cli-")); + const store = path.join(dir, "sessions.json"); + mockConfig(store); + + vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => { + rt.log?.("local"); + return { payloads: [{ text: "local" }], meta: { stub: true } }; + }); + + try { + await agentCliCommand( + { + message: "hi", + to: "+1555", + local: true, + }, + runtime, + ); + + expect(callGateway).not.toHaveBeenCalled(); + expect(agentCommand).toHaveBeenCalledTimes(1); + expect(runtime.log).toHaveBeenCalledWith("local"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts new file mode 100644 index 000000000..b841b5da5 --- /dev/null +++ b/src/commands/agent-via-gateway.ts @@ -0,0 +1,194 @@ +import type { CliDeps } from "../cli/deps.js"; +import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveSessionKey, + resolveStorePath, +} from "../config/sessions.js"; +import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { agentCommand } from "./agent.js"; + +type AgentGatewayResult = { + payloads?: Array<{ + text?: string; + mediaUrl?: string | null; + mediaUrls?: string[]; + }>; + meta?: unknown; +}; + +type GatewayAgentResponse = { + runId?: string; + status?: string; + summary?: string; + result?: AgentGatewayResult; +}; + +export type AgentCliOpts = { + message: string; + to?: string; + sessionId?: string; + thinking?: string; + verbose?: string; + json?: boolean; + timeout?: string; + deliver?: boolean; + provider?: string; + bestEffortDeliver?: boolean; + lane?: string; + runId?: string; + extraSystemPrompt?: string; + local?: boolean; +}; + +function resolveGatewaySessionKey(opts: { + cfg: ReturnType; + to?: string; + sessionId?: string; +}): string | undefined { + const sessionCfg = opts.cfg.session; + const scope = sessionCfg?.scope ?? "per-sender"; + const mainKey = sessionCfg?.mainKey ?? "main"; + const storePath = resolveStorePath(sessionCfg?.store); + const store = loadSessionStore(storePath); + + const ctx = opts.to?.trim() ? ({ From: opts.to } as { From: string }) : null; + let sessionKey: string | undefined = ctx + ? resolveSessionKey(scope, ctx, mainKey) + : undefined; + + if ( + opts.sessionId && + (!sessionKey || store[sessionKey]?.sessionId !== opts.sessionId) + ) { + const foundKey = Object.keys(store).find( + (key) => store[key]?.sessionId === opts.sessionId, + ); + if (foundKey) sessionKey = foundKey; + } + + return sessionKey; +} + +function parseTimeoutSeconds(opts: { + cfg: ReturnType; + timeout?: string; +}) { + const raw = + opts.timeout !== undefined + ? Number.parseInt(String(opts.timeout), 10) + : (opts.cfg.agent?.timeoutSeconds ?? 600); + if (Number.isNaN(raw) || raw <= 0) { + throw new Error("--timeout must be a positive integer (seconds)"); + } + return raw; +} + +function normalizeProvider(raw?: string): string | undefined { + const normalized = raw?.trim().toLowerCase(); + if (!normalized) return undefined; + return normalized === "imsg" ? "imessage" : normalized; +} + +function formatPayloadForLog(payload: { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string | null; +}) { + const lines: string[] = []; + if (payload.text) lines.push(payload.text.trimEnd()); + const mediaUrl = + typeof payload.mediaUrl === "string" && payload.mediaUrl.trim() + ? payload.mediaUrl.trim() + : undefined; + const media = payload.mediaUrls ?? (mediaUrl ? [mediaUrl] : []); + for (const url of media) lines.push(`MEDIA:${url}`); + return lines.join("\n").trimEnd(); +} + +export async function agentViaGatewayCommand( + opts: AgentCliOpts, + runtime: RuntimeEnv, +) { + const body = (opts.message ?? "").trim(); + if (!body) throw new Error("Message (--message) is required"); + if (!opts.to && !opts.sessionId) { + throw new Error("Pass --to or --session-id to choose a session"); + } + + const cfg = loadConfig(); + const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout }); + const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000); + + const sessionKey = resolveGatewaySessionKey({ + cfg, + to: opts.to, + sessionId: opts.sessionId, + }); + + const provider = normalizeProvider(opts.provider) ?? "whatsapp"; + const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey(); + + const response = await callGateway({ + method: "agent", + params: { + message: body, + to: opts.to, + sessionId: opts.sessionId, + sessionKey, + thinking: opts.thinking, + deliver: Boolean(opts.deliver), + provider, + timeout: timeoutSeconds, + lane: opts.lane, + extraSystemPrompt: opts.extraSystemPrompt, + idempotencyKey, + }, + expectFinal: true, + timeoutMs: gatewayTimeoutMs, + clientName: "cli", + mode: "cli", + }); + + if (opts.json) { + runtime.log(JSON.stringify(response, null, 2)); + return response; + } + + const result = response?.result; + const payloads = result?.payloads ?? []; + + if (payloads.length === 0) { + runtime.log( + response?.summary ? String(response.summary) : "No reply from agent.", + ); + return response; + } + + for (const payload of payloads) { + const out = formatPayloadForLog(payload); + if (out) runtime.log(out); + } + + return response; +} + +export async function agentCliCommand( + opts: AgentCliOpts, + runtime: RuntimeEnv, + deps?: CliDeps, +) { + if (opts.local === true) { + return await agentCommand(opts, runtime, deps); + } + + try { + return await agentViaGatewayCommand(opts, runtime); + } catch (err) { + runtime.error?.( + `Gateway agent failed; falling back to embedded: ${String(err)}`, + ); + return await agentCommand(opts, runtime, deps); + } +} diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 18599c6a0..bd263c3ad 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -16,11 +16,17 @@ import { } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; +import { hasNonzeroUsage } from "../agents/usage.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; -import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { + chunkMarkdownText, + chunkText, + resolveTextChunkLimit, +} from "../auto-reply/chunk.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { normalizeThinkLevel, @@ -58,7 +64,8 @@ type AgentCommandOpts = { json?: boolean; timeout?: string; deliver?: boolean; - surface?: string; + /** Message provider context (webchat|voicewake|whatsapp|...). */ + messageProvider?: string; provider?: string; // delivery provider (whatsapp|telegram|...) bestEffortDeliver?: boolean; abortSignal?: AbortSignal; @@ -161,7 +168,7 @@ export async function agentCommand( const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, - ensureBootstrapFiles: true, + ensureBootstrapFiles: !cfg.agent?.skipBootstrap, }); const workspaceDir = workspace.dir; @@ -190,11 +197,17 @@ export async function agentCommand( const timeoutSecondsRaw = opts.timeout !== undefined ? Number.parseInt(String(opts.timeout), 10) - : (agentCfg?.timeoutSeconds ?? 600); - if (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) { + : undefined; + if ( + timeoutSecondsRaw !== undefined && + (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) + ) { throw new Error("--timeout must be a positive integer (seconds)"); } - const timeoutMs = Math.max(timeoutSecondsRaw, 1) * 1000; + const timeoutMs = resolveAgentTimeoutMs({ + cfg, + overrideSeconds: timeoutSecondsRaw, + }); const sessionResolution = resolveSession({ cfg, @@ -224,7 +237,7 @@ export async function agentCommand( cfg, entry: sessionEntry, sessionKey, - surface: sessionEntry?.surface, + provider: sessionEntry?.provider, chatType: sessionEntry?.chatType, }); if (sendPolicy === "deny") { @@ -372,8 +385,8 @@ export async function agentCommand( let fallbackProvider = provider; let fallbackModel = model; try { - const surface = - opts.surface?.trim().toLowerCase() || + const messageProvider = + opts.messageProvider?.trim().toLowerCase() || (() => { const raw = opts.provider?.trim().toLowerCase(); if (!raw) return undefined; @@ -387,7 +400,7 @@ export async function agentCommand( runEmbeddedPiAgent({ sessionId, sessionKey, - surface, + messageProvider, sessionFile, workspaceDir, config: cfg, @@ -474,7 +487,7 @@ export async function agentCommand( contextTokens, }; next.abortedLastRun = result.meta.aborted ?? false; - if (usage) { + if (hasNonzeroUsage(usage)) { const input = usage.input ?? 0; const output = usage.output ?? 0; const promptTokens = @@ -598,12 +611,14 @@ export async function agentCommand( 2, ), ); - if (!deliver) return; + if (!deliver) { + return { payloads: normalizedPayloads, meta: result.meta }; + } } if (payloads.length === 0) { runtime.log("No reply from agent."); - return; + return { payloads: [], meta: result.meta }; } const deliveryTextLimit = @@ -656,7 +671,7 @@ export async function agentCommand( if (deliveryProvider === "telegram" && telegramTarget) { try { if (media.length === 0) { - for (const chunk of chunkText(text, deliveryTextLimit)) { + for (const chunk of chunkMarkdownText(text, deliveryTextLimit)) { await deps.sendMessageTelegram(telegramTarget, chunk, { verbose: false, token: telegramToken || undefined, @@ -787,4 +802,11 @@ export async function agentCommand( } } } + + const normalizedPayloads = payloads.map((p) => ({ + text: p.text ?? "", + mediaUrl: p.mediaUrl ?? null, + mediaUrls: p.mediaUrls ?? (p.mediaUrl ? [p.mediaUrl] : undefined), + })); + return { payloads: normalizedPayloads, meta: result.meta }; } diff --git a/src/commands/configure.ts b/src/commands/configure.ts index e2b8b6451..98eca3125 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -10,7 +10,12 @@ import { spinner, text, } from "@clack/prompts"; -import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai"; +import { + loginAnthropic, + loginOpenAICodex, + type OAuthCredentials, + type OAuthProvider, +} from "@mariozechner/pi-ai"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, @@ -54,6 +59,10 @@ import { import { setupProviders } from "./onboard-providers.js"; import { promptRemoteGatewayConfig } from "./onboard-remote.js"; import { setupSkills } from "./onboard-skills.js"; +import { + applyOpenAICodexModelDefault, + OPENAI_CODEX_DEFAULT_MODEL, +} from "./openai-codex-model-default.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; type WizardSection = @@ -234,6 +243,7 @@ async function promptAuthConfig( message: "Model/auth choice", options: [ { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, + { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, { value: "antigravity", label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", @@ -244,7 +254,7 @@ async function promptAuthConfig( ], }), runtime, - ) as "oauth" | "antigravity" | "apiKey" | "minimax" | "skip"; + ) as "oauth" | "openai-codex" | "antigravity" | "apiKey" | "minimax" | "skip"; let next = cfg; @@ -286,6 +296,79 @@ async function promptAuthConfig( spin.stop("OAuth failed"); runtime.error(String(err)); } + } else if (authChoice === "openai-codex") { + const isRemote = isRemoteEnvironment(); + note( + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, paste the redirect URL back here.", + ].join("\n") + : [ + "Browser will open for OpenAI authentication.", + "If the callback doesn't auto-complete, paste the redirect URL.", + "OpenAI OAuth uses localhost:1455 for the callback.", + ].join("\n"), + "OpenAI Codex OAuth", + ); + const spin = spinner(); + spin.start("Starting OAuth flow…"); + let manualCodePromise: Promise | undefined; + try { + const creds = await loginOpenAICodex({ + onAuth: async ({ url }) => { + if (isRemote) { + spin.message("OAuth URL ready (see below)…"); + runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); + manualCodePromise = text({ + message: "Paste the redirect URL (or authorization code)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }).then((value) => String(guardCancel(value, runtime))); + } else { + spin.message("Complete sign-in in browser…"); + await openUrl(url); + runtime.log(`Open: ${url}`); + } + }, + onPrompt: async (prompt) => { + if (manualCodePromise) return manualCodePromise; + const code = guardCancel( + await text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + return String(code); + }, + onProgress: (msg) => spin.message(msg), + }); + spin.stop("OpenAI OAuth complete"); + if (creds) { + await writeOAuthCredentials( + "openai-codex" as unknown as OAuthProvider, + creds, + ); + next = applyAuthProfileConfig(next, { + profileId: "openai-codex:default", + provider: "openai-codex", + mode: "oauth", + }); + const applied = applyOpenAICodexModelDefault(next); + next = applied.next; + if (applied.changed) { + note( + `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, + "Model configured", + ); + } + } + } catch (err) { + spin.stop("OpenAI OAuth failed"); + runtime.error(String(err)); + } } else if (authChoice === "antigravity") { const isRemote = isRemoteEnvironment(); note( @@ -323,7 +406,7 @@ async function promptAuthConfig( if (oauthCreds) { await writeOAuthCredentials("google-antigravity", oauthCreds); next = applyAuthProfileConfig(next, { - profileId: "google-antigravity:default", + profileId: `google-antigravity:${oauthCreds.email ?? "default"}`, provider: "google-antigravity", mode: "oauth", }); diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts new file mode 100644 index 000000000..de8d47750 --- /dev/null +++ b/src/commands/doctor-state-migrations.test.ts @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { + detectLegacyStateMigrations, + runLegacyStateMigrations, +} from "./doctor-state-migrations.js"; + +let tempRoot: string | null = null; + +async function makeTempRoot() { + const root = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "clawdbot-doctor-"), + ); + tempRoot = root; + return root; +} + +afterEach(async () => { + if (!tempRoot) return; + await fs.promises.rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; +}); + +function writeJson5(filePath: string, value: unknown) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8"); +} + +describe("doctor legacy state migrations", () => { + it("migrates legacy sessions into agents//sessions", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + const legacySessionsDir = path.join(root, "sessions"); + fs.mkdirSync(legacySessionsDir, { recursive: true }); + + writeJson5(path.join(legacySessionsDir, "sessions.json"), { + "+1555": { sessionId: "a", updatedAt: 10 }, + "+1666": { sessionId: "b", updatedAt: 20 }, + "slack:channel:C123": { sessionId: "c", updatedAt: 30 }, + "group:abc": { sessionId: "d", updatedAt: 40 }, + "subagent:xyz": { sessionId: "e", updatedAt: 50 }, + }); + fs.writeFileSync(path.join(legacySessionsDir, "a.jsonl"), "a", "utf-8"); + fs.writeFileSync(path.join(legacySessionsDir, "b.jsonl"), "b", "utf-8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + const result = await runLegacyStateMigrations({ + detected, + now: () => 123, + }); + + expect(result.warnings).toEqual([]); + const targetDir = path.join(root, "agents", "main", "sessions"); + expect(fs.existsSync(path.join(targetDir, "a.jsonl"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "b.jsonl"))).toBe(true); + expect(fs.existsSync(path.join(legacySessionsDir, "a.jsonl"))).toBe(false); + + const store = JSON.parse( + fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), + ) as Record; + expect(store["agent:main:main"]?.sessionId).toBe("b"); + expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c"); + expect(store["group:abc"]?.sessionId).toBe("d"); + expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e"); + }); + + it("migrates legacy agent dir with conflict fallback", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + + const legacyAgentDir = path.join(root, "agent"); + fs.mkdirSync(legacyAgentDir, { recursive: true }); + fs.writeFileSync(path.join(legacyAgentDir, "foo.txt"), "legacy", "utf-8"); + fs.writeFileSync(path.join(legacyAgentDir, "baz.txt"), "legacy2", "utf-8"); + + const targetAgentDir = path.join(root, "agents", "main", "agent"); + fs.mkdirSync(targetAgentDir, { recursive: true }); + fs.writeFileSync(path.join(targetAgentDir, "foo.txt"), "new", "utf-8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: () => 123 }); + + expect(fs.readFileSync(path.join(targetAgentDir, "baz.txt"), "utf-8")).toBe( + "legacy2", + ); + const backupDir = path.join(root, "agents", "main", "agent.legacy-123"); + expect(fs.existsSync(path.join(backupDir, "foo.txt"))).toBe(true); + }); + + it("migrates legacy WhatsApp auth files without touching oauth.json", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + + const oauthDir = path.join(root, "credentials"); + fs.mkdirSync(oauthDir, { recursive: true }); + fs.writeFileSync(path.join(oauthDir, "oauth.json"), "{}", "utf-8"); + fs.writeFileSync(path.join(oauthDir, "creds.json"), "{}", "utf-8"); + fs.writeFileSync(path.join(oauthDir, "session-abc.json"), "{}", "utf-8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: () => 123 }); + + const target = path.join(oauthDir, "whatsapp", "default"); + expect(fs.existsSync(path.join(target, "creds.json"))).toBe(true); + expect(fs.existsSync(path.join(target, "session-abc.json"))).toBe(true); + expect(fs.existsSync(path.join(oauthDir, "oauth.json"))).toBe(true); + expect(fs.existsSync(path.join(oauthDir, "creds.json"))).toBe(false); + }); + + it("no-ops when nothing detected", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + const result = await runLegacyStateMigrations({ detected }); + expect(result.changes).toEqual([]); + }); + + it("routes legacy state to routing.defaultAgentId", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = { routing: { defaultAgentId: "alpha" } }; + const legacySessionsDir = path.join(root, "sessions"); + fs.mkdirSync(legacySessionsDir, { recursive: true }); + writeJson5(path.join(legacySessionsDir, "sessions.json"), { + "+1555": { sessionId: "a", updatedAt: 10 }, + }); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: () => 123 }); + + const targetDir = path.join(root, "agents", "alpha", "sessions"); + const store = JSON.parse( + fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), + ) as Record; + expect(store["agent:alpha:main"]?.sessionId).toBe("a"); + }); + + it("honors session.mainKey when seeding the direct-chat bucket", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = { session: { mainKey: "work" } }; + const legacySessionsDir = path.join(root, "sessions"); + fs.mkdirSync(legacySessionsDir, { recursive: true }); + writeJson5(path.join(legacySessionsDir, "sessions.json"), { + "+1555": { sessionId: "a", updatedAt: 10 }, + "+1666": { sessionId: "b", updatedAt: 20 }, + }); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: () => 123 }); + + const targetDir = path.join(root, "agents", "main", "sessions"); + const store = JSON.parse( + fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), + ) as Record; + expect(store["agent:main:work"]?.sessionId).toBe("b"); + expect(store["agent:main:main"]).toBeUndefined(); + }); +}); diff --git a/src/commands/doctor-state-migrations.ts b/src/commands/doctor-state-migrations.ts new file mode 100644 index 000000000..b599e8a7f --- /dev/null +++ b/src/commands/doctor-state-migrations.ts @@ -0,0 +1,456 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import JSON5 from "json5"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { saveSessionStore } from "../config/sessions.js"; +import { + buildAgentMainSessionKey, + DEFAULT_ACCOUNT_ID, + DEFAULT_AGENT_ID, + DEFAULT_MAIN_KEY, + normalizeAgentId, +} from "../routing/session-key.js"; + +export type LegacyStateDetection = { + targetAgentId: string; + targetMainKey: string; + stateDir: string; + oauthDir: string; + sessions: { + legacyDir: string; + legacyStorePath: string; + targetDir: string; + targetStorePath: string; + hasLegacy: boolean; + }; + agentDir: { + legacyDir: string; + targetDir: string; + hasLegacy: boolean; + }; + whatsappAuth: { + legacyDir: string; + targetDir: string; + hasLegacy: boolean; + }; + preview: string[]; +}; + +type SessionEntryLike = { sessionId?: string; updatedAt?: number } & Record< + string, + unknown +>; + +function safeReadDir(dir: string): fs.Dirent[] { + try { + return fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +function existsDir(dir: string): boolean { + try { + return fs.existsSync(dir) && fs.statSync(dir).isDirectory(); + } catch { + return false; + } +} + +function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +function fileExists(p: string): boolean { + try { + return fs.existsSync(p) && fs.statSync(p).isFile(); + } catch { + return false; + } +} + +function isLegacyWhatsAppAuthFile(name: string): boolean { + if (name === "creds.json" || name === "creds.json.bak") return true; + if (!name.endsWith(".json")) return false; + return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); +} + +function readSessionStoreJson5(storePath: string): { + store: Record; + ok: boolean; +} { + try { + const raw = fs.readFileSync(storePath, "utf-8"); + const parsed = JSON5.parse(raw); + if (parsed && typeof parsed === "object") { + return { store: parsed as Record, ok: true }; + } + } catch { + // ignore + } + return { store: {}, ok: false }; +} + +function isSurfaceGroupKey(key: string): boolean { + return key.includes(":group:") || key.includes(":channel:"); +} + +function isLegacyGroupKey(key: string): boolean { + return key.startsWith("group:") || key.includes("@g.us"); +} + +function normalizeSessionKeyForAgent(key: string, agentId: string): string { + const raw = key.trim(); + if (!raw) return raw; + if (raw.startsWith("agent:")) return raw; + if (raw.toLowerCase().startsWith("subagent:")) { + const rest = raw.slice("subagent:".length); + return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`; + } + if (isSurfaceGroupKey(raw)) { + return `agent:${normalizeAgentId(agentId)}:${raw}`; + } + return raw; +} + +function pickLatestLegacyDirectEntry( + store: Record, +): SessionEntryLike | null { + let best: SessionEntryLike | null = null; + let bestUpdated = -1; + for (const [key, entry] of Object.entries(store)) { + if (!entry || typeof entry !== "object") continue; + const normalized = key.trim(); + if (!normalized) continue; + if (normalized === "global") continue; + if (normalized.startsWith("agent:")) continue; + if (normalized.toLowerCase().startsWith("subagent:")) continue; + if (isLegacyGroupKey(normalized) || isSurfaceGroupKey(normalized)) continue; + const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0; + if (updatedAt > bestUpdated) { + bestUpdated = updatedAt; + best = entry; + } + } + return best; +} + +function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null { + const sessionId = + typeof entry.sessionId === "string" ? entry.sessionId : null; + if (!sessionId) return null; + const updatedAt = + typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt) + ? entry.updatedAt + : Date.now(); + return { ...(entry as unknown as SessionEntry), sessionId, updatedAt }; +} + +function emptyDirOrMissing(dir: string): boolean { + if (!existsDir(dir)) return true; + return safeReadDir(dir).length === 0; +} + +function removeDirIfEmpty(dir: string) { + if (!existsDir(dir)) return; + if (!emptyDirOrMissing(dir)) return; + try { + fs.rmdirSync(dir); + } catch { + // ignore + } +} + +export async function detectLegacyStateMigrations(params: { + cfg: ClawdbotConfig; + env?: NodeJS.ProcessEnv; + homedir?: () => string; +}): Promise { + const env = params.env ?? process.env; + const homedir = params.homedir ?? os.homedir; + const stateDir = resolveStateDir(env, homedir); + const oauthDir = resolveOAuthDir(env, stateDir); + + const targetAgentId = normalizeAgentId( + params.cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID, + ); + const rawMainKey = params.cfg.session?.mainKey; + const targetMainKey = + typeof rawMainKey === "string" && rawMainKey.trim().length > 0 + ? rawMainKey.trim() + : DEFAULT_MAIN_KEY; + + const sessionsLegacyDir = path.join(stateDir, "sessions"); + const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json"); + const sessionsTargetDir = path.join( + stateDir, + "agents", + targetAgentId, + "sessions", + ); + const sessionsTargetStorePath = path.join(sessionsTargetDir, "sessions.json"); + const legacySessionEntries = safeReadDir(sessionsLegacyDir); + const hasLegacySessions = + fileExists(sessionsLegacyStorePath) || + legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl")); + + const legacyAgentDir = path.join(stateDir, "agent"); + const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent"); + const hasLegacyAgentDir = existsDir(legacyAgentDir); + + const targetWhatsAppAuthDir = path.join( + oauthDir, + "whatsapp", + DEFAULT_ACCOUNT_ID, + ); + const hasLegacyWhatsAppAuth = + fileExists(path.join(oauthDir, "creds.json")) && + !fileExists(path.join(targetWhatsAppAuthDir, "creds.json")); + + const preview: string[] = []; + if (hasLegacySessions) { + preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`); + } + if (hasLegacyAgentDir) { + preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`); + } + if (hasLegacyWhatsAppAuth) { + preview.push( + `- WhatsApp auth: ${oauthDir} → ${targetWhatsAppAuthDir} (keep oauth.json)`, + ); + } + + return { + targetAgentId, + targetMainKey, + stateDir, + oauthDir, + sessions: { + legacyDir: sessionsLegacyDir, + legacyStorePath: sessionsLegacyStorePath, + targetDir: sessionsTargetDir, + targetStorePath: sessionsTargetStorePath, + hasLegacy: hasLegacySessions, + }, + agentDir: { + legacyDir: legacyAgentDir, + targetDir: targetAgentDir, + hasLegacy: hasLegacyAgentDir, + }, + whatsappAuth: { + legacyDir: oauthDir, + targetDir: targetWhatsAppAuthDir, + hasLegacy: hasLegacyWhatsAppAuth, + }, + preview, + }; +} + +async function migrateLegacySessions( + detected: LegacyStateDetection, + now: () => number, +): Promise<{ changes: string[]; warnings: string[] }> { + const changes: string[] = []; + const warnings: string[] = []; + if (!detected.sessions.hasLegacy) return { changes, warnings }; + + ensureDir(detected.sessions.targetDir); + + const legacyParsed = fileExists(detected.sessions.legacyStorePath) + ? readSessionStoreJson5(detected.sessions.legacyStorePath) + : { store: {}, ok: true }; + const targetParsed = fileExists(detected.sessions.targetStorePath) + ? readSessionStoreJson5(detected.sessions.targetStorePath) + : { store: {}, ok: true }; + const legacyStore = legacyParsed.store; + const targetStore = targetParsed.store; + + const normalizedLegacy: Record = {}; + for (const [key, entry] of Object.entries(legacyStore)) { + const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId); + if (!nextKey) continue; + if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry; + } + + const merged: Record = { + ...normalizedLegacy, + ...targetStore, + }; + + const mainKey = buildAgentMainSessionKey({ + agentId: detected.targetAgentId, + mainKey: detected.targetMainKey, + }); + if (!merged[mainKey]) { + const latest = pickLatestLegacyDirectEntry(legacyStore); + if (latest?.sessionId) { + merged[mainKey] = latest; + changes.push(`Migrated latest direct-chat session → ${mainKey}`); + } + } + + if (!legacyParsed.ok) { + warnings.push( + `Legacy sessions store unreadable; left in place at ${detected.sessions.legacyStorePath}`, + ); + } + + if ( + legacyParsed.ok && + (Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0) + ) { + const normalized: Record = {}; + for (const [key, entry] of Object.entries(merged)) { + const normalizedEntry = normalizeSessionEntry(entry); + if (!normalizedEntry) continue; + normalized[key] = normalizedEntry; + } + await saveSessionStore(detected.sessions.targetStorePath, normalized); + changes.push( + `Merged sessions store → ${detected.sessions.targetStorePath}`, + ); + } + + const entries = safeReadDir(detected.sessions.legacyDir); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name === "sessions.json") continue; + const from = path.join(detected.sessions.legacyDir, entry.name); + const to = path.join(detected.sessions.targetDir, entry.name); + if (fileExists(to)) continue; + try { + fs.renameSync(from, to); + changes.push( + `Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`, + ); + } catch (err) { + warnings.push(`Failed moving ${from}: ${String(err)}`); + } + } + + if (legacyParsed.ok) { + try { + if (fileExists(detected.sessions.legacyStorePath)) { + fs.rmSync(detected.sessions.legacyStorePath, { force: true }); + } + } catch { + // ignore + } + } + + removeDirIfEmpty(detected.sessions.legacyDir); + const legacyLeft = safeReadDir(detected.sessions.legacyDir).filter((e) => + e.isFile(), + ); + if (legacyLeft.length > 0) { + const backupDir = `${detected.sessions.legacyDir}.legacy-${now()}`; + try { + fs.renameSync(detected.sessions.legacyDir, backupDir); + warnings.push(`Left legacy sessions at ${backupDir}`); + } catch { + // ignore + } + } + + return { changes, warnings }; +} + +async function migrateLegacyAgentDir( + detected: LegacyStateDetection, + now: () => number, +): Promise<{ changes: string[]; warnings: string[] }> { + const changes: string[] = []; + const warnings: string[] = []; + if (!detected.agentDir.hasLegacy) return { changes, warnings }; + + ensureDir(detected.agentDir.targetDir); + + const entries = safeReadDir(detected.agentDir.legacyDir); + for (const entry of entries) { + const from = path.join(detected.agentDir.legacyDir, entry.name); + const to = path.join(detected.agentDir.targetDir, entry.name); + if (fs.existsSync(to)) continue; + try { + fs.renameSync(from, to); + changes.push( + `Moved agent file ${entry.name} → agents/${detected.targetAgentId}/agent`, + ); + } catch (err) { + warnings.push(`Failed moving ${from}: ${String(err)}`); + } + } + + removeDirIfEmpty(detected.agentDir.legacyDir); + if (!emptyDirOrMissing(detected.agentDir.legacyDir)) { + const backupDir = path.join( + detected.stateDir, + "agents", + detected.targetAgentId, + `agent.legacy-${now()}`, + ); + try { + fs.renameSync(detected.agentDir.legacyDir, backupDir); + warnings.push(`Left legacy agent dir at ${backupDir}`); + } catch (err) { + warnings.push(`Failed relocating legacy agent dir: ${String(err)}`); + } + } + + return { changes, warnings }; +} + +async function migrateLegacyWhatsAppAuth( + detected: LegacyStateDetection, +): Promise<{ changes: string[]; warnings: string[] }> { + const changes: string[] = []; + const warnings: string[] = []; + if (!detected.whatsappAuth.hasLegacy) return { changes, warnings }; + + ensureDir(detected.whatsappAuth.targetDir); + + const entries = safeReadDir(detected.whatsappAuth.legacyDir); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name === "oauth.json") continue; + if (!isLegacyWhatsAppAuthFile(entry.name)) continue; + const from = path.join(detected.whatsappAuth.legacyDir, entry.name); + const to = path.join(detected.whatsappAuth.targetDir, entry.name); + if (fileExists(to)) continue; + try { + fs.renameSync(from, to); + changes.push(`Moved WhatsApp auth ${entry.name} → whatsapp/default`); + } catch (err) { + warnings.push(`Failed moving ${from}: ${String(err)}`); + } + } + + return { changes, warnings }; +} + +export async function runLegacyStateMigrations(params: { + detected: LegacyStateDetection; + now?: () => number; +}): Promise<{ changes: string[]; warnings: string[] }> { + const now = params.now ?? (() => Date.now()); + const detected = params.detected; + const sessions = await migrateLegacySessions(detected, now); + const agentDir = await migrateLegacyAgentDir(detected, now); + const whatsappAuth = await migrateLegacyWhatsAppAuth(detected); + return { + changes: [ + ...sessions.changes, + ...agentDir.changes, + ...whatsappAuth.changes, + ], + warnings: [ + ...sessions.warnings, + ...agentDir.warnings, + ...whatsappAuth.warnings, + ], + }; +} diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 8902edfe0..6be0753a2 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -93,6 +93,18 @@ vi.mock("../daemon/service.js", () => ({ }), })); +vi.mock("../telegram/pairing-store.js", () => ({ + readTelegramAllowFromStore: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../telegram/token.js", () => ({ + resolveTelegramToken: vi.fn(() => ({ token: "", source: "none" })), +})); + vi.mock("../runtime.js", () => ({ defaultRuntime: { log: () => {}, @@ -123,6 +135,37 @@ vi.mock("./onboard-helpers.js", () => ({ printWizardHeader: vi.fn(), })); +vi.mock("./doctor-state-migrations.js", () => ({ + detectLegacyStateMigrations: vi.fn().mockResolvedValue({ + targetAgentId: "main", + targetMainKey: "main", + stateDir: "/tmp/state", + oauthDir: "/tmp/oauth", + sessions: { + legacyDir: "/tmp/state/sessions", + legacyStorePath: "/tmp/state/sessions/sessions.json", + targetDir: "/tmp/state/agents/main/sessions", + targetStorePath: "/tmp/state/agents/main/sessions/sessions.json", + hasLegacy: false, + }, + agentDir: { + legacyDir: "/tmp/state/agent", + targetDir: "/tmp/state/agents/main/agent", + hasLegacy: false, + }, + whatsappAuth: { + legacyDir: "/tmp/oauth", + targetDir: "/tmp/oauth/whatsapp/default", + hasLegacy: false, + }, + preview: [], + }), + runLegacyStateMigrations: vi.fn().mockResolvedValue({ + changes: [], + warnings: [], + }), +})); + describe("doctor", () => { it("migrates routing.allowFrom to whatsapp.allowFrom", async () => { readConfigFileSnapshot.mockResolvedValue({ @@ -225,9 +268,9 @@ describe("doctor", () => { parsed: { gateway: { mode: "local", bind: "loopback" }, agent: { - workspace: "/Users/steipete/clawdbot", + workspace: "/Users/steipete/clawd", sandbox: { - workspaceRoot: "/Users/steipete/clawdbot/sandboxes", + workspaceRoot: "/Users/steipete/clawd/sandboxes", docker: { image: "clawdbot-sandbox", containerPrefix: "clawdbot-sbx", @@ -239,9 +282,9 @@ describe("doctor", () => { config: { gateway: { mode: "local", bind: "loopback" }, agent: { - workspace: "/Users/steipete/clawdbot", + workspace: "/Users/steipete/clawd", sandbox: { - workspaceRoot: "/Users/steipete/clawdbot/sandboxes", + workspaceRoot: "/Users/steipete/clawd/sandboxes", docker: { image: "clawdbot-sandbox", containerPrefix: "clawdbot-sbx", @@ -322,8 +365,8 @@ describe("doctor", () => { const sandbox = agent.sandbox as Record; const docker = sandbox.docker as Record; - expect(agent.workspace).toBe("/Users/steipete/clawdbot"); - expect(sandbox.workspaceRoot).toBe("/Users/steipete/clawdbot/sandboxes"); + expect(agent.workspace).toBe("/Users/steipete/clawd"); + expect(sandbox.workspaceRoot).toBe("/Users/steipete/clawd/sandboxes"); expect(docker.image).toBe("clawdbot-sandbox"); expect(docker.containerPrefix).toBe("clawdbot-sbx"); }); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 9c960ce2a..e0e4b8bcc 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -10,6 +10,7 @@ import { DEFAULT_SANDBOX_IMAGE, } from "../agents/sandbox.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; +import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, @@ -26,10 +27,17 @@ import { } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { readProviderAllowFromStore } from "../pairing/pairing-store.js"; import { runCommandWithTimeout, runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; -import { resolveUserPath, sleep } from "../utils.js"; +import { readTelegramAllowFromStore } from "../telegram/pairing-store.js"; +import { resolveTelegramToken } from "../telegram/token.js"; +import { normalizeE164, resolveUserPath, sleep } from "../utils.js"; +import { + detectLegacyStateMigrations, + runLegacyStateMigrations, +} from "./doctor-state-migrations.js"; import { healthCommand } from "./health.js"; import { applyWizardMetadata, @@ -49,15 +57,218 @@ function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string { return path.join(os.homedir(), ".clawdis", "clawdis.json"); } -function replacePathSegment( +async function noteSecurityWarnings(cfg: ClawdbotConfig) { + const warnings: string[] = []; + + const warnDmPolicy = async (params: { + label: string; + provider: + | "telegram" + | "signal" + | "imessage" + | "discord" + | "slack" + | "whatsapp"; + dmPolicy: string; + allowFrom?: Array | null; + allowFromPath: string; + approveHint: string; + normalizeEntry?: (raw: string) => string; + }) => { + const dmPolicy = params.dmPolicy; + const configAllowFrom = (params.allowFrom ?? []).map((v) => + String(v).trim(), + ); + const hasWildcard = configAllowFrom.includes("*"); + const storeAllowFrom = await readProviderAllowFromStore( + params.provider, + ).catch(() => []); + const normalizedCfg = configAllowFrom + .filter((v) => v !== "*") + .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v)) + .map((v) => v.trim()) + .filter(Boolean); + const normalizedStore = storeAllowFrom + .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v)) + .map((v) => v.trim()) + .filter(Boolean); + const allowCount = Array.from( + new Set([...normalizedCfg, ...normalizedStore]), + ).length; + + if (dmPolicy === "open") { + const policyPath = `${params.allowFromPath}policy`; + const allowFromPath = `${params.allowFromPath}allowFrom`; + warnings.push( + `- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`, + ); + if (!hasWildcard) { + warnings.push( + `- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`, + ); + } + return; + } + + if (dmPolicy === "disabled") { + const policyPath = `${params.allowFromPath}policy`; + warnings.push( + `- ${params.label} DMs: disabled (${policyPath}="disabled").`, + ); + return; + } + + if (allowCount === 0) { + const policyPath = `${params.allowFromPath}policy`; + warnings.push( + `- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, + ); + warnings.push(` ${params.approveHint}`); + } + }; + + const telegramConfigured = Boolean(cfg.telegram); + const { token: telegramToken } = resolveTelegramToken(cfg); + if (telegramConfigured && telegramToken.trim()) { + const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing"; + const configAllowFrom = (cfg.telegram?.allowFrom ?? []).map((v) => + String(v).trim(), + ); + const hasWildcard = configAllowFrom.includes("*"); + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + const allowCount = Array.from( + new Set([ + ...configAllowFrom + .filter((v) => v !== "*") + .map((v) => v.replace(/^(telegram|tg):/i, "")) + .filter(Boolean), + ...storeAllowFrom.filter((v) => v !== "*"), + ]), + ).length; + + if (dmPolicy === "open") { + warnings.push( + `- Telegram DMs: OPEN (telegram.dmPolicy="open"). Anyone who can find the bot can DM it.`, + ); + if (!hasWildcard) { + warnings.push( + `- Telegram DMs: config invalid — dmPolicy "open" requires telegram.allowFrom to include "*".`, + ); + } + } else if (dmPolicy === "disabled") { + warnings.push(`- Telegram DMs: disabled (telegram.dmPolicy="disabled").`); + } else if (allowCount === 0) { + warnings.push( + `- Telegram DMs: locked (telegram.dmPolicy="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, + ); + warnings.push( + ` Approve via: clawdbot telegram pairing list / clawdbot telegram pairing approve `, + ); + } + + const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + const groupAllowlistConfigured = + cfg.telegram?.groups && Object.keys(cfg.telegram.groups).length > 0; + if (groupPolicy === "open" && !groupAllowlistConfigured) { + warnings.push( + `- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`, + ); + } + } + + if (cfg.discord?.enabled !== false) { + await warnDmPolicy({ + label: "Discord", + provider: "discord", + dmPolicy: cfg.discord?.dm?.policy ?? "pairing", + allowFrom: cfg.discord?.dm?.allowFrom ?? [], + allowFromPath: "discord.dm.", + approveHint: + "Approve via: clawdbot pairing list --provider discord / clawdbot pairing approve --provider discord ", + normalizeEntry: (raw) => + raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), + }); + } + + if (cfg.slack?.enabled !== false) { + await warnDmPolicy({ + label: "Slack", + provider: "slack", + dmPolicy: cfg.slack?.dm?.policy ?? "pairing", + allowFrom: cfg.slack?.dm?.allowFrom ?? [], + allowFromPath: "slack.dm.", + approveHint: + "Approve via: clawdbot pairing list --provider slack / clawdbot pairing approve --provider slack ", + normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), + }); + } + + if (cfg.signal?.enabled !== false) { + await warnDmPolicy({ + label: "Signal", + provider: "signal", + dmPolicy: cfg.signal?.dmPolicy ?? "pairing", + allowFrom: cfg.signal?.allowFrom ?? [], + allowFromPath: "signal.", + approveHint: + "Approve via: clawdbot pairing list --provider signal / clawdbot pairing approve --provider signal ", + normalizeEntry: (raw) => + normalizeE164(raw.replace(/^signal:/i, "").trim()), + }); + } + + if (cfg.imessage?.enabled !== false) { + await warnDmPolicy({ + label: "iMessage", + provider: "imessage", + dmPolicy: cfg.imessage?.dmPolicy ?? "pairing", + allowFrom: cfg.imessage?.allowFrom ?? [], + allowFromPath: "imessage.", + approveHint: + "Approve via: clawdbot pairing list --provider imessage / clawdbot pairing approve --provider imessage ", + }); + } + + if (cfg.whatsapp) { + await warnDmPolicy({ + label: "WhatsApp", + provider: "whatsapp", + dmPolicy: cfg.whatsapp?.dmPolicy ?? "pairing", + allowFrom: cfg.whatsapp?.allowFrom ?? [], + allowFromPath: "whatsapp.", + approveHint: + "Approve via: clawdbot pairing list --provider whatsapp / clawdbot pairing approve --provider whatsapp ", + normalizeEntry: (raw) => normalizeE164(raw), + }); + } + + if (warnings.length > 0) { + note(warnings.join("\n"), "Security"); + } +} + +function normalizeDefaultWorkspacePath( value: string | undefined, - from: string, - to: string, ): string | undefined { if (!value) return value; - const pattern = new RegExp(`(^|[\\/])${from}([\\/]|$)`, "g"); - if (!pattern.test(value)) return value; - return value.replace(pattern, `$1${to}$2`); + + const resolved = resolveUserPath(value); + const home = os.homedir(); + + const next = [ + ["clawdis", "clawd"], + ["clawdbot", "clawd"], + ].reduce((acc, [from, to]) => { + const fromPrefix = path.join(home, from); + if (acc === fromPrefix) return path.join(home, to); + const withSep = `${fromPrefix}${path.sep}`; + if (acc.startsWith(withSep)) { + return path.join(home, to).concat(acc.slice(fromPrefix.length)); + } + return acc; + }, resolved); + + return next === resolved ? value : next; } function replaceLegacyName(value: string | undefined): string | undefined { @@ -128,6 +339,47 @@ async function runSandboxScript( return true; } +type DoctorOptions = { + workspaceSuggestions?: boolean; +}; + +const MEMORY_SYSTEM_PROMPT = [ + "Memory system not found in workspace.", + "Paste this into your agent:", + "", + "Install the memory system by applying:", + "https://github.com/clawdbot/clawdbot/commit/9ffea23f31ca1df5183b25668f8f814bee0fb34e", + "https://github.com/clawdbot/clawdbot/commit/7d1fee70e76f2f634f1b41fca927ee663914183a", +].join("\n"); + +async function shouldSuggestMemorySystem( + workspaceDir: string, +): Promise { + const memoryPaths = [ + path.join(workspaceDir, "MEMORY.md"), + path.join(workspaceDir, "memory.md"), + ]; + + for (const memoryPath of memoryPaths) { + try { + await fs.promises.access(memoryPath); + return false; + } catch { + // keep scanning + } + } + + const agentsPath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME); + try { + const content = await fs.promises.readFile(agentsPath, "utf-8"); + if (/memory\.md/i.test(content)) return false; + } catch { + // no AGENTS.md or unreadable; treat as missing memory guidance + } + + return true; +} + async function isDockerAvailable(): Promise { try { await runExec("docker", ["version", "--format", "{{.Server.Version}}"], { @@ -317,11 +569,7 @@ function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { let next: ClawdbotConfig = cfg; const workspace = cfg.agent?.workspace; - const updatedWorkspace = replacePathSegment( - replacePathSegment(workspace, "clawdis", "clawdbot"), - "clawd", - "clawdbot", - ); + const updatedWorkspace = normalizeDefaultWorkspacePath(workspace); if (updatedWorkspace && updatedWorkspace !== workspace) { next = { ...next, @@ -334,11 +582,7 @@ function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { } const workspaceRoot = cfg.agent?.sandbox?.workspaceRoot; - const updatedWorkspaceRoot = replacePathSegment( - replacePathSegment(workspaceRoot, "clawdis", "clawdbot"), - "clawd", - "clawdbot", - ); + const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(workspaceRoot); if (updatedWorkspaceRoot && updatedWorkspaceRoot !== workspaceRoot) { next = { ...next, @@ -546,7 +790,10 @@ async function maybeMigrateLegacyGatewayService( }); } -export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { +export async function doctorCommand( + runtime: RuntimeEnv = defaultRuntime, + options: DoctorOptions = {}, +) { printWizardHeader(runtime); intro("Clawdbot doctor"); @@ -596,10 +843,35 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { cfg = normalized.config; } + const legacyState = await detectLegacyStateMigrations({ cfg }); + if (legacyState.preview.length > 0) { + note(legacyState.preview.join("\n"), "Legacy state detected"); + const migrate = guardCancel( + await confirm({ + message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", + initialValue: true, + }), + runtime, + ); + if (migrate) { + const migrated = await runLegacyStateMigrations({ + detected: legacyState, + }); + if (migrated.changes.length > 0) { + note(migrated.changes.join("\n"), "Doctor changes"); + } + if (migrated.warnings.length > 0) { + note(migrated.warnings.join("\n"), "Doctor warnings"); + } + } + } + cfg = await maybeRepairSandboxImages(cfg, runtime); await maybeMigrateLegacyGatewayService(cfg, runtime); + await noteSecurityWarnings(cfg); + if (process.platform === "linux" && resolveMode(cfg) === "local") { const service = resolveGatewayService(); let loaded = false; @@ -694,5 +966,14 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { await writeConfigFile(cfg); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + if (options.workspaceSuggestions !== false) { + const workspaceDir = resolveUserPath( + cfg.agent?.workspace ?? DEFAULT_WORKSPACE, + ); + if (await shouldSuggestMemorySystem(workspaceDir)) { + note(MEMORY_SYSTEM_PROMPT, "Workspace"); + } + } + outro("Doctor complete."); } diff --git a/src/commands/health.ts b/src/commands/health.ts index 4b5392994..03bbbf062 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -6,6 +6,7 @@ import { info } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; import { resolveTelegramToken } from "../telegram/token.js"; +import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { getWebAuthAgeMs, @@ -58,8 +59,9 @@ export async function getHealthSnapshot( timeoutMs?: number, ): Promise { const cfg = loadConfig(); - const linked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); + const account = resolveWhatsAppAccount({ cfg }); + const linked = await webAuthExists(account.authDir); + const authAgeMs = getWebAuthAgeMs(account.authDir); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); @@ -128,7 +130,9 @@ export async function healthCommand( : "Web: not linked (run clawdbot login)", ); if (summary.web.linked) { - logWebSelfId(runtime, true); + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg }); + logWebSelfId(account.authDir, runtime, true); } if (summary.web.connect) { const base = summary.web.connect.ok diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts new file mode 100644 index 000000000..b4310fad1 --- /dev/null +++ b/src/commands/models/list.status.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const store = { + version: 1, + profiles: { + "anthropic:default": { + type: "oauth", + provider: "anthropic", + access: "sk-ant-oat01-ACCESS-TOKEN-1234567890", + refresh: "sk-ant-ort01-REFRESH-TOKEN-1234567890", + expires: Date.now() + 60_000, + email: "peter@example.com", + }, + "anthropic:work": { + type: "api_key", + provider: "anthropic", + key: "sk-ant-api-0123456789abcdefghijklmnopqrstuvwxyz", + }, + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "eyJhbGciOi-ACCESS", + refresh: "oai-refresh-1234567890", + expires: Date.now() + 60_000, + }, + }, + }; + + return { + store, + resolveClawdbotAgentDir: vi.fn().mockReturnValue("/tmp/clawdbot-agent"), + ensureAuthProfileStore: vi.fn().mockReturnValue(store), + listProfilesForProvider: vi.fn((s: typeof store, provider: string) => { + return Object.entries(s.profiles) + .filter(([, cred]) => cred.provider === provider) + .map(([id]) => id); + }), + resolveAuthProfileDisplayLabel: vi.fn( + ({ profileId }: { profileId: string }) => profileId, + ), + resolveAuthStorePathForDisplay: vi + .fn() + .mockReturnValue("/tmp/clawdbot-agent/auth-profiles.json"), + resolveEnvApiKey: vi.fn((provider: string) => { + if (provider === "openai") { + return { + apiKey: "sk-openai-0123456789abcdefghijklmnopqrstuvwxyz", + source: "shell env: OPENAI_API_KEY", + }; + } + if (provider === "anthropic") { + return { + apiKey: "sk-ant-oat01-ACCESS-TOKEN-1234567890", + source: "env: ANTHROPIC_OAUTH_TOKEN", + }; + } + return null; + }), + getCustomProviderApiKey: vi.fn().mockReturnValue(undefined), + getShellEnvAppliedKeys: vi + .fn() + .mockReturnValue(["OPENAI_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]), + shouldEnableShellEnvFallback: vi.fn().mockReturnValue(true), + loadConfig: vi.fn().mockReturnValue({ + agent: { + model: { primary: "anthropic/claude-opus-4-5", fallbacks: [] }, + models: { "anthropic/claude-opus-4-5": { alias: "Opus" } }, + }, + models: { providers: {} }, + env: { shellEnv: { enabled: true } }, + }), + }; +}); + +vi.mock("../../agents/agent-paths.js", () => ({ + resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir, +})); + +vi.mock("../../agents/auth-profiles.js", () => ({ + ensureAuthProfileStore: mocks.ensureAuthProfileStore, + listProfilesForProvider: mocks.listProfilesForProvider, + resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, +})); + +vi.mock("../../agents/model-auth.js", () => ({ + resolveEnvApiKey: mocks.resolveEnvApiKey, + getCustomProviderApiKey: mocks.getCustomProviderApiKey, +})); + +vi.mock("../../infra/shell-env.js", () => ({ + getShellEnvAppliedKeys: mocks.getShellEnvAppliedKeys, + shouldEnableShellEnvFallback: mocks.shouldEnableShellEnvFallback, +})); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadConfig: mocks.loadConfig, + }; +}); + +import { modelsStatusCommand } from "./list.js"; + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +describe("modelsStatusCommand auth overview", () => { + it("includes masked auth sources in JSON output", async () => { + await modelsStatusCommand({ json: true }, runtime as never); + const payload = JSON.parse( + String((runtime.log as vi.Mock).mock.calls[0][0]), + ); + + expect(payload.defaultModel).toBe("anthropic/claude-opus-4-5"); + expect(payload.auth.storePath).toBe( + "/tmp/clawdbot-agent/auth-profiles.json", + ); + expect(payload.auth.shellEnvFallback.enabled).toBe(true); + expect(payload.auth.shellEnvFallback.appliedKeys).toContain( + "OPENAI_API_KEY", + ); + + const providers = payload.auth.providers as Array<{ + provider: string; + profiles: { labels: string[] }; + env?: { value: string; source: string }; + }>; + const anthropic = providers.find((p) => p.provider === "anthropic"); + expect(anthropic).toBeTruthy(); + expect(anthropic?.profiles.labels.join(" ")).toContain("OAuth"); + expect(anthropic?.profiles.labels.join(" ")).toContain("..."); + + const openai = providers.find((p) => p.provider === "openai"); + expect(openai?.env?.source).toContain("OPENAI_API_KEY"); + expect(openai?.env?.value).toContain("..."); + + expect( + (payload.auth.providersWithOAuth as string[]).some((e) => + e.startsWith("anthropic"), + ), + ).toBe(true); + expect( + (payload.auth.providersWithOAuth as string[]).some((e) => + e.startsWith("openai-codex"), + ), + ).toBe(true); + }); +}); diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index 7a8fb7858..56baa5e62 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + import type { Api, Model } from "@mariozechner/pi-ai"; import { discoverAuthStorage, @@ -10,6 +12,8 @@ import { type AuthProfileStore, ensureAuthProfileStore, listProfilesForProvider, + resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay, } from "../../agents/auth-profiles.js"; import { getCustomProviderApiKey, @@ -28,7 +32,12 @@ import { loadConfig, } from "../../config/config.js"; import { info } from "../../globals.js"; +import { + getShellEnvAppliedKeys, + shouldEnableShellEnvFallback, +} from "../../infra/shell-env.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { shortenHomePath } from "../../utils.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER, @@ -56,6 +65,13 @@ const truncate = (value: string, max: number) => { return `${value.slice(0, max - 3)}...`; }; +const maskApiKey = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return "missing"; + if (trimmed.length <= 16) return trimmed; + return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`; +}; + type ConfiguredEntry = { key: string; ref: { provider: string; model: string }; @@ -101,6 +117,109 @@ const hasAuthForProvider = ( return false; }; +type ProviderAuthOverview = { + provider: string; + effective: { + kind: "profiles" | "env" | "models.json" | "missing"; + detail: string; + }; + profiles: { + count: number; + oauth: number; + apiKey: number; + labels: string[]; + }; + env?: { value: string; source: string }; + modelsJson?: { value: string; source: string }; +}; + +function resolveProviderAuthOverview(params: { + provider: string; + cfg: ClawdbotConfig; + store: AuthProfileStore; + modelsPath: string; +}): ProviderAuthOverview { + const { provider, cfg, store } = params; + const profiles = listProfilesForProvider(store, provider); + const labels = profiles.map((profileId) => { + const profile = store.profiles[profileId]; + if (!profile) return `${profileId}=missing`; + if (profile.type === "api_key") { + return `${profileId}=${maskApiKey(profile.key)}`; + } + const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + const suffix = + display === profileId + ? "" + : display.startsWith(profileId) + ? display.slice(profileId.length).trim() + : `(${display})`; + return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`; + }); + const oauthCount = profiles.filter( + (id) => store.profiles[id]?.type === "oauth", + ).length; + const apiKeyCount = profiles.filter( + (id) => store.profiles[id]?.type === "api_key", + ).length; + + const envKey = resolveEnvApiKey(provider); + const customKey = getCustomProviderApiKey(cfg, provider); + + const effective: ProviderAuthOverview["effective"] = (() => { + if (profiles.length > 0) { + return { + kind: "profiles", + detail: shortenHomePath(resolveAuthStorePathForDisplay()), + }; + } + if (envKey) { + const isOAuthEnv = + envKey.source.includes("OAUTH_TOKEN") || + envKey.source.toLowerCase().includes("oauth"); + return { + kind: "env", + detail: isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey), + }; + } + if (customKey) { + return { kind: "models.json", detail: maskApiKey(customKey) }; + } + return { kind: "missing", detail: "missing" }; + })(); + + return { + provider, + effective, + profiles: { + count: profiles.length, + oauth: oauthCount, + apiKey: apiKeyCount, + labels, + }, + ...(envKey + ? { + env: { + value: + envKey.source.includes("OAUTH_TOKEN") || + envKey.source.toLowerCase().includes("oauth") + ? "OAuth (env)" + : maskApiKey(envKey.apiKey), + source: envKey.source, + }, + } + : {}), + ...(customKey + ? { + modelsJson: { + value: maskApiKey(customKey), + source: `models.json: ${shortenHomePath(params.modelsPath)}`, + }, + } + : {}), + }; +} + const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { const resolvedDefault = resolveConfiguredModelRef({ cfg, @@ -462,11 +581,97 @@ export async function modelsStatusCommand( }, {}); const allowed = Object.keys(cfg.agent?.models ?? {}); + const agentDir = resolveClawdbotAgentDir(); + const store = ensureAuthProfileStore(); + const modelsPath = path.join(agentDir, "models.json"); + + const providersFromStore = new Set( + Object.values(store.profiles) + .map((profile) => profile.provider) + .filter((p): p is string => Boolean(p)), + ); + const providersFromConfig = new Set( + Object.keys(cfg.models?.providers ?? {}) + .map((p) => p.trim()) + .filter(Boolean), + ); + const providersFromModels = new Set(); + for (const raw of [ + defaultLabel, + ...fallbacks, + imageModel, + ...imageFallbacks, + ...allowed, + ]) { + const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); + if (parsed?.provider) providersFromModels.add(parsed.provider); + } + + const providersFromEnv = new Set(); + // Keep in sync with resolveEnvApiKey() mappings (we want visibility even when + // a provider isn't currently selected in config/models). + const envProbeProviders = [ + "anthropic", + "github-copilot", + "google-vertex", + "openai", + "google", + "groq", + "cerebras", + "xai", + "openrouter", + "zai", + "mistral", + ]; + for (const provider of envProbeProviders) { + if (resolveEnvApiKey(provider)) providersFromEnv.add(provider); + } + + const providers = Array.from( + new Set([ + ...providersFromStore, + ...providersFromConfig, + ...providersFromModels, + ...providersFromEnv, + ]), + ) + .map((p) => p.trim()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + + const applied = getShellEnvAppliedKeys(); + const shellFallbackEnabled = + shouldEnableShellEnvFallback(process.env) || + cfg.env?.shellEnv?.enabled === true; + + const providerAuth = providers + .map((provider) => + resolveProviderAuthOverview({ provider, cfg, store, modelsPath }), + ) + .filter((entry) => { + const hasAny = + entry.profiles.count > 0 || + Boolean(entry.env) || + Boolean(entry.modelsJson); + return hasAny; + }); + + const providersWithOauth = providerAuth + .filter( + (entry) => entry.profiles.oauth > 0 || entry.env?.value === "OAuth (env)", + ) + .map((entry) => { + const count = + entry.profiles.oauth || (entry.env?.value === "OAuth (env)" ? 1 : 0); + return `${entry.provider} (${count})`; + }); + if (opts.json) { runtime.log( JSON.stringify( { configPath: CONFIG_PATH_CLAWDBOT, + agentDir, defaultModel: defaultLabel, resolvedDefault: `${resolved.provider}/${resolved.model}`, fallbacks, @@ -474,6 +679,15 @@ export async function modelsStatusCommand( imageFallbacks, aliases, allowed, + auth: { + storePath: resolveAuthStorePathForDisplay(), + shellEnvFallback: { + enabled: shellFallbackEnabled, + appliedKeys: applied, + }, + providersWithOAuth: providersWithOauth, + providers: providerAuth, + }, }, null, 2, @@ -488,6 +702,7 @@ export async function modelsStatusCommand( } runtime.log(info(`Config: ${CONFIG_PATH_CLAWDBOT}`)); + runtime.log(info(`Agent dir: ${shortenHomePath(agentDir)}`)); runtime.log(`Default: ${defaultLabel}`); runtime.log( `Fallbacks (${fallbacks.length || 0}): ${fallbacks.join(", ") || "-"}`, @@ -512,4 +727,36 @@ export async function modelsStatusCommand( allowed.length ? allowed.join(", ") : "all" }`, ); + + runtime.log(""); + runtime.log(info("Auth overview")); + runtime.log( + `Auth store: ${shortenHomePath(resolveAuthStorePathForDisplay())}`, + ); + runtime.log( + `Shell env fallback: ${shellFallbackEnabled ? "on" : "off"}${ + applied.length ? ` (applied: ${applied.join(", ")})` : "" + }`, + ); + runtime.log( + `Providers with OAuth (${providersWithOauth.length || 0}): ${ + providersWithOauth.length ? providersWithOauth.join(", ") : "-" + }`, + ); + + for (const entry of providerAuth) { + const bits: string[] = []; + bits.push(`effective=${entry.effective.kind}:${entry.effective.detail}`); + if (entry.profiles.count > 0) { + bits.push( + `profiles=${entry.profiles.count} (oauth=${entry.profiles.oauth}, api_key=${entry.profiles.apiKey})`, + ); + if (entry.profiles.labels.length > 0) { + bits.push(entry.profiles.labels.join(", ")); + } + } + if (entry.env) bits.push(`env=${entry.env.value} (${entry.env.source})`); + if (entry.modelsJson) bits.push(`models.json=${entry.modelsJson.value}`); + runtime.log(`${entry.provider}: ${bits.join(" | ")}`); + } } diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 71526c61d..f50ab2823 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -36,9 +36,10 @@ describe("writeOAuthCredentials", () => { delete process.env.CLAWDBOT_OAUTH_DIR; }); - it("writes auth-profiles.json under CLAWDBOT_STATE_DIR/agent", async () => { + it("writes auth-profiles.json under CLAWDBOT_STATE_DIR/agents/main/agent", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-")); process.env.CLAWDBOT_STATE_DIR = tempStateDir; + // Even if legacy env vars are set, onboarding should write to the multi-agent path. process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; @@ -50,8 +51,11 @@ describe("writeOAuthCredentials", () => { await writeOAuthCredentials("anthropic", creds); + // Now writes to the multi-agent path: agents/main/agent const authProfilePath = path.join( tempStateDir, + "agents", + "main", "agent", "auth-profiles.json", ); @@ -64,5 +68,12 @@ describe("writeOAuthCredentials", () => { access: "access-token", type: "oauth", }); + + await expect( + fs.readFile( + path.join(tempStateDir, "agent", "auth-profiles.json"), + "utf8", + ), + ).rejects.toThrow(); }); }); diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 3da496b34..f35f5a59c 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -1,4 +1,5 @@ import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; +import { resolveDefaultAgentDir } from "../agents/agent-scope.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../config/config.js"; @@ -6,17 +7,22 @@ export async function writeOAuthCredentials( provider: OAuthProvider, creds: OAuthCredentials, ): Promise { + // Write to the multi-agent path so gateway finds credentials on startup + const agentDir = resolveDefaultAgentDir(); upsertAuthProfile({ - profileId: `${provider}:default`, + profileId: `${provider}:${creds.email ?? "default"}`, credential: { type: "oauth", provider, ...creds, }, + agentDir, }); } export async function setAnthropicApiKey(key: string) { + // Write to the multi-agent path so gateway finds credentials on startup + const agentDir = resolveDefaultAgentDir(); upsertAuthProfile({ profileId: "anthropic:default", credential: { @@ -24,6 +30,7 @@ export async function setAnthropicApiKey(key: string) { provider: "anthropic", key, }, + agentDir, }); } @@ -44,16 +51,25 @@ export function applyAuthProfileConfig( ...(params.email ? { email: params.email } : {}), }, }; - const order = { ...cfg.auth?.order }; - const list = order[params.provider] ? [...order[params.provider]] : []; - if (!list.includes(params.profileId)) list.push(params.profileId); - order[params.provider] = list; + + // Only maintain `auth.order` when the user explicitly configured it. + // Default behavior: no explicit order -> resolveAuthProfileOrder can round-robin by lastUsed. + const existingProviderOrder = cfg.auth?.order?.[params.provider]; + const order = + existingProviderOrder !== undefined + ? { + ...cfg.auth?.order, + [params.provider]: existingProviderOrder.includes(params.profileId) + ? existingProviderOrder + : [...existingProviderOrder, params.profileId], + } + : cfg.auth?.order; return { ...cfg, auth: { ...cfg.auth, profiles, - order, + ...(order ? { order } : {}), }, }; } diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 0e81bf768..43e91e33d 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -223,10 +223,11 @@ export async function openUrl(url: string): Promise { export async function ensureWorkspaceAndSessions( workspaceDir: string, runtime: RuntimeEnv, + options?: { skipBootstrap?: boolean }, ) { const ws = await ensureAgentWorkspace({ dir: workspaceDir, - ensureBootstrapFiles: true, + ensureBootstrapFiles: !options?.skipBootstrap, }); runtime.log(`Workspace OK: ${ws.dir}`); const sessionsDir = resolveSessionTranscriptsDir(); diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 17d845cd7..7b8127ddc 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -219,7 +219,9 @@ export async function runNonInteractiveOnboarding( nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - await ensureWorkspaceAndSessions(workspaceDir, runtime); + await ensureWorkspaceAndSessions(workspaceDir, runtime, { + skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap), + }); if (opts.installDaemon) { const service = resolveGatewayService(); diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 93797b732..4ac6f032f 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -1,15 +1,24 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { ClawdbotConfig } from "../config/config.js"; +import type { DmPolicy } from "../config/types.js"; import { loginWeb } from "../provider-web.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeE164 } from "../utils.js"; -import { resolveWebAuthDir } from "../web/session.js"; +import { WA_WEB_AUTH_DIR } from "../web/session.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { detectBinary } from "./onboard-helpers.js"; import type { ProviderChoice } from "./onboard-types.js"; import { installSignalCli } from "./signal-install.js"; +function addWildcardAllowFrom( + allowFrom?: Array | null, +): Array { + const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean); + if (!next.includes("*")) next.push("*"); + return next; +} + async function pathExists(filePath: string): Promise { try { await fs.access(filePath); @@ -20,13 +29,17 @@ async function pathExists(filePath: string): Promise { } async function detectWhatsAppLinked(): Promise { - const credsPath = path.join(resolveWebAuthDir(), "creds.json"); + const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json"); return await pathExists(credsPath); } async function noteProviderPrimer(prompter: WizardPrompter): Promise { await prompter.note( [ + "DM security: default is pairing; unknown DMs get a pairing code.", + "Approve with: clawdbot pairing approve --provider ", + 'Public DMs require dmPolicy="open" + allowFrom=["*"].', + "", "WhatsApp: links via WhatsApp Web (scan QR), stores creds for future sends.", "WhatsApp: dedicated second number recommended; primary number OK (self-chat).", "Telegram: Bot API (token from @BotFather), replies via your bot.", @@ -153,6 +166,16 @@ async function noteSlackTokenHelp( ); } +function setWhatsAppDmPolicy(cfg: ClawdbotConfig, dmPolicy?: DmPolicy) { + return { + ...cfg, + whatsapp: { + ...cfg.whatsapp, + dmPolicy, + }, + }; +} + function setWhatsAppAllowFrom(cfg: ClawdbotConfig, allowFrom?: string[]) { return { ...cfg, @@ -163,46 +186,243 @@ function setWhatsAppAllowFrom(cfg: ClawdbotConfig, allowFrom?: string[]) { }; } +function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.telegram?.allowFrom) + : undefined; + return { + ...cfg, + telegram: { + ...cfg.telegram, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }; +} + +function setDiscordDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.discord?.dm?.allowFrom) + : undefined; + return { + ...cfg, + discord: { + ...cfg.discord, + dm: { + ...cfg.discord?.dm, + enabled: cfg.discord?.dm?.enabled ?? true, + policy: dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.slack?.dm?.allowFrom) + : undefined; + return { + ...cfg, + slack: { + ...cfg.slack, + dm: { + ...cfg.slack?.dm, + enabled: cfg.slack?.dm?.enabled ?? true, + policy: dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.signal?.allowFrom) + : undefined; + return { + ...cfg, + signal: { + ...cfg.signal, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }; +} + +function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.imessage?.allowFrom) + : undefined; + return { + ...cfg, + imessage: { + ...cfg.imessage, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }; +} + +async function maybeConfigureDmPolicies(params: { + cfg: ClawdbotConfig; + selection: ProviderChoice[]; + prompter: WizardPrompter; +}): Promise { + const { selection, prompter } = params; + const supportsDmPolicy = selection.some((p) => + ["telegram", "discord", "slack", "signal", "imessage"].includes(p), + ); + if (!supportsDmPolicy) return params.cfg; + + const wants = await prompter.confirm({ + message: "Configure DM access policies now? (default: pairing)", + initialValue: false, + }); + if (!wants) return params.cfg; + + let cfg = params.cfg; + const selectPolicy = async (params: { + label: string; + provider: ProviderChoice; + policyKey: string; + allowFromKey: string; + }) => { + await prompter.note( + [ + "Default: pairing (unknown DMs get a pairing code).", + `Approve: clawdbot pairing approve --provider ${params.provider} `, + `Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`, + ].join("\n"), + `${params.label} DM access`, + ); + return (await prompter.select({ + message: `${params.label} DM policy`, + options: [ + { value: "pairing", label: "Pairing (recommended)" }, + { value: "open", label: "Open (public inbound DMs)" }, + { value: "disabled", label: "Disabled (ignore DMs)" }, + ], + })) as DmPolicy; + }; + + if (selection.includes("telegram")) { + const current = cfg.telegram?.dmPolicy ?? "pairing"; + const policy = await selectPolicy({ + label: "Telegram", + provider: "telegram", + policyKey: "telegram.dmPolicy", + allowFromKey: "telegram.allowFrom", + }); + if (policy !== current) cfg = setTelegramDmPolicy(cfg, policy); + } + if (selection.includes("discord")) { + const current = cfg.discord?.dm?.policy ?? "pairing"; + const policy = await selectPolicy({ + label: "Discord", + provider: "discord", + policyKey: "discord.dm.policy", + allowFromKey: "discord.dm.allowFrom", + }); + if (policy !== current) cfg = setDiscordDmPolicy(cfg, policy); + } + if (selection.includes("slack")) { + const current = cfg.slack?.dm?.policy ?? "pairing"; + const policy = await selectPolicy({ + label: "Slack", + provider: "slack", + policyKey: "slack.dm.policy", + allowFromKey: "slack.dm.allowFrom", + }); + if (policy !== current) cfg = setSlackDmPolicy(cfg, policy); + } + if (selection.includes("signal")) { + const current = cfg.signal?.dmPolicy ?? "pairing"; + const policy = await selectPolicy({ + label: "Signal", + provider: "signal", + policyKey: "signal.dmPolicy", + allowFromKey: "signal.allowFrom", + }); + if (policy !== current) cfg = setSignalDmPolicy(cfg, policy); + } + if (selection.includes("imessage")) { + const current = cfg.imessage?.dmPolicy ?? "pairing"; + const policy = await selectPolicy({ + label: "iMessage", + provider: "imessage", + policyKey: "imessage.dmPolicy", + allowFromKey: "imessage.allowFrom", + }); + if (policy !== current) cfg = setIMessageDmPolicy(cfg, policy); + } + return cfg; +} + async function promptWhatsAppAllowFrom( cfg: ClawdbotConfig, _runtime: RuntimeEnv, prompter: WizardPrompter, ): Promise { + const existingPolicy = cfg.whatsapp?.dmPolicy ?? "pairing"; const existingAllowFrom = cfg.whatsapp?.allowFrom ?? []; const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; await prompter.note( [ - "WhatsApp direct chats are gated by `whatsapp.allowFrom`.", - 'Default (unset) = self-chat only; use "*" to allow anyone.', - `Current: ${existingLabel}`, + "WhatsApp direct chats are gated by `whatsapp.dmPolicy` + `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}`, ].join("\n"), - "WhatsApp allowlist", + "WhatsApp DM access", ); + 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; + + const next = setWhatsAppDmPolicy(cfg, policy); + if (policy === "open") return setWhatsAppAllowFrom(next, ["*"]); + if (policy === "disabled") return next; + const options = existingAllowFrom.length > 0 ? ([ - { value: "keep", label: "Keep current" }, - { value: "self", label: "Self-chat only (unset)" }, - { value: "list", label: "Specific numbers (recommended)" }, - { value: "any", label: "Anyone (*)" }, + { 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: "self", label: "Self-chat only (default)" }, - { value: "list", label: "Specific numbers (recommended)" }, - { value: "any", label: "Anyone (*)" }, + { value: "unset", label: "Unset allowFrom (default)" }, + { value: "list", label: "Set allowFrom to specific numbers" }, ] as const); const mode = (await prompter.select({ - message: "Who can trigger the bot via WhatsApp?", + message: "WhatsApp allowFrom (optional pre-allowlist)", options: options.map((opt) => ({ value: opt.value, label: opt.label })), })) as (typeof options)[number]["value"]; - if (mode === "keep") return cfg; - if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined); - if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]); + if (mode === "keep") return next; + if (mode === "unset") return setWhatsAppAllowFrom(next, undefined); const allowRaw = await prompter.text({ message: "Allowed sender numbers (comma-separated, E.164)", @@ -232,7 +452,7 @@ async function promptWhatsAppAllowFrom( part === "*" ? "*" : normalizeE164(part), ); const unique = [...new Set(normalized.filter(Boolean))]; - return setWhatsAppAllowFrom(cfg, unique); + return setWhatsAppAllowFrom(next, unique); } export async function setupProviders( @@ -330,7 +550,7 @@ export async function setupProviders( await prompter.note( [ "Scan the QR with WhatsApp on your phone.", - `Credentials are stored under ${resolveWebAuthDir()}/ for future runs.`, + `Credentials are stored under ${WA_WEB_AUTH_DIR}/ for future runs.`, ].join("\n"), "WhatsApp linking", ); @@ -687,6 +907,8 @@ export async function setupProviders( ); } + next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter }); + if (options?.allowDisable) { if (!selection.includes("telegram") && telegramConfigured) { const disable = await prompter.confirm({ diff --git a/src/commands/openai-codex-model-default.test.ts b/src/commands/openai-codex-model-default.test.ts new file mode 100644 index 000000000..86497bb90 --- /dev/null +++ b/src/commands/openai-codex-model-default.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { + applyOpenAICodexModelDefault, + OPENAI_CODEX_DEFAULT_MODEL, +} from "./openai-codex-model-default.js"; + +describe("applyOpenAICodexModelDefault", () => { + it("sets openai-codex default when model is unset", () => { + const cfg: ClawdbotConfig = { agent: {} }; + const applied = applyOpenAICodexModelDefault(cfg); + expect(applied.changed).toBe(true); + expect(applied.next.agent?.model).toEqual({ + primary: OPENAI_CODEX_DEFAULT_MODEL, + }); + }); + + it("sets openai-codex default when model is openai/*", () => { + const cfg: ClawdbotConfig = { agent: { model: "openai/gpt-5.2" } }; + const applied = applyOpenAICodexModelDefault(cfg); + expect(applied.changed).toBe(true); + expect(applied.next.agent?.model).toEqual({ + primary: OPENAI_CODEX_DEFAULT_MODEL, + }); + }); + + it("does not override openai-codex/*", () => { + const cfg: ClawdbotConfig = { agent: { model: "openai-codex/gpt-5.2" } }; + const applied = applyOpenAICodexModelDefault(cfg); + expect(applied.changed).toBe(false); + expect(applied.next).toEqual(cfg); + }); + + it("does not override non-openai models", () => { + const cfg: ClawdbotConfig = { + agent: { model: "anthropic/claude-opus-4-5" }, + }; + const applied = applyOpenAICodexModelDefault(cfg); + expect(applied.changed).toBe(false); + expect(applied.next).toEqual(cfg); + }); +}); diff --git a/src/commands/openai-codex-model-default.ts b/src/commands/openai-codex-model-default.ts new file mode 100644 index 000000000..d1d5b0914 --- /dev/null +++ b/src/commands/openai-codex-model-default.ts @@ -0,0 +1,46 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { AgentModelListConfig } from "../config/types.js"; + +export const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.2"; + +function shouldSetOpenAICodexModel(model?: string): boolean { + const trimmed = model?.trim(); + if (!trimmed) return true; + const normalized = trimmed.toLowerCase(); + if (normalized.startsWith("openai-codex/")) return false; + if (normalized.startsWith("openai/")) return true; + return normalized === "gpt" || normalized === "gpt-mini"; +} + +function resolvePrimaryModel( + model?: AgentModelListConfig | string, +): string | undefined { + if (typeof model === "string") return model; + if (model && typeof model === "object" && typeof model.primary === "string") { + return model.primary; + } + return undefined; +} + +export function applyOpenAICodexModelDefault(cfg: ClawdbotConfig): { + next: ClawdbotConfig; + changed: boolean; +} { + const current = resolvePrimaryModel(cfg.agent?.model); + if (!shouldSetOpenAICodexModel(current)) { + return { next: cfg, changed: false }; + } + return { + next: { + ...cfg, + agent: { + ...cfg.agent, + model: + cfg.agent?.model && typeof cfg.agent.model === "object" + ? { ...cfg.agent.model, primary: OPENAI_CODEX_DEFAULT_MODEL } + : { primary: OPENAI_CODEX_DEFAULT_MODEL }, + }, + }, + changed: true, + }; +} diff --git a/src/commands/poll.test.ts b/src/commands/poll.test.ts new file mode 100644 index 000000000..50b3ba5f6 --- /dev/null +++ b/src/commands/poll.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CliDeps } from "../cli/deps.js"; +import { pollCommand } from "./poll.js"; + +let testConfig: Record = {}; +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => testConfig, + }; +}); + +const callGatewayMock = vi.fn(); +vi.mock("../gateway/call.js", () => ({ + callGateway: (...args: unknown[]) => callGatewayMock(...args), + randomIdempotencyKey: () => "idem-1", +})); + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSlack: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), +}; + +describe("pollCommand", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + testConfig = {}; + }); + + it("routes through gateway", async () => { + callGatewayMock.mockResolvedValueOnce({ messageId: "p1" }); + await pollCommand( + { + to: "+1", + question: "hi?", + option: ["y", "n"], + }, + deps, + runtime, + ); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ method: "poll" }), + ); + }); + + it("does not override remote gateway URL", async () => { + callGatewayMock.mockResolvedValueOnce({ messageId: "p1" }); + testConfig = { + gateway: { mode: "remote", remote: { url: "wss://remote.example" } }, + }; + await pollCommand( + { + to: "+1", + question: "hi?", + option: ["y", "n"], + }, + deps, + runtime, + ); + const args = callGatewayMock.mock.calls.at(-1)?.[0] as + | Record + | undefined; + expect(args?.url).toBeUndefined(); + }); +}); diff --git a/src/commands/poll.ts b/src/commands/poll.ts new file mode 100644 index 000000000..44f546c25 --- /dev/null +++ b/src/commands/poll.ts @@ -0,0 +1,100 @@ +import type { CliDeps } from "../cli/deps.js"; +import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; +import { success } from "../globals.js"; +import { normalizePollInput, type PollInput } from "../polls.js"; +import type { RuntimeEnv } from "../runtime.js"; + +function parseIntOption(value: unknown, label: string): number | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || value.trim().length === 0) return undefined; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`${label} must be a number`); + } + return parsed; +} + +export async function pollCommand( + opts: { + to: string; + question: string; + option: string[]; + maxSelections?: string; + durationHours?: string; + provider?: string; + json?: boolean; + dryRun?: boolean; + }, + _deps: CliDeps, + runtime: RuntimeEnv, +) { + const provider = (opts.provider ?? "whatsapp").toLowerCase(); + if (provider !== "whatsapp" && provider !== "discord") { + throw new Error(`Unsupported poll provider: ${provider}`); + } + + const maxSelections = parseIntOption(opts.maxSelections, "max-selections"); + const durationHours = parseIntOption(opts.durationHours, "duration-hours"); + + const pollInput: PollInput = { + question: opts.question, + options: opts.option, + maxSelections, + durationHours, + }; + const maxOptions = provider === "discord" ? 10 : 12; + const normalized = normalizePollInput(pollInput, { maxOptions }); + + if (opts.dryRun) { + runtime.log( + `[dry-run] would send poll via ${provider} -> ${opts.to}:\n Question: ${normalized.question}\n Options: ${normalized.options.join(", ")}\n Max selections: ${normalized.maxSelections}`, + ); + return; + } + + const result = await callGateway<{ + messageId: string; + toJid?: string; + channelId?: string; + }>({ + method: "poll", + params: { + to: opts.to, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + durationHours: normalized.durationHours, + provider, + idempotencyKey: randomIdempotencyKey(), + }, + timeoutMs: 10_000, + clientName: "cli", + mode: "cli", + }); + + runtime.log( + success( + `✅ Poll sent via gateway (${provider}). Message ID: ${result.messageId ?? "unknown"}`, + ), + ); + if (opts.json) { + runtime.log( + JSON.stringify( + { + provider, + via: "gateway", + to: opts.to, + toJid: result.toJid ?? null, + channelId: result.channelId ?? null, + messageId: result.messageId, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + durationHours: normalized.durationHours ?? null, + }, + null, + 2, + ), + ); + } +} diff --git a/src/commands/send.test.ts b/src/commands/send.test.ts index 03ced5bf2..d42557b87 100644 --- a/src/commands/send.test.ts +++ b/src/commands/send.test.ts @@ -81,6 +81,26 @@ describe("sendCommand", () => { expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("g1")); }); + it("does not override remote gateway URL", async () => { + callGatewayMock.mockResolvedValueOnce({ messageId: "g2" }); + testConfig = { + gateway: { mode: "remote", remote: { url: "wss://remote.example" } }, + }; + const deps = makeDeps(); + await sendCommand( + { + to: "+1", + message: "hi", + }, + deps, + runtime, + ); + const args = callGatewayMock.mock.calls.at(-1)?.[0] as + | Record + | undefined; + expect(args?.url).toBeUndefined(); + }); + it("passes gifPlayback to gateway send", async () => { callGatewayMock.mockClear(); callGatewayMock.mockResolvedValueOnce({ messageId: "g1" }); diff --git a/src/commands/send.ts b/src/commands/send.ts index 39db2462b..8d2e4e7c8 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -14,6 +14,7 @@ export async function sendCommand( dryRun?: boolean; media?: string; gifPlayback?: boolean; + account?: string; }, deps: CliDeps, runtime: RuntimeEnv, @@ -167,13 +168,13 @@ export async function sendCommand( callGateway<{ messageId: string; }>({ - url: "ws://127.0.0.1:18789", method: "send", params: { to: opts.to, message: opts.message, mediaUrl: opts.media, gifPlayback: opts.gifPlayback, + accountId: opts.account, provider, idempotencyKey: randomIdempotencyKey(), }, diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 3bc176df9..0dc1d9048 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -74,7 +74,7 @@ export async function setupCommand( const ws = await ensureAgentWorkspace({ dir: workspace, - ensureBootstrapFiles: true, + ensureBootstrapFiles: !next.agent?.skipBootstrap, }); runtime.log(`Workspace OK: ${ws.dir}`); diff --git a/src/commands/status.ts b/src/commands/status.ts index e3c50ef3d..96fdcdf7c 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -16,6 +16,7 @@ import { info } from "../globals.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import type { RuntimeEnv } from "../runtime.js"; +import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { getWebAuthAgeMs, @@ -60,8 +61,9 @@ export type StatusSummary = { export async function getStatusSummary(): Promise { const cfg = loadConfig(); - const linked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); + const account = resolveWhatsAppAccount({ cfg }); + const linked = await webAuthExists(account.authDir); + const authAgeMs = getWebAuthAgeMs(account.authDir); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const providerSummary = await buildProviderSummary(cfg); const queuedSystemEvents = peekSystemEvents(); @@ -230,7 +232,9 @@ export async function statusCommand( `Web session: ${summary.web.linked ? "linked" : "not linked"}${summary.web.linked ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` : ""}`, ); if (summary.web.linked) { - logWebSelfId(runtime, true); + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg }); + logWebSelfId(account.authDir, runtime, true); } runtime.log(info("System:")); for (const line of summary.providerSummary) { diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 88de32c84..f69ec69ae 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -87,6 +87,57 @@ describe("config identity defaults", () => { }); }); + it("defaults ackReaction to identity emoji", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, + messages: {}, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.messages?.ackReaction).toBe("🦥"); + expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); + }); + }); + + it("defaults ackReaction to 👀 when identity is missing", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + messages: {}, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.messages?.ackReaction).toBe("👀"); + expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); + }); + }); + it("does not override explicit values", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); @@ -628,6 +679,166 @@ describe("legacy config detection", () => { } }); + it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + telegram: { dmPolicy: "open", allowFrom: ["123456789"] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("telegram.allowFrom"); + } + }); + + it('accepts telegram.dmPolicy="open" with allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.telegram?.dmPolicy).toBe("open"); + } + }); + + it("defaults telegram.dmPolicy to pairing when telegram section exists", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ telegram: {} }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.telegram?.dmPolicy).toBe("pairing"); + } + }); + + it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("whatsapp.allowFrom"); + } + }); + + it('accepts whatsapp.dmPolicy="open" with allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + whatsapp: { dmPolicy: "open", allowFrom: ["*"] }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.whatsapp?.dmPolicy).toBe("open"); + } + }); + + it("defaults whatsapp.dmPolicy to pairing when whatsapp section exists", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ whatsapp: {} }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.whatsapp?.dmPolicy).toBe("pairing"); + } + }); + + it('rejects signal.dmPolicy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + signal: { dmPolicy: "open", allowFrom: ["+15555550123"] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("signal.allowFrom"); + } + }); + + it('accepts signal.dmPolicy="open" with allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + signal: { dmPolicy: "open", allowFrom: ["*"] }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.signal?.dmPolicy).toBe("open"); + } + }); + + it("defaults signal.dmPolicy to pairing when signal section exists", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ signal: {} }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.signal?.dmPolicy).toBe("pairing"); + } + }); + + it('rejects imessage.dmPolicy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("imessage.allowFrom"); + } + }); + + it('accepts imessage.dmPolicy="open" with allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + imessage: { dmPolicy: "open", allowFrom: ["*"] }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.imessage?.dmPolicy).toBe("open"); + } + }); + + it("defaults imessage.dmPolicy to pairing when imessage section exists", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ imessage: {} }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.imessage?.dmPolicy).toBe("pairing"); + } + }); + + it('rejects discord.dm.policy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + discord: { dm: { policy: "open", allowFrom: ["123"] } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("discord.dm.allowFrom"); + } + }); + + it('rejects slack.dm.policy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + slack: { dm: { policy: "open", allowFrom: ["U123"] } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("slack.dm.allowFrom"); + } + }); + it("rejects legacy agent.model string", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 11a23699a..68d35bef8 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -54,6 +54,32 @@ export function applyIdentityDefaults(cfg: ClawdbotConfig): ClawdbotConfig { return mutated ? next : cfg; } +export function applyMessageDefaults(cfg: ClawdbotConfig): ClawdbotConfig { + const messages = cfg.messages; + const hasAckReaction = messages?.ackReaction !== undefined; + const hasAckScope = messages?.ackReactionScope !== undefined; + if (hasAckReaction && hasAckScope) return cfg; + + const fallbackEmoji = cfg.identity?.emoji?.trim() || "👀"; + const nextMessages = messages ? { ...messages } : {}; + let mutated = false; + + if (!hasAckReaction) { + nextMessages.ackReaction = fallbackEmoji; + mutated = true; + } + if (!hasAckScope) { + nextMessages.ackReactionScope = "group-mentions"; + mutated = true; + } + + if (!mutated) return cfg; + return { + ...cfg, + messages: nextMessages, + }; +} + export function applySessionDefaults( cfg: ClawdbotConfig, options: SessionDefaultsOptions = {}, diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts new file mode 100644 index 000000000..611d16913 --- /dev/null +++ b/src/config/group-policy.ts @@ -0,0 +1,85 @@ +import type { ClawdbotConfig } from "./config.js"; + +export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage"; + +export type ProviderGroupConfig = { + requireMention?: boolean; +}; + +export type ProviderGroupPolicy = { + allowlistEnabled: boolean; + allowed: boolean; + groupConfig?: ProviderGroupConfig; + defaultConfig?: ProviderGroupConfig; +}; + +type ProviderGroups = Record; + +function resolveProviderGroups( + cfg: ClawdbotConfig, + provider: GroupPolicyProvider, +): ProviderGroups | undefined { + if (provider === "whatsapp") return cfg.whatsapp?.groups; + if (provider === "telegram") return cfg.telegram?.groups; + if (provider === "imessage") return cfg.imessage?.groups; + return undefined; +} + +export function resolveProviderGroupPolicy(params: { + cfg: ClawdbotConfig; + provider: GroupPolicyProvider; + groupId?: string | null; +}): ProviderGroupPolicy { + const { cfg, provider } = params; + const groups = resolveProviderGroups(cfg, provider); + const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0); + const normalizedId = params.groupId?.trim(); + const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined; + const defaultConfig = groups?.["*"]; + const allowAll = + allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*")); + const allowed = + !allowlistEnabled || + allowAll || + (normalizedId + ? Boolean(groups && Object.hasOwn(groups, normalizedId)) + : false); + return { + allowlistEnabled, + allowed, + groupConfig, + defaultConfig, + }; +} + +export function resolveProviderGroupRequireMention(params: { + cfg: ClawdbotConfig; + provider: GroupPolicyProvider; + groupId?: string | null; + requireMentionOverride?: boolean; + overrideOrder?: "before-config" | "after-config"; +}): boolean { + const { requireMentionOverride, overrideOrder = "after-config" } = params; + const { groupConfig, defaultConfig } = resolveProviderGroupPolicy(params); + const configMention = + typeof groupConfig?.requireMention === "boolean" + ? groupConfig.requireMention + : typeof defaultConfig?.requireMention === "boolean" + ? defaultConfig.requireMention + : undefined; + + if ( + overrideOrder === "before-config" && + typeof requireMentionOverride === "boolean" + ) { + return requireMentionOverride; + } + if (typeof configMention === "boolean") return configMention; + if ( + overrideOrder !== "before-config" && + typeof requireMentionOverride === "boolean" + ) { + return requireMentionOverride; + } + return true; +} diff --git a/src/config/io.ts b/src/config/io.ts index 878dc0cc7..9ce2f72e5 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -11,6 +11,7 @@ import { import { applyIdentityDefaults, applyLoggingDefaults, + applyMessageDefaults, applyModelDefaults, applySessionDefaults, applyTalkApiKey, @@ -117,7 +118,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const cfg = applyModelDefaults( applySessionDefaults( applyLoggingDefaults( - applyIdentityDefaults(validated.data as ClawdbotConfig), + applyMessageDefaults( + applyIdentityDefaults(validated.data as ClawdbotConfig), + ), ), ), ); @@ -148,7 +151,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const exists = deps.fs.existsSync(configPath); if (!exists) { const config = applyTalkApiKey( - applyModelDefaults(applySessionDefaults({})), + applyModelDefaults(applySessionDefaults(applyMessageDefaults({}))), ); const legacyIssues: LegacyConfigIssue[] = []; return { @@ -205,7 +208,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { valid: true, config: applyTalkApiKey( applyModelDefaults( - applySessionDefaults(applyLoggingDefaults(validated.config)), + applySessionDefaults( + applyLoggingDefaults(applyMessageDefaults(validated.config)), + ), ), ), issues: [], diff --git a/src/config/schema.ts b/src/config/schema.ts index ab582bab9..1e718cd75 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -32,6 +32,7 @@ const GROUP_LABELS: Record = { models: "Models", routing: "Routing", messages: "Messages", + commands: "Commands", session: "Session", cron: "Cron", hooks: "Hooks", @@ -58,6 +59,7 @@ const GROUP_ORDER: Record = { models: 50, routing: 60, messages: 70, + commands: 75, session: 80, cron: 90, hooks: 100, @@ -94,11 +96,22 @@ const FIELD_LABELS: Record = { "agent.model.fallbacks": "Model Fallbacks", "agent.imageModel.primary": "Image Model", "agent.imageModel.fallbacks": "Image Model Fallbacks", + "commands.native": "Native Commands", + "commands.text": "Text Commands", + "commands.useAccessGroups": "Use Access Groups", "ui.seamColor": "Accent Color", "browser.controlUrl": "Browser Control URL", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", + "messages.ackReaction": "Ack Reaction Emoji", + "messages.ackReactionScope": "Ack Reaction Scope", "talk.apiKey": "Talk API Key", "telegram.botToken": "Telegram Bot Token", + "telegram.dmPolicy": "Telegram DM Policy", + "whatsapp.dmPolicy": "WhatsApp DM Policy", + "signal.dmPolicy": "Signal DM Policy", + "imessage.dmPolicy": "iMessage DM Policy", + "discord.dm.policy": "Discord DM Policy", + "slack.dm.policy": "Slack DM Policy", "discord.token": "Discord Bot Token", "slack.botToken": "Slack Bot Token", "slack.appToken": "Slack App Token", @@ -129,8 +142,29 @@ const FIELD_HELP: Record = { "Optional image model (provider/model) used when the primary model lacks image input.", "agent.imageModel.fallbacks": "Ordered fallback image models (provider/model).", + "commands.native": + "Register native commands with connectors that support it (Discord/Slack/Telegram).", + "commands.text": "Allow text command parsing (slash commands only).", + "commands.useAccessGroups": + "Enforce access-group allowlists/policies for commands.", "session.agentToAgent.maxPingPongTurns": "Max reply-back turns between requester and target (0–5).", + "messages.ackReaction": + "Emoji reaction used to acknowledge inbound messages (empty disables).", + "messages.ackReactionScope": + 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', + "telegram.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].', + "whatsapp.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].', + "signal.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires signal.allowFrom=["*"].', + "imessage.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires imessage.allowFrom=["*"].', + "discord.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires discord.dm.allowFrom=["*"].', + "slack.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].', }; const FIELD_PLACEHOLDERS: Record = { diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 982c6e918..c7529eaf1 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -33,30 +33,30 @@ describe("sessions", () => { ); }); - it("prefixes group keys with surface when available", () => { + it("prefixes group keys with provider when available", () => { expect( deriveSessionKey("per-sender", { From: "12345-678@g.us", ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", }), ).toBe("whatsapp:group:12345-678@g.us"); }); - it("keeps explicit surface when provided in group key", () => { + it("keeps explicit provider when provided in group key", () => { expect( resolveSessionKey( "per-sender", { From: "group:discord:12345", ChatType: "group" }, "main", ), - ).toBe("discord:group:12345"); + ).toBe("agent:main:discord:group:12345"); }); it("builds discord display name with guild+channel slugs", () => { expect( buildGroupDisplayName({ - surface: "discord", + provider: "discord", room: "#general", space: "friends-of-clawd", id: "123", @@ -66,22 +66,24 @@ describe("sessions", () => { }); it("collapses direct chats to main by default", () => { - expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main"); + expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe( + "agent:main:main", + ); }); it("collapses direct chats to main even when sender missing", () => { - expect(resolveSessionKey("per-sender", {})).toBe("main"); + expect(resolveSessionKey("per-sender", {})).toBe("agent:main:main"); }); it("maps direct chats to main key when provided", () => { expect( resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"), - ).toBe("main"); + ).toBe("agent:main:main"); }); it("uses custom main key when provided", () => { expect(resolveSessionKey("per-sender", { From: "+1555" }, "primary")).toBe( - "primary", + "agent:main:primary", ); }); @@ -92,17 +94,18 @@ describe("sessions", () => { it("leaves groups untouched even with main key", () => { expect( resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"), - ).toBe("group:12345-678@g.us"); + ).toBe("agent:main:group:12345-678@g.us"); }); - it("updateLastRoute persists channel and target", async () => { + it("updateLastRoute persists provider and target", async () => { + const mainSessionKey = "agent:main:main"; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); const storePath = path.join(dir, "sessions.json"); await fs.writeFile( storePath, JSON.stringify( { - main: { + [mainSessionKey]: { sessionId: "sess-1", updatedAt: 123, systemSent: true, @@ -117,16 +120,16 @@ describe("sessions", () => { await updateLastRoute({ storePath, - sessionKey: "main", - channel: "telegram", + sessionKey: mainSessionKey, + provider: "telegram", to: " 12345 ", }); const store = loadSessionStore(storePath); - expect(store.main?.sessionId).toBe("sess-1"); - expect(store.main?.updatedAt).toBeGreaterThanOrEqual(123); - expect(store.main?.lastChannel).toBe("telegram"); - expect(store.main?.lastTo).toBe("12345"); + expect(store[mainSessionKey]?.sessionId).toBe("sess-1"); + expect(store[mainSessionKey]?.updatedAt).toBeGreaterThanOrEqual(123); + expect(store[mainSessionKey]?.lastProvider).toBe("telegram"); + expect(store[mainSessionKey]?.lastTo).toBe("12345"); }); it("derives session transcripts dir from CLAWDBOT_STATE_DIR", () => { @@ -134,7 +137,7 @@ describe("sessions", () => { { CLAWDBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv, () => "/home/ignored", ); - expect(dir).toBe("/custom/state/sessions"); + expect(dir).toBe("/custom/state/agents/main/sessions"); }); it("falls back to CLAWDIS_STATE_DIR for session transcripts dir", () => { @@ -142,6 +145,6 @@ describe("sessions", () => { { CLAWDIS_STATE_DIR: "/legacy/state" } as NodeJS.ProcessEnv, () => "/home/ignored", ); - expect(dir).toBe("/legacy/state/sessions"); + expect(dir).toBe("/legacy/state/agents/main/sessions"); }); }); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index ff440eab8..5b297ec01 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -6,6 +6,13 @@ import path from "node:path"; import type { Skill } from "@mariozechner/pi-coding-agent"; import JSON5 from "json5"; import type { MsgContext } from "../auto-reply/templating.js"; +import { + buildAgentMainSessionKey, + DEFAULT_AGENT_ID, + DEFAULT_MAIN_KEY, + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; import { normalizeE164 } from "../utils.js"; import { resolveStateDir } from "./paths.js"; @@ -26,6 +33,8 @@ export type SessionChatType = "direct" | "group" | "room"; export type SessionEntry = { sessionId: string; updatedAt: number; + /** Parent session key that spawned this session (used for sandbox session-tool scoping). */ + spawnedBy?: string; systemSent?: boolean; abortedLastRun?: boolean; chatType?: SessionChatType; @@ -57,11 +66,11 @@ export type SessionEntry = { contextTokens?: number; compactionCount?: number; displayName?: string; - surface?: string; + provider?: string; subject?: string; room?: string; space?: string; - lastChannel?: + lastProvider?: | "whatsapp" | "telegram" | "discord" @@ -70,13 +79,14 @@ export type SessionEntry = { | "imessage" | "webchat"; lastTo?: string; + lastAccountId?: string; skillsSnapshot?: SessionSkillSnapshot; }; export type GroupKeyResolution = { key: string; legacyKey?: string; - surface?: string; + provider?: string; id?: string; chatType?: SessionChatType; }; @@ -87,26 +97,45 @@ export type SessionSkillSnapshot = { resolvedSkills?: Skill[]; }; +function resolveAgentSessionsDir( + agentId?: string, + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + const root = resolveStateDir(env, homedir); + const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID); + return path.join(root, "agents", id, "sessions"); +} + export function resolveSessionTranscriptsDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, ): string { - return path.join(resolveStateDir(env, homedir), "sessions"); + return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir); } -export function resolveDefaultSessionStorePath(): string { - return path.join(resolveSessionTranscriptsDir(), "sessions.json"); +export function resolveDefaultSessionStorePath(agentId?: string): string { + return path.join(resolveAgentSessionsDir(agentId), "sessions.json"); } export const DEFAULT_RESET_TRIGGER = "/new"; export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"]; export const DEFAULT_IDLE_MINUTES = 60; -export function resolveSessionTranscriptPath(sessionId: string): string { - return path.join(resolveSessionTranscriptsDir(), `${sessionId}.jsonl`); +export function resolveSessionTranscriptPath( + sessionId: string, + agentId?: string, +): string { + return path.join(resolveAgentSessionsDir(agentId), `${sessionId}.jsonl`); } -export function resolveStorePath(store?: string) { - if (!store) return resolveDefaultSessionStorePath(); +export function resolveStorePath(store?: string, opts?: { agentId?: string }) { + const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID); + if (!store) return resolveDefaultSessionStorePath(agentId); + if (store.includes("{agentId}")) { + return path.resolve( + store.replaceAll("{agentId}", agentId).replace("~", os.homedir()), + ); + } if (store.startsWith("~")) return path.resolve(store.replace("~", os.homedir())); return path.resolve(store); @@ -114,9 +143,32 @@ export function resolveStorePath(store?: string) { export function resolveMainSessionKey(cfg?: { session?: { scope?: SessionScope; mainKey?: string }; + routing?: { defaultAgentId?: string }; }): string { if (cfg?.session?.scope === "global") return "global"; - return "main"; + const agentId = normalizeAgentId( + cfg?.routing?.defaultAgentId ?? DEFAULT_AGENT_ID, + ); + const mainKey = + (cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY; + return buildAgentMainSessionKey({ agentId, mainKey }); +} + +export function resolveAgentIdFromSessionKey( + sessionKey?: string | null, +): string { + const parsed = parseAgentSessionKey(sessionKey); + return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); +} + +export function resolveAgentMainSessionKey(params: { + cfg?: { session?: { mainKey?: string } }; + agentId: string; +}): string { + const mainKey = + (params.cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || + DEFAULT_MAIN_KEY; + return buildAgentMainSessionKey({ agentId: params.agentId, mainKey }); } function normalizeGroupLabel(raw?: string) { @@ -135,14 +187,14 @@ function shortenGroupId(value?: string) { } export function buildGroupDisplayName(params: { - surface?: string; + provider?: string; subject?: string; room?: string; space?: string; id?: string; key: string; }) { - const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim(); + const providerKey = (params.provider?.trim().toLowerCase() || "group").trim(); const room = params.room?.trim(); const space = params.space?.trim(); const subject = params.subject?.trim(); @@ -167,7 +219,7 @@ export function buildGroupDisplayName(params: { ) { token = `g-${token}`; } - return token ? `${surfaceKey}:${token}` : surfaceKey; + return token ? `${providerKey}:${token}` : providerKey; } export function resolveGroupSessionKey( @@ -184,13 +236,13 @@ export function resolveGroupSessionKey( from.includes(":channel:"); if (!isGroup) return null; - const surfaceHint = ctx.Surface?.trim().toLowerCase(); + const providerHint = ctx.Provider?.trim().toLowerCase(); const hasLegacyGroupPrefix = from.startsWith("group:"); const raw = ( hasLegacyGroupPrefix ? from.slice("group:".length) : from ).trim(); - let surface: string | undefined; + let provider: string | undefined; let kind: "group" | "channel" | undefined; let id = ""; @@ -201,7 +253,7 @@ export function resolveGroupSessionKey( const parseParts = (parts: string[]) => { if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) { - surface = parts[0]; + provider = parts[0]; if (parts.length >= 3) { const kindCandidate = parts[1]; if (["group", "channel"].includes(kindCandidate)) { @@ -237,8 +289,8 @@ export function resolveGroupSessionKey( } } - const resolvedSurface = surface ?? surfaceHint; - if (!resolvedSurface) { + const resolvedProvider = provider ?? providerHint; + if (!resolvedProvider) { const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`; return { key: legacy, @@ -249,7 +301,7 @@ export function resolveGroupSessionKey( } const resolvedKind = kind === "channel" ? "channel" : "group"; - const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`; + const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`; let legacyKey: string | undefined; if (hasLegacyGroupPrefix || from.includes("@g.us")) { legacyKey = `group:${id || raw || from}`; @@ -258,7 +310,7 @@ export function resolveGroupSessionKey( return { key, legacyKey, - surface: resolvedSurface, + provider: resolvedProvider, id: id || raw || from, chatType: resolvedKind === "channel" ? "room" : "group", }; @@ -321,10 +373,11 @@ export async function saveSessionStore( export async function updateLastRoute(params: { storePath: string; sessionKey: string; - channel: SessionEntry["lastChannel"]; + provider: SessionEntry["lastProvider"]; to?: string; + accountId?: string; }) { - const { storePath, sessionKey, channel, to } = params; + const { storePath, sessionKey, provider, to, accountId } = params; const store = loadSessionStore(storePath); const existing = store[sessionKey]; const now = Date.now(); @@ -347,13 +400,16 @@ export async function updateLastRoute(params: { contextTokens: existing?.contextTokens, displayName: existing?.displayName, chatType: existing?.chatType, - surface: existing?.surface, + provider: existing?.provider, subject: existing?.subject, room: existing?.room, space: existing?.space, skillsSnapshot: existing?.skillsSnapshot, - lastChannel: channel, + lastProvider: provider, lastTo: to?.trim() ? to.trim() : undefined, + lastAccountId: accountId?.trim() + ? accountId.trim() + : existing?.lastAccountId, }; store[sessionKey] = next; await saveSessionStore(storePath, store); @@ -382,12 +438,16 @@ export function resolveSessionKey( if (explicit) return explicit; const raw = deriveSessionKey(scope, ctx); if (scope === "global") return raw; - // Default to a single shared direct-chat session called "main"; groups stay isolated. - const canonical = (mainKey ?? "main").trim() || "main"; + const canonicalMainKey = + (mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY; + const canonical = buildAgentMainSessionKey({ + agentId: DEFAULT_AGENT_ID, + mainKey: canonicalMainKey, + }); const isGroup = raw.startsWith("group:") || raw.includes(":group:") || raw.includes(":channel:"); if (!isGroup) return canonical; - return raw; + return `agent:${DEFAULT_AGENT_ID}:${raw}`; } diff --git a/src/config/types.ts b/src/config/types.ts index 9e8feb291..a103ab1a7 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,10 +1,12 @@ export type ReplyMode = "text" | "command"; export type SessionScope = "per-sender" | "global"; export type ReplyToMode = "off" | "first" | "all"; +export type GroupPolicy = "open" | "disabled" | "allowlist"; +export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyMatch = { - surface?: string; + provider?: string; chatType?: "direct" | "group" | "room"; keyPrefix?: string; }; @@ -76,8 +78,21 @@ export type AgentElevatedAllowFromConfig = { }; export type WhatsAppConfig = { + /** Optional per-account WhatsApp configuration (multi-account). */ + accounts?: Record; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; + /** Optional allowlist for WhatsApp group senders (E.164). */ + groupAllowFrom?: string[]; + /** + * Controls how group messages are handled: + * - "open" (default): groups bypass allowFrom, only mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; groups?: Record< @@ -88,6 +103,25 @@ export type WhatsAppConfig = { >; }; +export type WhatsAppAccountConfig = { + /** If false, do not start this WhatsApp account provider. Default: true. */ + enabled?: boolean; + /** Override auth directory (Baileys multi-file auth state). */ + authDir?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + allowFrom?: string[]; + groupAllowFrom?: string[]; + groupPolicy?: GroupPolicy; + textChunkLimit?: number; + groups?: Record< + string, + { + requireMention?: boolean; + } + >; +}; + export type BrowserProfileConfig = { /** CDP port for this profile. Allocated once at creation, persisted permanently. */ cdpPort?: number; @@ -144,7 +178,7 @@ export type HookMappingConfig = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" @@ -193,6 +227,14 @@ export type HooksConfig = { }; export type TelegramConfig = { + /** + * Controls how Telegram direct chats (DMs) are handled: + * - "pairing" (default): unknown senders get a pairing code; owner must approve + * - "allowlist": only allow senders in allowFrom (or paired allow store) + * - "open": allow all inbound DMs (requires allowFrom to include "*") + * - "disabled": ignore all inbound DMs + */ + dmPolicy?: DmPolicy; /** If false, do not start the Telegram provider. Default: true. */ enabled?: boolean; botToken?: string; @@ -207,6 +249,15 @@ export type TelegramConfig = { } >; allowFrom?: Array; + /** Optional allowlist for Telegram group senders (user ids or usernames). */ + groupAllowFrom?: Array; + /** + * Controls how group messages are handled: + * - "open" (default): groups bypass allowFrom, only mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; mediaMaxMb?: number; @@ -219,6 +270,8 @@ export type TelegramConfig = { export type DiscordDmConfig = { /** If false, ignore all incoming Discord DMs. Default: true. */ enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; /** Allowlist for DM senders (ids or names). */ allowFrom?: Array; /** If true, allow group DMs (default: false). */ @@ -247,17 +300,6 @@ export type DiscordGuildEntry = { channels?: Record; }; -export type DiscordSlashCommandConfig = { - /** Enable handling for the configured slash command (default: false). */ - enabled?: boolean; - /** Slash command name (default: "clawd"). */ - name?: string; - /** Session key prefix for slash commands (default: "discord:slash"). */ - sessionPrefix?: string; - /** Reply ephemerally (default: true). */ - ephemeral?: boolean; -}; - export type DiscordActionConfig = { reactions?: boolean; stickers?: boolean; @@ -282,6 +324,13 @@ export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; token?: string; + /** + * Controls how guild channel messages are handled: + * - "open" (default): guild channels bypass allowlists; mention-gating applies + * - "disabled": block all guild channel messages + * - "allowlist": only allow channels present in discord.guilds.*.channels + */ + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 2000. */ textChunkLimit?: number; mediaMaxMb?: number; @@ -290,7 +339,6 @@ export type DiscordConfig = { actions?: DiscordActionConfig; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; - slashCommand?: DiscordSlashCommandConfig; dm?: DiscordDmConfig; /** New per-guild config keyed by guild id or slug. */ guilds?: Record; @@ -299,6 +347,8 @@ export type DiscordConfig = { export type SlackDmConfig = { /** If false, ignore all incoming Slack DMs. Default: true. */ enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; /** Allowlist for DM senders (ids). */ allowFrom?: Array; /** If true, allow group DMs (default: false). */ @@ -341,6 +391,13 @@ export type SlackConfig = { enabled?: boolean; botToken?: string; appToken?: string; + /** + * Controls how channel messages are handled: + * - "open" (default): channels bypass allowlists; mention-gating applies + * - "disabled": block all channel messages + * - "allowlist": only allow channels present in slack.channels + */ + groupPolicy?: GroupPolicy; textChunkLimit?: number; mediaMaxMb?: number; /** Reaction notification mode (off|own|all|allowlist). Default: own. */ @@ -372,7 +429,18 @@ export type SignalConfig = { ignoreAttachments?: boolean; ignoreStories?: boolean; sendReadReceipts?: boolean; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; allowFrom?: Array; + /** Optional allowlist for Signal group senders (E.164). */ + groupAllowFrom?: Array; + /** + * Controls how group messages are handled: + * - "open" (default): groups bypass allowFrom, no extra gating + * - "disabled": block all group messages + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; mediaMaxMb?: number; @@ -389,8 +457,19 @@ export type IMessageConfig = { service?: "imessage" | "sms" | "auto"; /** Optional default region (used when sending SMS). */ region?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; /** Optional allowlist for inbound handles or chat_id targets. */ allowFrom?: Array; + /** Optional allowlist for group senders or chat_id targets. */ + groupAllowFrom?: Array; + /** + * Controls how group messages are handled: + * - "open" (default): groups bypass allowFrom; mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; /** Include attachments + reactions in watch payloads. */ includeAttachments?: boolean; /** Max outbound media size in MB. */ @@ -415,7 +494,7 @@ export type QueueMode = | "interrupt"; export type QueueDropPolicy = "old" | "new" | "summarize"; -export type QueueModeBySurface = { +export type QueueModeByProvider = { whatsapp?: QueueMode; telegram?: QueueMode; discord?: QueueMode; @@ -437,9 +516,40 @@ export type RoutingConfig = { timeoutSeconds?: number; }; groupChat?: GroupChatConfig; + /** Default agent id when no binding matches. Default: "main". */ + defaultAgentId?: string; + agentToAgent?: { + /** Enable agent-to-agent messaging tools. Default: false. */ + enabled?: boolean; + /** Allowlist of agent ids or patterns (implementation-defined). */ + allow?: string[]; + }; + agents?: Record< + string, + { + workspace?: string; + agentDir?: string; + model?: string; + sandbox?: { + mode?: "off" | "non-main" | "all"; + perSession?: boolean; + workspaceRoot?: string; + }; + } + >; + bindings?: Array<{ + agentId: string; + match: { + provider: string; + accountId?: string; + peer?: { kind: "dm" | "group" | "channel"; id: string }; + guildId?: string; + teamId?: string; + }; + }>; queue?: { mode?: QueueMode; - bySurface?: QueueModeBySurface; + byProvider?: QueueModeByProvider; debounceMs?: number; cap?: number; drop?: QueueDropPolicy; @@ -449,6 +559,19 @@ export type RoutingConfig = { export type MessagesConfig = { messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") + /** Emoji reaction used to acknowledge inbound messages (empty disables). */ + ackReaction?: string; + /** When to send ack reactions. Default: "group-mentions". */ + ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; +}; + +export type CommandsConfig = { + /** Enable native command registration when supported (default: false). */ + native?: boolean; + /** Enable text command parsing (default: true). */ + text?: boolean; + /** Enforce access-group allowlists/policies for commands (default: true). */ + useAccessGroups?: boolean; }; export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; @@ -698,6 +821,8 @@ export type ClawdbotConfig = { models?: Record; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; + /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ + skipBootstrap?: boolean; /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ userTimezone?: string; /** Optional display-only context window override (used for % in status UIs). */ @@ -728,7 +853,7 @@ export type ClawdbotConfig = { typingIntervalSeconds?: number; /** Periodic background heartbeat runs. */ heartbeat?: { - /** Heartbeat interval (duration string, default unit: minutes). */ + /** Heartbeat interval (duration string, default unit: minutes; default: 30m). */ every?: string; /** Heartbeat model override (provider/model). */ model?: string; @@ -744,13 +869,23 @@ export type ClawdbotConfig = { | "none"; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ to?: string; - /** Override the heartbeat prompt body (default: "HEARTBEAT"). */ + /** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */ prompt?: string; /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ ackMaxChars?: number; }; /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ maxConcurrent?: number; + /** Sub-agent defaults (spawned via sessions_spawn). */ + subagents?: { + /** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */ + maxConcurrent?: number; + /** Tool allow/deny policy for sub-agent sessions (deny wins). */ + tools?: { + allow?: string[]; + deny?: string[]; + }; + }; /** Bash tool defaults. */ bash?: { /** Default time (ms) before a bash command auto-backgrounds. */ @@ -764,13 +899,19 @@ export type ClawdbotConfig = { elevated?: { /** Enable or disable elevated mode (default: true). */ enabled?: boolean; - /** Approved senders for /elevated (per-surface allowlists). */ + /** Approved senders for /elevated (per-provider allowlists). */ allowFrom?: AgentElevatedAllowFromConfig; }; /** Optional sandbox settings for non-main sessions. */ sandbox?: { /** Enable sandboxing for sessions. */ mode?: "off" | "non-main" | "all"; + /** + * Session tools visibility for sandboxed sessions. + * - "spawned": only allow session tools to target sessions spawned from this session (default) + * - "all": allow session tools to target any session + */ + sessionToolsVisibility?: "spawned" | "all"; /** Use one container per session (recommended for hard isolation). */ perSession?: boolean; /** Root directory for sandbox workspaces. */ @@ -854,6 +995,7 @@ export type ClawdbotConfig = { }; routing?: RoutingConfig; messages?: MessagesConfig; + commands?: CommandsConfig; session?: SessionConfig; web?: WebConfig; whatsapp?: WhatsAppConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 51cd99726..acf134e1c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -81,6 +81,14 @@ const ReplyToModeSchema = z.union([ z.literal("all"), ]); +// GroupPolicySchema: controls how group messages are handled +// Used with .default("open").optional() pattern: +// - .optional() allows field omission in input config +// - .default("open") ensures runtime always resolves to "open" if not provided +const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]); + +const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]); + const QueueModeBySurfaceSchema = z .object({ whatsapp: QueueModeSchema.optional(), @@ -122,7 +130,7 @@ const SessionSchema = z action: z.union([z.literal("allow"), z.literal("deny")]), match: z .object({ - surface: z.string().optional(), + provider: z.string().optional(), chatType: z .union([ z.literal("direct"), @@ -150,6 +158,18 @@ const MessagesSchema = z .object({ messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), + ackReaction: z.string().optional(), + ackReactionScope: z + .enum(["group-mentions", "group-all", "direct", "all"]) + .optional(), + }) + .optional(); + +const CommandsSchema = z + .object({ + native: z.boolean().optional(), + text: z.boolean().optional(), + useAccessGroups: z.boolean().optional(), }) .optional(); @@ -191,10 +211,65 @@ const RoutingSchema = z .object({ groupChat: GroupChatSchema, transcribeAudio: TranscribeAudioSchema, + defaultAgentId: z.string().optional(), + agentToAgent: z + .object({ + enabled: z.boolean().optional(), + allow: z.array(z.string()).optional(), + }) + .optional(), + agents: z + .record( + z.string(), + z + .object({ + workspace: z.string().optional(), + agentDir: z.string().optional(), + model: z.string().optional(), + sandbox: z + .object({ + mode: z + .union([ + z.literal("off"), + z.literal("non-main"), + z.literal("all"), + ]) + .optional(), + perSession: z.boolean().optional(), + workspaceRoot: z.string().optional(), + }) + .optional(), + }) + .optional(), + ) + .optional(), + bindings: z + .array( + z.object({ + agentId: z.string(), + match: z.object({ + provider: z.string(), + accountId: z.string().optional(), + peer: z + .object({ + kind: z.union([ + z.literal("dm"), + z.literal("group"), + z.literal("channel"), + ]), + id: z.string(), + }) + .optional(), + guildId: z.string().optional(), + teamId: z.string().optional(), + }), + }), + ) + .optional(), queue: z .object({ mode: QueueModeSchema.optional(), - bySurface: QueueModeBySurfaceSchema, + byProvider: QueueModeBySurfaceSchema, debounceMs: z.number().int().nonnegative().optional(), cap: z.number().int().positive().optional(), drop: QueueDropSchema.optional(), @@ -221,7 +296,7 @@ const HookMappingSchema = z messageTemplate: z.string().optional(), textTemplate: z.string().optional(), deliver: z.boolean().optional(), - channel: z + provider: z .union([ z.literal("last"), z.literal("whatsapp"), @@ -412,6 +487,7 @@ export const ClawdbotSchema = z.object({ ) .optional(), workspace: z.string().optional(), + skipBootstrap: z.boolean().optional(), userTimezone: z.string().optional(), contextTokens: z.number().int().positive().optional(), tools: z @@ -455,6 +531,17 @@ export const ClawdbotSchema = z.object({ typingIntervalSeconds: z.number().int().positive().optional(), heartbeat: HeartbeatSchema, maxConcurrent: z.number().int().positive().optional(), + subagents: z + .object({ + maxConcurrent: z.number().int().positive().optional(), + tools: z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + }) + .optional(), + }) + .optional(), bash: z .object({ backgroundMs: z.number().int().positive().optional(), @@ -483,6 +570,9 @@ export const ClawdbotSchema = z.object({ mode: z .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) .optional(), + sessionToolsVisibility: z + .union([z.literal("spawned"), z.literal("all")]) + .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: z @@ -550,6 +640,7 @@ export const ClawdbotSchema = z.object({ .optional(), routing: RoutingSchema, messages: MessagesSchema, + commands: CommandsSchema, session: SessionSchema, cron: z .object({ @@ -587,7 +678,50 @@ export const ClawdbotSchema = z.object({ .optional(), whatsapp: z .object({ + accounts: z + .record( + z.string(), + z + .object({ + enabled: z.boolean().optional(), + /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ + authDir: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), + textChunkLimit: z.number().int().positive().optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), + }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"', + }); + }) + .optional(), + ) + .optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), groups: z .record( @@ -600,10 +734,24 @@ export const ClawdbotSchema = z.object({ ) .optional(), }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'whatsapp.dmPolicy="open" requires whatsapp.allowFrom to include "*"', + }); + }) .optional(), telegram: z .object({ enabled: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), botToken: z.string().optional(), tokenFile: z.string().optional(), replyToMode: ReplyToModeSchema.optional(), @@ -618,6 +766,8 @@ export const ClawdbotSchema = z.object({ ) .optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), proxy: z.string().optional(), @@ -625,20 +775,26 @@ export const ClawdbotSchema = z.object({ webhookSecret: z.string().optional(), webhookPath: z.string().optional(), }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"', + }); + }) .optional(), discord: z .object({ enabled: z.boolean().optional(), token: z.string().optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), - slashCommand: z - .object({ - enabled: z.boolean().optional(), - name: z.string().optional(), - sessionPrefix: z.string().optional(), - ephemeral: z.boolean().optional(), - }) - .optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), actions: z @@ -664,10 +820,24 @@ export const ClawdbotSchema = z.object({ dm: z .object({ enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupEnabled: z.boolean().optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(), }) + .superRefine((value, ctx) => { + if (value.policy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"', + }); + }) .optional(), guilds: z .record( @@ -702,6 +872,7 @@ export const ClawdbotSchema = z.object({ enabled: z.boolean().optional(), botToken: z.string().optional(), appToken: z.string().optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), reactionNotifications: z @@ -731,10 +902,24 @@ export const ClawdbotSchema = z.object({ dm: z .object({ enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupEnabled: z.boolean().optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(), }) + .superRefine((value, ctx) => { + if (value.policy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"', + }); + }) .optional(), channels: z .record( @@ -764,9 +949,25 @@ export const ClawdbotSchema = z.object({ ignoreAttachments: z.boolean().optional(), ignoreStories: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), - mediaMaxMb: z.number().positive().optional(), + mediaMaxMb: z.number().int().positive().optional(), + }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'signal.dmPolicy="open" requires signal.allowFrom to include "*"', + }); }) .optional(), imessage: z @@ -778,9 +979,12 @@ export const ClawdbotSchema = z.object({ .union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]) .optional(), region: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), includeAttachments: z.boolean().optional(), - mediaMaxMb: z.number().positive().optional(), + mediaMaxMb: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(), groups: z .record( @@ -793,6 +997,19 @@ export const ClawdbotSchema = z.object({ ) .optional(), }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"', + }); + }) .optional(), bridge: z .object({ diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index f7b57c551..00e4c2aaf 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -9,22 +9,22 @@ type SchemaLike = { const?: unknown; }; -type ChannelSchema = { +type ProviderSchema = { anyOf?: Array<{ const?: unknown }>; }; -function extractCronChannels(schema: SchemaLike): string[] { +function extractCronProviders(schema: SchemaLike): string[] { const union = schema.anyOf ?? []; - const payloadWithChannel = union.find((entry) => - Boolean(entry?.properties && "channel" in entry.properties), + const payloadWithProvider = union.find((entry) => + Boolean(entry?.properties && "provider" in entry.properties), ); - const channelSchema = payloadWithChannel?.properties - ? (payloadWithChannel.properties.channel as ChannelSchema) + const providerSchema = payloadWithProvider?.properties + ? (payloadWithProvider.properties.provider as ProviderSchema) : undefined; - const channels = (channelSchema?.anyOf ?? []) + const providers = (providerSchema?.anyOf ?? []) .map((entry) => entry?.const) .filter((value): value is string => typeof value === "string"); - return channels; + return providers; } const UI_FILES = [ @@ -36,28 +36,28 @@ const UI_FILES = [ const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"]; describe("cron protocol conformance", () => { - it("ui + swift include all cron channels from gateway schema", async () => { - const channels = extractCronChannels(CronPayloadSchema as SchemaLike); - expect(channels.length).toBeGreaterThan(0); + it("ui + swift include all cron providers from gateway schema", async () => { + const providers = extractCronProviders(CronPayloadSchema as SchemaLike); + expect(providers.length).toBeGreaterThan(0); const cwd = process.cwd(); for (const relPath of UI_FILES) { const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); - for (const channel of channels) { + for (const provider of providers) { expect( - content.includes(`"${channel}"`), - `${relPath} missing ${channel}`, + content.includes(`"${provider}"`), + `${relPath} missing ${provider}`, ).toBe(true); } } for (const relPath of SWIFT_FILES) { const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); - for (const channel of channels) { - const pattern = new RegExp(`\\bcase\\s+${channel}\\b`); + for (const provider of providers) { + const pattern = new RegExp(`\\bcase\\s+${provider}\\b`); expect( pattern.test(content), - `${relPath} missing case ${channel}`, + `${relPath} missing case ${provider}`, ).toBe(true); } } diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 23b29e6b2..85afb10e8 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -42,10 +42,10 @@ async function writeSessionStore(home: string) { storePath, JSON.stringify( { - main: { + "agent:main:main": { sessionId: "main-session", updatedAt: Date.now(), - lastChannel: "webchat", + lastProvider: "webchat", lastTo: "", }, }, @@ -224,7 +224,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "whatsapp", + provider: "whatsapp", bestEffortDeliver: false, }), message: "do it", @@ -264,7 +264,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "whatsapp", + provider: "whatsapp", bestEffortDeliver: true, }), message: "do it", @@ -309,7 +309,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", @@ -361,7 +361,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "discord", + provider: "discord", to: "channel:1122", }), message: "do it", @@ -406,7 +406,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", @@ -450,7 +450,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "whatsapp", + provider: "whatsapp", to: "+1234", }), message: "do it", @@ -493,7 +493,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", @@ -537,7 +537,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", @@ -585,7 +585,7 @@ describe("runCronIsolatedAgentTurn", () => { kind: "agentTurn", message: "do it", deliver: true, - channel: "telegram", + provider: "telegram", to: "123", }), message: "do it", diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 93c24083a..156e7407a 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -13,11 +13,17 @@ import { } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; +import { hasNonzeroUsage } from "../agents/usage.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; -import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { + chunkMarkdownText, + chunkText, + resolveTextChunkLimit, +} from "../auto-reply/chunk.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken, @@ -28,6 +34,8 @@ import type { ClawdbotConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, + resolveAgentIdFromSessionKey, + resolveMainSessionKey, resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, @@ -86,7 +94,7 @@ function isHeartbeatOnlyResponse( function resolveDeliveryTarget( cfg: ClawdbotConfig, jobPayload: { - channel?: + provider?: | "last" | "whatsapp" | "telegram" @@ -97,36 +105,37 @@ function resolveDeliveryTarget( to?: string; }, ) { - const requestedChannel = - typeof jobPayload.channel === "string" ? jobPayload.channel : "last"; + const requestedProvider = + typeof jobPayload.provider === "string" ? jobPayload.provider : "last"; const explicitTo = typeof jobPayload.to === "string" && jobPayload.to.trim() ? jobPayload.to.trim() : undefined; const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const mainSessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(mainSessionKey); + const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const store = loadSessionStore(storePath); - const main = store[mainKey]; - const lastChannel = - main?.lastChannel && main.lastChannel !== "webchat" - ? main.lastChannel + const main = store[mainSessionKey]; + const lastProvider = + main?.lastProvider && main.lastProvider !== "webchat" + ? main.lastProvider : undefined; const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : ""; - const channel = (() => { + const provider = (() => { if ( - requestedChannel === "whatsapp" || - requestedChannel === "telegram" || - requestedChannel === "discord" || - requestedChannel === "slack" || - requestedChannel === "signal" || - requestedChannel === "imessage" + requestedProvider === "whatsapp" || + requestedProvider === "telegram" || + requestedProvider === "discord" || + requestedProvider === "slack" || + requestedProvider === "signal" || + requestedProvider === "imessage" ) { - return requestedChannel; + return requestedProvider; } - return lastChannel ?? "whatsapp"; + return lastProvider ?? "whatsapp"; })(); const to = (() => { @@ -135,7 +144,7 @@ function resolveDeliveryTarget( })(); const sanitizedWhatsappTo = (() => { - if (channel !== "whatsapp") return to; + if (provider !== "whatsapp") return to; const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) return to; const allowFrom = rawAllow @@ -149,8 +158,8 @@ function resolveDeliveryTarget( })(); return { - channel, - to: channel === "whatsapp" ? sanitizedWhatsappTo : to, + provider, + to: provider === "whatsapp" ? sanitizedWhatsappTo : to, }; } @@ -180,7 +189,7 @@ function resolveCronSession(params: { model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh }; @@ -199,7 +208,7 @@ export async function runCronIsolatedAgentTurn(params: { params.cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, - ensureBootstrapFiles: true, + ensureBootstrapFiles: !params.cfg.agent?.skipBootstrap, }); const workspaceDir = workspace.dir; @@ -234,12 +243,13 @@ export async function runCronIsolatedAgentTurn(params: { }); } - const timeoutSecondsRaw = - params.job.payload.kind === "agentTurn" && params.job.payload.timeoutSeconds - ? params.job.payload.timeoutSeconds - : (agentCfg?.timeoutSeconds ?? 600); - const timeoutSeconds = Math.max(Math.floor(timeoutSecondsRaw), 1); - const timeoutMs = timeoutSeconds * 1000; + const timeoutMs = resolveAgentTimeoutMs({ + cfg: params.cfg, + overrideSeconds: + params.job.payload.kind === "agentTurn" + ? params.job.payload.timeoutSeconds + : undefined, + }); const delivery = params.job.payload.kind === "agentTurn" && @@ -249,9 +259,9 @@ export async function runCronIsolatedAgentTurn(params: { params.job.payload.bestEffortDeliver === true; const resolvedDelivery = resolveDeliveryTarget(params.cfg, { - channel: + provider: params.job.payload.kind === "agentTurn" - ? params.job.payload.channel + ? params.job.payload.provider : "last", to: params.job.payload.kind === "agentTurn" @@ -300,7 +310,7 @@ export async function runCronIsolatedAgentTurn(params: { registerAgentRunContext(cronSession.sessionEntry.sessionId, { sessionKey: params.sessionKey, }); - const surface = resolvedDelivery.channel; + const messageProvider = resolvedDelivery.provider; const fallbackResult = await runWithModelFallback({ cfg: params.cfg, provider, @@ -309,7 +319,7 @@ export async function runCronIsolatedAgentTurn(params: { runEmbeddedPiAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: params.sessionKey, - surface, + messageProvider, sessionFile, workspaceDir, config: params.cfg, @@ -352,7 +362,7 @@ export async function runCronIsolatedAgentTurn(params: { cronSession.sessionEntry.modelProvider = providerUsed; cronSession.sessionEntry.model = modelUsed; cronSession.sessionEntry.contextTokens = contextTokens; - if (usage) { + if (hasNonzeroUsage(usage)) { const input = usage.input ?? 0; const output = usage.output ?? 0; const promptTokens = @@ -378,7 +388,7 @@ export async function runCronIsolatedAgentTurn(params: { delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars)); if (delivery && !skipHeartbeatDelivery) { - if (resolvedDelivery.channel === "whatsapp") { + if (resolvedDelivery.provider === "whatsapp") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { @@ -413,7 +423,7 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "telegram") { + } else if (resolvedDelivery.provider === "telegram") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { @@ -433,7 +443,10 @@ export async function runCronIsolatedAgentTurn(params: { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); if (mediaList.length === 0) { - for (const chunk of chunkText(payload.text ?? "", textLimit)) { + for (const chunk of chunkMarkdownText( + payload.text ?? "", + textLimit, + )) { await params.deps.sendMessageTelegram(chatId, chunk, { verbose: false, token: telegramToken || undefined, @@ -457,14 +470,14 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "discord") { + } else if (resolvedDelivery.provider === "discord") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { status: "error", summary, error: - "Cron delivery to Discord requires --channel discord and --to ", + "Cron delivery to Discord requires --provider discord and --to ", }; return { status: "skipped", @@ -501,14 +514,14 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "slack") { + } else if (resolvedDelivery.provider === "slack") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { status: "error", summary, error: - "Cron delivery to Slack requires --channel slack and --to ", + "Cron delivery to Slack requires --provider slack and --to ", }; return { status: "skipped", @@ -522,7 +535,10 @@ export async function runCronIsolatedAgentTurn(params: { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); if (mediaList.length === 0) { - for (const chunk of chunkText(payload.text ?? "", textLimit)) { + for (const chunk of chunkMarkdownText( + payload.text ?? "", + textLimit, + )) { await params.deps.sendMessageSlack(slackTarget, chunk); } } else { @@ -541,7 +557,7 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "signal") { + } else if (resolvedDelivery.provider === "signal") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { @@ -580,7 +596,7 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } - } else if (resolvedDelivery.channel === "imessage") { + } else if (resolvedDelivery.provider === "imessage") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) return { diff --git a/src/cron/types.ts b/src/cron/types.ts index a01479b8a..0ac709f8f 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -14,7 +14,7 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index b2dddfb6c..bd530a789 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -11,6 +11,11 @@ function isNodeRuntime(execPath: string): boolean { return base === "node" || base === "node.exe"; } +function isBunRuntime(execPath: string): boolean { + const base = path.basename(execPath).toLowerCase(); + return base === "bun" || base === "bun.exe"; +} + async function resolveCliEntrypointPathForService(): Promise { const argv1 = process.argv[1]; if (!argv1) throw new Error("Unable to resolve CLI entrypoint path"); @@ -108,16 +113,16 @@ function resolveRepoRootForDev(): string { return parts.slice(0, srcIndex).join(path.sep); } -async function resolveTsxCliPath(repoRoot: string): Promise { - const candidate = path.join( - repoRoot, - "node_modules", - "tsx", - "dist", - "cli.mjs", - ); - await fs.access(candidate); - return candidate; +async function resolveBunPath(): Promise { + // Bun is expected to be in PATH, resolve via which/where + const { execSync } = await import("node:child_process"); + try { + const bunPath = execSync("which bun", { encoding: "utf8" }).trim(); + await fs.access(bunPath); + return bunPath; + } catch { + throw new Error("Bun not found in PATH. Install bun: https://bun.sh"); + } } export async function resolveGatewayProgramArguments(params: { @@ -125,28 +130,40 @@ export async function resolveGatewayProgramArguments(params: { dev?: boolean; }): Promise { const gatewayArgs = ["gateway-daemon", "--port", String(params.port)]; - const nodePath = process.execPath; + const execPath = process.execPath; if (!params.dev) { try { const cliEntrypointPath = await resolveCliEntrypointPathForService(); return { - programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs], + programArguments: [execPath, cliEntrypointPath, ...gatewayArgs], }; } catch (error) { - if (!isNodeRuntime(nodePath)) { - return { programArguments: [nodePath, ...gatewayArgs] }; + // If running under bun or another runtime that can execute TS directly + if (!isNodeRuntime(execPath)) { + return { programArguments: [execPath, ...gatewayArgs] }; } throw error; } } + // Dev mode: use bun to run TypeScript directly const repoRoot = resolveRepoRootForDev(); - const tsxCliPath = await resolveTsxCliPath(repoRoot); const devCliPath = path.join(repoRoot, "src", "index.ts"); await fs.access(devCliPath); + + // If already running under bun, use current execPath + if (isBunRuntime(execPath)) { + return { + programArguments: [execPath, devCliPath, ...gatewayArgs], + workingDirectory: repoRoot, + }; + } + + // Otherwise resolve bun from PATH + const bunPath = await resolveBunPath(); return { - programArguments: [nodePath, tsxCliPath, devCliPath, ...gatewayArgs], + programArguments: [bunPath, devCliPath, ...gatewayArgs], workingDirectory: repoRoot, }; } diff --git a/src/discord/index.ts b/src/discord/index.ts index 4bd4018e3..c9e1b3c83 100644 --- a/src/discord/index.ts +++ b/src/discord/index.ts @@ -1,2 +1,2 @@ export { monitorDiscordProvider } from "./monitor.js"; -export { sendMessageDiscord } from "./send.js"; +export { sendMessageDiscord, sendPollDiscord } from "./send.js"; diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index b9f6bb6ac..f0925e734 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -1,7 +1,9 @@ +import type { Guild } from "@buape/carbon"; import { describe, expect, it } from "vitest"; import { allowListMatches, type DiscordGuildEntryResolved, + isDiscordGroupAllowedByPolicy, normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordChannelConfig, @@ -11,8 +13,7 @@ import { shouldEmitDiscordReactionNotification, } from "./monitor.js"; -const fakeGuild = (id: string, name: string) => - ({ id, name }) as unknown as import("discord.js").Guild; +const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild; const makeEntries = ( entries: Record>, @@ -132,6 +133,58 @@ describe("discord guild/channel resolution", () => { }); }); +describe("discord groupPolicy gating", () => { + it("allows when policy is open", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "open", + channelAllowlistConfigured: false, + channelAllowed: false, + }), + ).toBe(true); + }); + + it("blocks when policy is disabled", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "disabled", + channelAllowlistConfigured: true, + channelAllowed: true, + }), + ).toBe(false); + }); + + it("blocks allowlist when no channel allowlist configured", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: false, + channelAllowed: true, + }), + ).toBe(false); + }); + + it("allows allowlist when channel is allowed", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: true, + channelAllowed: true, + }), + ).toBe(true); + }); + + it("blocks allowlist when channel is not allowed", () => { + expect( + isDiscordGroupAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: true, + channelAllowed: false, + }), + ).toBe(false); + }); +}); + describe("discord group DM gating", () => { it("allows all when no allowlist", () => { expect( diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 2d000c61d..310c07e82 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -1,205 +1,170 @@ +import type { Client } from "@buape/carbon"; +import { ChannelType, MessageType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { monitorDiscordProvider } from "./monitor.js"; - const sendMock = vi.fn(); -const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); -let config: Record = {}; - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => config, - }; -}); - -vi.mock("../auto-reply/reply.js", () => ({ - getReplyFromConfig: (...args: unknown[]) => replyMock(...args), -})); +const dispatchMock = vi.fn(); vi.mock("./send.js", () => ({ sendMessageDiscord: (...args: unknown[]) => sendMock(...args), })); - -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), - updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), - resolveSessionKey: vi.fn(), +vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ + dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), })); - -vi.mock("discord.js", () => { - const handlers = new Map void>>(); - class Client { - static lastClient: Client | null = null; - user = { id: "bot-id", tag: "bot#1" }; - constructor() { - Client.lastClient = this; - } - on(event: string, handler: (...args: unknown[]) => void) { - if (!handlers.has(event)) handlers.set(event, new Set()); - handlers.get(event)?.add(handler); - } - once(event: string, handler: (...args: unknown[]) => void) { - this.on(event, handler); - } - off(event: string, handler: (...args: unknown[]) => void) { - handlers.get(event)?.delete(handler); - } - emit(event: string, ...args: unknown[]) { - for (const handler of handlers.get(event) ?? []) { - Promise.resolve(handler(...args)).catch(() => {}); - } - } - login = vi.fn().mockResolvedValue(undefined); - destroy = vi.fn().mockResolvedValue(undefined); - } - +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { - Client, - __getLastClient: () => Client.lastClient, - Events: { - ClientReady: "ready", - Error: "error", - MessageCreate: "messageCreate", - MessageReactionAdd: "reactionAdd", - MessageReactionRemove: "reactionRemove", - }, - ChannelType: { - DM: "dm", - GroupDM: "group_dm", - GuildText: "guild_text", - }, - MessageType: { - Default: "default", - ChatInputCommand: "chat_command", - ContextMenuCommand: "context_command", - }, - GatewayIntentBits: {}, - Partials: {}, + ...actual, + resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), + updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), + resolveSessionKey: vi.fn(), }; }); -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -async function waitForClient() { - const discord = (await import("discord.js")) as unknown as { - __getLastClient: () => { emit: (...args: unknown[]) => void } | null; - }; - for (let i = 0; i < 10; i += 1) { - const client = discord.__getLastClient(); - if (client) return client; - await flush(); - } - return null; -} - beforeEach(() => { - config = { - messages: { responsePrefix: "PFX" }, - discord: { dm: { enabled: true } }, - routing: { allowFrom: [] }, - }; sendMock.mockReset().mockResolvedValue(undefined); - replyMock.mockReset(); updateLastRouteMock.mockReset(); + dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { + dispatcher.sendFinalReply({ text: "hi" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + vi.resetModules(); }); -describe("monitorDiscordProvider tool results", () => { - it("sends tool summaries with responsePrefix", async () => { - replyMock.mockImplementation(async (_ctx, opts) => { - await opts?.onToolResult?.({ text: "tool update" }); - return { text: "final reply" }; - }); +describe("discord tool result dispatch", () => { + it("sends status replies with responsePrefix", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + session: { store: "/tmp/clawdbot-sessions.json" }, + messages: { responsePrefix: "PFX" }, + discord: { dm: { enabled: true, policy: "open" } }, + routing: { allowFrom: [] }, + } as ReturnType; - const controller = new AbortController(); - const run = monitorDiscordProvider({ + const runtimeError = vi.fn(); + const handler = createDiscordMessageHandler({ + cfg, token: "token", - abortSignal: controller.signal, - }); - - const discord = await import("discord.js"); - const client = await waitForClient(); - if (!client) throw new Error("Discord client not created"); - - client.emit(discord.Events.MessageCreate, { - id: "m1", - content: "hello", - author: { id: "u1", bot: false, username: "Ada" }, - channelId: "c1", - channel: { - type: discord.ChannelType.DM, - isSendable: () => false, + runtime: { + log: vi.fn(), + error: runtimeError, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, }, - guild: undefined, - mentions: { has: () => false }, - attachments: { first: () => undefined }, - type: discord.MessageType.Default, - createdTimestamp: Date.now(), + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, }); - await flush(); - controller.abort(); - await run; + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.DM, + name: "dm", + }), + } as unknown as Client; - expect(sendMock).toHaveBeenCalledTimes(2); - expect(sendMock.mock.calls[0][1]).toBe("PFX tool update"); - expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); - }); + await handler( + { + message: { + id: "m1", + content: "/status", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + }, + author: { id: "u1", bot: false, username: "Ada" }, + guild_id: null, + }, + client, + ); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /); + }, 10000); it("accepts guild messages when mentionPatterns match", async () => { - config = { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + session: { store: "/tmp/clawdbot-sessions.json" }, messages: { responsePrefix: "PFX" }, discord: { - dm: { enabled: true }, + dm: { enabled: true, policy: "open" }, guilds: { "*": { requireMention: true } }, }, routing: { allowFrom: [], groupChat: { mentionPatterns: ["\\bclawd\\b"] }, }, - }; - replyMock.mockResolvedValue({ text: "hi" }); + } as ReturnType; - const controller = new AbortController(); - const run = monitorDiscordProvider({ + const handler = createDiscordMessageHandler({ + cfg, token: "token", - abortSignal: controller.signal, + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { "*": { requireMention: true } }, }); - const discord = await import("discord.js"); - const client = await waitForClient(); - if (!client) throw new Error("Discord client not created"); - - client.emit(discord.Events.MessageCreate, { - id: "m2", - content: "clawd: hello", - author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, - member: { displayName: "Ada" }, - channelId: "c1", - channel: { - type: discord.ChannelType.GuildText, + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, name: "general", - isSendable: () => false, - }, - guild: { id: "g1", name: "Guild" }, - mentions: { - has: () => false, - everyone: false, - users: { size: 0 }, - roles: { size: 0 }, - }, - attachments: { first: () => undefined }, - type: discord.MessageType.Default, - createdTimestamp: Date.now(), - }); + }), + } as unknown as Client; - await flush(); - controller.abort(); - await run; + await handler( + { + message: { + id: "m2", + content: "clawd: hello", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + }, + author: { id: "u1", bot: false, username: "Ada" }, + member: { nickname: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + }, + client, + ); - expect(replyMock).toHaveBeenCalledTimes(1); - expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); - }); + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledTimes(1); + }, 10000); }); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 4a8ecebf6..7f6e9f355 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,47 +1,61 @@ import { - type Attachment, ChannelType, Client, - Events, - GatewayIntentBits, + Command, + type CommandInteraction, + type CommandOptions, type Guild, type Message, - type MessageReaction, - type MessageSnapshot, + MessageCreateListener, + MessageReactionAddListener, + MessageReactionRemoveListener, MessageType, - type PartialMessage, - type PartialMessageReaction, - Partials, - type PartialUser, + type RequestClient, type User, -} from "discord.js"; -import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +} from "@buape/carbon"; +import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; +import type { APIAttachment } from "discord-api-types/v10"; +import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10"; + +import { + chunkMarkdownText, + resolveTextChunkLimit, +} from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; +import { + buildCommandText, + listNativeCommandSpecs, + shouldHandleTextCommands, +} from "../auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { buildMentionRegexes, matchesMentionPatterns, } from "../auto-reply/reply/mentions.js"; -import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; +import { + createReplyDispatcher, + createReplyDispatcherWithTyping, +} from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; -import type { - DiscordSlashCommandConfig, - ReplyToMode, -} from "../config/config.js"; +import type { ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; -import { - resolveSessionKey, - resolveStorePath, - updateLastRoute, -} from "../config/sessions.js"; +import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; -import { sendMessageDiscord } from "./send.js"; +import { loadWebMedia } from "../web/media.js"; +import { fetchDiscordApplicationId } from "./probe.js"; +import { reactMessageDiscord, sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; export type MonitorDiscordOpts = { @@ -51,7 +65,6 @@ export type MonitorDiscordOpts = { mediaMaxMb?: number; historyLimit?: number; replyToMode?: ReplyToMode; - slashCommand?: DiscordSlashCommandConfig; }; type DiscordMediaInfo = { @@ -67,6 +80,8 @@ type DiscordHistoryEntry = { messageId?: string; }; +type DiscordReactionEvent = Parameters[0]; + export type DiscordAllowList = { allowAll: boolean; ids: Set; @@ -87,6 +102,15 @@ export type DiscordChannelConfigResolved = { requireMention?: boolean; }; +export type DiscordMessageEvent = Parameters< + MessageCreateListener["handle"] +>[0]; + +export type DiscordMessageHandler = ( + data: DiscordMessageEvent, + client: Client, +) => Promise; + export function resolveDiscordReplyTarget(opts: { replyToMode: ReplyToMode; replyToId?: string; @@ -140,64 +164,219 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dmConfig = cfg.discord?.dm; const guildEntries = cfg.discord?.guilds; + const groupPolicy = cfg.discord?.groupPolicy ?? "open"; const allowFrom = dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord"); - const mentionRegexes = buildMentionRegexes(cfg); const historyLimit = Math.max( 0, opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, ); const replyToMode = opts.replyToMode ?? cfg.discord?.replyToMode ?? "off"; const dmEnabled = dmConfig?.enabled ?? true; + const dmPolicy = dmConfig?.policy ?? "pairing"; const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; + const nativeEnabled = cfg.commands?.native === true; + const nativeDisabledExplicit = cfg.commands?.native === false; + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const sessionPrefix = "discord:slash"; + const ephemeralDefault = true; if (shouldLogVerbose()) { logVerbose( - `discord: config dm=${dmEnabled ? "on" : "off"} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`, + `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`, ); } - const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.MessageContent, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.DirectMessageReactions, + const applicationId = await fetchDiscordApplicationId(token, 4000); + if (!applicationId) { + throw new Error("Failed to resolve Discord application id"); + } + + const commandSpecs = nativeEnabled ? listNativeCommandSpecs() : []; + const commands = commandSpecs.map((spec) => + createDiscordNativeCommand({ + command: spec, + cfg, + sessionPrefix, + ephemeralDefault, + }), + ); + + const client = new Client( + { + baseUrl: "http://localhost", + deploySecret: "a", + clientId: applicationId, + publicKey: "a", + token, + autoDeploy: nativeEnabled, + }, + { + commands, + listeners: [], + }, + [ + new GatewayPlugin({ + intents: + GatewayIntents.Guilds | + GatewayIntents.GuildMessages | + GatewayIntents.MessageContent | + GatewayIntents.DirectMessages | + GatewayIntents.GuildMessageReactions | + GatewayIntents.DirectMessageReactions, + autoInteractions: true, + }), ], - partials: [ - Partials.Channel, - Partials.Message, - Partials.Reaction, - Partials.User, - ], - }); + ); const logger = getChildLogger({ module: "discord-auto-reply" }); const guildHistories = new Map(); + let botUserId: string | undefined; - client.once(Events.ClientReady, () => { - runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`); + if (nativeDisabledExplicit) { + await clearDiscordNativeCommands({ + client, + applicationId, + runtime, + }); + } + + try { + const botUser = await client.fetchUser("@me"); + botUserId = botUser?.id; + } catch (err) { + runtime.error?.( + danger(`discord: failed to fetch bot identity: ${String(err)}`), + ); + } + + const messageHandler = createDiscordMessageHandler({ + cfg, + token, + runtime, + botUserId, + guildHistories, + historyLimit, + mediaMaxBytes, + textLimit, + replyToMode, + dmEnabled, + groupDmEnabled, + groupDmChannels, + allowFrom, + guildEntries, }); - client.on(Events.Error, (err) => { - runtime.error?.(danger(`client error: ${String(err)}`)); - }); + client.listeners.push(new DiscordMessageListener(messageHandler)); + client.listeners.push( + new DiscordReactionListener({ + runtime, + botUserId, + guildEntries, + logger, + }), + ); + client.listeners.push( + new DiscordReactionRemoveListener({ + runtime, + botUserId, + guildEntries, + logger, + }), + ); - client.on(Events.MessageCreate, async (message) => { + runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`); + + await new Promise((resolve) => { + const onAbort = async () => { + try { + const gateway = client.getPlugin("gateway"); + gateway?.disconnect(); + } finally { + resolve(); + } + }; + opts.abortSignal?.addEventListener("abort", () => { + void onAbort(); + }); + }); +} + +async function clearDiscordNativeCommands(params: { + client: Client; + applicationId: string; + runtime: RuntimeEnv; +}) { + try { + await params.client.rest.put( + Routes.applicationCommands(params.applicationId), + { + body: [], + }, + ); + logVerbose("discord: cleared native commands (commands.native=false)"); + } catch (err) { + params.runtime.error?.( + danger(`discord: failed to clear native commands: ${String(err)}`), + ); + } +} + +export function createDiscordMessageHandler(params: { + cfg: ReturnType; + token: string; + runtime: RuntimeEnv; + botUserId?: string; + guildHistories: Map; + historyLimit: number; + mediaMaxBytes: number; + textLimit: number; + replyToMode: ReplyToMode; + dmEnabled: boolean; + groupDmEnabled: boolean; + groupDmChannels?: Array; + allowFrom?: Array; + guildEntries?: Record; +}): DiscordMessageHandler { + const { + cfg, + token, + runtime, + botUserId, + guildHistories, + historyLimit, + mediaMaxBytes, + textLimit, + replyToMode, + dmEnabled, + groupDmEnabled, + groupDmChannels, + allowFrom, + guildEntries, + } = params; + const logger = getChildLogger({ module: "discord-auto-reply" }); + const mentionRegexes = buildMentionRegexes(cfg); + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const groupPolicy = cfg.discord?.groupPolicy ?? "open"; + + return async (data, client) => { try { - if (message.author?.bot) return; - if (!message.author) return; + const message = data.message; + const author = data.author; + if (!author || author.bot) return; + + const isGuildMessage = Boolean(data.guild_id); + const channelInfo = await resolveDiscordChannelInfo( + client, + message.channelId, + ); + const isDirectMessage = channelInfo?.type === ChannelType.DM; + const isGroupDm = channelInfo?.type === ChannelType.GroupDM; - // Discord.js typing excludes GroupDM for message.channel.type; widen for runtime check. - const channelType = message.channel.type as ChannelType; - const isGroupDm = channelType === ChannelType.GroupDM; - const isDirectMessage = channelType === ChannelType.DM; - const isGuildMessage = Boolean(message.guild); if (isGroupDm && !groupDmEnabled) { logVerbose("discord: drop group dm (group dms disabled)"); return; @@ -206,15 +385,80 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { logVerbose("discord: drop dm (dms disabled)"); return; } - const botId = client.user?.id; - const forwardedSnapshot = resolveForwardedSnapshot(message); - const forwardedText = forwardedSnapshot - ? resolveDiscordSnapshotText(forwardedSnapshot.snapshot) - : ""; - const baseText = resolveDiscordMessageText(message, forwardedText); + + const dmPolicy = cfg.discord?.dm?.policy ?? "pairing"; + let commandAuthorized = true; + if (isDirectMessage) { + if (dmPolicy === "disabled") { + logVerbose("discord: drop dm (dmPolicy: disabled)"); + return; + } + if (dmPolicy !== "open") { + const storeAllowFrom = await readProviderAllowFromStore( + "discord", + ).catch(() => []); + const effectiveAllowFrom = [...(allowFrom ?? []), ...storeAllowFrom]; + const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ + "discord:", + "user:", + ]); + const permitted = allowList + ? allowListMatches(allowList, { + id: author.id, + name: author.username, + tag: formatDiscordUserTag(author), + }) + : false; + if (!permitted) { + commandAuthorized = false; + if (dmPolicy === "pairing") { + const { code } = await upsertProviderPairingRequest({ + provider: "discord", + id: author.id, + meta: { + tag: formatDiscordUserTag(author), + name: author.username ?? undefined, + }, + }); + logVerbose( + `discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} code=${code}`, + ); + try { + await sendMessageDiscord( + `user:${author.id}`, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider discord ", + ].join("\n"), + { token, rest: client.rest }, + ); + } catch (err) { + logVerbose( + `discord pairing reply failed for ${author.id}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + commandAuthorized = true; + } + } + const botId = botUserId; + const baseText = resolveDiscordMessageText(message); const wasMentioned = !isDirectMessage && - (Boolean(botId && message.mentions.has(botId)) || + (Boolean( + botId && + message.mentionedUsers?.some((user: User) => user.id === botId), + ) || matchesMentionPatterns(baseText, mentionRegexes)); if (shouldLogVerbose()) { logVerbose( @@ -233,7 +477,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const guildInfo = isGuildMessage ? resolveDiscordGuildEntry({ - guild: message.guild, + guild: data.guild ?? undefined, guildEntries, }) : null; @@ -244,19 +488,26 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { !guildInfo ) { logVerbose( - `Blocked discord guild ${message.guild?.id ?? "unknown"} (not in discord.guilds)`, + `Blocked discord guild ${data.guild_id ?? "unknown"} (not in discord.guilds)`, ); return; } - const channelName = - (isGuildMessage || isGroupDm) && "name" in message.channel - ? message.channel.name - : undefined; + const channelName = channelInfo?.name; const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; const guildSlug = guildInfo?.slug || - (message.guild?.name ? normalizeDiscordSlug(message.guild.name) : ""); + (data.guild?.name ? normalizeDiscordSlug(data.guild.name) : ""); + + const route = resolveAgentRoute({ + cfg, + provider: "discord", + guildId: data.guild_id ?? undefined, + peer: { + kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", + id: isDirectMessage ? author.id : message.channelId, + }, + }); const channelConfig = isGuildMessage ? resolveDiscordChannelConfig({ guildInfo, @@ -276,6 +527,32 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }); if (isGroupDm && !groupDmAllowed) return; + const channelAllowlistConfigured = + Boolean(guildInfo?.channels) && + Object.keys(guildInfo?.channels ?? {}).length > 0; + const channelAllowed = channelConfig?.allowed !== false; + if ( + isGuildMessage && + !isDiscordGroupAllowedByPolicy({ + groupPolicy, + channelAllowlistConfigured, + channelAllowed, + }) + ) { + if (groupPolicy === "disabled") { + logVerbose("discord: drop guild message (groupPolicy: disabled)"); + } else if (!channelAllowlistConfigured) { + logVerbose( + "discord: drop guild message (groupPolicy: allowlist, no channel allowlist)", + ); + } else { + logVerbose( + `Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist)`, + ); + } + return; + } + if (isGuildMessage && channelConfig?.allowed === false) { logVerbose( `Blocked discord channel ${message.channelId} not in guild channel allowlist`, @@ -283,12 +560,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { return; } - if (isGuildMessage && historyLimit > 0 && baseText) { + const textForHistory = resolveDiscordMessageText(message); + if (isGuildMessage && historyLimit > 0 && textForHistory) { const history = guildHistories.get(message.channelId) ?? []; history.push({ - sender: message.member?.displayName ?? message.author.tag, - body: baseText, - timestamp: message.createdTimestamp, + sender: + data.member?.nickname ?? + author.globalName ?? + author.username ?? + author.id, + body: textForHistory, + timestamp: resolveTimestampMs(message.timestamp), messageId: message.id, }); while (history.length > historyLimit) history.shift(); @@ -299,17 +581,24 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { channelConfig?.requireMention ?? guildInfo?.requireMention ?? true; const hasAnyMention = Boolean( !isDirectMessage && - (message.mentions?.everyone || - (message.mentions?.users?.size ?? 0) > 0 || - (message.mentions?.roles?.size ?? 0) > 0), + (message.mentionedEveryone || + (message.mentionedUsers?.length ?? 0) > 0 || + (message.mentionedRoles?.length ?? 0) > 0), ); - const commandAuthorized = resolveDiscordCommandAuthorized({ - isDirectMessage, - allowFrom, - guildInfo, - author: message.author, + if (!isDirectMessage) { + commandAuthorized = resolveDiscordCommandAuthorized({ + isDirectMessage, + allowFrom, + guildInfo, + author, + }); + } + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: "discord", }); const shouldBypassMention = + allowTextCommands && isGuildMessage && resolvedRequireMention && !wasMentioned && @@ -317,8 +606,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { commandAuthorized && hasControlCommand(baseText); const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; - if (isGuildMessage && resolvedRequireMention && canDetectMention) { - if (!wasMentioned && !shouldBypassMention) { + if (isGuildMessage && resolvedRequireMention) { + if (botId && !wasMentioned && !shouldBypassMention) { logVerbose( `discord: drop guild message (mention required, botId=${botId})`, ); @@ -343,57 +632,29 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const userOk = !users || allowListMatches(users, { - id: message.author.id, - name: message.author.username, - tag: message.author.tag, + id: author.id, + name: author.username, + tag: formatDiscordUserTag(author), }); if (!userOk) { logVerbose( - `Blocked discord guild sender ${message.author.id} (not in guild users allowlist)`, + `Blocked discord guild sender ${author.id} (not in guild users allowlist)`, ); return; } } } - if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) { - const allowList = normalizeDiscordAllowList(allowFrom, [ - "discord:", - "user:", - ]); - const permitted = - allowList && - allowListMatches(allowList, { - id: message.author.id, - name: message.author.username, - tag: message.author.tag, - }); - if (!permitted) { - logVerbose( - `Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`, - ); - return; - } - } - - const systemText = resolveDiscordSystemEvent(message); + const systemLocation = resolveDiscordSystemLocation({ + isDirectMessage, + isGroupDm, + guild: data.guild ?? undefined, + channelName: channelName ?? message.channelId, + }); + const systemText = resolveDiscordSystemEvent(message, systemLocation); if (systemText) { - const sessionCfg = cfg.session; - const sessionScope = sessionCfg?.scope ?? "per-sender"; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const sessionKey = resolveSessionKey( - sessionScope, - { - From: isDirectMessage - ? `discord:${message.author.id}` - : `group:${message.channelId}`, - ChatType: isDirectMessage ? "direct" : "group", - Surface: "discord", - }, - mainKey, - ); enqueueSystemEvent(systemText, { - sessionKey, + sessionKey: route.sessionKey, contextKey: `discord:system:${message.channelId}:${message.id}`, }); return; @@ -403,25 +664,51 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const text = message.content?.trim() ?? media?.placeholder ?? - message.embeds[0]?.description ?? - (forwardedSnapshot ? "" : ""); + message.embeds?.[0]?.description ?? + ""; if (!text) { logVerbose(`discord: drop message ${message.id} (empty content)`); return; } + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return isDirectMessage; + const isGroupChat = isGuildMessage || isGroupDm; + if (ackReactionScope === "group-all") return isGroupChat; + if (ackReactionScope === "group-mentions") { + if (!isGuildMessage) return false; + if (!resolvedRequireMention) return false; + if (!canDetectMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction()) { + reactMessageDiscord(message.channelId, message.id, ackReaction, { + rest: client.rest, + }).catch((err) => { + logVerbose( + `discord react failed for channel ${message.channelId}: ${String(err)}`, + ); + }); + } const fromLabel = isDirectMessage - ? buildDirectLabel(message) - : buildGuildLabel(message); + ? buildDirectLabel(author) + : buildGuildLabel({ + guild: data.guild ?? undefined, + channelName: channelName ?? message.channelId, + channelId: message.channelId, + }); const groupRoom = isGuildMessage && channelSlug ? `#${channelSlug}` : undefined; const groupSubject = isDirectMessage ? undefined : groupRoom; - const messageText = text; let combinedBody = formatAgentEnvelope({ - surface: "Discord", + provider: "Discord", from: fromLabel, - timestamp: message.createdTimestamp, - body: messageText, + timestamp: resolveTimestampMs(message.timestamp), + body: text, }); let shouldClearHistory = false; if (!isDirectMessage) { @@ -433,7 +720,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const historyText = historyWithoutCurrent .map((entry) => formatAgentEnvelope({ - surface: "Discord", + provider: "Discord", from: fromLabel, timestamp: entry.timestamp, body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`, @@ -442,78 +729,47 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { .join("\n"); combinedBody = `[Chat messages since your last reply - for context]\n${historyText}\n\n[Current message - respond to this]\n${combinedBody}`; } - const name = message.author.tag; - const id = message.author.id; + const name = formatDiscordUserTag(author); + const id = author.id; combinedBody = `${combinedBody}\n[from: ${name} user id:${id}]`; shouldClearHistory = true; } - const replyContext = await resolveReplyContext(message); + const replyContext = resolveReplyContext(message); if (replyContext) { combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`; } - if (forwardedSnapshot) { - const forwarderName = message.author.tag ?? message.author.username; - const forwarder = forwarderName - ? `${forwarderName} id:${message.author.id}` - : message.author.id; - const snapshotText = - resolveDiscordSnapshotText(forwardedSnapshot.snapshot) || - ""; - const forwardMetaParts = [ - forwardedSnapshot.messageId - ? `forwarded message id: ${forwardedSnapshot.messageId}` - : null, - forwardedSnapshot.channelId - ? `channel: ${forwardedSnapshot.channelId}` - : null, - forwardedSnapshot.guildId - ? `guild: ${forwardedSnapshot.guildId}` - : null, - typeof forwardedSnapshot.snapshot.type === "number" - ? `snapshot type: ${forwardedSnapshot.snapshot.type}` - : null, - ].filter((entry): entry is string => Boolean(entry)); - const forwardedBody = forwardMetaParts.length - ? `${snapshotText}\n[${forwardMetaParts.join(" ")}]` - : snapshotText; - const forwardedEnvelope = formatAgentEnvelope({ - surface: "Discord", - from: `Forwarded by ${forwarder}`, - timestamp: - forwardedSnapshot.snapshot.createdTimestamp ?? - message.createdTimestamp ?? - undefined, - body: forwardedBody, - }); - combinedBody = `[Forwarded message]\n${forwardedEnvelope}\n\n${combinedBody}`; - } const ctxPayload = { Body: combinedBody, From: isDirectMessage - ? `discord:${message.author.id}` + ? `discord:${author.id}` : `group:${message.channelId}`, To: isDirectMessage - ? `user:${message.author.id}` + ? `user:${author.id}` : `channel:${message.channelId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : "group", - SenderName: message.member?.displayName ?? message.author.tag, - SenderId: message.author.id, - SenderUsername: message.author.username, - SenderTag: message.author.tag, + SenderName: + data.member?.nickname ?? author.globalName ?? author.username, + SenderId: author.id, + SenderUsername: author.username, + SenderTag: formatDiscordUserTag(author), GroupSubject: groupSubject, GroupRoom: groupRoom, GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, + Provider: "discord" as const, Surface: "discord" as const, WasMentioned: wasMentioned, MessageSid: message.id, - Timestamp: message.createdTimestamp, + Timestamp: resolveTimestampMs(message.timestamp), MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, CommandAuthorized: commandAuthorized, + CommandSource: "text" as const, }; const replyTarget = ctxPayload.To ?? undefined; if (!replyTarget) { @@ -523,13 +779,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (isDirectMessage) { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); await updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "discord", - to: `user:${message.author.id}`, + sessionKey: route.mainSessionKey, + provider: "discord", + to: `user:${author.id}`, + accountId: route.accountId, }); } @@ -541,49 +799,36 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } let didSendReply = false; - const dispatcher = createReplyDispatcher({ - responsePrefix: cfg.messages?.responsePrefix, - deliver: async (payload) => { - await deliverReplies({ - replies: [payload], - target: replyTarget, - token, - runtime, - replyToMode, - textLimit, - }); - didSendReply = true; - }, - onError: (err, info) => { - runtime.error?.( - danger(`discord ${info.kind} reply failed: ${String(err)}`), - ); - }, - }); - - const replyResult = await getReplyFromConfig( - ctxPayload, - { + const { dispatcher, replyOptions, markDispatchIdle } = + createReplyDispatcherWithTyping({ + responsePrefix: cfg.messages?.responsePrefix, + deliver: async (payload) => { + await deliverDiscordReply({ + replies: [payload], + target: replyTarget, + token, + rest: client.rest, + runtime, + replyToMode, + textLimit, + }); + didSendReply = true; + }, + onError: (err, info) => { + runtime.error?.( + danger(`discord ${info.kind} reply failed: ${String(err)}`), + ); + }, onReplyStart: () => sendTyping(message), - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); - }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); - }, - }, + }); + + const { queuedFinal, counts } = await dispatchReplyFromConfig({ + ctx: ctxPayload, cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; - } - await dispatcher.waitForIdle(); + dispatcher, + replyOptions, + }); + markDispatchIdle(); if (!queuedFinal) { if ( isGuildMessage && @@ -597,7 +842,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } didSendReply = true; if (shouldLogVerbose()) { - const finalCount = dispatcher.getQueuedCounts().final; + const finalCount = counts.final; logVerbose( `discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); @@ -613,129 +858,502 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } catch (err) { runtime.error?.(danger(`handler failed: ${String(err)}`)); } - }); + }; +} - const handleReactionEvent = async ( - reaction: MessageReaction | PartialMessageReaction, - user: User | PartialUser, - action: "added" | "removed", - ) => { - try { - if (!user || user.bot) return; - const resolvedReaction = reaction.partial - ? await reaction.fetch() - : reaction; - const message = (resolvedReaction.message as Message | PartialMessage) - .partial - ? await resolvedReaction.message.fetch() - : resolvedReaction.message; - const guild = message.guild; - if (!guild) return; +class DiscordMessageListener extends MessageCreateListener { + constructor(private handler: DiscordMessageHandler) { + super(); + } + + async handle(data: DiscordMessageEvent, client: Client) { + await this.handler(data, client); + } +} + +class DiscordReactionListener extends MessageReactionAddListener { + constructor( + private params: { + runtime: RuntimeEnv; + botUserId?: string; + guildEntries?: Record; + logger: ReturnType; + }, + ) { + super(); + } + + async handle(data: DiscordReactionEvent, client: Client) { + await handleDiscordReactionEvent({ + data, + client, + action: "added", + botUserId: this.params.botUserId, + guildEntries: this.params.guildEntries, + logger: this.params.logger, + }); + } +} + +class DiscordReactionRemoveListener extends MessageReactionRemoveListener { + constructor( + private params: { + runtime: RuntimeEnv; + botUserId?: string; + guildEntries?: Record; + logger: ReturnType; + }, + ) { + super(); + } + + async handle(data: DiscordReactionEvent, client: Client) { + await handleDiscordReactionEvent({ + data, + client, + action: "removed", + botUserId: this.params.botUserId, + guildEntries: this.params.guildEntries, + logger: this.params.logger, + }); + } +} + +async function handleDiscordReactionEvent(params: { + data: DiscordReactionEvent; + client: Client; + action: "added" | "removed"; + botUserId?: string; + guildEntries?: Record; + logger: ReturnType; +}) { + try { + const { data, client, action, botUserId, guildEntries } = params; + if (!("user" in data)) return; + const user = data.user; + if (!user || user.bot) return; + if (!data.guild_id) return; + + const guildInfo = resolveDiscordGuildEntry({ + guild: data.guild ?? undefined, + guildEntries, + }); + if (guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) { + return; + } + + const channel = await client.fetchChannel(data.channel_id); + if (!channel) return; + const channelName = + "name" in channel ? (channel.name ?? undefined) : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: data.channel_id, + channelName, + channelSlug, + }); + if (channelConfig?.allowed === false) return; + + if (botUserId && user.id === botUserId) return; + + const reactionMode = guildInfo?.reactionNotifications ?? "own"; + const message = await data.message.fetch().catch(() => null); + const messageAuthorId = message?.author?.id ?? undefined; + const shouldNotify = shouldEmitDiscordReactionNotification({ + mode: reactionMode, + botId: botUserId, + messageAuthorId, + userId: user.id, + userName: user.username, + userTag: formatDiscordUserTag(user), + allowlist: guildInfo?.users, + }); + if (!shouldNotify) return; + + const emojiLabel = formatDiscordReactionEmoji(data.emoji); + const actorLabel = formatDiscordUserTag(user); + const guildSlug = + guildInfo?.slug || + (data.guild?.name + ? normalizeDiscordSlug(data.guild.name) + : data.guild_id); + const channelLabel = channelSlug + ? `#${channelSlug}` + : channelName + ? `#${normalizeDiscordSlug(channelName)}` + : `#${data.channel_id}`; + const authorLabel = message?.author + ? formatDiscordUserTag(message.author) + : undefined; + const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`; + const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; + const cfg = loadConfig(); + const route = resolveAgentRoute({ + cfg, + provider: "discord", + guildId: data.guild_id ?? undefined, + peer: { kind: "channel", id: data.channel_id }, + }); + enqueueSystemEvent(text, { + sessionKey: route.sessionKey, + contextKey: `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`, + }); + } catch (err) { + params.logger.error( + danger(`discord reaction handler failed: ${String(err)}`), + ); + } +} + +function createDiscordNativeCommand(params: { + command: { + name: string; + description: string; + acceptsArgs: boolean; + }; + cfg: ReturnType; + sessionPrefix: string; + ephemeralDefault: boolean; +}) { + const { command, cfg, sessionPrefix, ephemeralDefault } = params; + return new (class extends Command { + name = command.name; + description = command.description; + defer = true; + ephemeral = ephemeralDefault; + options = command.acceptsArgs + ? ([ + { + name: "input", + description: "Command input", + type: ApplicationCommandOptionType.String, + required: false, + }, + ] satisfies CommandOptions) + : undefined; + + async run(interaction: CommandInteraction) { + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const user = interaction.user; + if (!user) return; + const channel = interaction.channel; + const channelType = channel?.type; + const isDirectMessage = channelType === ChannelType.DM; + const isGroupDm = channelType === ChannelType.GroupDM; + const channelName = + channel && "name" in channel ? (channel.name as string) : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const prompt = buildCommandText( + this.name, + command.acceptsArgs + ? interaction.options.getString("input") + : undefined, + ); const guildInfo = resolveDiscordGuildEntry({ - guild, - guildEntries, + guild: interaction.guild ?? undefined, + guildEntries: cfg.discord?.guilds, }); - if (guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) { + if (useAccessGroups && interaction.guild) { + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: channel?.id ?? "", + channelName, + channelSlug, + }); + const channelAllowlistConfigured = + Boolean(guildInfo?.channels) && + Object.keys(guildInfo?.channels ?? {}).length > 0; + const channelAllowed = channelConfig?.allowed !== false; + const allowByPolicy = isDiscordGroupAllowedByPolicy({ + groupPolicy: cfg.discord?.groupPolicy ?? "open", + channelAllowlistConfigured, + channelAllowed, + }); + if (!allowByPolicy) { + await interaction.reply({ + content: "This channel is not allowed.", + }); + return; + } + } + const dmEnabled = cfg.discord?.dm?.enabled ?? true; + const dmPolicy = cfg.discord?.dm?.policy ?? "pairing"; + let commandAuthorized = true; + if (isDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { + await interaction.reply({ content: "Discord DMs are disabled." }); + return; + } + if (dmPolicy !== "open") { + const storeAllowFrom = await readProviderAllowFromStore( + "discord", + ).catch(() => []); + const effectiveAllowFrom = [ + ...(cfg.discord?.dm?.allowFrom ?? []), + ...storeAllowFrom, + ]; + const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ + "discord:", + "user:", + ]); + const permitted = allowList + ? allowListMatches(allowList, { + id: user.id, + name: user.username, + tag: formatDiscordUserTag(user), + }) + : false; + if (!permitted) { + commandAuthorized = false; + if (dmPolicy === "pairing") { + const { code } = await upsertProviderPairingRequest({ + provider: "discord", + id: user.id, + meta: { + tag: formatDiscordUserTag(user), + name: user.username ?? undefined, + }, + }); + await interaction.reply({ + content: [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider discord ", + ].join("\n"), + ephemeral: true, + }); + } else { + await interaction.reply({ + content: "You are not authorized to use this command.", + ephemeral: true, + }); + } + return; + } + commandAuthorized = true; + } + } + if (guildInfo?.users && !isDirectMessage) { + const allowList = normalizeDiscordAllowList(guildInfo.users, [ + "discord:", + "user:", + ]); + if ( + allowList && + !allowListMatches(allowList, { + id: user.id, + name: user.username, + tag: formatDiscordUserTag(user), + }) + ) { + await interaction.reply({ + content: "You are not authorized to use this command.", + }); + return; + } + } + if (isGroupDm && cfg.discord?.dm?.groupEnabled === false) { + await interaction.reply({ content: "Discord group DMs are disabled." }); return; } - const channelName = - "name" in message.channel - ? (message.channel.name ?? undefined) - : undefined; - const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; - const channelConfig = resolveDiscordChannelConfig({ - guildInfo, - channelId: message.channelId, - channelName, - channelSlug, - }); - if (channelConfig?.allowed === false) return; - const botId = client.user?.id; - if (botId && user.id === botId) return; - - const reactionMode = guildInfo?.reactionNotifications ?? "own"; - const shouldNotify = shouldEmitDiscordReactionNotification({ - mode: reactionMode, - botId, - messageAuthorId: message.author?.id, - userId: user.id, - userName: user.username, - userTag: user.tag, - allowlist: guildInfo?.users, - }); - if (!shouldNotify) return; - - const emojiLabel = formatDiscordReactionEmoji(resolvedReaction); - const actorLabel = user.tag ?? user.username ?? user.id; - const guildSlug = - guildInfo?.slug || - (guild.name ? normalizeDiscordSlug(guild.name) : guild.id); - const channelLabel = channelSlug - ? `#${channelSlug}` - : channelName - ? `#${normalizeDiscordSlug(channelName)}` - : `#${message.channelId}`; - const authorLabel = message.author?.tag ?? message.author?.username; - const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`; - const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; - const sessionCfg = cfg.session; - const sessionScope = sessionCfg?.scope ?? "per-sender"; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const sessionKey = resolveSessionKey( - sessionScope, - { - From: `group:${message.channelId}`, - ChatType: "group", - Surface: "discord", + const isGuild = Boolean(interaction.guild); + const channelId = channel?.id ?? "unknown"; + const interactionId = interaction.rawData.id; + const route = resolveAgentRoute({ + cfg, + provider: "discord", + guildId: interaction.guild?.id ?? undefined, + peer: { + kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", + id: isDirectMessage ? user.id : channelId, }, - mainKey, - ); - enqueueSystemEvent(text, { - sessionKey, - contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`, }); - } catch (err) { - runtime.error?.( - danger(`discord reaction handler failed: ${String(err)}`), - ); + const ctxPayload = { + Body: prompt, + From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`, + To: `slash:${user.id}`, + SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`, + AccountId: route.accountId, + ChatType: isDirectMessage ? "direct" : "group", + GroupSubject: isGuild ? interaction.guild?.name : undefined, + SenderName: user.globalName ?? user.username, + SenderId: user.id, + SenderUsername: user.username, + SenderTag: formatDiscordUserTag(user), + Provider: "discord" as const, + Surface: "discord" as const, + WasMentioned: true, + MessageSid: interactionId, + Timestamp: Date.now(), + CommandAuthorized: commandAuthorized, + CommandSource: "native" as const, + }; + + let didReply = false; + const dispatcher = createReplyDispatcher({ + responsePrefix: cfg.messages?.responsePrefix, + deliver: async (payload, _info) => { + await deliverDiscordInteractionReply({ + interaction, + payload, + textLimit: resolveTextChunkLimit(cfg, "discord"), + preferFollowUp: didReply, + }); + didReply = true; + }, + onError: (err) => { + console.error(err); + }, + }); + + const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + for (const reply of replies) { + dispatcher.sendFinalReply(reply); + } + await dispatcher.waitForIdle(); } + })(); +} + +async function deliverDiscordInteractionReply(params: { + interaction: CommandInteraction; + payload: ReplyPayload; + textLimit: number; + preferFollowUp: boolean; +}) { + const { interaction, payload, textLimit, preferFollowUp } = params; + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = payload.text ?? ""; + + const sendMessage = async ( + content: string, + files?: { name: string; data: Buffer }[], + ) => { + const payload = + files && files.length > 0 + ? { + content, + files: files.map((file) => { + if (file.data instanceof Blob) { + return { name: file.name, data: file.data }; + } + const arrayBuffer = Uint8Array.from(file.data).buffer; + return { name: file.name, data: new Blob([arrayBuffer]) }; + }), + } + : { content }; + if (!preferFollowUp) { + await interaction.reply(payload); + return; + } + await interaction.followUp(payload); }; - client.on(Events.MessageReactionAdd, async (reaction, user) => { - await handleReactionEvent(reaction, user, "added"); - }); + if (mediaList.length > 0) { + const media = await Promise.all( + mediaList.map(async (url) => { + const loaded = await loadWebMedia(url); + return { + name: loaded.fileName ?? "upload", + data: loaded.buffer, + }; + }), + ); + const caption = text.length > textLimit ? text.slice(0, textLimit) : text; + await sendMessage(caption, media); + if (text.length > textLimit) { + const remaining = text.slice(textLimit).trim(); + if (remaining) { + for (const chunk of chunkMarkdownText(remaining, textLimit)) { + await interaction.followUp({ content: chunk }); + } + } + } + return; + } - client.on(Events.MessageReactionRemove, async (reaction, user) => { - await handleReactionEvent(reaction, user, "removed"); - }); + if (!text.trim()) return; + for (const chunk of chunkMarkdownText(text, textLimit)) { + await sendMessage(chunk); + } +} - await client.login(token); +async function deliverDiscordReply(params: { + replies: ReplyPayload[]; + target: string; + token: string; + rest?: RequestClient; + runtime: RuntimeEnv; + textLimit: number; + replyToMode: ReplyToMode; +}) { + const chunkLimit = Math.min(params.textLimit, 2000); + for (const payload of params.replies) { + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = payload.text ?? ""; + if (!text && mediaList.length === 0) continue; - await new Promise((resolve, reject) => { - const onAbort = () => { - cleanup(); - void client.destroy(); - resolve(); - }; - const onError = (err: Error) => { - cleanup(); - reject(err); - }; - const cleanup = () => { - opts.abortSignal?.removeEventListener("abort", onAbort); - client.off(Events.Error, onError); - }; - opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); - client.on(Events.Error, onError); - }); + if (mediaList.length === 0) { + for (const chunk of chunkMarkdownText(text, chunkLimit)) { + const trimmed = chunk.trim(); + if (!trimmed) continue; + await sendMessageDiscord(params.target, trimmed, { + token: params.token, + rest: params.rest, + }); + } + continue; + } + + const firstMedia = mediaList[0]; + if (!firstMedia) continue; + await sendMessageDiscord(params.target, text, { + token: params.token, + rest: params.rest, + mediaUrl: firstMedia, + }); + for (const extra of mediaList.slice(1)) { + await sendMessageDiscord(params.target, "", { + token: params.token, + rest: params.rest, + mediaUrl: extra, + }); + } + } +} + +async function resolveDiscordChannelInfo( + client: Client, + channelId: string, +): Promise<{ type: ChannelType; name?: string } | null> { + try { + const channel = await client.fetchChannel(channelId); + if (!channel) return null; + const name = "name" in channel ? (channel.name ?? undefined) : undefined; + return { type: channel.type, name }; + } catch (err) { + logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); + return null; + } } async function resolveMedia( message: Message, maxBytes: number, ): Promise { - const attachment = message.attachments.first(); + const attachment = message.attachments?.[0]; if (!attachment) return null; const res = await fetch(attachment.url); if (!res.ok) { @@ -746,8 +1364,8 @@ async function resolveMedia( const buffer = Buffer.from(await res.arrayBuffer()); const mime = await detectMime({ buffer, - headerMime: attachment.contentType ?? res.headers.get("content-type"), - filePath: attachment.name ?? attachment.url, + headerMime: attachment.content_type ?? res.headers.get("content-type"), + filePath: attachment.filename ?? attachment.url, }); const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes); return { @@ -757,8 +1375,8 @@ async function resolveMedia( }; } -function inferPlaceholder(attachment: Attachment): string { - const mime = attachment.contentType ?? ""; +function inferPlaceholder(attachment: APIAttachment): string { + const mime = attachment.content_type ?? ""; if (mime.startsWith("image/")) return ""; if (mime.startsWith("video/")) return ""; if (mime.startsWith("audio/")) return ""; @@ -769,476 +1387,379 @@ function resolveDiscordMessageText( message: Message, fallbackText?: string, ): string { - const attachment = message.attachments.first(); + const attachment = message.attachments?.[0]; return ( message.content?.trim() || (attachment ? inferPlaceholder(attachment) : "") || - message.embeds[0]?.description || + message.embeds?.[0]?.description || fallbackText?.trim() || "" ); } -function resolveDiscordSnapshotText(snapshot: MessageSnapshot): string { - return snapshot.content?.trim() || snapshot.embeds[0]?.description || ""; +function resolveReplyContext(message: Message): string | null { + const referenced = message.referencedMessage; + if (!referenced?.author) return null; + const referencedText = resolveDiscordMessageText(referenced); + if (!referencedText) return null; + const fromLabel = referenced.author + ? buildDirectLabel(referenced.author) + : "Unknown"; + const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${formatDiscordUserTag(referenced.author)} user id:${referenced.author?.id ?? "unknown"}]`; + return formatAgentEnvelope({ + provider: "Discord", + from: fromLabel, + timestamp: resolveTimestampMs(referenced.timestamp), + body, + }); } -async function resolveReplyContext(message: Message): Promise { - if (!message.reference?.messageId) return null; - try { - const referenced = await message.fetchReference(); - if (!referenced?.author) return null; - const referencedText = resolveDiscordMessageText(referenced); - if (!referencedText) return null; - const channelType = referenced.channel.type as ChannelType; - const isDirectMessage = channelType === ChannelType.DM; - const fromLabel = isDirectMessage - ? buildDirectLabel(referenced) - : (referenced.member?.displayName ?? referenced.author.tag); - const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${referenced.author.tag} user id:${referenced.author.id}]`; - return formatAgentEnvelope({ - surface: "Discord", - from: fromLabel, - timestamp: referenced.createdTimestamp, - body, - }); - } catch (err) { - logVerbose( - `discord: failed to fetch reply context for ${message.id}: ${String(err)}`, - ); - return null; - } +function buildDirectLabel(author: User) { + const username = formatDiscordUserTag(author); + return `${username} user id:${author.id}`; } -function buildDirectLabel(message: Message) { - const username = message.author.tag; - return `${username} user id:${message.author.id}`; +function buildGuildLabel(params: { + guild?: Guild; + channelName: string; + channelId: string; +}) { + const { guild, channelName, channelId } = params; + return `${guild?.name ?? "Guild"} #${channelName} channel id:${channelId}`; } -function buildGuildLabel(message: Message) { - const channelName = - "name" in message.channel ? message.channel.name : message.channelId; - return `${message.guild?.name ?? "Guild"} #${channelName} channel id:${message.channelId}`; -} - -function resolveDiscordSystemEvent(message: Message): string | null { +function resolveDiscordSystemEvent( + message: Message, + location: string, +): string | null { switch (message.type) { case MessageType.ChannelPinnedMessage: - return buildDiscordSystemEvent(message, "pinned a message"); + return buildDiscordSystemEvent(message, location, "pinned a message"); case MessageType.RecipientAdd: - return buildDiscordSystemEvent(message, "added a recipient"); + return buildDiscordSystemEvent(message, location, "added a recipient"); case MessageType.RecipientRemove: - return buildDiscordSystemEvent(message, "removed a recipient"); + return buildDiscordSystemEvent(message, location, "removed a recipient"); case MessageType.UserJoin: - return buildDiscordSystemEvent(message, "user joined"); + return buildDiscordSystemEvent(message, location, "user joined"); case MessageType.GuildBoost: - return buildDiscordSystemEvent(message, "boosted the server"); + return buildDiscordSystemEvent(message, location, "boosted the server"); case MessageType.GuildBoostTier1: return buildDiscordSystemEvent( message, + location, "boosted the server (Tier 1 reached)", ); case MessageType.GuildBoostTier2: return buildDiscordSystemEvent( message, + location, "boosted the server (Tier 2 reached)", ); case MessageType.GuildBoostTier3: return buildDiscordSystemEvent( message, + location, "boosted the server (Tier 3 reached)", ); case MessageType.ThreadCreated: - return buildDiscordSystemEvent(message, "created a thread"); + return buildDiscordSystemEvent(message, location, "created a thread"); case MessageType.AutoModerationAction: - return buildDiscordSystemEvent(message, "auto moderation action"); + return buildDiscordSystemEvent( + message, + location, + "auto moderation action", + ); case MessageType.GuildIncidentAlertModeEnabled: - return buildDiscordSystemEvent(message, "raid protection enabled"); + return buildDiscordSystemEvent( + message, + location, + "raid protection enabled", + ); case MessageType.GuildIncidentAlertModeDisabled: - return buildDiscordSystemEvent(message, "raid protection disabled"); + return buildDiscordSystemEvent( + message, + location, + "raid protection disabled", + ); case MessageType.GuildIncidentReportRaid: - return buildDiscordSystemEvent(message, "raid reported"); + return buildDiscordSystemEvent(message, location, "raid reported"); case MessageType.GuildIncidentReportFalseAlarm: - return buildDiscordSystemEvent(message, "raid report marked false alarm"); + return buildDiscordSystemEvent( + message, + location, + "raid report marked false alarm", + ); case MessageType.StageStart: - return buildDiscordSystemEvent(message, "stage started"); + return buildDiscordSystemEvent(message, location, "stage started"); case MessageType.StageEnd: - return buildDiscordSystemEvent(message, "stage ended"); + return buildDiscordSystemEvent(message, location, "stage ended"); case MessageType.StageSpeaker: - return buildDiscordSystemEvent(message, "stage speaker updated"); + return buildDiscordSystemEvent( + message, + location, + "stage speaker updated", + ); case MessageType.StageTopic: - return buildDiscordSystemEvent(message, "stage topic updated"); + return buildDiscordSystemEvent(message, location, "stage topic updated"); case MessageType.PollResult: - return buildDiscordSystemEvent(message, "poll results posted"); + return buildDiscordSystemEvent(message, location, "poll results posted"); case MessageType.PurchaseNotification: - return buildDiscordSystemEvent(message, "purchase notification"); + return buildDiscordSystemEvent( + message, + location, + "purchase notification", + ); default: return null; } } -function resolveForwardedSnapshot(message: Message): { - snapshot: MessageSnapshot; - messageId?: string; - channelId?: string; - guildId?: string; -} | null { - const snapshots = message.messageSnapshots; - if (!snapshots || snapshots.size === 0) return null; - const snapshot = snapshots.first(); - if (!snapshot) return null; - const reference = message.reference; - return { - snapshot, - messageId: reference?.messageId ?? undefined, - channelId: reference?.channelId ?? undefined, - guildId: reference?.guildId ?? undefined, - }; -} - -function buildDiscordSystemEvent(message: Message, action: string) { - const channelName = - "name" in message.channel ? message.channel.name : message.channelId; - const channelType = message.channel.type as ChannelType; - const location = message.guild?.name - ? `${message.guild.name} #${channelName}` - : channelType === ChannelType.GroupDM - ? `Group DM #${channelName}` - : "DM"; - const authorLabel = message.author?.tag ?? message.author?.username; +function buildDiscordSystemEvent( + message: Message, + location: string, + action: string, +) { + const authorLabel = message.author + ? formatDiscordUserTag(message.author) + : ""; const actor = authorLabel ? `${authorLabel} ` : ""; return `Discord system: ${actor}${action} in ${location}`; } -function formatDiscordReactionEmoji( - reaction: MessageReaction | PartialMessageReaction, -) { - if (typeof reaction.emoji.toString === "function") { - const rendered = reaction.emoji.toString(); - if (rendered && rendered !== "[object Object]") return rendered; +function resolveDiscordSystemLocation(params: { + isDirectMessage: boolean; + isGroupDm: boolean; + guild?: Guild; + channelName: string; +}) { + const { isDirectMessage, isGroupDm, guild, channelName } = params; + if (isDirectMessage) return "DM"; + if (isGroupDm) return `Group DM #${channelName}`; + return guild?.name ? `${guild.name} #${channelName}` : `#${channelName}`; +} + +function formatDiscordReactionEmoji(emoji: { + id?: string | null; + name?: string | null; +}) { + if (emoji.id && emoji.name) { + return `${emoji.name}:${emoji.id}`; } - if (reaction.emoji.id && reaction.emoji.name) { - return `${reaction.emoji.name}:${reaction.emoji.id}`; + return emoji.name ?? "emoji"; +} + +function formatDiscordUserTag(user: User) { + const discriminator = (user.discriminator ?? "").trim(); + if (discriminator && discriminator !== "0") { + return `${user.username}#${discriminator}`; } - return reaction.emoji.name ?? "emoji"; + return user.username ?? user.id; +} + +function resolveTimestampMs(timestamp?: string | null) { + if (!timestamp) return undefined; + const parsed = Date.parse(timestamp); + return Number.isNaN(parsed) ? undefined : parsed; } export function normalizeDiscordAllowList( raw: Array | undefined, prefixes: string[], -): DiscordAllowList | null { +) { if (!raw || raw.length === 0) return null; const ids = new Set(); const names = new Set(); - let allowAll = false; - - for (const rawEntry of raw) { - let entry = String(rawEntry).trim(); - if (!entry) continue; - if (entry === "*") { - allowAll = true; + const allowAll = raw.some((entry) => String(entry).trim() === "*"); + for (const entry of raw) { + const text = String(entry).trim(); + if (!text || text === "*") continue; + const normalized = normalizeDiscordSlug(text); + const maybeId = text.replace(/^<@!?/, "").replace(/>$/, ""); + if (/^\d+$/.test(maybeId)) { + ids.add(maybeId); continue; } - for (const prefix of prefixes) { - if (entry.toLowerCase().startsWith(prefix)) { - entry = entry.slice(prefix.length); - break; - } - } - const mentionMatch = entry.match(/^<[@#][!]?(\d+)>$/); - if (mentionMatch?.[1]) { - ids.add(mentionMatch[1]); + const prefix = prefixes.find((entry) => text.startsWith(entry)); + if (prefix) { + const candidate = text.slice(prefix.length); + if (candidate) ids.add(candidate); continue; } - entry = entry.trim(); - if (entry.startsWith("@") || entry.startsWith("#")) { - entry = entry.slice(1); + if (normalized) { + names.add(normalized); } - if (/^\d+$/.test(entry)) { - ids.add(entry); - continue; - } - const normalized = normalizeDiscordName(entry); - if (normalized) names.add(normalized); - const slugged = normalizeDiscordSlug(entry); - if (slugged) names.add(slugged); } - - if (!allowAll && ids.size === 0 && names.size === 0) return null; - return { allowAll, ids, names }; + return { allowAll, ids, names } satisfies DiscordAllowList; } -function normalizeDiscordName(value?: string | null) { - if (!value) return ""; - return value.trim().toLowerCase(); -} - -export function normalizeDiscordSlug(value?: string | null) { - if (!value) return ""; - let text = value.trim().toLowerCase(); - if (!text) return ""; - text = text.replace(/^[@#]+/, ""); - text = text.replace(/[\s_]+/g, "-"); - text = text.replace(/[^a-z0-9-]+/g, "-"); - text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); - return text; +export function normalizeDiscordSlug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/^#/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); } export function allowListMatches( - allowList: DiscordAllowList, - candidates: { - id?: string; - name?: string | null; - tag?: string | null; - }, + list: DiscordAllowList, + candidate: { id?: string; name?: string; tag?: string }, ) { - if (allowList.allowAll) return true; - const { id, name, tag } = candidates; - if (id && allowList.ids.has(id)) return true; - const normalizedName = normalizeDiscordName(name); - if (normalizedName && allowList.names.has(normalizedName)) return true; - const normalizedTag = normalizeDiscordName(tag); - if (normalizedTag && allowList.names.has(normalizedTag)) return true; - const slugName = normalizeDiscordSlug(name); - if (slugName && allowList.names.has(slugName)) return true; - const slugTag = normalizeDiscordSlug(tag); - if (slugTag && allowList.names.has(slugTag)) return true; + if (list.allowAll) return true; + if (candidate.id && list.ids.has(candidate.id)) return true; + const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; + if (slug && list.names.has(slug)) return true; + if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag))) + return true; return false; } -function resolveDiscordCommandAuthorized(params: { +export function resolveDiscordCommandAuthorized(params: { isDirectMessage: boolean; allowFrom?: Array; guildInfo?: DiscordGuildEntryResolved | null; author: User; -}): boolean { - const { isDirectMessage, allowFrom, guildInfo, author } = params; - if (isDirectMessage) { - if (!Array.isArray(allowFrom) || allowFrom.length === 0) return true; - const allowList = normalizeDiscordAllowList(allowFrom, [ - "discord:", - "user:", - ]); - if (!allowList) return true; - return allowListMatches(allowList, { - id: author.id, - name: author.username, - tag: author.tag, - }); - } - const users = guildInfo?.users; - if (!Array.isArray(users) || users.length === 0) return true; - const allowList = normalizeDiscordAllowList(users, ["discord:", "user:"]); +}) { + if (!params.isDirectMessage) return true; + const allowList = normalizeDiscordAllowList(params.allowFrom, [ + "discord:", + "user:", + ]); if (!allowList) return true; return allowListMatches(allowList, { - id: author.id, - name: author.username, - tag: author.tag, + id: params.author.id, + name: params.author.username, + tag: formatDiscordUserTag(params.author), }); } -export function shouldEmitDiscordReactionNotification(params: { - mode: "off" | "own" | "all" | "allowlist" | undefined; - botId?: string | null; - messageAuthorId?: string | null; - userId: string; - userName?: string | null; - userTag?: string | null; - allowlist?: Array | null; -}) { - const { mode, botId, messageAuthorId, userId, userName, userTag, allowlist } = - params; - const effectiveMode = mode ?? "own"; - if (effectiveMode === "off") return false; - if (effectiveMode === "own") { - if (!botId || !messageAuthorId) return false; - return messageAuthorId === botId; - } - if (effectiveMode === "allowlist") { - if (!Array.isArray(allowlist) || allowlist.length === 0) return false; - const users = normalizeDiscordAllowList(allowlist, ["discord:", "user:"]); - if (!users) return false; - return allowListMatches(users, { - id: userId, - name: userName ?? undefined, - tag: userTag ?? undefined, - }); - } - return true; -} - export function resolveDiscordGuildEntry(params: { - guild: Guild | null; - guildEntries: Record | undefined; + guild?: Guild | Guild | null; + guildEntries?: Record; }): DiscordGuildEntryResolved | null { - const { guild, guildEntries } = params; - if (!guild || !guildEntries || Object.keys(guildEntries).length === 0) { - return null; - } - const guildId = guild.id; - const guildSlug = normalizeDiscordSlug(guild.name); - const direct = guildEntries[guildId]; - if (direct) { - return { - id: guildId, - slug: direct.slug ?? guildSlug, - requireMention: direct.requireMention, - reactionNotifications: direct.reactionNotifications, - users: direct.users, - channels: direct.channels, - }; - } - if (guildSlug && guildEntries[guildSlug]) { - const entry = guildEntries[guildSlug]; - return { - id: guildId, - slug: entry.slug ?? guildSlug, - requireMention: entry.requireMention, - reactionNotifications: entry.reactionNotifications, - users: entry.users, - channels: entry.channels, - }; - } - const matchBySlug = Object.entries(guildEntries).find(([, entry]) => { - const entrySlug = normalizeDiscordSlug(entry.slug); - return entrySlug && entrySlug === guildSlug; - }); - if (matchBySlug) { - const entry = matchBySlug[1]; - return { - id: guildId, - slug: entry.slug ?? guildSlug, - requireMention: entry.requireMention, - reactionNotifications: entry.reactionNotifications, - users: entry.users, - channels: entry.channels, - }; - } - const wildcard = guildEntries["*"]; - if (wildcard) { - return { - id: guildId, - slug: wildcard.slug ?? guildSlug, - requireMention: wildcard.requireMention, - reactionNotifications: wildcard.reactionNotifications, - users: wildcard.users, - channels: wildcard.channels, - }; - } + const guild = params.guild; + const entries = params.guildEntries; + if (!guild || !entries) return null; + const byId = entries[guild.id]; + if (byId) return { ...byId, id: guild.id }; + const slug = normalizeDiscordSlug(guild.name ?? ""); + const bySlug = entries[slug]; + if (bySlug) return { ...bySlug, id: guild.id, slug: slug || bySlug.slug }; + const wildcard = entries["*"]; + if (wildcard) + return { ...wildcard, id: guild.id, slug: slug || wildcard.slug }; return null; } export function resolveDiscordChannelConfig(params: { - guildInfo: DiscordGuildEntryResolved | null; + guildInfo?: DiscordGuildEntryResolved | null; channelId: string; channelName?: string; - channelSlug?: string; + channelSlug: string; }): DiscordChannelConfigResolved | null { const { guildInfo, channelId, channelName, channelSlug } = params; - const channelEntries = guildInfo?.channels; - if (channelEntries && Object.keys(channelEntries).length > 0) { - const entry = - channelEntries[channelId] ?? - (channelSlug - ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) - : undefined) ?? - (channelName - ? channelEntries[normalizeDiscordSlug(channelName)] - : undefined); - if (!entry) return { allowed: false }; + const channels = guildInfo?.channels; + if (!channels) return null; + const byId = channels[channelId]; + if (byId) + return { + allowed: byId.allow !== false, + requireMention: byId.requireMention, + }; + if (channelSlug && channels[channelSlug]) { + const entry = channels[channelSlug]; return { allowed: entry.allow !== false, requireMention: entry.requireMention, }; } - return { allowed: true }; + if (channelName && channels[channelName]) { + const entry = channels[channelName]; + return { + allowed: entry.allow !== false, + requireMention: entry.requireMention, + }; + } + return { allowed: false }; +} + +export function isDiscordGroupAllowedByPolicy(params: { + groupPolicy: "open" | "disabled" | "allowlist"; + channelAllowlistConfigured: boolean; + channelAllowed: boolean; +}): boolean { + const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + if (!channelAllowlistConfigured) return false; + return channelAllowed; } export function resolveGroupDmAllow(params: { - channels: Array | undefined; + channels?: Array; channelId: string; channelName?: string; - channelSlug?: string; + channelSlug: string; }) { const { channels, channelId, channelName, channelSlug } = params; if (!channels || channels.length === 0) return true; - const allowList = normalizeDiscordAllowList(channels, ["channel:"]); - if (!allowList) return true; - return allowListMatches(allowList, { - id: channelId, - name: channelSlug || channelName, - }); + const allowList = channels.map((entry) => + normalizeDiscordSlug(String(entry)), + ); + const candidates = [ + normalizeDiscordSlug(channelId), + channelSlug, + channelName ? normalizeDiscordSlug(channelName) : "", + ].filter(Boolean); + return ( + allowList.includes("*") || + candidates.some((candidate) => allowList.includes(candidate)) + ); } -async function sendTyping(message: Message) { - try { - const channel = message.channel; - if (channel.isSendable()) { - await channel.sendTyping(); - } - } catch { - /* ignore */ - } -} - -async function deliverReplies({ - replies, - target, - token, - runtime, - replyToMode, - textLimit, -}: { - replies: ReplyPayload[]; - target: string; - token: string; - runtime: RuntimeEnv; - replyToMode: ReplyToMode; - textLimit: number; +export function shouldEmitDiscordReactionNotification(params: { + mode?: "off" | "own" | "all" | "allowlist"; + botId?: string; + messageAuthorId?: string; + userId: string; + userName?: string; + userTag?: string; + allowlist?: Array; }) { - let hasReplied = false; - const chunkLimit = Math.min(textLimit, 2000); - for (const payload of replies) { - const mediaList = - payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; - const replyToId = payload.replyToId; - if (!text && mediaList.length === 0) continue; - if (mediaList.length === 0) { - for (const chunk of chunkText(text, chunkLimit)) { - const replyTo = resolveDiscordReplyTarget({ - replyToMode, - replyToId, - hasReplied, - }); - await sendMessageDiscord(target, chunk, { - token, - replyTo, - }); - if (replyTo && !hasReplied) { - hasReplied = true; - } - } - } else { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : ""; - first = false; - const replyTo = resolveDiscordReplyTarget({ - replyToMode, - replyToId, - hasReplied, - }); - await sendMessageDiscord(target, caption, { - token, - mediaUrl, - replyTo, - }); - if (replyTo && !hasReplied) { - hasReplied = true; - } - } + const mode = params.mode ?? "own"; + if (mode === "off") return false; + if (mode === "all") return true; + if (mode === "own") { + return Boolean(params.botId && params.messageAuthorId === params.botId); + } + if (mode === "allowlist") { + const list = normalizeDiscordAllowList(params.allowlist, [ + "discord:", + "user:", + ]); + if (!list) return false; + return allowListMatches(list, { + id: params.userId, + name: params.userName, + tag: params.userTag, + }); + } + return false; +} + +async function sendTyping(params: { client: Client; channelId: string }) { + try { + const channel = await params.client.fetchChannel(params.channelId); + if (!channel) return; + if ( + "triggerTyping" in channel && + typeof channel.triggerTyping === "function" + ) { + await channel.triggerTyping(); } - runtime.log?.(`delivered reply to ${target}`); + } catch (err) { + logVerbose( + `discord typing cue failed for channel ${params.channelId}: ${String(err)}`, + ); } } diff --git a/src/discord/probe.ts b/src/discord/probe.ts index 074d6b59e..523169f32 100644 --- a/src/discord/probe.ts +++ b/src/discord/probe.ts @@ -74,3 +74,27 @@ export async function probeDiscord( }; } } + +export async function fetchDiscordApplicationId( + token: string, + timeoutMs: number, + fetcher: typeof fetch = fetch, +): Promise { + const normalized = normalizeDiscordToken(token); + if (!normalized) return undefined; + try { + const res = await fetchWithTimeout( + `${DISCORD_API_BASE}/oauth2/applications/@me`, + timeoutMs, + fetcher, + { + Authorization: `Bot ${normalized}`, + }, + ); + if (!res.ok) return undefined; + const json = (await res.json()) as { id?: string }; + return json.id ?? undefined; + } catch { + return undefined; + } +} diff --git a/src/discord/send.test.ts b/src/discord/send.test.ts index 675cb464a..62b3a17f5 100644 --- a/src/discord/send.test.ts +++ b/src/discord/send.test.ts @@ -1,4 +1,4 @@ -import { PermissionsBitField, Routes } from "discord.js"; +import { PermissionFlagsBits, Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -53,7 +53,7 @@ const makeRest = () => { get: getMock, patch: patchMock, delete: deleteMock, - } as unknown as import("discord.js").REST, + } as unknown as import("@buape/carbon").RequestClient, postMock, putMock, getMock, @@ -108,9 +108,7 @@ describe("sendMessageDiscord", () => { it("adds missing permission hints on 50013", async () => { const { rest, postMock, getMock } = makeRest(); - const perms = new PermissionsBitField([ - PermissionsBitField.Flags.ViewChannel, - ]); + const perms = PermissionFlagsBits.ViewChannel; const apiError = Object.assign(new Error("Missing Permissions"), { code: 50013, status: 403, @@ -126,7 +124,7 @@ describe("sendMessageDiscord", () => { .mockResolvedValueOnce({ id: "bot1" }) .mockResolvedValueOnce({ id: "guild1", - roles: [{ id: "guild1", permissions: perms.bitfield.toString() }], + roles: [{ id: "guild1", permissions: perms.toString() }], }) .mockResolvedValueOnce({ roles: [] }); @@ -152,7 +150,9 @@ describe("sendMessageDiscord", () => { expect(postMock).toHaveBeenCalledWith( Routes.channelMessages("789"), expect.objectContaining({ - files: [expect.objectContaining({ name: "photo.jpg" })], + body: expect.objectContaining({ + files: [expect.objectContaining({ name: "photo.jpg" })], + }), }), ); }); @@ -268,10 +268,8 @@ describe("fetchChannelPermissionsDiscord", () => { it("calculates permissions from guild roles", async () => { const { rest, getMock } = makeRest(); - const perms = new PermissionsBitField([ - PermissionsBitField.Flags.ViewChannel, - PermissionsBitField.Flags.SendMessages, - ]); + const perms = + PermissionFlagsBits.ViewChannel | PermissionFlagsBits.SendMessages; getMock .mockResolvedValueOnce({ id: "chan1", @@ -282,7 +280,7 @@ describe("fetchChannelPermissionsDiscord", () => { .mockResolvedValueOnce({ id: "guild1", roles: [ - { id: "guild1", permissions: perms.bitfield.toString() }, + { id: "guild1", permissions: perms.toString() }, { id: "role2", permissions: "0" }, ], }) @@ -303,7 +301,7 @@ describe("readMessagesDiscord", () => { vi.clearAllMocks(); }); - it("passes query params as URLSearchParams", async () => { + it("passes query params as an object", async () => { const { rest, getMock } = makeRest(); getMock.mockResolvedValue([]); await readMessagesDiscord( @@ -312,8 +310,8 @@ describe("readMessagesDiscord", () => { { rest, token: "t" }, ); const call = getMock.mock.calls[0]; - const options = call?.[1] as { query?: URLSearchParams }; - expect(options.query?.toString()).toBe("limit=5&before=10"); + const options = call?.[1] as Record; + expect(options).toEqual({ limit: 5, before: "10" }); }); }); @@ -376,8 +374,7 @@ describe("searchMessagesDiscord", () => { { rest, token: "t" }, ); const call = getMock.mock.calls[0]; - const options = call?.[1] as { query?: URLSearchParams }; - expect(options.query?.toString()).toBe("content=hello&limit=5"); + expect(call?.[0]).toBe("/guilds/g1/messages/search?content=hello&limit=5"); }); it("supports channel/author arrays and clamps limit", async () => { @@ -394,9 +391,8 @@ describe("searchMessagesDiscord", () => { { rest, token: "t" }, ); const call = getMock.mock.calls[0]; - const options = call?.[1] as { query?: URLSearchParams }; - expect(options.query?.toString()).toBe( - "content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25", + expect(call?.[0]).toBe( + "/guilds/g1/messages/search?content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25", ); }); }); @@ -546,13 +542,13 @@ describe("uploadStickerDiscord", () => { name: "clawdbot_wave", description: "Clawdbot waving", tags: "👋", + files: [ + expect.objectContaining({ + name: "asset.png", + contentType: "image/png", + }), + ], }, - files: [ - expect.objectContaining({ - name: "asset.png", - contentType: "image/png", - }), - ], }), ); }); @@ -596,7 +592,7 @@ describe("sendPollDiscord", () => { "channel:789", { question: "Lunch?", - answers: ["Pizza", "Sushi"], + options: ["Pizza", "Sushi"], }, { rest, diff --git a/src/discord/send.ts b/src/discord/send.ts index 821cd1b80..fe4f60f92 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -1,4 +1,4 @@ -import { ChannelType, PermissionsBitField, REST, Routes } from "discord.js"; +import { RequestClient } from "@buape/carbon"; import { PollLayoutType } from "discord-api-types/payloads/v10"; import type { RESTAPIPoll } from "discord-api-types/rest/v10"; import type { @@ -11,9 +11,19 @@ import type { APIVoiceState, RESTPostAPIGuildScheduledEventJSONBody, } from "discord-api-types/v10"; +import { + ChannelType, + PermissionFlagsBits, + Routes, +} from "discord-api-types/v10"; -import { chunkText } from "../auto-reply/chunk.js"; +import { chunkMarkdownText } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; +import { + normalizePollDurationHours, + normalizePollInput, + type PollInput, +} from "../polls.js"; import { loadWebMedia, loadWebMediaRaw } from "../web/media.js"; import { normalizeDiscordToken } from "./token.js"; @@ -21,7 +31,6 @@ const DISCORD_TEXT_LIMIT = 2000; const DISCORD_MAX_STICKERS = 3; const DISCORD_MAX_EMOJI_BYTES = 256 * 1024; const DISCORD_MAX_STICKER_BYTES = 512 * 1024; -const DISCORD_POLL_MIN_ANSWERS = 2; const DISCORD_POLL_MAX_ANSWERS = 10; const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24; const DISCORD_MISSING_PERMISSIONS = 50013; @@ -43,6 +52,10 @@ export class DiscordSendError extends Error { } } +const PERMISSION_ENTRIES = Object.entries(PermissionFlagsBits).filter( + ([, value]) => typeof value === "bigint", +) as Array<[string, bigint]>; + type DiscordRecipient = | { kind: "user"; @@ -57,7 +70,7 @@ type DiscordSendOpts = { token?: string; mediaUrl?: string; verbose?: boolean; - rest?: REST; + rest?: RequestClient; replyTo?: string; }; @@ -66,16 +79,9 @@ export type DiscordSendResult = { channelId: string; }; -export type DiscordPollInput = { - question: string; - answers: string[]; - allowMultiselect?: boolean; - durationHours?: number; -}; - export type DiscordReactOpts = { token?: string; - rest?: REST; + rest?: RequestClient; }; export type DiscordReactionUser = { @@ -177,6 +183,10 @@ function resolveToken(explicit?: string) { return token; } +function resolveRest(token: string, rest?: RequestClient) { + return rest ?? new RequestClient(token); +} + function normalizeReactionEmoji(raw: string) { const trimmed = raw.trim(); if (!trimmed) { @@ -238,38 +248,39 @@ function normalizeEmojiName(raw: string, label: string) { return name; } -function normalizePollInput(input: DiscordPollInput): RESTAPIPoll { - const question = input.question.trim(); - if (!question) { - throw new Error("Poll question is required"); - } - const answers = (input.answers ?? []) - .map((answer) => answer.trim()) - .filter(Boolean); - if (answers.length < DISCORD_POLL_MIN_ANSWERS) { - throw new Error("Polls require at least 2 answers"); - } - if (answers.length > DISCORD_POLL_MAX_ANSWERS) { - throw new Error("Polls support up to 10 answers"); - } - const durationRaw = - typeof input.durationHours === "number" && - Number.isFinite(input.durationHours) - ? Math.floor(input.durationHours) - : 24; - const duration = Math.min( - Math.max(durationRaw, 1), - DISCORD_POLL_MAX_DURATION_HOURS, - ); +function normalizeDiscordPollInput(input: PollInput): RESTAPIPoll { + const poll = normalizePollInput(input, { + maxOptions: DISCORD_POLL_MAX_ANSWERS, + }); + const duration = normalizePollDurationHours(poll.durationHours, { + defaultHours: 24, + maxHours: DISCORD_POLL_MAX_DURATION_HOURS, + }); return { - question: { text: question }, - answers: answers.map((answer) => ({ poll_media: { text: answer } })), + question: { text: poll.question }, + answers: poll.options.map((answer) => ({ poll_media: { text: answer } })), duration, - allow_multiselect: input.allowMultiselect ?? false, + allow_multiselect: poll.maxSelections > 1, layout_type: PollLayoutType.Default, }; } +function addPermissionBits(base: bigint, add?: string) { + if (!add) return base; + return base | BigInt(add); +} + +function removePermissionBits(base: bigint, deny?: string) { + if (!deny) return base; + return base & ~BigInt(deny); +} + +function bitfieldToPermissions(bitfield: bigint) { + return PERMISSION_ENTRIES.filter(([, value]) => (bitfield & value) === value) + .map(([name]) => name) + .sort(); +} + function getDiscordErrorCode(err: unknown) { if (!err || typeof err !== "object") return undefined; const candidate = @@ -297,7 +308,7 @@ async function buildDiscordSendError( err: unknown, ctx: { channelId: string; - rest: REST; + rest: RequestClient; token: string; hasMedia: boolean; }, @@ -345,7 +356,7 @@ async function buildDiscordSendError( } async function resolveChannelId( - rest: REST, + rest: RequestClient, recipient: DiscordRecipient, ): Promise<{ channelId: string; dm?: boolean }> { if (recipient.kind === "channel") { @@ -361,7 +372,7 @@ async function resolveChannelId( } async function sendDiscordText( - rest: REST, + rest: RequestClient, channelId: string, text: string, replyTo?: string, @@ -378,7 +389,7 @@ async function sendDiscordText( })) as { id: string; channel_id: string }; return res; } - const chunks = chunkText(text, DISCORD_TEXT_LIMIT); + const chunks = chunkMarkdownText(text, DISCORD_TEXT_LIMIT); let last: { id: string; channel_id: string } | null = null; let isFirst = true; for (const chunk of chunks) { @@ -397,7 +408,7 @@ async function sendDiscordText( } async function sendDiscordMedia( - rest: REST, + rest: RequestClient, channelId: string, text: string, mediaUrl: string, @@ -413,13 +424,13 @@ async function sendDiscordMedia( body: { content: caption || undefined, message_reference: messageReference, + files: [ + { + data: media.buffer, + name: media.fileName ?? "upload", + }, + ], }, - files: [ - { - data: media.buffer, - name: media.fileName ?? "upload", - }, - ], })) as { id: string; channel_id: string }; if (text.length > DISCORD_TEXT_LIMIT) { const remaining = text.slice(DISCORD_TEXT_LIMIT).trim(); @@ -447,7 +458,7 @@ function formatReactionEmoji(emoji: { return buildReactionIdentifier(emoji); } -async function fetchBotUserId(rest: REST) { +async function fetchBotUserId(rest: RequestClient) { const me = (await rest.get(Routes.user("@me"))) as { id?: string }; if (!me?.id) { throw new Error("Failed to resolve bot user id"); @@ -461,7 +472,7 @@ export async function sendMessageDiscord( opts: DiscordSendOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient); let result: @@ -500,7 +511,7 @@ export async function sendStickerDiscord( opts: DiscordSendOpts & { content?: string } = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient); const content = opts.content?.trim(); @@ -519,15 +530,15 @@ export async function sendStickerDiscord( export async function sendPollDiscord( to: string, - poll: DiscordPollInput, + poll: PollInput, opts: DiscordSendOpts & { content?: string } = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient); const content = opts.content?.trim(); - const payload = normalizePollInput(poll); + const payload = normalizeDiscordPollInput(poll); const res = (await rest.post(Routes.channelMessages(channelId), { body: { content: content || undefined, @@ -547,7 +558,7 @@ export async function reactMessageDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const encoded = normalizeReactionEmoji(emoji); await rest.put( Routes.channelMessageOwnReaction(channelId, messageId, encoded), @@ -561,7 +572,7 @@ export async function fetchReactionsDiscord( opts: DiscordReactOpts & { limit?: number } = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const message = (await rest.get( Routes.channelMessage(channelId, messageId), )) as { @@ -584,7 +595,7 @@ export async function fetchReactionsDiscord( const encoded = encodeURIComponent(identifier); const users = (await rest.get( Routes.channelMessageReaction(channelId, messageId, encoded), - { query: new URLSearchParams({ limit: String(limit) }) }, + { limit }, )) as Array<{ id: string; username?: string; discriminator?: string }>; summaries.push({ emoji: { @@ -611,7 +622,7 @@ export async function fetchChannelPermissionsDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const channel = (await rest.get(Routes.channel(channelId))) as APIChannel; const channelType = "type" in channel ? channel.type : undefined; const guildId = "guild_id" in channel ? channel.guild_id : undefined; @@ -634,47 +645,47 @@ export async function fetchChannelPermissionsDiscord( const rolesById = new Map( (guild.roles ?? []).map((role) => [role.id, role]), ); - const base = new PermissionsBitField(); const everyoneRole = rolesById.get(guildId); + let base = 0n; if (everyoneRole?.permissions) { - base.add(BigInt(everyoneRole.permissions)); + base = addPermissionBits(base, everyoneRole.permissions); } for (const roleId of member.roles ?? []) { const role = rolesById.get(roleId); if (role?.permissions) { - base.add(BigInt(role.permissions)); + base = addPermissionBits(base, role.permissions); } } - const permissions = new PermissionsBitField(base); + let permissions = base; const overwrites = "permission_overwrites" in channel ? (channel.permission_overwrites ?? []) : []; for (const overwrite of overwrites) { if (overwrite.id === guildId) { - permissions.remove(BigInt(overwrite.deny ?? "0")); - permissions.add(BigInt(overwrite.allow ?? "0")); + permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); + permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); } } for (const overwrite of overwrites) { if (member.roles?.includes(overwrite.id)) { - permissions.remove(BigInt(overwrite.deny ?? "0")); - permissions.add(BigInt(overwrite.allow ?? "0")); + permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); + permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); } } for (const overwrite of overwrites) { if (overwrite.id === botId) { - permissions.remove(BigInt(overwrite.deny ?? "0")); - permissions.add(BigInt(overwrite.allow ?? "0")); + permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); + permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); } } return { channelId, guildId, - permissions: permissions.toArray(), - raw: permissions.bitfield.toString(), + permissions: bitfieldToPermissions(permissions), + raw: permissions.toString(), isDm: false, channelType, }; @@ -686,19 +697,20 @@ export async function readMessagesDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const limit = typeof query.limit === "number" && Number.isFinite(query.limit) ? Math.min(Math.max(Math.floor(query.limit), 1), 100) : undefined; - const params = new URLSearchParams(); - if (limit) params.set("limit", String(limit)); - if (query.before) params.set("before", query.before); - if (query.after) params.set("after", query.after); - if (query.around) params.set("around", query.around); - return (await rest.get(Routes.channelMessages(channelId), { - query: params, - })) as APIMessage[]; + const params: Record = {}; + if (limit) params.limit = limit; + if (query.before) params.before = query.before; + if (query.after) params.after = query.after; + if (query.around) params.around = query.around; + return (await rest.get( + Routes.channelMessages(channelId), + params, + )) as APIMessage[]; } export async function editMessageDiscord( @@ -708,7 +720,7 @@ export async function editMessageDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.patch(Routes.channelMessage(channelId, messageId), { body: { content: payload.content }, })) as APIMessage; @@ -720,7 +732,7 @@ export async function deleteMessageDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.delete(Routes.channelMessage(channelId, messageId)); return { ok: true }; } @@ -731,7 +743,7 @@ export async function pinMessageDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.put(Routes.channelPin(channelId, messageId)); return { ok: true }; } @@ -742,7 +754,7 @@ export async function unpinMessageDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.delete(Routes.channelPin(channelId, messageId)); return { ok: true }; } @@ -752,7 +764,7 @@ export async function listPinsDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get(Routes.channelPins(channelId))) as APIMessage[]; } @@ -762,7 +774,7 @@ export async function createThreadDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const body: Record = { name: payload.name }; if (payload.autoArchiveMinutes) { body.auto_archive_duration = payload.autoArchiveMinutes; @@ -776,17 +788,18 @@ export async function listThreadsDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); if (payload.includeArchived) { if (!payload.channelId) { throw new Error("channelId required to list archived threads"); } - const params = new URLSearchParams(); - if (payload.before) params.set("before", payload.before); - if (payload.limit) params.set("limit", String(payload.limit)); - return await rest.get(Routes.channelThreads(payload.channelId, "public"), { - query: params, - }); + const params: Record = {}; + if (payload.before) params.before = payload.before; + if (payload.limit) params.limit = payload.limit; + return await rest.get( + Routes.channelThreads(payload.channelId, "public"), + params, + ); } return await rest.get(Routes.guildActiveThreads(payload.guildId)); } @@ -796,7 +809,7 @@ export async function searchMessagesDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const params = new URLSearchParams(); params.set("content", query.content); if (query.channelIds?.length) { @@ -813,9 +826,9 @@ export async function searchMessagesDiscord( const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25); params.set("limit", String(limit)); } - return await rest.get(`/guilds/${query.guildId}/messages/search`, { - query: params, - }); + return await rest.get( + `/guilds/${query.guildId}/messages/search?${params.toString()}`, + ); } export async function listGuildEmojisDiscord( @@ -823,7 +836,7 @@ export async function listGuildEmojisDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return await rest.get(Routes.guildEmojis(guildId)); } @@ -832,7 +845,7 @@ export async function uploadEmojiDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const media = await loadWebMediaRaw( payload.mediaUrl, DISCORD_MAX_EMOJI_BYTES, @@ -862,7 +875,7 @@ export async function uploadStickerDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const media = await loadWebMediaRaw( payload.mediaUrl, DISCORD_MAX_STICKER_BYTES, @@ -884,14 +897,14 @@ export async function uploadStickerDiscord( "Sticker description", ), tags: normalizeEmojiName(payload.tags, "Sticker tags"), + files: [ + { + data: media.buffer, + name: media.fileName ?? "sticker", + contentType, + }, + ], }, - files: [ - { - data: media.buffer, - name: media.fileName ?? "sticker", - contentType, - }, - ], }); } @@ -901,7 +914,7 @@ export async function fetchMemberInfoDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get( Routes.guildMember(guildId, userId), )) as APIGuildMember; @@ -912,7 +925,7 @@ export async function fetchRoleInfoDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get(Routes.guildRoles(guildId))) as APIRole[]; } @@ -921,7 +934,7 @@ export async function addRoleDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.put( Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), ); @@ -933,7 +946,7 @@ export async function removeRoleDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.delete( Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), ); @@ -945,7 +958,7 @@ export async function fetchChannelInfoDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get(Routes.channel(channelId))) as APIChannel; } @@ -954,7 +967,7 @@ export async function listGuildChannelsDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[]; } @@ -964,7 +977,7 @@ export async function fetchVoiceStatusDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get( Routes.guildVoiceState(guildId, userId), )) as APIVoiceState; @@ -975,7 +988,7 @@ export async function listScheduledEventsDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get( Routes.guildScheduledEvents(guildId), )) as APIGuildScheduledEvent[]; @@ -987,7 +1000,7 @@ export async function createScheduledEventDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.post(Routes.guildScheduledEvents(guildId), { body: payload, })) as APIGuildScheduledEvent; @@ -998,7 +1011,7 @@ export async function timeoutMemberDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); let until = payload.until; if (!until && payload.durationMinutes) { const ms = payload.durationMinutes * 60 * 1000; @@ -1008,7 +1021,9 @@ export async function timeoutMemberDiscord( Routes.guildMember(payload.guildId, payload.userId), { body: { communication_disabled_until: until ?? null }, - reason: payload.reason, + headers: payload.reason + ? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) } + : undefined, }, )) as APIGuildMember; } @@ -1018,9 +1033,11 @@ export async function kickMemberDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.delete(Routes.guildMember(payload.guildId, payload.userId), { - reason: payload.reason, + headers: payload.reason + ? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) } + : undefined, }); return { ok: true }; } @@ -1030,7 +1047,7 @@ export async function banMemberDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const deleteMessageDays = typeof payload.deleteMessageDays === "number" && Number.isFinite(payload.deleteMessageDays) @@ -1041,7 +1058,9 @@ export async function banMemberDiscord( deleteMessageDays !== undefined ? { delete_message_days: deleteMessageDays } : undefined, - reason: payload.reason, + headers: payload.reason + ? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) } + : undefined, }); return { ok: true }; } diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index e53f4e352..8f69e806a 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -157,7 +157,7 @@ export function handleControlUiHttpRequest( res.statusCode = 503; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end( - "Control UI assets not found. Build them with `pnpm ui:build` (or run `pnpm ui:dev` during development).", + "Control UI assets not found. Build them with `bun run ui:build` (or run `bun run ui:dev` during development).", ); return true; } diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 1146828b5..eab89c6d3 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -18,7 +18,7 @@ export type HookMappingResolved = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" @@ -57,7 +57,7 @@ export type HookAction = wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" @@ -101,7 +101,7 @@ type HookTransformResult = Partial<{ name: string; sessionKey: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" @@ -196,7 +196,7 @@ function normalizeHookMapping( messageTemplate: mapping.messageTemplate, textTemplate: mapping.textTemplate, deliver: mapping.deliver, - channel: mapping.channel, + provider: mapping.provider, to: mapping.to, thinking: mapping.thinking, timeoutSeconds: mapping.timeoutSeconds, @@ -241,7 +241,7 @@ function buildActionFromMapping( wakeMode: mapping.wakeMode ?? "now", sessionKey: renderOptional(mapping.sessionKey, ctx), deliver: mapping.deliver, - channel: mapping.channel, + provider: mapping.provider, to: renderOptional(mapping.to, ctx), thinking: renderOptional(mapping.thinking, ctx), timeoutSeconds: mapping.timeoutSeconds, @@ -291,7 +291,7 @@ function mergeAction( typeof override.deliver === "boolean" ? override.deliver : baseAgent?.deliver, - channel: override.channel ?? baseAgent?.channel, + provider: override.provider ?? baseAgent?.provider, to: override.to ?? baseAgent?.to, thinking: override.thinking ?? baseAgent?.thinking, timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds, diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 1537c5ba5..09f9e66a3 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -56,7 +56,7 @@ describe("gateway hooks helpers", () => { expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false); }); - test("normalizeAgentPayload defaults + validates channel", () => { + test("normalizeAgentPayload defaults + validates provider", () => { const ok = normalizeAgentPayload( { message: "hello" }, { idFactory: () => "fixed" }, @@ -64,20 +64,20 @@ describe("gateway hooks helpers", () => { expect(ok.ok).toBe(true); if (ok.ok) { expect(ok.value.sessionKey).toBe("hook:fixed"); - expect(ok.value.channel).toBe("last"); + expect(ok.value.provider).toBe("last"); expect(ok.value.name).toBe("Hook"); } const imsg = normalizeAgentPayload( - { message: "yo", channel: "imsg" }, + { message: "yo", provider: "imsg" }, { idFactory: () => "x" }, ); expect(imsg.ok).toBe(true); if (imsg.ok) { - expect(imsg.value.channel).toBe("imessage"); + expect(imsg.value.provider).toBe("imessage"); } - const bad = normalizeAgentPayload({ message: "yo", channel: "sms" }); + const bad = normalizeAgentPayload({ message: "yo", provider: "sms" }); expect(bad.ok).toBe(false); }); }); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 160695ee3..0cfc5490b 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -137,7 +137,7 @@ export type HookAgentPayload = { wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" @@ -173,26 +173,26 @@ export function normalizeAgentPayload( typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() ? sessionKeyRaw.trim() : `hook:${idFactory()}`; - const channelRaw = payload.channel; - const channel = - channelRaw === "whatsapp" || - channelRaw === "telegram" || - channelRaw === "discord" || - channelRaw === "slack" || - channelRaw === "signal" || - channelRaw === "imessage" || - channelRaw === "last" - ? channelRaw - : channelRaw === "imsg" + const providerRaw = payload.provider; + const provider = + providerRaw === "whatsapp" || + providerRaw === "telegram" || + providerRaw === "discord" || + providerRaw === "slack" || + providerRaw === "signal" || + providerRaw === "imessage" || + providerRaw === "last" + ? providerRaw + : providerRaw === "imsg" ? "imessage" - : channelRaw === undefined + : providerRaw === undefined ? "last" : null; - if (channel === null) { + if (provider === null) { return { ok: false, error: - "channel must be last|whatsapp|telegram|discord|slack|signal|imessage", + "provider must be last|whatsapp|telegram|discord|slack|signal|imessage", }; } const toRaw = payload.to; @@ -219,7 +219,7 @@ export function normalizeAgentPayload( wakeMode, sessionKey, deliver, - channel, + provider, to, thinking, timeoutSeconds, diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 3e39b0628..16b0b2176 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -68,6 +68,8 @@ import { NodePairVerifyParamsSchema, type NodeRenameParams, NodeRenameParamsSchema, + type PollParams, + PollParamsSchema, PROTOCOL_VERSION, type PresenceEntry, PresenceEntrySchema, @@ -147,6 +149,7 @@ export const validateResponseFrame = ajv.compile(ResponseFrameSchema); export const validateEventFrame = ajv.compile(EventFrameSchema); export const validateSendParams = ajv.compile(SendParamsSchema); +export const validatePollParams = ajv.compile(PollParamsSchema); export const validateAgentParams = ajv.compile(AgentParamsSchema); export const validateAgentWaitParams = ajv.compile( AgentWaitParamsSchema, @@ -282,6 +285,7 @@ export { AgentEventSchema, ChatEventSchema, SendParamsSchema, + PollParamsSchema, AgentParamsSchema, WakeParamsSchema, NodePairRequestParamsSchema, @@ -390,4 +394,5 @@ export type { CronRunParams, CronRunsParams, CronRunLogEntry, + PollParams, }; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index c4a2b1448..f58652eb2 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -193,11 +193,25 @@ export const SendParamsSchema = Type.Object( mediaUrl: Type.Optional(Type.String()), gifPlayback: Type.Optional(Type.Boolean()), provider: Type.Optional(Type.String()), + accountId: Type.Optional(Type.String()), idempotencyKey: NonEmptyString, }, { additionalProperties: false }, ); +export const PollParamsSchema = Type.Object( + { + to: NonEmptyString, + question: NonEmptyString, + options: Type.Array(NonEmptyString, { minItems: 2, maxItems: 12 }), + maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })), + durationHours: Type.Optional(Type.Integer({ minimum: 1 })), + provider: Type.Optional(Type.String()), + accountId: Type.Optional(Type.String()), + idempotencyKey: NonEmptyString, + }, + { additionalProperties: false }, +); export const AgentParamsSchema = Type.Object( { message: NonEmptyString, @@ -206,7 +220,7 @@ export const AgentParamsSchema = Type.Object( sessionKey: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), deliver: Type.Optional(Type.Boolean()), - channel: Type.Optional(Type.String()), + provider: Type.Optional(Type.String()), timeout: Type.Optional(Type.Integer({ minimum: 0 })), lane: Type.Optional(Type.String()), extraSystemPrompt: Type.Optional(Type.String()), @@ -299,6 +313,7 @@ export const SessionsListParamsSchema = Type.Object( activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), includeGlobal: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()), + spawnedBy: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); @@ -310,6 +325,7 @@ export const SessionsPatchParamsSchema = Type.Object( verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), sendPolicy: Type.Optional( Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), ), @@ -529,6 +545,7 @@ export const WebLoginStartParamsSchema = Type.Object( force: Type.Optional(Type.Boolean()), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), verbose: Type.Optional(Type.Boolean()), + accountId: Type.Optional(Type.String()), }, { additionalProperties: false }, ); @@ -536,6 +553,7 @@ export const WebLoginStartParamsSchema = Type.Object( export const WebLoginWaitParamsSchema = Type.Object( { timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), + accountId: Type.Optional(Type.String()), }, { additionalProperties: false }, ); @@ -628,7 +646,7 @@ export const CronPayloadSchema = Type.Union([ thinking: Type.Optional(Type.String()), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })), deliver: Type.Optional(Type.Boolean()), - channel: Type.Optional( + provider: Type.Optional( Type.Union([ Type.Literal("last"), Type.Literal("whatsapp"), @@ -831,6 +849,7 @@ export const ProtocolSchemas: Record = { ErrorShape: ErrorShapeSchema, AgentEvent: AgentEventSchema, SendParams: SendParamsSchema, + PollParams: PollParamsSchema, AgentParams: AgentParamsSchema, AgentWaitParams: AgentWaitParamsSchema, WakeParams: WakeParamsSchema, @@ -900,6 +919,7 @@ export type PresenceEntry = Static; export type ErrorShape = Static; export type StateVersion = Static; export type AgentEvent = Static; +export type PollParams = Static; export type AgentWaitParams = Static; export type WakeParams = Static; export type NodePairRequestParams = Static; diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 71eec2329..c062bfea1 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -16,6 +16,7 @@ import { resolveEmbeddedSessionLane, waitForEmbeddedPiRunEnd, } from "../agents/pi-embedded.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { normalizeElevatedLevel, @@ -47,6 +48,7 @@ import { setVoiceWakeTriggers, } from "../infra/voicewake.js"; import { clearCommandLane } from "../process/command-queue.js"; +import { isSubagentSessionKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { buildMessageWithAttachments } from "./chat-attachments.js"; @@ -348,6 +350,52 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } : { sessionId: randomUUID(), updatedAt: now }; + if ("spawnedBy" in p) { + const raw = p.spawnedBy; + if (raw === null) { + if (existing?.spawnedBy) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "spawnedBy cannot be cleared once set", + }, + }; + } + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid spawnedBy: empty", + }, + }; + } + if (!isSubagentSessionKey(key)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: + "spawnedBy is only supported for subagent:* sessions", + }, + }; + } + if (existing?.spawnedBy && existing.spawnedBy !== trimmed) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "spawnedBy cannot be changed once set", + }, + }; + } + next.spawnedBy = trimmed; + } + } + if ("thinkingLevel" in p) { const raw = p.thinkingLevel; if (raw === null) { @@ -559,11 +607,11 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { sendPolicy: entry?.sendPolicy, displayName: entry?.displayName, chatType: entry?.chatType, - surface: entry?.surface, + provider: entry?.provider, subject: entry?.subject, room: entry?.room, space: entry?.space, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, }; @@ -886,10 +934,6 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { timeoutMs?: number; idempotencyKey: string; }; - const timeoutMs = Math.min( - Math.max(p.timeoutMs ?? 30_000, 0), - 30_000, - ); const normalizedAttachments = p.attachments?.map((a) => ({ type: typeof a?.type === "string" ? a.type : undefined, @@ -928,7 +972,13 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } } - const { storePath, store, entry } = loadSessionEntry(p.sessionKey); + const { cfg, storePath, store, entry } = loadSessionEntry( + p.sessionKey, + ); + const timeoutMs = resolveAgentTimeoutMs({ + cfg, + overrideMs: p.timeoutMs, + }); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); const sessionEntry: SessionEntry = { @@ -937,7 +987,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; const clientRunId = p.idempotencyKey; @@ -984,7 +1034,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { thinking: p.thinking, deliver: p.deliver, timeout: Math.ceil(timeoutMs / 1000).toString(), - surface: `Node(${nodeId})`, + messageProvider: `node(${nodeId})`, abortSignal: abortController.signal, }, defaultRuntime, @@ -1077,7 +1127,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; if (storePath) { @@ -1097,7 +1147,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { sessionId, thinking: "low", deliver: false, - surface: "Node", + messageProvider: "node", }, defaultRuntime, ctx.deps, @@ -1159,7 +1209,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; if (storePath) { @@ -1178,7 +1228,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : undefined, - surface: "Node", + messageProvider: "node", }, defaultRuntime, ctx.deps, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 69ac71516..e3524391c 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -32,7 +32,7 @@ type HookDispatchers = { wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" @@ -175,7 +175,7 @@ export function createHooksRequestHandler( wakeMode: mapped.action.wakeMode, sessionKey: mapped.action.sessionKey ?? "", deliver: mapped.action.deliver === true, - channel: mapped.action.channel ?? "last", + provider: mapped.action.provider ?? "last", to: mapped.action.to, thinking: mapped.action.thinking, timeoutSeconds: mapped.action.timeoutSeconds, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 6bc1d6df4..432b8336e 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -2,7 +2,12 @@ import { randomUUID } from "node:crypto"; import { agentCommand } from "../../commands/agent.js"; import { loadConfig } from "../../config/config.js"; -import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { + resolveAgentIdFromSessionKey, + resolveAgentMainSessionKey, + type SessionEntry, + saveSessionStore, +} from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -41,7 +46,7 @@ export const agentHandlers: GatewayRequestHandlers = { sessionKey?: string; thinking?: string; deliver?: boolean; - channel?: string; + provider?: string; lane?: string; extraSystemPrompt?: string; idempotencyKey: string; @@ -72,7 +77,7 @@ export const agentHandlers: GatewayRequestHandlers = { cfgForAgent = cfg; const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); - sessionEntry = { + const nextEntry: SessionEntry = { sessionId, updatedAt: now, thinkingLevel: entry?.thinkingLevel, @@ -80,14 +85,15 @@ export const agentHandlers: GatewayRequestHandlers = { systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, skillsSnapshot: entry?.skillsSnapshot, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; + sessionEntry = nextEntry; const sendPolicy = resolveSendPolicy({ cfg, entry, sessionKey: requestedSessionKey, - surface: entry?.surface, + provider: entry?.provider, chatType: entry?.chatType, }); if (sendPolicy === "deny") { @@ -102,14 +108,22 @@ export const agentHandlers: GatewayRequestHandlers = { return; } if (store) { - store[requestedSessionKey] = sessionEntry; + store[requestedSessionKey] = nextEntry; if (storePath) { await saveSessionStore(storePath, store); } } resolvedSessionId = sessionId; - const mainKey = (cfg.session?.mainKey ?? "main").trim() || "main"; - if (requestedSessionKey === mainKey) { + const agentId = resolveAgentIdFromSessionKey(requestedSessionKey); + const mainSessionKey = resolveAgentMainSessionKey({ + cfg, + agentId, + }); + const rawMainKey = (cfg.session?.mainKey ?? "main").trim() || "main"; + if ( + requestedSessionKey === mainSessionKey || + requestedSessionKey === rawMainKey + ) { context.addChatRun(idem, { sessionKey: requestedSessionKey, clientRunId: idem, @@ -121,42 +135,42 @@ export const agentHandlers: GatewayRequestHandlers = { const runId = idem; - const requestedChannelRaw = - typeof request.channel === "string" ? request.channel.trim() : ""; - const requestedChannelNormalized = requestedChannelRaw - ? requestedChannelRaw.toLowerCase() + const requestedProviderRaw = + typeof request.provider === "string" ? request.provider.trim() : ""; + const requestedProviderNormalized = requestedProviderRaw + ? requestedProviderRaw.toLowerCase() : "last"; - const requestedChannel = - requestedChannelNormalized === "imsg" + const requestedProvider = + requestedProviderNormalized === "imsg" ? "imessage" - : requestedChannelNormalized; + : requestedProviderNormalized; - const lastChannel = sessionEntry?.lastChannel; + const lastProvider = sessionEntry?.lastProvider; const lastTo = typeof sessionEntry?.lastTo === "string" ? sessionEntry.lastTo.trim() : ""; - const resolvedChannel = (() => { - if (requestedChannel === "last") { + const resolvedProvider = (() => { + if (requestedProvider === "last") { // WebChat is not a deliverable surface. Treat it as "unset" for routing, // so VoiceWake and CLI callers don't get stuck with deliver=false. - return lastChannel && lastChannel !== "webchat" - ? lastChannel + return lastProvider && lastProvider !== "webchat" + ? lastProvider : "whatsapp"; } if ( - requestedChannel === "whatsapp" || - requestedChannel === "telegram" || - requestedChannel === "discord" || - requestedChannel === "signal" || - requestedChannel === "imessage" || - requestedChannel === "webchat" + requestedProvider === "whatsapp" || + requestedProvider === "telegram" || + requestedProvider === "discord" || + requestedProvider === "signal" || + requestedProvider === "imessage" || + requestedProvider === "webchat" ) { - return requestedChannel; + return requestedProvider; } - return lastChannel && lastChannel !== "webchat" - ? lastChannel + return lastProvider && lastProvider !== "webchat" + ? lastProvider : "whatsapp"; })(); @@ -167,11 +181,11 @@ export const agentHandlers: GatewayRequestHandlers = { : undefined; if (explicit) return explicit; if ( - resolvedChannel === "whatsapp" || - resolvedChannel === "telegram" || - resolvedChannel === "discord" || - resolvedChannel === "signal" || - resolvedChannel === "imessage" + resolvedProvider === "whatsapp" || + resolvedProvider === "telegram" || + resolvedProvider === "discord" || + resolvedProvider === "signal" || + resolvedProvider === "imessage" ) { return lastTo || undefined; } @@ -182,7 +196,7 @@ export const agentHandlers: GatewayRequestHandlers = { // If we derived a WhatsApp recipient from session "lastTo", ensure it is still valid // for the configured allowlist. Otherwise, fall back to the first allowed number so // voice wake doesn't silently route to stale/test recipients. - if (resolvedChannel !== "whatsapp") return resolvedTo; + if (resolvedProvider !== "whatsapp") return resolvedTo; const explicit = typeof request.to === "string" && request.to.trim() ? request.to.trim() @@ -207,7 +221,7 @@ export const agentHandlers: GatewayRequestHandlers = { return allowFrom[0]; })(); - const deliver = request.deliver === true && resolvedChannel !== "webchat"; + const deliver = request.deliver === true && resolvedProvider !== "webchat"; const accepted = { runId, @@ -229,10 +243,10 @@ export const agentHandlers: GatewayRequestHandlers = { sessionId: resolvedSessionId, thinking: request.thinking, deliver, - provider: resolvedChannel, + provider: resolvedProvider, timeout: request.timeout?.toString(), bestEffortDeliver, - surface: "VoiceWake", + messageProvider: "voicewake", runId, lane: request.lane, extraSystemPrompt: request.extraSystemPrompt, @@ -240,11 +254,12 @@ export const agentHandlers: GatewayRequestHandlers = { defaultRuntime, context.deps, ) - .then(() => { + .then((result) => { const payload = { runId, status: "ok" as const, summary: "completed", + result, }; context.dedupe.set(`agent:${idem}`, { ts: Date.now(), diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 8d21cccd4..05104323e 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; +import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { agentCommand } from "../../commands/agent.js"; import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -154,7 +155,6 @@ export const chatHandlers: GatewayRequestHandlers = { timeoutMs?: number; idempotencyKey: string; }; - const timeoutMs = Math.min(Math.max(p.timeoutMs ?? 30_000, 0), 30_000); const normalizedAttachments = p.attachments?.map((a) => ({ type: typeof a?.type === "string" ? a.type : undefined, @@ -189,6 +189,10 @@ export const chatHandlers: GatewayRequestHandlers = { } } const { cfg, storePath, store, entry } = loadSessionEntry(p.sessionKey); + const timeoutMs = resolveAgentTimeoutMs({ + cfg, + overrideMs: p.timeoutMs, + }); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); const sessionEntry: SessionEntry = { @@ -198,7 +202,7 @@ export const chatHandlers: GatewayRequestHandlers = { verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, }; const clientRunId = p.idempotencyKey; @@ -208,7 +212,7 @@ export const chatHandlers: GatewayRequestHandlers = { cfg, entry, sessionKey: p.sessionKey, - surface: entry?.surface, + provider: entry?.provider, chatType: entry?.chatType, }); if (sendPolicy === "deny") { @@ -258,7 +262,7 @@ export const chatHandlers: GatewayRequestHandlers = { thinking: p.thinking, deliver: p.deliver, timeout: Math.ceil(timeoutMs / 1000).toString(), - surface: "WebChat", + messageProvider: "webchat", abortSignal: abortController.signal, }, defaultRuntime, diff --git a/src/gateway/server-methods/providers.ts b/src/gateway/server-methods/providers.ts index 6b794f749..00c2f7e10 100644 --- a/src/gateway/server-methods/providers.ts +++ b/src/gateway/server-methods/providers.ts @@ -6,7 +6,6 @@ import { } from "../../config/config.js"; import { type DiscordProbe, probeDiscord } from "../../discord/probe.js"; import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js"; -import { webAuthExists } from "../../providers/web/index.js"; import { probeSignal, type SignalProbe } from "../../signal/probe.js"; import { probeSlack, type SlackProbe } from "../../slack/probe.js"; import { @@ -15,7 +14,15 @@ import { } from "../../slack/token.js"; import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js"; import { resolveTelegramToken } from "../../telegram/token.js"; -import { getWebAuthAgeMs, readWebSelfId } from "../../web/session.js"; +import { + listEnabledWhatsAppAccounts, + resolveDefaultWhatsAppAccountId, +} from "../../web/accounts.js"; +import { + getWebAuthAgeMs, + readWebSelfId, + webAuthExists, +} from "../../web/session.js"; import { ErrorCodes, errorShape, @@ -148,10 +155,55 @@ export const providersHandlers: GatewayRequestHandlers = { imessageLastProbeAt = Date.now(); } - const linked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); - const self = readWebSelfId(); const runtime = context.getRuntimeSnapshot(); + const defaultWhatsAppAccountId = resolveDefaultWhatsAppAccountId(cfg); + const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg); + const defaultWhatsAppAccount = + enabledWhatsAppAccounts.find( + (account) => account.accountId === defaultWhatsAppAccountId, + ) ?? enabledWhatsAppAccounts[0]; + const linked = defaultWhatsAppAccount + ? await webAuthExists(defaultWhatsAppAccount.authDir) + : false; + const authAgeMs = defaultWhatsAppAccount + ? getWebAuthAgeMs(defaultWhatsAppAccount.authDir) + : null; + const self = defaultWhatsAppAccount + ? readWebSelfId(defaultWhatsAppAccount.authDir) + : { e164: null, jid: null }; + + const defaultWhatsAppStatus = { + running: false, + connected: false, + reconnectAttempts: 0, + lastConnectedAt: null, + lastDisconnect: null, + lastMessageAt: null, + lastEventAt: null, + lastError: null, + } as const; + const whatsappAccounts = await Promise.all( + enabledWhatsAppAccounts.map(async (account) => { + const rt = + runtime.whatsappAccounts?.[account.accountId] ?? + defaultWhatsAppStatus; + return { + accountId: account.accountId, + enabled: account.enabled, + linked: await webAuthExists(account.authDir), + authAgeMs: getWebAuthAgeMs(account.authDir), + self: readWebSelfId(account.authDir), + running: rt.running, + connected: rt.connected, + lastConnectedAt: rt.lastConnectedAt ?? null, + lastDisconnect: rt.lastDisconnect ?? null, + reconnectAttempts: rt.reconnectAttempts, + lastMessageAt: rt.lastMessageAt ?? null, + lastEventAt: rt.lastEventAt ?? null, + lastError: rt.lastError ?? null, + }; + }), + ); respond( true, @@ -171,6 +223,8 @@ export const providersHandlers: GatewayRequestHandlers = { lastEventAt: runtime.whatsapp.lastEventAt ?? null, lastError: runtime.whatsapp.lastError ?? null, }, + whatsappAccounts, + whatsappDefaultAccountId: defaultWhatsAppAccountId, telegram: { configured: telegramEnabled && Boolean(telegramToken), tokenSource, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 07ebf4cdb..9e9f1e277 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -1,16 +1,18 @@ import { loadConfig } from "../../config/config.js"; -import { sendMessageDiscord } from "../../discord/index.js"; +import { sendMessageDiscord, sendPollDiscord } from "../../discord/index.js"; import { shouldLogVerbose } from "../../globals.js"; import { sendMessageIMessage } from "../../imessage/index.js"; import { sendMessageSignal } from "../../signal/index.js"; import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageTelegram } from "../../telegram/send.js"; import { resolveTelegramToken } from "../../telegram/token.js"; -import { sendMessageWhatsApp } from "../../web/outbound.js"; +import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js"; +import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { ErrorCodes, errorShape, formatValidationErrors, + validatePollParams, validateSendParams, } from "../protocol/index.js"; import { formatForLog } from "../ws-log.js"; @@ -36,6 +38,7 @@ export const sendHandlers: GatewayRequestHandlers = { mediaUrl?: string; gifPlayback?: boolean; provider?: string; + accountId?: string; idempotencyKey: string; }; const idem = request.idempotencyKey; @@ -147,10 +150,17 @@ export const sendHandlers: GatewayRequestHandlers = { }); respond(true, payload, undefined, { provider }); } else { + const cfg = loadConfig(); + const accountId = + typeof request.accountId === "string" && + request.accountId.trim().length > 0 + ? request.accountId.trim() + : resolveDefaultWhatsAppAccountId(cfg); const result = await sendMessageWhatsApp(to, message, { mediaUrl: request.mediaUrl, verbose: shouldLogVerbose(), gifPlayback: request.gifPlayback, + accountId, }); const payload = { runId: idem, @@ -178,4 +188,107 @@ export const sendHandlers: GatewayRequestHandlers = { }); } }, + poll: async ({ params, respond, context }) => { + const p = params as Record; + if (!validatePollParams(p)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid poll params: ${formatValidationErrors(validatePollParams.errors)}`, + ), + ); + return; + } + const request = p as { + to: string; + question: string; + options: string[]; + maxSelections?: number; + durationHours?: number; + provider?: string; + accountId?: string; + idempotencyKey: string; + }; + const idem = request.idempotencyKey; + const cached = context.dedupe.get(`poll:${idem}`); + if (cached) { + respond(cached.ok, cached.payload, cached.error, { + cached: true, + }); + return; + } + const to = request.to.trim(); + const providerRaw = (request.provider ?? "whatsapp").toLowerCase(); + const provider = providerRaw === "imsg" ? "imessage" : providerRaw; + if (provider !== "whatsapp" && provider !== "discord") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `unsupported poll provider: ${provider}`, + ), + ); + return; + } + const poll = { + question: request.question, + options: request.options, + maxSelections: request.maxSelections, + durationHours: request.durationHours, + }; + try { + if (provider === "discord") { + const result = await sendPollDiscord(to, poll); + const payload = { + runId: idem, + messageId: result.messageId, + channelId: result.channelId, + provider, + }; + context.dedupe.set(`poll:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); + } else { + const cfg = loadConfig(); + const accountId = + typeof request.accountId === "string" && + request.accountId.trim().length > 0 + ? request.accountId.trim() + : resolveDefaultWhatsAppAccountId(cfg); + const result = await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId, + }); + const payload = { + runId: idem, + messageId: result.messageId, + toJid: result.toJid ?? `${to}@s.whatsapp.net`, + provider, + }; + context.dedupe.set(`poll:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); + } + } catch (err) { + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + context.dedupe.set(`poll:${idem}`, { + ts: Date.now(), + ok: false, + error, + }); + respond(false, undefined, error, { + provider, + error: formatForLog(err), + }); + } + }, }; diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index a0ec54352..24074ae07 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -24,11 +24,11 @@ import { loadConfig } from "../../config/config.js"; import { loadSessionStore, resolveMainSessionKey, - resolveStorePath, type SessionEntry, saveSessionStore, } from "../../config/sessions.js"; import { clearCommandLane } from "../../process/command-queue.js"; +import { isSubagentSessionKey } from "../../routing/session-key.js"; import { normalizeSendPolicy } from "../../sessions/send-policy.js"; import { ErrorCodes, @@ -43,7 +43,8 @@ import { import { archiveFileOnDisk, listSessionsFromStore, - loadSessionEntry, + loadCombinedSessionStoreForGateway, + resolveGatewaySessionStoreTarget, resolveSessionTranscriptCandidates, type SessionsPatchResult, } from "../session-utils.js"; @@ -64,8 +65,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { } const p = params as import("../protocol/index.js").SessionsListParams; const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); const result = listSessionsFromStore({ cfg, storePath, @@ -98,11 +98,18 @@ export const sessionsHandlers: GatewayRequestHandlers = { } const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const storePath = target.storePath; const store = loadSessionStore(storePath); const now = Date.now(); - const existing = store[key]; + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find((candidate) => store[candidate]); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } + const existing = store[primaryKey]; const next: SessionEntry = existing ? { ...existing, @@ -110,6 +117,56 @@ export const sessionsHandlers: GatewayRequestHandlers = { } : { sessionId: randomUUID(), updatedAt: now }; + if ("spawnedBy" in p) { + const raw = p.spawnedBy; + if (raw === null) { + if (existing?.spawnedBy) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "spawnedBy cannot be cleared once set", + ), + ); + return; + } + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid spawnedBy: empty"), + ); + return; + } + if (!isSubagentSessionKey(primaryKey)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "spawnedBy is only supported for subagent:* sessions", + ), + ); + return; + } + if (existing?.spawnedBy && existing.spawnedBy !== trimmed) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "spawnedBy cannot be changed once set", + ), + ); + return; + } + next.spawnedBy = trimmed; + } + } + if ("thinkingLevel" in p) { const raw = p.thinkingLevel; if (raw === null) { @@ -261,12 +318,12 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } - store[key] = next; + store[primaryKey] = next; await saveSessionStore(storePath, store); const result: SessionsPatchResult = { ok: true, path: storePath, - key, + key: target.canonicalKey, entry: next, }; respond(true, result, undefined); @@ -294,7 +351,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const { storePath, store, entry } = loadSessionEntry(key); + const cfg = loadConfig(); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const storePath = target.storePath; + const store = loadSessionStore(storePath); + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find((candidate) => store[candidate]); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } + const entry = store[primaryKey]; const now = Date.now(); const next: SessionEntry = { sessionId: randomUUID(), @@ -306,13 +373,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, }; - store[key] = next; + store[primaryKey] = next; await saveSessionStore(storePath, store); - respond(true, { ok: true, key, entry: next }, undefined); + respond( + true, + { ok: true, key: target.canonicalKey, entry: next }, + undefined, + ); }, "sessions.delete": async ({ params, respond }) => { if (!validateSessionsDeleteParams(params)) { @@ -337,8 +408,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const mainKey = resolveMainSessionKey(loadConfig()); - if (key === mainKey) { + const cfg = loadConfig(); + const mainKey = resolveMainSessionKey(cfg); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + if (target.canonicalKey === mainKey) { respond( false, undefined, @@ -353,10 +426,18 @@ export const sessionsHandlers: GatewayRequestHandlers = { const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; - const { storePath, store, entry } = loadSessionEntry(key); + const storePath = target.storePath; + const store = loadSessionStore(storePath); + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find((candidate) => store[candidate]); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } + const entry = store[primaryKey]; const sessionId = entry?.sessionId; - const existed = Boolean(store[key]); - clearCommandLane(resolveEmbeddedSessionLane(key)); + const existed = Boolean(entry); + clearCommandLane(resolveEmbeddedSessionLane(target.canonicalKey)); if (sessionId && isEmbeddedPiRunActive(sessionId)) { abortEmbeddedPiRun(sessionId); const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000); @@ -372,7 +453,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } } - if (existed) delete store[key]; + if (existed) delete store[primaryKey]; await saveSessionStore(storePath, store); const archived: string[] = []; @@ -380,6 +461,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { for (const candidate of resolveSessionTranscriptCandidates( sessionId, storePath, + target.agentId, )) { if (!fs.existsSync(candidate)) continue; try { @@ -390,7 +472,11 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } - respond(true, { ok: true, key, deleted: existed, archived }, undefined); + respond( + true, + { ok: true, key: target.canonicalKey, deleted: existed, archived }, + undefined, + ); }, "sessions.compact": async ({ params, respond }) => { if (!validateSessionsCompactParams(params)) { @@ -420,12 +506,27 @@ export const sessionsHandlers: GatewayRequestHandlers = { ? Math.max(1, Math.floor(p.maxLines)) : 400; - const { storePath, store, entry } = loadSessionEntry(key); + const cfg = loadConfig(); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const storePath = target.storePath; + const store = loadSessionStore(storePath); + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find((candidate) => store[candidate]); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } + const entry = store[primaryKey]; const sessionId = entry?.sessionId; if (!sessionId) { respond( true, - { ok: true, key, compacted: false, reason: "no sessionId" }, + { + ok: true, + key: target.canonicalKey, + compacted: false, + reason: "no sessionId", + }, undefined, ); return; @@ -434,11 +535,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { const filePath = resolveSessionTranscriptCandidates( sessionId, storePath, + target.agentId, ).find((candidate) => fs.existsSync(candidate)); if (!filePath) { respond( true, - { ok: true, key, compacted: false, reason: "no transcript" }, + { + ok: true, + key: target.canonicalKey, + compacted: false, + reason: "no transcript", + }, undefined, ); return; @@ -449,7 +556,12 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (lines.length <= maxLines) { respond( true, - { ok: true, key, compacted: false, kept: lines.length }, + { + ok: true, + key: target.canonicalKey, + compacted: false, + kept: lines.length, + }, undefined, ); return; @@ -459,11 +571,11 @@ export const sessionsHandlers: GatewayRequestHandlers = { const keptLines = lines.slice(-maxLines); fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); - if (store[key]) { - delete store[key].inputTokens; - delete store[key].outputTokens; - delete store[key].totalTokens; - store[key].updatedAt = Date.now(); + if (store[primaryKey]) { + delete store[primaryKey].inputTokens; + delete store[primaryKey].outputTokens; + delete store[primaryKey].totalTokens; + store[primaryKey].updatedAt = Date.now(); await saveSessionStore(storePath, store); } @@ -471,7 +583,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { true, { ok: true, - key, + key: target.canonicalKey, compacted: true, archived, kept: keptLines.length, diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index 7bd607963..04b7897bf 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -69,10 +69,10 @@ export type GatewayRequestContext = { findRunningWizard: () => string | null; purgeWizardSession: (id: string) => void; getRuntimeSnapshot: () => ProviderRuntimeSnapshot; - startWhatsAppProvider: () => Promise; - stopWhatsAppProvider: () => Promise; + startWhatsAppProvider: (accountId?: string) => Promise; + stopWhatsAppProvider: (accountId?: string) => Promise; stopTelegramProvider: () => Promise; - markWhatsAppLoggedOut: (cleared: boolean) => void; + markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void; wizardRunner: ( opts: import("../../commands/onboard-types.js").OnboardOptions, runtime: import("../../runtime.js").RuntimeEnv, diff --git a/src/gateway/server-methods/web.ts b/src/gateway/server-methods/web.ts index 9e7761b47..9fa9c8b7f 100644 --- a/src/gateway/server-methods/web.ts +++ b/src/gateway/server-methods/web.ts @@ -1,4 +1,6 @@ +import { loadConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; +import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js"; import { logoutWeb } from "../../web/session.js"; import { @@ -25,7 +27,11 @@ export const webHandlers: GatewayRequestHandlers = { return; } try { - await context.stopWhatsAppProvider(); + const accountId = + typeof (params as { accountId?: unknown }).accountId === "string" + ? (params as { accountId?: string }).accountId + : undefined; + await context.stopWhatsAppProvider(accountId); const result = await startWebLoginWithQr({ force: Boolean((params as { force?: boolean }).force), timeoutMs: @@ -33,6 +39,7 @@ export const webHandlers: GatewayRequestHandlers = { ? (params as { timeoutMs?: number }).timeoutMs : undefined, verbose: Boolean((params as { verbose?: boolean }).verbose), + accountId, }); respond(true, result, undefined); } catch (err) { @@ -56,14 +63,19 @@ export const webHandlers: GatewayRequestHandlers = { return; } try { + const accountId = + typeof (params as { accountId?: unknown }).accountId === "string" + ? (params as { accountId?: string }).accountId + : undefined; const result = await waitForWebLogin({ timeoutMs: typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" ? (params as { timeoutMs?: number }).timeoutMs : undefined, + accountId, }); if (result.connected) { - await context.startWhatsAppProvider(); + await context.startWhatsAppProvider(accountId); } respond(true, result, undefined); } catch (err) { @@ -74,11 +86,26 @@ export const webHandlers: GatewayRequestHandlers = { ); } }, - "web.logout": async ({ respond, context }) => { + "web.logout": async ({ params, respond, context }) => { try { - await context.stopWhatsAppProvider(); - const cleared = await logoutWeb(defaultRuntime); - context.markWhatsAppLoggedOut(cleared); + const rawAccountId = + params && typeof params === "object" && "accountId" in params + ? (params as { accountId?: unknown }).accountId + : undefined; + const accountId = + typeof rawAccountId === "string" ? rawAccountId.trim() : ""; + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ + cfg, + accountId: accountId || undefined, + }); + await context.stopWhatsAppProvider(account.accountId); + const cleared = await logoutWeb({ + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, + runtime: defaultRuntime, + }); + context.markWhatsAppLoggedOut(cleared, account.accountId); respond(true, { cleared }, undefined); } catch (err) { respond( diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts index b204e2b61..645985b83 100644 --- a/src/gateway/server-providers.ts +++ b/src/gateway/server-providers.ts @@ -15,6 +15,10 @@ import { import { monitorTelegramProvider } from "../telegram/monitor.js"; import { probeTelegram } from "../telegram/probe.js"; import { resolveTelegramToken } from "../telegram/token.js"; +import { + listEnabledWhatsAppAccounts, + resolveDefaultWhatsAppAccountId, +} from "../web/accounts.js"; import type { WebProviderStatus } from "../web/auto-reply.js"; import { readWebSelfId } from "../web/session.js"; import { formatError } from "./server-utils.js"; @@ -60,6 +64,7 @@ export type IMessageRuntimeStatus = { export type ProviderRuntimeSnapshot = { whatsapp: WebProviderStatus; + whatsappAccounts?: Record; telegram: TelegramRuntimeStatus; discord: DiscordRuntimeStatus; slack: SlackRuntimeStatus; @@ -88,8 +93,8 @@ type ProviderManagerOptions = { export type ProviderManager = { getRuntimeSnapshot: () => ProviderRuntimeSnapshot; startProviders: () => Promise; - startWhatsAppProvider: () => Promise; - stopWhatsAppProvider: () => Promise; + startWhatsAppProvider: (accountId?: string) => Promise; + stopWhatsAppProvider: (accountId?: string) => Promise; startTelegramProvider: () => Promise; stopTelegramProvider: () => Promise; startDiscordProvider: () => Promise; @@ -100,7 +105,7 @@ export type ProviderManager = { stopSignalProvider: () => Promise; startIMessageProvider: () => Promise; stopIMessageProvider: () => Promise; - markWhatsAppLoggedOut: (cleared: boolean) => void; + markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void; }; export function createProviderManager( @@ -122,20 +127,21 @@ export function createProviderManager( imessageRuntimeEnv, } = opts; - let whatsappAbort: AbortController | null = null; + const whatsappAborts = new Map(); let telegramAbort: AbortController | null = null; let discordAbort: AbortController | null = null; let slackAbort: AbortController | null = null; let signalAbort: AbortController | null = null; let imessageAbort: AbortController | null = null; - let whatsappTask: Promise | null = null; + const whatsappTasks = new Map>(); let telegramTask: Promise | null = null; let discordTask: Promise | null = null; let slackTask: Promise | null = null; let signalTask: Promise | null = null; let imessageTask: Promise | null = null; - let whatsappRuntime: WebProviderStatus = { + const whatsappRuntimes = new Map(); + const defaultWhatsAppStatus = (): WebProviderStatus => ({ running: false, connected: false, reconnectAttempts: 0, @@ -144,7 +150,7 @@ export function createProviderManager( lastMessageAt: null, lastEventAt: null, lastError: null, - }; + }); let telegramRuntime: TelegramRuntimeStatus = { running: false, lastStartAt: null, @@ -180,86 +186,134 @@ export function createProviderManager( dbPath: null, }; - const updateWhatsAppStatus = (next: WebProviderStatus) => { - whatsappRuntime = next; + const updateWhatsAppStatus = (accountId: string, next: WebProviderStatus) => { + whatsappRuntimes.set(accountId, next); }; - const startWhatsAppProvider = async () => { - if (whatsappTask) return; + const startWhatsAppProvider = async (accountId?: string) => { const cfg = loadConfig(); + const enabledAccounts = listEnabledWhatsAppAccounts(cfg); + const targets = accountId + ? enabledAccounts.filter((a) => a.accountId === accountId) + : enabledAccounts; + if (targets.length === 0) return; + if (cfg.web?.enabled === false) { - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - lastError: "disabled", - }; + for (const account of targets) { + const current = + whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus(); + whatsappRuntimes.set(account.accountId, { + ...current, + running: false, + connected: false, + lastError: "disabled", + }); + } logWhatsApp.info("skipping provider start (web.enabled=false)"); return; } - if (!(await webAuthExists())) { - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - lastError: "not linked", - }; - logWhatsApp.info("skipping provider start (no linked session)"); - return; - } - const { e164, jid } = readWebSelfId(); - const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; - logWhatsApp.info(`starting provider (${identity})`); - whatsappAbort = new AbortController(); - whatsappRuntime = { - ...whatsappRuntime, - running: true, - connected: false, - lastError: null, - }; - const task = monitorWebProvider( - shouldLogVerbose(), - undefined, - true, - undefined, - whatsappRuntimeEnv, - whatsappAbort.signal, - { statusSink: updateWhatsAppStatus }, - ) - .catch((err) => { - whatsappRuntime = { - ...whatsappRuntime, - lastError: formatError(err), - }; - logWhatsApp.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - whatsappAbort = null; - whatsappTask = null; - whatsappRuntime = { - ...whatsappRuntime, - running: false, + + await Promise.all( + targets.map(async (account) => { + if (whatsappTasks.has(account.accountId)) return; + const current = + whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus(); + if (!(await webAuthExists(account.authDir))) { + whatsappRuntimes.set(account.accountId, { + ...current, + running: false, + connected: false, + lastError: "not linked", + }); + logWhatsApp.info( + `[${account.accountId}] skipping provider start (no linked session)`, + ); + return; + } + + const { e164, jid } = readWebSelfId(account.authDir); + const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; + logWhatsApp.info( + `[${account.accountId}] starting provider (${identity})`, + ); + const abort = new AbortController(); + whatsappAborts.set(account.accountId, abort); + whatsappRuntimes.set(account.accountId, { + ...current, + running: true, connected: false, - }; - }); - whatsappTask = task; + lastError: null, + }); + + const task = monitorWebProvider( + shouldLogVerbose(), + undefined, + true, + undefined, + whatsappRuntimeEnv, + abort.signal, + { + statusSink: (next) => updateWhatsAppStatus(account.accountId, next), + accountId: account.accountId, + }, + ) + .catch((err) => { + const latest = + whatsappRuntimes.get(account.accountId) ?? + defaultWhatsAppStatus(); + whatsappRuntimes.set(account.accountId, { + ...latest, + lastError: formatError(err), + }); + logWhatsApp.error( + `[${account.accountId}] provider exited: ${formatError(err)}`, + ); + }) + .finally(() => { + whatsappAborts.delete(account.accountId); + whatsappTasks.delete(account.accountId); + const latest = + whatsappRuntimes.get(account.accountId) ?? + defaultWhatsAppStatus(); + whatsappRuntimes.set(account.accountId, { + ...latest, + running: false, + connected: false, + }); + }); + + whatsappTasks.set(account.accountId, task); + }), + ); }; - const stopWhatsAppProvider = async () => { - if (!whatsappAbort && !whatsappTask) return; - whatsappAbort?.abort(); - try { - await whatsappTask; - } catch { - // ignore - } - whatsappAbort = null; - whatsappTask = null; - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - }; + const stopWhatsAppProvider = async (accountId?: string) => { + const ids = accountId + ? [accountId] + : Array.from( + new Set([...whatsappAborts.keys(), ...whatsappTasks.keys()]), + ); + await Promise.all( + ids.map(async (id) => { + const abort = whatsappAborts.get(id); + const task = whatsappTasks.get(id); + if (!abort && !task) return; + abort?.abort(); + try { + await task; + } catch { + // ignore + } + whatsappAborts.delete(id); + whatsappTasks.delete(id); + const latest = whatsappRuntimes.get(id) ?? defaultWhatsAppStatus(); + whatsappRuntimes.set(id, { + ...latest, + running: false, + connected: false, + }); + }), + ); }; const startTelegramProvider = async () => { @@ -419,7 +473,6 @@ export function createProviderManager( token: discordToken.trim(), runtime: discordRuntimeEnv, abortSignal: discordAbort.signal, - slashCommand: cfg.discord?.slashCommand, mediaMaxMb: cfg.discord?.mediaMaxMb, historyLimit: cfg.discord?.historyLimit, }) @@ -754,23 +807,38 @@ export function createProviderManager( await startIMessageProvider(); }; - const markWhatsAppLoggedOut = (cleared: boolean) => { - whatsappRuntime = { - ...whatsappRuntime, + const markWhatsAppLoggedOut = (cleared: boolean, accountId?: string) => { + const cfg = loadConfig(); + const resolvedId = accountId ?? resolveDefaultWhatsAppAccountId(cfg); + const current = whatsappRuntimes.get(resolvedId) ?? defaultWhatsAppStatus(); + whatsappRuntimes.set(resolvedId, { + ...current, running: false, connected: false, - lastError: cleared ? "logged out" : whatsappRuntime.lastError, - }; + lastError: cleared ? "logged out" : current.lastError, + }); }; - const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => ({ - whatsapp: { ...whatsappRuntime }, - telegram: { ...telegramRuntime }, - discord: { ...discordRuntime }, - slack: { ...slackRuntime }, - signal: { ...signalRuntime }, - imessage: { ...imessageRuntime }, - }); + const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => { + const cfg = loadConfig(); + const defaultId = resolveDefaultWhatsAppAccountId(cfg); + const whatsapp = whatsappRuntimes.get(defaultId) ?? defaultWhatsAppStatus(); + const whatsappAccounts = Object.fromEntries( + Array.from(whatsappRuntimes.entries()).map(([id, status]) => [ + id, + { ...status }, + ]), + ); + return { + whatsapp: { ...whatsapp }, + whatsappAccounts, + telegram: { ...telegramRuntime }, + discord: { ...discordRuntime }, + slack: { ...slackRuntime }, + signal: { ...signalRuntime }, + imessage: { ...imessageRuntime }, + }; + }; return { getRuntimeSnapshot, diff --git a/src/gateway/server.agent.test.ts b/src/gateway/server.agent.test.ts index 9edea8f38..a13df9206 100644 --- a/src/gateway/server.agent.test.ts +++ b/src/gateway/server.agent.test.ts @@ -33,7 +33,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-main-stale", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -49,7 +49,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last-stale", }); @@ -76,7 +76,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-main-whatsapp", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -92,7 +92,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last-whatsapp", }); @@ -120,7 +120,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-main", updatedAt: Date.now(), - lastChannel: "telegram", + lastProvider: "telegram", lastTo: "123", }, }, @@ -136,7 +136,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last", }); @@ -164,7 +164,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-discord", updatedAt: Date.now(), - lastChannel: "discord", + lastProvider: "discord", lastTo: "channel:discord-123", }, }, @@ -180,7 +180,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last-discord", }); @@ -208,7 +208,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-signal", updatedAt: Date.now(), - lastChannel: "signal", + lastProvider: "signal", lastTo: "+15551234567", }, }, @@ -224,7 +224,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-last-signal", }); @@ -253,7 +253,7 @@ describe("gateway server agent", () => { main: { sessionId: "sess-main-webchat", updatedAt: Date.now(), - lastChannel: "webchat", + lastProvider: "webchat", lastTo: "+1555", }, }, @@ -269,7 +269,7 @@ describe("gateway server agent", () => { const res = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", - channel: "last", + provider: "last", deliver: true, idempotencyKey: "idem-agent-webchat", }); diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts index ffb8e09a8..748203ee0 100644 --- a/src/gateway/server.chat.test.ts +++ b/src/gateway/server.chat.test.ts @@ -40,6 +40,27 @@ describe("gateway server chat", () => { await server.close(); }); + test("chat.send defaults to agent timeout config", async () => { + testState.agentConfig = { timeoutSeconds: 123 }; + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-timeout-1", + }); + expect(res.ok).toBe(true); + + const call = vi.mocked(agentCommand).mock.calls.at(-1)?.[0] as + | { timeout?: string } + | undefined; + expect(call?.timeout).toBe("123"); + + ws.close(); + await server.close(); + }); + test("chat.send blocked by send policy", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); @@ -49,7 +70,7 @@ describe("gateway server chat", () => { rules: [ { action: "deny", - match: { surface: "discord", chatType: "group" }, + match: { provider: "discord", chatType: "group" }, }, ], }, @@ -63,7 +84,7 @@ describe("gateway server chat", () => { sessionId: "sess-discord", updatedAt: Date.now(), chatType: "group", - surface: "discord", + provider: "discord", }, }, null, @@ -402,7 +423,7 @@ describe("gateway server chat", () => { main: { sessionId: "sess-main", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -425,9 +446,9 @@ describe("gateway server chat", () => { const stored = JSON.parse( await fs.readFile(testState.sessionStorePath, "utf-8"), ) as { - main?: { lastChannel?: string; lastTo?: string }; + main?: { lastProvider?: string; lastTo?: string }; }; - expect(stored.main?.lastChannel).toBe("whatsapp"); + expect(stored.main?.lastProvider).toBe("whatsapp"); expect(stored.main?.lastTo).toBe("+1555"); ws.close(); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 6b387a4be..33f0f9112 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -327,7 +327,9 @@ describe("gateway server cron", () => { : ""; expect(storePath).toContain("jobs.json"); - const atMs = Date.now() + 80; + // Avoid races: if we schedule too close to "now", the cron runner can + // finish before we start listening for the "finished" event. + const atMs = Date.now() + 1000; const addRes = await rpcReq(ws, "cron.add", { name: "auto run test", enabled: true, @@ -345,8 +347,12 @@ describe("gateway server cron", () => { type: "event"; event: string; payload?: { jobId?: string; action?: string; status?: string } | null; - }>((resolve) => { - const timeout = setTimeout(() => resolve(null as never), 8000); + }>((resolve, reject) => { + const timeout = setTimeout(() => { + reject( + new Error(`timeout waiting for cron finished event: ${jobId}`), + ); + }, 8000); ws.on("message", (data) => { const obj = JSON.parse(decodeWsData(data)); if ( diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index defe83a99..c68744089 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -86,7 +86,7 @@ describe("gateway server hooks", () => { await server.close(); }); - test("hooks agent rejects invalid channel", async () => { + test("hooks agent rejects invalid provider", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); @@ -96,7 +96,7 @@ describe("gateway server hooks", () => { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, - body: JSON.stringify({ message: "Nope", channel: "sms" }), + body: JSON.stringify({ message: "Nope", provider: "sms" }), }); expect(res.status).toBe(400); expect(peekSystemEvents().length).toBe(0); diff --git a/src/gateway/server.node-bridge.test.ts b/src/gateway/server.node-bridge.test.ts index fc1e29fc8..3b25d4c8d 100644 --- a/src/gateway/server.node-bridge.test.ts +++ b/src/gateway/server.node-bridge.test.ts @@ -732,7 +732,7 @@ describe("gateway server node/bridge", () => { main: { sessionId: "sess-main", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -759,7 +759,7 @@ describe("gateway server node/bridge", () => { const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.sessionId).toBe("sess-main"); expect(call.deliver).toBe(false); - expect(call.surface).toBe("Node"); + expect(call.messageProvider).toBe("node"); const stored = JSON.parse( await fs.readFile(testState.sessionStorePath, "utf-8"), diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 1c8ef9176..d700c2d65 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -40,19 +40,27 @@ describe("gateway server sessions", () => { storePath, JSON.stringify( { - main: { + "agent:main:main": { sessionId: "sess-main", updatedAt: now - 30_000, inputTokens: 10, outputTokens: 20, thinkingLevel: "low", verboseLevel: "on", + lastProvider: "whatsapp", + lastTo: "+1555", + lastAccountId: "work", }, - "discord:group:dev": { + "agent:main:discord:group:dev": { sessionId: "sess-group", updatedAt: now - 120_000, totalTokens: 50, }, + "agent:main:subagent:one": { + sessionId: "sess-subagent", + updatedAt: now - 120_000, + spawnedBy: "agent:main:main", + }, global: { sessionId: "sess-global", updatedAt: now - 10_000, @@ -86,16 +94,20 @@ describe("gateway server sessions", () => { totalTokens?: number; thinkingLevel?: string; verboseLevel?: string; + lastAccountId?: string; }>; }>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false }); expect(list1.ok).toBe(true); expect(list1.payload?.path).toBe(storePath); expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false); - const main = list1.payload?.sessions.find((s) => s.key === "main"); + const main = list1.payload?.sessions.find( + (s) => s.key === "agent:main:main", + ); expect(main?.totalTokens).toBe(30); expect(main?.thinkingLevel).toBe("low"); expect(main?.verboseLevel).toBe("on"); + expect(main?.lastAccountId).toBe("work"); const active = await rpcReq<{ sessions: Array<{ key: string }>; @@ -105,7 +117,9 @@ describe("gateway server sessions", () => { activeMinutes: 1, }); expect(active.ok).toBe(true); - expect(active.payload?.sessions.map((s) => s.key)).toEqual(["main"]); + expect(active.payload?.sessions.map((s) => s.key)).toEqual([ + "agent:main:main", + ]); const limited = await rpcReq<{ sessions: Array<{ key: string }>; @@ -121,16 +135,16 @@ describe("gateway server sessions", () => { const patched = await rpcReq<{ ok: true; key: string }>( ws, "sessions.patch", - { key: "main", thinkingLevel: "medium", verboseLevel: null }, + { key: "agent:main:main", thinkingLevel: "medium", verboseLevel: null }, ); expect(patched.ok).toBe(true); expect(patched.payload?.ok).toBe(true); - expect(patched.payload?.key).toBe("main"); + expect(patched.payload?.key).toBe("agent:main:main"); const sendPolicyPatched = await rpcReq<{ ok: true; entry: { sendPolicy?: string }; - }>(ws, "sessions.patch", { key: "main", sendPolicy: "deny" }); + }>(ws, "sessions.patch", { key: "agent:main:main", sendPolicy: "deny" }); expect(sendPolicyPatched.ok).toBe(true); expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny"); @@ -143,17 +157,50 @@ describe("gateway server sessions", () => { }>; }>(ws, "sessions.list", {}); expect(list2.ok).toBe(true); - const main2 = list2.payload?.sessions.find((s) => s.key === "main"); + const main2 = list2.payload?.sessions.find( + (s) => s.key === "agent:main:main", + ); expect(main2?.thinkingLevel).toBe("medium"); expect(main2?.verboseLevel).toBeUndefined(); expect(main2?.sendPolicy).toBe("deny"); + const spawnedOnly = await rpcReq<{ + sessions: Array<{ key: string }>; + }>(ws, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + spawnedBy: "agent:main:main", + }); + expect(spawnedOnly.ok).toBe(true); + expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual([ + "agent:main:subagent:one", + ]); + + const spawnedPatched = await rpcReq<{ + ok: true; + entry: { spawnedBy?: string }; + }>(ws, "sessions.patch", { + key: "agent:main:subagent:two", + spawnedBy: "agent:main:main", + }); + expect(spawnedPatched.ok).toBe(true); + expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main"); + + const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", { + key: "agent:main:main", + spawnedBy: "agent:main:main", + }); + expect(spawnedPatchedInvalidKey.ok).toBe(false); + piSdkMock.enabled = true; piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const modelPatched = await rpcReq<{ ok: true; entry: { modelOverride?: string; providerOverride?: string }; - }>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" }); + }>(ws, "sessions.patch", { + key: "agent:main:main", + model: "openai/gpt-test-a", + }); expect(modelPatched.ok).toBe(true); expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a"); expect(modelPatched.payload?.entry.providerOverride).toBe("openai"); @@ -161,7 +208,7 @@ describe("gateway server sessions", () => { const compacted = await rpcReq<{ ok: true; compacted: boolean }>( ws, "sessions.compact", - { key: "main", maxLines: 3 }, + { key: "agent:main:main", maxLines: 3 }, ); expect(compacted.ok).toBe(true); expect(compacted.payload?.compacted).toBe(true); @@ -179,7 +226,7 @@ describe("gateway server sessions", () => { const deleted = await rpcReq<{ ok: true; deleted: boolean }>( ws, "sessions.delete", - { key: "discord:group:dev" }, + { key: "agent:main:discord:group:dev" }, ); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); @@ -189,7 +236,7 @@ describe("gateway server sessions", () => { expect(listAfterDelete.ok).toBe(true); expect( listAfterDelete.payload?.sessions.some( - (s) => s.key === "discord:group:dev", + (s) => s.key === "agent:main:discord:group:dev", ), ).toBe(false); const filesAfterDelete = await fs.readdir(dir); @@ -201,13 +248,13 @@ describe("gateway server sessions", () => { ok: true; key: string; entry: { sessionId: string }; - }>(ws, "sessions.reset", { key: "main" }); + }>(ws, "sessions.reset", { key: "agent:main:main" }); expect(reset.ok).toBe(true); - expect(reset.payload?.key).toBe("main"); + expect(reset.payload?.key).toBe("agent:main:main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); const badThinking = await rpcReq(ws, "sessions.patch", { - key: "main", + key: "agent:main:main", thinkingLevel: "banana", }); expect(badThinking.ok).toBe(false); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 58afa2259..523e7f3df 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -482,7 +482,7 @@ export async function startGatewayServer( wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" @@ -514,7 +514,7 @@ export async function startGatewayServer( thinking: value.thinking, timeoutSeconds: value.timeoutSeconds, deliver: value.deliver, - channel: value.channel, + provider: value.provider, to: value.to, }, state: { nextRunAtMs: now }, @@ -671,6 +671,10 @@ export async function startGatewayServer( >(); setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1); setCommandLaneConcurrency("main", cfgAtStart.agent?.maxConcurrent ?? 1); + setCommandLaneConcurrency( + "subagent", + cfgAtStart.agent?.subagents?.maxConcurrent ?? 1, + ); const cronLogger = getChildLogger({ module: "cron", @@ -1757,6 +1761,10 @@ export async function startGatewayServer( setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1); setCommandLaneConcurrency("main", nextConfig.agent?.maxConcurrent ?? 1); + setCommandLaneConcurrency( + "subagent", + nextConfig.agent?.subagents?.maxConcurrent ?? 1, + ); if (plan.hotReasons.length > 0) { logReload.info( diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 5a6368c15..9fa2cf36c 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -15,7 +15,7 @@ describe("gateway session utils", () => { test("parseGroupKey handles group prefixes", () => { expect(parseGroupKey("group:abc")).toEqual({ id: "abc" }); expect(parseGroupKey("discord:group:dev")).toEqual({ - surface: "discord", + provider: "discord", kind: "group", id: "dev", }); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 3deba73d9..e299ad0e4 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -9,12 +9,19 @@ import { } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; import { buildGroupDisplayName, loadSessionStore, + resolveAgentIdFromSessionKey, + resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, } from "../config/sessions.js"; +import { + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; export type GatewaySessionsDefaults = { model: string | null; @@ -25,7 +32,7 @@ export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; displayName?: string; - surface?: string; + provider?: string; subject?: string; room?: string; space?: string; @@ -43,8 +50,9 @@ export type GatewaySessionRow = { totalTokens?: number; model?: string; contextTokens?: number; - lastChannel?: SessionEntry["lastChannel"]; + lastProvider?: SessionEntry["lastProvider"]; lastTo?: string; + lastAccountId?: string; }; export type SessionsListResult = { @@ -90,12 +98,16 @@ export function readSessionMessages( export function resolveSessionTranscriptCandidates( sessionId: string, storePath: string | undefined, + agentId?: string, ): string[] { const candidates: string[] = []; if (storePath) { const dir = path.dirname(storePath); candidates.push(path.join(dir, `${sessionId}.jsonl`)); } + if (agentId) { + candidates.push(resolveSessionTranscriptPath(sessionId, agentId)); + } candidates.push( path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`), ); @@ -136,11 +148,12 @@ export function capArrayByJsonBytes( export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const sessionCfg = cfg.session; - const storePath = sessionCfg?.store - ? resolveStorePath(sessionCfg.store) - : resolveStorePath(undefined); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const store = loadSessionStore(storePath); - const entry = store[sessionKey]; + const parsed = parseAgentSessionKey(sessionKey); + const legacyKey = parsed?.rest; + const entry = store[sessionKey] ?? (legacyKey ? store[legacyKey] : undefined); return { cfg, storePath, store, entry }; } @@ -163,22 +176,167 @@ export function classifySessionKey( export function parseGroupKey( key: string, -): { surface?: string; kind?: "group" | "channel"; id?: string } | null { - if (key.startsWith("group:")) { - const raw = key.slice("group:".length); +): { provider?: string; kind?: "group" | "channel"; id?: string } | null { + const agentParsed = parseAgentSessionKey(key); + const rawKey = agentParsed?.rest ?? key; + if (rawKey.startsWith("group:")) { + const raw = rawKey.slice("group:".length); return raw ? { id: raw } : null; } - const parts = key.split(":").filter(Boolean); + const parts = rawKey.split(":").filter(Boolean); if (parts.length >= 3) { - const [surface, kind, ...rest] = parts; + const [provider, kind, ...rest] = parts; if (kind === "group" || kind === "channel") { const id = rest.join(":"); - return { surface, kind, id }; + return { provider, kind, id }; } } return null; } +function isStorePathTemplate(store?: string): boolean { + return typeof store === "string" && store.includes("{agentId}"); +} + +function listExistingAgentIdsFromDisk(): string[] { + const root = resolveStateDir(); + const agentsDir = path.join(root, "agents"); + try { + const entries = fs.readdirSync(agentsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => normalizeAgentId(entry.name)) + .filter(Boolean); + } catch { + return []; + } +} + +function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] { + const ids = new Set(); + const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId); + ids.add(defaultId); + const agents = cfg.routing?.agents; + if (agents && typeof agents === "object") { + for (const id of Object.keys(agents)) ids.add(normalizeAgentId(id)); + } + for (const id of listExistingAgentIdsFromDisk()) ids.add(id); + const sorted = Array.from(ids).filter(Boolean); + sorted.sort((a, b) => a.localeCompare(b)); + if (sorted.includes(defaultId)) { + return [defaultId, ...sorted.filter((id) => id !== defaultId)]; + } + return sorted; +} + +function canonicalizeSessionKeyForAgent(agentId: string, key: string): string { + if (key === "global" || key === "unknown") return key; + if (key.startsWith("agent:")) return key; + return `agent:${normalizeAgentId(agentId)}:${key}`; +} + +function canonicalizeSpawnedByForAgent( + agentId: string, + spawnedBy?: string, +): string | undefined { + const raw = spawnedBy?.trim(); + if (!raw) return undefined; + if (raw === "global" || raw === "unknown") return raw; + if (raw.startsWith("agent:")) return raw; + return `agent:${normalizeAgentId(agentId)}:${raw}`; +} + +export function resolveGatewaySessionStoreTarget(params: { + cfg: ClawdbotConfig; + key: string; +}): { + agentId: string; + storePath: string; + canonicalKey: string; + storeKeys: string[]; +} { + const key = params.key.trim(); + const agentId = resolveAgentIdFromSessionKey(key); + const storeConfig = params.cfg.session?.store; + const storePath = resolveStorePath(storeConfig, { agentId }); + + if (key === "global" || key === "unknown") { + return { agentId, storePath, canonicalKey: key, storeKeys: [key] }; + } + + const parsed = parseAgentSessionKey(key); + if (parsed) { + return { + agentId, + storePath, + canonicalKey: key, + storeKeys: [key, parsed.rest], + }; + } + + if (key.startsWith("subagent:")) { + const canonical = canonicalizeSessionKeyForAgent(agentId, key); + return { + agentId, + storePath, + canonicalKey: canonical, + storeKeys: [canonical, key], + }; + } + + const canonical = canonicalizeSessionKeyForAgent(agentId, key); + return { + agentId, + storePath, + canonicalKey: canonical, + storeKeys: [canonical, key], + }; +} + +export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): { + storePath: string; + store: Record; +} { + const storeConfig = cfg.session?.store; + if (storeConfig && !isStorePathTemplate(storeConfig)) { + const storePath = resolveStorePath(storeConfig); + const defaultAgentId = normalizeAgentId(cfg.routing?.defaultAgentId); + const store = loadSessionStore(storePath); + const combined: Record = {}; + for (const [key, entry] of Object.entries(store)) { + const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key); + combined[canonicalKey] = { + ...entry, + spawnedBy: canonicalizeSpawnedByForAgent( + defaultAgentId, + entry.spawnedBy, + ), + }; + } + return { storePath, store: combined }; + } + + const agentIds = listConfiguredAgentIds(cfg); + const combined: Record = {}; + for (const agentId of agentIds) { + const storePath = resolveStorePath(storeConfig, { agentId }); + const store = loadSessionStore(storePath); + for (const [key, entry] of Object.entries(store)) { + const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key); + combined[canonicalKey] = { + ...entry, + spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy), + }; + } + } + + const storePath = + typeof storeConfig === "string" && storeConfig.trim() + ? storeConfig.trim() + : "(multiple)"; + return { storePath, store: combined }; +} + export function getSessionDefaults( cfg: ClawdbotConfig, ): GatewaySessionsDefaults { @@ -227,6 +385,7 @@ export function listSessionsFromStore(params: { const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; + const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; const activeMinutes = typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes) @@ -239,22 +398,27 @@ export function listSessionsFromStore(params: { if (!includeUnknown && key === "unknown") return false; return true; }) + .filter(([key, entry]) => { + if (!spawnedBy) return true; + if (key === "unknown" || key === "global") return false; + return entry?.spawnedBy === spawnedBy; + }) .map(([key, entry]) => { const updatedAt = entry?.updatedAt ?? null; const input = entry?.inputTokens ?? 0; const output = entry?.outputTokens ?? 0; const total = entry?.totalTokens ?? input + output; const parsed = parseGroupKey(key); - const surface = entry?.surface ?? parsed?.surface; + const provider = entry?.provider ?? parsed?.provider; const subject = entry?.subject; const room = entry?.room; const space = entry?.space; const id = parsed?.id; const displayName = entry?.displayName ?? - (surface + (provider ? buildGroupDisplayName({ - surface, + provider, subject, room, space, @@ -266,7 +430,7 @@ export function listSessionsFromStore(params: { key, kind: classifySessionKey(key, entry), displayName, - surface, + provider, subject, room, space, @@ -284,8 +448,9 @@ export function listSessionsFromStore(params: { totalTokens: total, model: entry?.model, contextTokens: entry?.contextTokens, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, + lastAccountId: entry?.lastAccountId, } satisfies GatewaySessionRow; }) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index fe2d99db8..98caaa89a 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -84,6 +84,7 @@ export const cronIsolatedRun = hoisted.cronIsolatedRun; export const agentCommand = hoisted.agentCommand; export const testState = { + agentConfig: undefined as Record | undefined, sessionStorePath: undefined as string | undefined, sessionConfig: undefined as Record | undefined, allowFrom: undefined as string[] | undefined, @@ -243,6 +244,7 @@ vi.mock("../config/config.js", async () => { agent: { model: "anthropic/claude-opus-4-5", workspace: path.join(os.tmpdir(), "clawd-gateway-test"), + ...testState.agentConfig, }, whatsapp: { allowFrom: testState.allowFrom, @@ -351,6 +353,7 @@ export function installGatewayTestHooks() { testState.cronStorePath = undefined; testState.sessionConfig = undefined; testState.sessionStorePath = undefined; + testState.agentConfig = undefined; testState.allowFrom = undefined; testIsNixMode.value = false; cronIsolatedRun.mockClear(); diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index 16810333c..773ff55c6 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -7,6 +7,8 @@ const stopMock = vi.fn(); const sendMock = vi.fn(); const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); +const readAllowFromStoreMock = vi.fn(); +const upsertPairingRequestMock = vi.fn(); let config: Record = {}; let notificationHandler: @@ -30,6 +32,13 @@ vi.mock("./send.js", () => ({ sendMessageIMessage: (...args: unknown[]) => sendMock(...args), })); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), @@ -63,7 +72,11 @@ async function waitForSubscribe() { beforeEach(() => { config = { - imessage: { groups: { "*": { requireMention: true } } }, + imessage: { + dmPolicy: "open", + allowFrom: ["*"], + groups: { "*": { requireMention: true } }, + }, session: { mainKey: "main" }, routing: { groupChat: { mentionPatterns: ["@clawd"] }, @@ -79,6 +92,10 @@ beforeEach(() => { sendMock.mockReset().mockResolvedValue({ messageId: "ok" }); replyMock.mockReset().mockResolvedValue({ text: "ok" }); updateLastRouteMock.mockReset(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock + .mockReset() + .mockResolvedValue({ code: "PAIRCODE", created: true }); notificationHandler = undefined; closeResolve = undefined; }); @@ -169,6 +186,36 @@ describe("monitorIMessageProvider", () => { expect(replyMock).toHaveBeenCalled(); }); + it("blocks group messages when imessage.groups is set without a wildcard", async () => { + config = { + ...config, + imessage: { groups: { "99": { requireMention: false } } }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 13, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + text: "@clawd hello", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); + }); + it("prefixes tool and final replies with responsePrefix", async () => { config = { ...config, @@ -204,6 +251,44 @@ describe("monitorIMessageProvider", () => { expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => { + config = { + ...config, + imessage: { + dmPolicy: "pairing", + allowFrom: [], + groups: { "*": { requireMention: true } }, + }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 99, + chat_id: 77, + sender: "+15550001111", + is_from_me: false, + text: "hello", + is_group: false, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Pairing code: PAIRCODE", + ); + }); + it("delivers group replies when mentioned", async () => { replyMock.mockResolvedValueOnce({ text: "yo" }); const run = monitorIMessageProvider(); @@ -236,10 +321,13 @@ describe("monitorIMessageProvider", () => { ); }); - it("honors allowFrom entries", async () => { + it("honors group allowlist when groupPolicy is allowlist", async () => { config = { ...config, - imessage: { allowFrom: ["chat_id:101"] }, + imessage: { + groupPolicy: "allowlist", + groupAllowFrom: ["chat_id:101"], + }, }; const run = monitorIMessageProvider(); await waitForSubscribe(); @@ -265,6 +353,35 @@ describe("monitorIMessageProvider", () => { expect(replyMock).not.toHaveBeenCalled(); }); + it("blocks group messages when groupPolicy is disabled", async () => { + config = { + ...config, + imessage: { groupPolicy: "disabled" }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 10, + chat_id: 303, + sender: "+15550003333", + is_from_me: false, + text: "@clawd hi", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + }); + it("updates last route with chat_id for direct messages", async () => { replyMock.mockResolvedValueOnce({ text: "ok" }); const run = monitorIMessageProvider(); @@ -290,7 +407,7 @@ describe("monitorIMessageProvider", () => { expect(updateLastRouteMock).toHaveBeenCalledWith( expect.objectContaining({ - channel: "imessage", + provider: "imessage", to: "chat_id:7", }), ); diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 30f12e7ee..d6e1d42da 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -1,17 +1,26 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { buildMentionRegexes, matchesMentionPatterns, } from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; -import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { loadConfig } from "../config/config.js"; +import { + resolveProviderGroupPolicy, + resolveProviderGroupRequireMention, +} from "../config/group-policy.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { mediaKindFromMime } from "../media/constants.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { createIMessageRpcClient } from "./client.js"; import { sendMessageIMessage } from "./send.js"; @@ -48,6 +57,7 @@ export type MonitorIMessageOpts = { cliPath?: string; dbPath?: string; allowFrom?: Array; + groupAllowFrom?: Array; includeAttachments?: boolean; mediaMaxMb?: number; requireMention?: boolean; @@ -71,22 +81,15 @@ function resolveAllowFrom(opts: MonitorIMessageOpts): string[] { return raw.map((entry) => String(entry).trim()).filter(Boolean); } -function resolveGroupRequireMention( - cfg: ReturnType, - opts: MonitorIMessageOpts, - chatId?: number | null, -): boolean { - if (typeof opts.requireMention === "boolean") return opts.requireMention; - const groupId = chatId != null ? String(chatId) : undefined; - if (groupId) { - const groupConfig = cfg.imessage?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; +function resolveGroupAllowFrom(opts: MonitorIMessageOpts): string[] { + const cfg = loadConfig(); + const raw = + opts.groupAllowFrom ?? + cfg.imessage?.groupAllowFrom ?? + (cfg.imessage?.allowFrom && cfg.imessage.allowFrom.length > 0 + ? cfg.imessage.allowFrom + : []); + return raw.map((entry) => String(entry).trim()).filter(Boolean); } async function deliverReplies(params: { @@ -130,6 +133,9 @@ export async function monitorIMessageProvider( const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "imessage"); const allowFrom = resolveAllowFrom(opts); + const groupAllowFrom = resolveGroupAllowFrom(opts); + const groupPolicy = cfg.imessage?.groupPolicy ?? "open"; + const dmPolicy = cfg.imessage?.dmPolicy ?? "pairing"; const mentionRegexes = buildMentionRegexes(cfg); const includeAttachments = opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false; @@ -152,24 +158,143 @@ export async function monitorIMessageProvider( const isGroup = Boolean(message.is_group); if (isGroup && !chatId) return; - const commandAuthorized = isAllowedIMessageSender({ - allowFrom, - sender, - chatId: chatId ?? undefined, - chatGuid, - chatIdentifier, - }); - if (!commandAuthorized) { - logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`); - return; + const groupId = isGroup ? String(chatId) : undefined; + const storeAllowFrom = await readProviderAllowFromStore("imessage").catch( + () => [], + ); + const effectiveDmAllowFrom = Array.from( + new Set([...allowFrom, ...storeAllowFrom]), + ) + .map((v) => String(v).trim()) + .filter(Boolean); + const effectiveGroupAllowFrom = Array.from( + new Set([...groupAllowFrom, ...storeAllowFrom]), + ) + .map((v) => String(v).trim()) + .filter(Boolean); + + if (isGroup) { + if (groupPolicy === "disabled") { + logVerbose("Blocked iMessage group message (groupPolicy: disabled)"); + return; + } + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) { + logVerbose( + "Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)", + ); + return; + } + const allowed = isAllowedIMessageSender({ + allowFrom: effectiveGroupAllowFrom, + sender, + chatId: chatId ?? undefined, + chatGuid, + chatIdentifier, + }); + if (!allowed) { + logVerbose( + `Blocked iMessage sender ${sender} (not in groupAllowFrom)`, + ); + return; + } + } + const groupListPolicy = resolveProviderGroupPolicy({ + cfg, + provider: "imessage", + groupId, + }); + if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { + logVerbose( + `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, + ); + return; + } + } + + const dmHasWildcard = effectiveDmAllowFrom.includes("*"); + const dmAuthorized = + dmPolicy === "open" + ? true + : dmHasWildcard || + (effectiveDmAllowFrom.length > 0 && + isAllowedIMessageSender({ + allowFrom: effectiveDmAllowFrom, + sender, + chatId: chatId ?? undefined, + chatGuid, + chatIdentifier, + })); + if (!isGroup) { + if (dmPolicy === "disabled") return; + if (!dmAuthorized) { + if (dmPolicy === "pairing") { + const senderId = normalizeIMessageHandle(sender); + const { code } = await upsertProviderPairingRequest({ + provider: "imessage", + id: senderId, + meta: { + sender: senderId, + chatId: chatId ? String(chatId) : undefined, + }, + }); + logVerbose( + `imessage pairing request sender=${senderId} code=${code}`, + ); + try { + await sendMessageIMessage( + sender, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider imessage ", + ].join("\n"), + { + client, + maxBytes: mediaMaxBytes, + ...(chatId ? { chatId } : {}), + }, + ); + } catch (err) { + logVerbose( + `imessage pairing reply failed for ${senderId}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked iMessage sender ${sender} (dmPolicy=${dmPolicy})`, + ); + } + return; + } } const messageText = (message.text ?? "").trim(); const mentioned = isGroup ? matchesMentionPatterns(messageText, mentionRegexes) : true; - const requireMention = resolveGroupRequireMention(cfg, opts, chatId); + const requireMention = resolveProviderGroupRequireMention({ + cfg, + provider: "imessage", + groupId, + requireMentionOverride: opts.requireMention, + overrideOrder: "before-config", + }); const canDetectMention = mentionRegexes.length > 0; + const commandAuthorized = isGroup + ? effectiveGroupAllowFrom.length > 0 + ? isAllowedIMessageSender({ + allowFrom: effectiveGroupAllowFrom, + sender, + chatId: chatId ?? undefined, + chatGuid, + chatIdentifier, + }) + : true + : dmAuthorized; const shouldBypassMention = isGroup && requireMention && @@ -210,16 +335,28 @@ export async function monitorIMessageProvider( ? Date.parse(message.created_at) : undefined; const body = formatAgentEnvelope({ - surface: "iMessage", + provider: "iMessage", from: fromLabel, timestamp: createdAt, body: bodyText, }); + const route = resolveAgentRoute({ + cfg, + provider: "imessage", + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup + ? String(chatId ?? "unknown") + : normalizeIMessageHandle(sender), + }, + }); const ctxPayload = { Body: body, From: isGroup ? `group:${chatId}` : `imessage:${sender}`, To: chatTarget || `imessage:${sender}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (message.chat_name ?? undefined) : undefined, GroupMembers: isGroup @@ -227,7 +364,7 @@ export async function monitorIMessageProvider( : undefined, SenderName: sender, SenderId: sender, - Surface: "imessage", + Provider: "imessage", MessageSid: message.id ? String(message.id) : undefined, Timestamp: createdAt, MediaPath: mediaPath, @@ -239,15 +376,17 @@ export async function monitorIMessageProvider( if (!isGroup) { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); const to = chatTarget || sender; if (to) { await updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "imessage", + sessionKey: route.mainSessionKey, + provider: "imessage", to, + accountId: route.accountId, }); } } @@ -278,28 +417,11 @@ export async function monitorIMessageProvider( }, }); - const replyResult = await getReplyFromConfig( - ctxPayload, - { - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); - }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); - }, - }, + const { queuedFinal } = await dispatchReplyFromConfig({ + ctx: ctxPayload, cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; - } - await dispatcher.waitForIdle(); + dispatcher, + }); if (!queuedFinal) return; }; diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index e005789dc..0b995a0cd 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { runCommandWithTimeout, runExec } from "../process/exec.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; export function resolveControlUiRepoRoot( @@ -76,7 +76,7 @@ export async function ensureControlUiAssetsBuilt( return { ok: false, built: false, - message: `${hint}. Build them with \`pnpm ui:build\`.`, + message: `${hint}. Build them with \`bun run ui:build\`.`, }; } @@ -85,35 +85,28 @@ export async function ensureControlUiAssetsBuilt( return { ok: true, built: false }; } - const pnpmWhich = process.platform === "win32" ? "where" : "which"; - const pnpm = await runExec(pnpmWhich, ["pnpm"]) - .then( - (r) => - r.stdout - .split(/\r?\n/g) - .map((l) => l.trim()) - .find(Boolean) ?? "", - ) - .catch(() => ""); - if (!pnpm) { + const uiScript = path.join(repoRoot, "scripts", "ui.js"); + if (!fs.existsSync(uiScript)) { return { ok: false, built: false, - message: - "Control UI assets not found and pnpm missing. Install pnpm, then run `pnpm ui:build`.", + message: `Control UI assets missing but ${uiScript} is unavailable.`, }; } - runtime.log("Control UI assets missing; building (pnpm ui:build)…"); + runtime.log("Control UI assets missing; building (ui:build)…"); const ensureInstalled = !fs.existsSync( path.join(repoRoot, "ui", "node_modules"), ); if (ensureInstalled) { - const install = await runCommandWithTimeout([pnpm, "ui:install"], { - cwd: repoRoot, - timeoutMs: opts?.timeoutMs ?? 10 * 60_000, - }); + const install = await runCommandWithTimeout( + [process.execPath, uiScript, "install"], + { + cwd: repoRoot, + timeoutMs: opts?.timeoutMs ?? 10 * 60_000, + }, + ); if (install.code !== 0) { return { ok: false, @@ -123,10 +116,13 @@ export async function ensureControlUiAssetsBuilt( } } - const build = await runCommandWithTimeout([pnpm, "ui:build"], { - cwd: repoRoot, - timeoutMs: opts?.timeoutMs ?? 10 * 60_000, - }); + const build = await runCommandWithTimeout( + [process.execPath, uiScript, "build"], + { + cwd: repoRoot, + timeoutMs: opts?.timeoutMs ?? 10 * 60_000, + }, + ); if (build.code !== 0) { return { ok: false, diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 107c66b9e..1a74d49aa 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -13,8 +13,11 @@ import { } from "./heartbeat-runner.js"; describe("resolveHeartbeatIntervalMs", () => { - it("returns null when unset or invalid", () => { - expect(resolveHeartbeatIntervalMs({})).toBeNull(); + it("returns default when unset", () => { + expect(resolveHeartbeatIntervalMs({})).toBe(30 * 60_000); + }); + + it("returns null when invalid or zero", () => { expect( resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "0m" } } }), ).toBeNull(); @@ -60,7 +63,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { agent: { heartbeat: { target: "none" } }, }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ - channel: "none", + provider: "none", reason: "target-none", }); }); @@ -69,11 +72,11 @@ describe("resolveHeartbeatDeliveryTarget", () => { const cfg: ClawdbotConfig = {}; const entry = { ...baseEntry, - lastChannel: "whatsapp" as const, + lastProvider: "whatsapp" as const, lastTo: "+1555", }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({ - channel: "whatsapp", + provider: "whatsapp", to: "+1555", }); }); @@ -82,11 +85,11 @@ describe("resolveHeartbeatDeliveryTarget", () => { const cfg: ClawdbotConfig = {}; const entry = { ...baseEntry, - lastChannel: "webchat" as const, + lastProvider: "webchat" as const, lastTo: "web", }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({ - channel: "none", + provider: "none", reason: "no-target", }); }); @@ -98,11 +101,11 @@ describe("resolveHeartbeatDeliveryTarget", () => { }; const entry = { ...baseEntry, - lastChannel: "whatsapp" as const, + lastProvider: "whatsapp" as const, lastTo: "+1222", }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({ - channel: "whatsapp", + provider: "whatsapp", to: "+1555", reason: "allowFrom-fallback", }); @@ -113,7 +116,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { agent: { heartbeat: { target: "telegram", to: "123" } }, }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ - channel: "telegram", + provider: "telegram", to: "123", }); }); @@ -132,7 +135,7 @@ describe("runHeartbeatOnce", () => { main: { sessionId: "sid", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -193,7 +196,7 @@ describe("runHeartbeatOnce", () => { main: { sessionId: "sid", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, @@ -251,7 +254,7 @@ describe("runHeartbeatOnce", () => { main: { sessionId: "sid", updatedAt: Date.now(), - lastChannel: "whatsapp", + lastProvider: "whatsapp", lastTo: "+1555", }, }, diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 9f136e3df..1fa7065bc 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,7 +1,8 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - HEARTBEAT_PROMPT, + DEFAULT_HEARTBEAT_EVERY, + resolveHeartbeatPrompt as resolveHeartbeatPromptText, stripHeartbeatToken, } from "../auto-reply/heartbeat.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; @@ -46,7 +47,7 @@ export type HeartbeatTarget = | "none"; export type HeartbeatDeliveryTarget = { - channel: + provider: | "whatsapp" | "telegram" | "discord" @@ -83,7 +84,8 @@ export function resolveHeartbeatIntervalMs( cfg: ClawdbotConfig, overrideEvery?: string, ) { - const raw = overrideEvery ?? cfg.agent?.heartbeat?.every; + const raw = + overrideEvery ?? cfg.agent?.heartbeat?.every ?? DEFAULT_HEARTBEAT_EVERY; if (!raw) return null; const trimmed = String(raw).trim(); if (!trimmed) return null; @@ -98,9 +100,7 @@ export function resolveHeartbeatIntervalMs( } export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) { - const raw = cfg.agent?.heartbeat?.prompt; - const trimmed = typeof raw === "string" ? raw.trim() : ""; - return trimmed || HEARTBEAT_PROMPT; + return resolveHeartbeatPromptText(cfg.agent?.heartbeat?.prompt); } function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) { @@ -143,13 +143,13 @@ function resolveHeartbeatReplyPayload( function resolveHeartbeatSender(params: { allowFrom: Array; lastTo?: string; - lastChannel?: SessionEntry["lastChannel"]; + lastProvider?: SessionEntry["lastProvider"]; }) { - const { allowFrom, lastTo, lastChannel } = params; + const { allowFrom, lastTo, lastProvider } = params; const candidates = [ lastTo?.trim(), - lastChannel === "telegram" && lastTo ? `telegram:${lastTo}` : undefined, - lastChannel === "whatsapp" && lastTo ? `whatsapp:${lastTo}` : undefined, + lastProvider === "telegram" && lastTo ? `telegram:${lastTo}` : undefined, + lastProvider === "whatsapp" && lastTo ? `whatsapp:${lastTo}` : undefined, ].filter((val): val is string => Boolean(val?.trim())); const allowList = allowFrom @@ -209,7 +209,7 @@ export function resolveHeartbeatDeliveryTarget(params: { ? rawTarget : "last"; if (target === "none") { - return { channel: "none", reason: "target-none" }; + return { provider: "none", reason: "target-none" }; } const explicitTo = @@ -218,13 +218,13 @@ export function resolveHeartbeatDeliveryTarget(params: { ? cfg.agent.heartbeat.to.trim() : undefined; - const lastChannel = - entry?.lastChannel && entry.lastChannel !== "webchat" - ? entry.lastChannel + const lastProvider = + entry?.lastProvider && entry.lastProvider !== "webchat" + ? entry.lastProvider : undefined; const lastTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : ""; - const channel: + const provider: | "whatsapp" | "telegram" | "discord" @@ -233,7 +233,7 @@ export function resolveHeartbeatDeliveryTarget(params: { | "imessage" | undefined = target === "last" - ? lastChannel + ? lastProvider : target === "whatsapp" || target === "telegram" || target === "discord" || @@ -245,27 +245,27 @@ export function resolveHeartbeatDeliveryTarget(params: { const to = explicitTo || - (channel && lastChannel === channel ? lastTo : undefined) || + (provider && lastProvider === provider ? lastTo : undefined) || (target === "last" ? lastTo : undefined); - if (!channel || !to) { - return { channel: "none", reason: "no-target" }; + if (!provider || !to) { + return { provider: "none", reason: "no-target" }; } - if (channel !== "whatsapp") { - return { channel, to }; + if (provider !== "whatsapp") { + return { provider, to }; } const rawAllow = cfg.whatsapp?.allowFrom ?? []; - if (rawAllow.includes("*")) return { channel, to }; + if (rawAllow.includes("*")) return { provider, to }; const allowFrom = rawAllow .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); - if (allowFrom.length === 0) return { channel, to }; + if (allowFrom.length === 0) return { provider, to }; const normalized = normalizeE164(to); - if (allowFrom.includes(normalized)) return { channel, to: normalized }; - return { channel, to: allowFrom[0], reason: "allowFrom-fallback" }; + if (allowFrom.includes(normalized)) return { provider, to: normalized }; + return { provider, to: allowFrom[0], reason: "allowFrom-fallback" }; } async function restoreHeartbeatUpdatedAt(params: { @@ -310,7 +310,7 @@ function normalizeHeartbeatReply( } async function deliverHeartbeatReply(params: { - channel: + provider: | "whatsapp" | "telegram" | "discord" @@ -333,8 +333,8 @@ async function deliverHeartbeatReply(params: { > >; }) { - const { channel, to, text, mediaUrls, deps, textLimit } = params; - if (channel === "whatsapp") { + const { provider, to, text, mediaUrls, deps, textLimit } = params; + if (provider === "whatsapp") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendWhatsApp(to, chunk, { verbose: false }); @@ -350,7 +350,7 @@ async function deliverHeartbeatReply(params: { return; } - if (channel === "signal") { + if (provider === "signal") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendSignal(to, chunk); @@ -366,7 +366,7 @@ async function deliverHeartbeatReply(params: { return; } - if (channel === "imessage") { + if (provider === "imessage") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendIMessage(to, chunk); @@ -382,7 +382,7 @@ async function deliverHeartbeatReply(params: { return; } - if (channel === "telegram") { + if (provider === "telegram") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendTelegram(to, chunk, { verbose: false }); @@ -398,7 +398,7 @@ async function deliverHeartbeatReply(params: { return; } - if (channel === "slack") { + if (provider === "slack") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, textLimit)) { await deps.sendSlack(to, chunk); @@ -413,6 +413,7 @@ async function deliverHeartbeatReply(params: { } return; } + // provider is "discord" here if (mediaUrls.length === 0) { await deps.sendDiscord(to, text, { verbose: false }); return; @@ -450,14 +451,14 @@ export async function runHeartbeatOnce(opts: { const sender = resolveHeartbeatSender({ allowFrom, lastTo: entry?.lastTo, - lastChannel: entry?.lastChannel, + lastProvider: entry?.lastProvider, }); const prompt = resolveHeartbeatPrompt(cfg); const ctx = { Body: prompt, From: sender, To: sender, - Surface: "heartbeat", + Provider: "heartbeat", }; try { @@ -512,7 +513,7 @@ export async function runHeartbeatOnce(opts: { replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []); - if (delivery.channel === "none" || !delivery.to) { + if (delivery.provider === "none" || !delivery.to) { emitHeartbeatEvent({ status: "skipped", reason: delivery.reason ?? "no-target", @@ -523,7 +524,7 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } - if (delivery.channel === "whatsapp") { + if (delivery.provider === "whatsapp") { const readiness = await resolveWhatsAppReadiness(cfg, opts.deps); if (!readiness.ok) { emitHeartbeatEvent({ @@ -548,9 +549,9 @@ export async function runHeartbeatOnce(opts: { sendSignal: opts.deps?.sendSignal ?? sendMessageSignal, sendIMessage: opts.deps?.sendIMessage ?? sendMessageIMessage, }; - const textLimit = resolveTextChunkLimit(cfg, delivery.channel); + const textLimit = resolveTextChunkLimit(cfg, delivery.provider); await deliverHeartbeatReply({ - channel: delivery.channel, + provider: delivery.provider, to: delivery.to, text: normalized.text, mediaUrls, diff --git a/src/markdown/fences.ts b/src/markdown/fences.ts new file mode 100644 index 000000000..efd31da99 --- /dev/null +++ b/src/markdown/fences.ts @@ -0,0 +1,85 @@ +export type FenceSpan = { + start: number; + end: number; + openLine: string; + marker: string; + indent: string; +}; + +export function parseFenceSpans(buffer: string): FenceSpan[] { + const spans: FenceSpan[] = []; + let open: + | { + start: number; + markerChar: string; + markerLen: number; + openLine: string; + marker: string; + indent: string; + } + | undefined; + + let offset = 0; + while (offset <= buffer.length) { + const nextNewline = buffer.indexOf("\n", offset); + const lineEnd = nextNewline === -1 ? buffer.length : nextNewline; + const line = buffer.slice(offset, lineEnd); + + const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); + if (match) { + const indent = match[1]; + const marker = match[2]; + const markerChar = marker[0]; + const markerLen = marker.length; + if (!open) { + open = { + start: offset, + markerChar, + markerLen, + openLine: line, + marker, + indent, + }; + } else if ( + open.markerChar === markerChar && + markerLen >= open.markerLen + ) { + const end = nextNewline === -1 ? buffer.length : nextNewline + 1; + spans.push({ + start: open.start, + end, + openLine: open.openLine, + marker: open.marker, + indent: open.indent, + }); + open = undefined; + } + } + + if (nextNewline === -1) break; + offset = nextNewline + 1; + } + + if (open) { + spans.push({ + start: open.start, + end: buffer.length, + openLine: open.openLine, + marker: open.marker, + indent: open.indent, + }); + } + + return spans; +} + +export function findFenceSpanAt( + spans: FenceSpan[], + index: number, +): FenceSpan | undefined { + return spans.find((span) => index > span.start && index < span.end); +} + +export function isSafeFenceBreak(spans: FenceSpan[], index: number): boolean { + return !findFenceSpanAt(spans, index); +} diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts new file mode 100644 index 000000000..02f31e345 --- /dev/null +++ b/src/pairing/pairing-store.ts @@ -0,0 +1,268 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; + +export type PairingProvider = + | "telegram" + | "signal" + | "imessage" + | "discord" + | "slack" + | "whatsapp"; + +export type PairingRequest = { + id: string; + code: string; + createdAt: string; + lastSeenAt: string; + meta?: Record; +}; + +type PairingStore = { + version: 1; + requests: PairingRequest[]; +}; + +type AllowFromStore = { + version: 1; + allowFrom: string[]; +}; + +function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { + const stateDir = resolveStateDir(env, os.homedir); + return resolveOAuthDir(env, stateDir); +} + +function resolvePairingPath( + provider: PairingProvider, + env: NodeJS.ProcessEnv = process.env, +): string { + return path.join(resolveCredentialsDir(env), `${provider}-pairing.json`); +} + +function resolveAllowFromPath( + provider: PairingProvider, + env: NodeJS.ProcessEnv = process.env, +): string { + return path.join(resolveCredentialsDir(env), `${provider}-allowFrom.json`); +} + +function safeParseJson(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +async function readJsonFile( + filePath: string, + fallback: T, +): Promise<{ value: T; exists: boolean }> { + try { + const raw = await fs.promises.readFile(filePath, "utf-8"); + const parsed = safeParseJson(raw); + if (parsed == null) return { value: fallback, exists: true }; + return { value: parsed, exists: true }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") return { value: fallback, exists: false }; + return { value: fallback, exists: false }; + } +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile( + filePath, + `${JSON.stringify(value, null, 2)}\n`, + "utf-8", + ); +} + +function randomCode(): string { + // Human-friendly: 8 chars, upper, no ambiguous chars (0O1I). + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let out = ""; + for (let i = 0; i < 8; i++) { + out += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return out; +} + +function normalizeId(value: string | number): string { + return String(value).trim(); +} + +function normalizeAllowEntry(provider: PairingProvider, entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) return ""; + if (trimmed === "*") return ""; + if (provider === "telegram") return trimmed.replace(/^(telegram|tg):/i, ""); + if (provider === "signal") return trimmed.replace(/^signal:/i, ""); + if (provider === "discord") return trimmed.replace(/^(discord|user):/i, ""); + if (provider === "slack") return trimmed.replace(/^(slack|user):/i, ""); + return trimmed; +} + +export async function readProviderAllowFromStore( + provider: PairingProvider, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const filePath = resolveAllowFromPath(provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + allowFrom: [], + }); + const list = Array.isArray(value.allowFrom) ? value.allowFrom : []; + return list + .map((v) => normalizeAllowEntry(provider, String(v))) + .filter(Boolean); +} + +export async function addProviderAllowFromStoreEntry(params: { + provider: PairingProvider; + entry: string | number; + env?: NodeJS.ProcessEnv; +}): Promise<{ changed: boolean; allowFrom: string[] }> { + const env = params.env ?? process.env; + const filePath = resolveAllowFromPath(params.provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + allowFrom: [], + }); + const current = (Array.isArray(value.allowFrom) ? value.allowFrom : []) + .map((v) => normalizeAllowEntry(params.provider, String(v))) + .filter(Boolean); + const normalized = normalizeAllowEntry( + params.provider, + normalizeId(params.entry), + ); + if (!normalized) return { changed: false, allowFrom: current }; + if (current.includes(normalized)) + return { changed: false, allowFrom: current }; + const next = [...current, normalized]; + await writeJsonFile(filePath, { + version: 1, + allowFrom: next, + } satisfies AllowFromStore); + return { changed: true, allowFrom: next }; +} + +export async function listProviderPairingRequests( + provider: PairingProvider, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const filePath = resolvePairingPath(provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + requests: [], + }); + const reqs = Array.isArray(value.requests) ? value.requests : []; + return reqs + .filter( + (r) => + r && + typeof r.id === "string" && + typeof r.code === "string" && + typeof r.createdAt === "string", + ) + .slice() + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} + +export async function upsertProviderPairingRequest(params: { + provider: PairingProvider; + id: string | number; + meta?: Record; + env?: NodeJS.ProcessEnv; +}): Promise<{ code: string; created: boolean }> { + const env = params.env ?? process.env; + const filePath = resolvePairingPath(params.provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + requests: [], + }); + const now = new Date().toISOString(); + const id = normalizeId(params.id); + const meta = + params.meta && typeof params.meta === "object" + ? Object.fromEntries( + Object.entries(params.meta) + .map(([k, v]) => [k, String(v ?? "").trim()] as const) + .filter(([_, v]) => Boolean(v)), + ) + : undefined; + + const reqs = Array.isArray(value.requests) ? value.requests : []; + const existingIdx = reqs.findIndex((r) => r.id === id); + if (existingIdx >= 0) { + const existing = reqs[existingIdx]; + const existingCode = + existing && typeof existing.code === "string" ? existing.code.trim() : ""; + const code = existingCode || randomCode(); + const next: PairingRequest = { + id, + code, + createdAt: existing?.createdAt ?? now, + lastSeenAt: now, + meta: meta ?? existing?.meta, + }; + reqs[existingIdx] = next; + await writeJsonFile(filePath, { + version: 1, + requests: reqs, + } satisfies PairingStore); + return { code, created: false }; + } + + const code = randomCode(); + const next: PairingRequest = { + id, + code, + createdAt: now, + lastSeenAt: now, + ...(meta ? { meta } : {}), + }; + await writeJsonFile(filePath, { + version: 1, + requests: [...reqs, next], + } satisfies PairingStore); + return { code, created: true }; +} + +export async function approveProviderPairingCode(params: { + provider: PairingProvider; + code: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ id: string; entry?: PairingRequest } | null> { + const env = params.env ?? process.env; + const code = params.code.trim().toUpperCase(); + if (!code) return null; + + const filePath = resolvePairingPath(params.provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + requests: [], + }); + const reqs = Array.isArray(value.requests) ? value.requests : []; + const idx = reqs.findIndex( + (r) => String(r.code ?? "").toUpperCase() === code, + ); + if (idx < 0) return null; + const entry = reqs[idx]; + if (!entry) return null; + reqs.splice(idx, 1); + await writeJsonFile(filePath, { + version: 1, + requests: reqs, + } satisfies PairingStore); + await addProviderAllowFromStoreEntry({ + provider: params.provider, + entry: entry.id, + env, + }); + return { id: entry.id, entry }; +} diff --git a/src/polls.test.ts b/src/polls.test.ts new file mode 100644 index 000000000..e2f351b9a --- /dev/null +++ b/src/polls.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { normalizePollDurationHours, normalizePollInput } from "./polls.js"; + +describe("polls", () => { + it("normalizes question/options and validates maxSelections", () => { + expect( + normalizePollInput({ + question: " Lunch? ", + options: [" Pizza ", " ", "Sushi"], + maxSelections: 2, + }), + ).toEqual({ + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 2, + durationHours: undefined, + }); + }); + + it("enforces max option count when configured", () => { + expect(() => + normalizePollInput( + { question: "Q", options: ["A", "B", "C"] }, + { maxOptions: 2 }, + ), + ).toThrow(/at most 2/); + }); + + it("clamps poll duration with defaults", () => { + expect( + normalizePollDurationHours(undefined, { defaultHours: 24, maxHours: 48 }), + ).toBe(24); + expect( + normalizePollDurationHours(999, { defaultHours: 24, maxHours: 48 }), + ).toBe(48); + expect( + normalizePollDurationHours(1, { defaultHours: 24, maxHours: 48 }), + ).toBe(1); + }); +}); diff --git a/src/polls.ts b/src/polls.ts new file mode 100644 index 000000000..784412fd4 --- /dev/null +++ b/src/polls.ts @@ -0,0 +1,71 @@ +export type PollInput = { + question: string; + options: string[]; + maxSelections?: number; + durationHours?: number; +}; + +export type NormalizedPollInput = { + question: string; + options: string[]; + maxSelections: number; + durationHours?: number; +}; + +type NormalizePollOptions = { + maxOptions?: number; +}; + +export function normalizePollInput( + input: PollInput, + options: NormalizePollOptions = {}, +): NormalizedPollInput { + const question = input.question.trim(); + if (!question) { + throw new Error("Poll question is required"); + } + const pollOptions = (input.options ?? []).map((option) => option.trim()); + const cleaned = pollOptions.filter(Boolean); + if (cleaned.length < 2) { + throw new Error("Poll requires at least 2 options"); + } + if (options.maxOptions !== undefined && cleaned.length > options.maxOptions) { + throw new Error(`Poll supports at most ${options.maxOptions} options`); + } + const maxSelectionsRaw = input.maxSelections; + const maxSelections = + typeof maxSelectionsRaw === "number" && Number.isFinite(maxSelectionsRaw) + ? Math.floor(maxSelectionsRaw) + : 1; + if (maxSelections < 1) { + throw new Error("maxSelections must be at least 1"); + } + if (maxSelections > cleaned.length) { + throw new Error("maxSelections cannot exceed option count"); + } + const durationRaw = input.durationHours; + const durationHours = + typeof durationRaw === "number" && Number.isFinite(durationRaw) + ? Math.floor(durationRaw) + : undefined; + if (durationHours !== undefined && durationHours < 1) { + throw new Error("durationHours must be at least 1"); + } + return { + question, + options: cleaned, + maxSelections, + durationHours, + }; +} + +export function normalizePollDurationHours( + value: number | undefined, + options: { defaultHours: number; maxHours: number }, +): number { + const base = + typeof value === "number" && Number.isFinite(value) + ? Math.floor(value) + : options.defaultHours; + return Math.min(Math.max(base, 1), options.maxHours); +} diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index 9b35bc060..fef51a316 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -231,4 +231,252 @@ describe("google-shared convertMessages", () => { thoughtSignature: "sig", }); }); + + it("merges consecutive user messages to satisfy Gemini role alternation", () => { + const model = makeModel("gemini-1.5-pro"); + const context = { + messages: [ + { + role: "user", + content: "Hello", + }, + { + role: "user", + content: "How are you?", + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + expect(contents).toHaveLength(1); + expect(contents[0].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); + }); + + it("merges consecutive user messages for non-Gemini Google models", () => { + const model = makeModel("claude-3-opus"); + const context = { + messages: [ + { + role: "user", + content: "First", + }, + { + role: "user", + content: "Second", + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + expect(contents).toHaveLength(1); + expect(contents[0].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); + }); + + it("merges consecutive model messages to satisfy Gemini role alternation", () => { + const model = makeModel("gemini-1.5-pro"); + const context = { + messages: [ + { + role: "user", + content: "Hello", + }, + { + role: "assistant", + content: [{ type: "text", text: "Hi there!" }], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + { + role: "assistant", + content: [{ type: "text", text: "How can I help?" }], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + expect(contents).toHaveLength(2); + expect(contents[0].role).toBe("user"); + expect(contents[1].role).toBe("model"); + expect(contents[1].parts).toHaveLength(2); + }); + + it("handles user message after tool result without model response in between", () => { + const model = makeModel("gemini-1.5-pro"); + const context = { + messages: [ + { + role: "user", + content: "Use a tool", + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "myTool", + arguments: { arg: "value" }, + }, + ], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "myTool", + content: [{ type: "text", text: "Tool result" }], + isError: false, + timestamp: 0, + }, + { + role: "user", + content: "Now do something else", + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + expect(contents).toHaveLength(3); + expect(contents[0].role).toBe("user"); + expect(contents[1].role).toBe("model"); + expect(contents[2].role).toBe("user"); + const toolResponsePart = contents[2].parts?.find( + (part) => + typeof part === "object" && part !== null && "functionResponse" in part, + ); + const toolResponse = asRecord(toolResponsePart); + expect(toolResponse.functionResponse).toBeTruthy(); + }); + + it("ensures function call comes after user turn, not after model turn", () => { + const model = makeModel("gemini-1.5-pro"); + const context = { + messages: [ + { + role: "user", + content: "Hello", + }, + { + role: "assistant", + content: [{ type: "text", text: "Hi!" }], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "myTool", + arguments: {}, + }, + ], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + expect(contents).toHaveLength(2); + expect(contents[0].role).toBe("user"); + expect(contents[1].role).toBe("model"); + const toolCallPart = contents[1].parts?.find( + (part) => + typeof part === "object" && part !== null && "functionCall" in part, + ); + const toolCall = asRecord(toolCallPart); + expect(toolCall.functionCall).toBeTruthy(); + }); }); diff --git a/src/providers/location.test.ts b/src/providers/location.test.ts new file mode 100644 index 000000000..1db7e2115 --- /dev/null +++ b/src/providers/location.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { formatLocationText, toLocationContext } from "./location.js"; + +describe("provider location helpers", () => { + it("formats pin locations with accuracy", () => { + const text = formatLocationText({ + latitude: 48.858844, + longitude: 2.294351, + accuracy: 12, + }); + expect(text).toBe("📍 48.858844, 2.294351 ±12m"); + }); + + it("formats named places with address and caption", () => { + const text = formatLocationText({ + latitude: 40.689247, + longitude: -74.044502, + name: "Statue of Liberty", + address: "Liberty Island, NY", + accuracy: 8, + caption: "Bring snacks", + }); + expect(text).toBe( + "📍 Statue of Liberty — Liberty Island, NY (40.689247, -74.044502 ±8m)\nBring snacks", + ); + }); + + it("formats live locations with live label", () => { + const text = formatLocationText({ + latitude: 37.819929, + longitude: -122.478255, + accuracy: 20, + caption: "On the move", + isLive: true, + source: "live", + }); + expect(text).toBe( + "🛰 Live location: 37.819929, -122.478255 ±20m\nOn the move", + ); + }); + + it("builds ctx fields with normalized source", () => { + const ctx = toLocationContext({ + latitude: 1, + longitude: 2, + name: "Cafe", + address: "Main St", + }); + expect(ctx).toEqual({ + LocationLat: 1, + LocationLon: 2, + LocationAccuracy: undefined, + LocationName: "Cafe", + LocationAddress: "Main St", + LocationSource: "place", + LocationIsLive: false, + }); + }); +}); diff --git a/src/providers/location.ts b/src/providers/location.ts new file mode 100644 index 000000000..6cc4997ef --- /dev/null +++ b/src/providers/location.ts @@ -0,0 +1,78 @@ +export type LocationSource = "pin" | "place" | "live"; + +export type NormalizedLocation = { + latitude: number; + longitude: number; + accuracy?: number; + name?: string; + address?: string; + isLive?: boolean; + source?: LocationSource; + caption?: string; +}; + +type ResolvedLocation = NormalizedLocation & { + source: LocationSource; + isLive: boolean; +}; + +function resolveLocation(location: NormalizedLocation): ResolvedLocation { + const source = + location.source ?? + (location.isLive + ? "live" + : location.name || location.address + ? "place" + : "pin"); + const isLive = Boolean(location.isLive ?? source === "live"); + return { ...location, source, isLive }; +} + +function formatAccuracy(accuracy?: number): string { + if (!Number.isFinite(accuracy)) return ""; + return ` ±${Math.round(accuracy ?? 0)}m`; +} + +function formatCoords(latitude: number, longitude: number): string { + return `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`; +} + +export function formatLocationText(location: NormalizedLocation): string { + const resolved = resolveLocation(location); + const coords = formatCoords(resolved.latitude, resolved.longitude); + const accuracy = formatAccuracy(resolved.accuracy); + const caption = resolved.caption?.trim(); + let header = ""; + + if (resolved.source === "live" || resolved.isLive) { + header = `🛰 Live location: ${coords}${accuracy}`; + } else if (resolved.name || resolved.address) { + const label = [resolved.name, resolved.address].filter(Boolean).join(" — "); + header = `📍 ${label} (${coords}${accuracy})`; + } else { + header = `📍 ${coords}${accuracy}`; + } + + return caption ? `${header}\n${caption}` : header; +} + +export function toLocationContext(location: NormalizedLocation): { + LocationLat: number; + LocationLon: number; + LocationAccuracy?: number; + LocationName?: string; + LocationAddress?: string; + LocationSource: LocationSource; + LocationIsLive: boolean; +} { + const resolved = resolveLocation(location); + return { + LocationLat: resolved.latitude, + LocationLon: resolved.longitude, + LocationAccuracy: resolved.accuracy, + LocationName: resolved.name, + LocationAddress: resolved.address, + LocationSource: resolved.source, + LocationIsLive: resolved.isLive, + }; +} diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts new file mode 100644 index 000000000..9e18a00cd --- /dev/null +++ b/src/routing/resolve-route.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveAgentRoute } from "./resolve-route.js"; + +describe("resolveAgentRoute", () => { + test("defaults to main/default when no bindings exist", () => { + const cfg: ClawdbotConfig = {}; + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: null, + peer: { kind: "dm", id: "+15551234567" }, + }); + expect(route.agentId).toBe("main"); + expect(route.accountId).toBe("default"); + expect(route.sessionKey).toBe("agent:main:main"); + expect(route.matchedBy).toBe("default"); + }); + + test("peer binding wins over account binding", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [ + { + agentId: "a", + match: { + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }, + }, + { + agentId: "b", + match: { provider: "whatsapp", accountId: "biz" }, + }, + ], + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }); + expect(route.agentId).toBe("a"); + expect(route.sessionKey).toBe("agent:a:main"); + expect(route.matchedBy).toBe("binding.peer"); + }); + + test("discord channel peer binding wins over guild binding", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [ + { + agentId: "chan", + match: { + provider: "discord", + accountId: "default", + peer: { kind: "channel", id: "c1" }, + }, + }, + { + agentId: "guild", + match: { + provider: "discord", + accountId: "default", + guildId: "g1", + }, + }, + ], + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "discord", + accountId: "default", + peer: { kind: "channel", id: "c1" }, + guildId: "g1", + }); + expect(route.agentId).toBe("chan"); + expect(route.sessionKey).toBe("agent:chan:discord:channel:c1"); + expect(route.matchedBy).toBe("binding.peer"); + }); + + test("guild binding wins over account binding when peer not bound", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [ + { + agentId: "guild", + match: { + provider: "discord", + accountId: "default", + guildId: "g1", + }, + }, + { + agentId: "acct", + match: { provider: "discord", accountId: "default" }, + }, + ], + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "discord", + accountId: "default", + peer: { kind: "channel", id: "c1" }, + guildId: "g1", + }); + expect(route.agentId).toBe("guild"); + expect(route.matchedBy).toBe("binding.guild"); + }); + + test("missing accountId in binding matches default account only", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [{ agentId: "defaultAcct", match: { provider: "whatsapp" } }], + }, + }; + + const defaultRoute = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: undefined, + peer: { kind: "dm", id: "+1000" }, + }); + expect(defaultRoute.agentId).toBe("defaultAcct"); + expect(defaultRoute.matchedBy).toBe("binding.account"); + + const otherRoute = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }); + expect(otherRoute.agentId).toBe("main"); + }); + + test("accountId=* matches any account as a provider fallback", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [ + { + agentId: "any", + match: { provider: "whatsapp", accountId: "*" }, + }, + ], + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }); + expect(route.agentId).toBe("any"); + expect(route.matchedBy).toBe("binding.provider"); + }); + + test("defaultAgentId is used when no binding matches", () => { + const cfg: ClawdbotConfig = { + routing: { + defaultAgentId: "home", + agents: { home: { workspace: "~/clawd-home" } }, + }, + }; + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, + }); + expect(route.agentId).toBe("home"); + expect(route.sessionKey).toBe("agent:home:main"); + }); +}); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts new file mode 100644 index 000000000..568ace79e --- /dev/null +++ b/src/routing/resolve-route.ts @@ -0,0 +1,223 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { + buildAgentMainSessionKey, + buildAgentPeerSessionKey, + DEFAULT_ACCOUNT_ID, + DEFAULT_AGENT_ID, + DEFAULT_MAIN_KEY, + normalizeAgentId, +} from "./session-key.js"; + +export type RoutePeerKind = "dm" | "group" | "channel"; + +export type RoutePeer = { + kind: RoutePeerKind; + id: string; +}; + +export type ResolveAgentRouteInput = { + cfg: ClawdbotConfig; + provider: string; + accountId?: string | null; + peer?: RoutePeer | null; + guildId?: string | null; + teamId?: string | null; +}; + +export type ResolvedAgentRoute = { + agentId: string; + provider: string; + accountId: string; + /** Internal session key used for persistence + concurrency. */ + sessionKey: string; + /** Convenience alias for direct-chat collapse. */ + mainSessionKey: string; + /** Match description for debugging/logging. */ + matchedBy: + | "binding.peer" + | "binding.guild" + | "binding.team" + | "binding.account" + | "binding.provider" + | "default"; +}; + +export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js"; + +function normalizeToken(value: string | undefined | null): string { + return (value ?? "").trim().toLowerCase(); +} + +function normalizeId(value: string | undefined | null): string { + return (value ?? "").trim(); +} + +function normalizeAccountId(value: string | undefined | null): string { + const trimmed = (value ?? "").trim(); + return trimmed ? trimmed : DEFAULT_ACCOUNT_ID; +} + +function matchesAccountId(match: string | undefined, actual: string): boolean { + const trimmed = (match ?? "").trim(); + if (!trimmed) return actual === DEFAULT_ACCOUNT_ID; + if (trimmed === "*") return true; + return trimmed === actual; +} + +export function buildAgentSessionKey(params: { + agentId: string; + provider: string; + peer?: RoutePeer | null; +}): string { + const provider = normalizeToken(params.provider) || "unknown"; + const peer = params.peer; + return buildAgentPeerSessionKey({ + agentId: params.agentId, + mainKey: DEFAULT_MAIN_KEY, + provider, + peerKind: peer?.kind ?? "dm", + peerId: peer ? normalizeId(peer.id) || "unknown" : null, + }); +} + +function listBindings(cfg: ClawdbotConfig) { + const bindings = cfg.routing?.bindings; + return Array.isArray(bindings) ? bindings : []; +} + +function listAgents(cfg: ClawdbotConfig) { + const agents = cfg.routing?.agents; + return agents && typeof agents === "object" ? agents : undefined; +} + +function resolveDefaultAgentId(cfg: ClawdbotConfig): string { + const explicit = cfg.routing?.defaultAgentId?.trim(); + if (explicit) return explicit; + return DEFAULT_AGENT_ID; +} + +function pickFirstExistingAgentId( + cfg: ClawdbotConfig, + agentId: string, +): string { + const normalized = normalizeAgentId(agentId); + const agents = listAgents(cfg); + if (!agents) return normalized; + if (Object.hasOwn(agents, normalized)) return normalized; + return normalizeAgentId(resolveDefaultAgentId(cfg)); +} + +function matchesProvider( + match: { provider?: string | undefined } | undefined, + provider: string, +): boolean { + const key = normalizeToken(match?.provider); + if (!key) return false; + return key === provider; +} + +function matchesPeer( + match: { peer?: { kind?: string; id?: string } | undefined } | undefined, + peer: RoutePeer, +): boolean { + const m = match?.peer; + if (!m) return false; + const kind = normalizeToken(m.kind); + const id = normalizeId(m.id); + if (!kind || !id) return false; + return kind === peer.kind && id === peer.id; +} + +function matchesGuild( + match: { guildId?: string | undefined } | undefined, + guildId: string, +): boolean { + const id = normalizeId(match?.guildId); + if (!id) return false; + return id === guildId; +} + +function matchesTeam( + match: { teamId?: string | undefined } | undefined, + teamId: string, +): boolean { + const id = normalizeId(match?.teamId); + if (!id) return false; + return id === teamId; +} + +export function resolveAgentRoute( + input: ResolveAgentRouteInput, +): ResolvedAgentRoute { + const provider = normalizeToken(input.provider); + const accountId = normalizeAccountId(input.accountId); + const peer = input.peer + ? { kind: input.peer.kind, id: normalizeId(input.peer.id) } + : null; + const guildId = normalizeId(input.guildId); + const teamId = normalizeId(input.teamId); + + const bindings = listBindings(input.cfg).filter((binding) => { + if (!binding || typeof binding !== "object") return false; + if (!matchesProvider(binding.match, provider)) return false; + return matchesAccountId(binding.match?.accountId, accountId); + }); + + const choose = ( + agentId: string, + matchedBy: ResolvedAgentRoute["matchedBy"], + ) => { + const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId); + return { + agentId: resolvedAgentId, + provider, + accountId, + sessionKey: buildAgentSessionKey({ + agentId: resolvedAgentId, + provider, + peer, + }), + mainSessionKey: buildAgentMainSessionKey({ + agentId: resolvedAgentId, + mainKey: DEFAULT_MAIN_KEY, + }), + matchedBy, + }; + }; + + if (peer) { + const peerMatch = bindings.find((b) => matchesPeer(b.match, peer)); + if (peerMatch) return choose(peerMatch.agentId, "binding.peer"); + } + + if (guildId) { + const guildMatch = bindings.find((b) => matchesGuild(b.match, guildId)); + if (guildMatch) return choose(guildMatch.agentId, "binding.guild"); + } + + if (teamId) { + const teamMatch = bindings.find((b) => matchesTeam(b.match, teamId)); + if (teamMatch) return choose(teamMatch.agentId, "binding.team"); + } + + const accountMatch = bindings.find( + (b) => + b.match?.accountId?.trim() !== "*" && + !b.match?.peer && + !b.match?.guildId && + !b.match?.teamId, + ); + if (accountMatch) return choose(accountMatch.agentId, "binding.account"); + + const anyAccountMatch = bindings.find( + (b) => + b.match?.accountId?.trim() === "*" && + !b.match?.peer && + !b.match?.guildId && + !b.match?.teamId, + ); + if (anyAccountMatch) + return choose(anyAccountMatch.agentId, "binding.provider"); + + return choose(resolveDefaultAgentId(input.cfg), "default"); +} diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts new file mode 100644 index 000000000..3a4ecc7a7 --- /dev/null +++ b/src/routing/session-key.ts @@ -0,0 +1,77 @@ +export const DEFAULT_AGENT_ID = "main"; +export const DEFAULT_MAIN_KEY = "main"; +export const DEFAULT_ACCOUNT_ID = "default"; + +export type ParsedAgentSessionKey = { + agentId: string; + rest: string; +}; + +export function normalizeAgentId(value: string | undefined | null): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) return DEFAULT_AGENT_ID; + // Keep it path-safe + shell-friendly. + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed; + // Best-effort fallback: collapse invalid characters to "-" + return ( + trimmed + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 64) || DEFAULT_AGENT_ID + ); +} + +export function parseAgentSessionKey( + sessionKey: string | undefined | null, +): ParsedAgentSessionKey | null { + const raw = (sessionKey ?? "").trim(); + if (!raw) return null; + const parts = raw.split(":").filter(Boolean); + if (parts.length < 3) return null; + if (parts[0] !== "agent") return null; + const agentId = parts[1]?.trim(); + const rest = parts.slice(2).join(":"); + if (!agentId || !rest) return null; + return { agentId, rest }; +} + +export function isSubagentSessionKey( + sessionKey: string | undefined | null, +): boolean { + const raw = (sessionKey ?? "").trim(); + if (!raw) return false; + if (raw.toLowerCase().startsWith("subagent:")) return true; + const parsed = parseAgentSessionKey(raw); + return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:")); +} + +export function buildAgentMainSessionKey(params: { + agentId: string; + mainKey?: string | undefined; +}): string { + const agentId = normalizeAgentId(params.agentId); + const mainKey = + (params.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY; + return `agent:${agentId}:${mainKey}`; +} + +export function buildAgentPeerSessionKey(params: { + agentId: string; + mainKey?: string | undefined; + provider: string; + peerKind?: "dm" | "group" | "channel" | null; + peerId?: string | null; +}): string { + const peerKind = params.peerKind ?? "dm"; + if (peerKind === "dm") { + return buildAgentMainSessionKey({ + agentId: params.agentId, + mainKey: params.mainKey, + }); + } + const provider = (params.provider ?? "").trim().toLowerCase() || "unknown"; + const peerId = (params.peerId ?? "").trim() || "unknown"; + return `agent:${normalizeAgentId(params.agentId)}:${provider}:${peerKind}:${peerId}`; +} diff --git a/src/sessions/send-policy.test.ts b/src/sessions/send-policy.test.ts index 7dd99ffda..d6a95eeb8 100644 --- a/src/sessions/send-policy.test.ts +++ b/src/sessions/send-policy.test.ts @@ -21,7 +21,7 @@ describe("resolveSendPolicy", () => { expect(resolveSendPolicy({ cfg, entry })).toBe("deny"); }); - it("rule match by surface + chatType", () => { + it("rule match by provider + chatType", () => { const cfg = { session: { sendPolicy: { @@ -29,7 +29,7 @@ describe("resolveSendPolicy", () => { rules: [ { action: "deny", - match: { surface: "discord", chatType: "group" }, + match: { provider: "discord", chatType: "group" }, }, ], }, @@ -38,7 +38,7 @@ describe("resolveSendPolicy", () => { const entry: SessionEntry = { sessionId: "s", updatedAt: 0, - surface: "discord", + provider: "discord", chatType: "group", }; expect( diff --git a/src/sessions/send-policy.ts b/src/sessions/send-policy.ts index 10b5311fc..b742a6ff3 100644 --- a/src/sessions/send-policy.ts +++ b/src/sessions/send-policy.ts @@ -17,7 +17,7 @@ function normalizeMatchValue(raw?: string | null) { return value ? value : undefined; } -function deriveSurfaceFromKey(key?: string) { +function deriveProviderFromKey(key?: string) { if (!key) return undefined; const parts = key.split(":").filter(Boolean); if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { @@ -37,7 +37,7 @@ export function resolveSendPolicy(params: { cfg: ClawdbotConfig; entry?: SessionEntry; sessionKey?: string; - surface?: string; + provider?: string; chatType?: SessionChatType; }): SessionSendPolicyDecision { const override = normalizeSendPolicy(params.entry?.sendPolicy); @@ -46,11 +46,11 @@ export function resolveSendPolicy(params: { const policy = params.cfg.session?.sendPolicy; if (!policy) return "allow"; - const surface = - normalizeMatchValue(params.surface) ?? - normalizeMatchValue(params.entry?.surface) ?? - normalizeMatchValue(params.entry?.lastChannel) ?? - deriveSurfaceFromKey(params.sessionKey); + const provider = + normalizeMatchValue(params.provider) ?? + normalizeMatchValue(params.entry?.provider) ?? + normalizeMatchValue(params.entry?.lastProvider) ?? + deriveProviderFromKey(params.sessionKey); const chatType = normalizeMatchValue(params.chatType ?? params.entry?.chatType) ?? normalizeMatchValue(deriveChatTypeFromKey(params.sessionKey)); @@ -61,11 +61,11 @@ export function resolveSendPolicy(params: { if (!rule) continue; const action = normalizeSendPolicy(rule.action) ?? "allow"; const match = rule.match ?? {}; - const matchSurface = normalizeMatchValue(match.surface); + const matchProvider = normalizeMatchValue(match.provider); const matchChatType = normalizeMatchValue(match.chatType); const matchPrefix = normalizeMatchValue(match.keyPrefix); - if (matchSurface && matchSurface !== surface) continue; + if (matchProvider && matchProvider !== provider) continue; if (matchChatType && matchChatType !== chatType) continue; if (matchPrefix && !sessionKey.startsWith(matchPrefix)) continue; if (action === "deny") return "deny"; diff --git a/src/signal/monitor.test.ts b/src/signal/monitor.test.ts new file mode 100644 index 000000000..e99907922 --- /dev/null +++ b/src/signal/monitor.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { isSignalGroupAllowed } from "./monitor.js"; + +describe("signal groupPolicy gating", () => { + it("allows when policy is open", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "open", + allowFrom: [], + sender: "+15550001111", + }), + ).toBe(true); + }); + + it("blocks when policy is disabled", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "disabled", + allowFrom: ["+15550001111"], + sender: "+15550001111", + }), + ).toBe(false); + }); + + it("blocks allowlist when empty", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: [], + sender: "+15550001111", + }), + ).toBe(false); + }); + + it("allows allowlist when sender matches", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["+15550001111"], + sender: "+15550001111", + }), + ).toBe(true); + }); + + it("allows allowlist wildcard", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["*"], + sender: "+15550002222", + }), + ).toBe(true); + }); +}); diff --git a/src/signal/monitor.tool-result.test.ts b/src/signal/monitor.tool-result.test.ts index 5bf354a53..3bd3ed130 100644 --- a/src/signal/monitor.tool-result.test.ts +++ b/src/signal/monitor.tool-result.test.ts @@ -6,6 +6,8 @@ const sendMock = vi.fn(); const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); let config: Record = {}; +const readAllowFromStoreMock = vi.fn(); +const upsertPairingRequestMock = vi.fn(); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -23,6 +25,13 @@ vi.mock("./send.js", () => ({ sendMessageSignal: (...args: unknown[]) => sendMock(...args), })); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), @@ -47,7 +56,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); beforeEach(() => { config = { messages: { responsePrefix: "PFX" }, - signal: { autoStart: false }, + signal: { autoStart: false, dmPolicy: "open", allowFrom: ["*"] }, routing: { allowFrom: [] }, }; sendMock.mockReset().mockResolvedValue(undefined); @@ -56,6 +65,10 @@ beforeEach(() => { streamMock.mockReset(); signalCheckMock.mockReset().mockResolvedValue({}); signalRpcRequestMock.mockReset().mockResolvedValue({}); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock + .mockReset() + .mockResolvedValue({ code: "PAIRCODE", created: true }); }); describe("monitorSignalProvider tool results", () => { @@ -93,4 +106,42 @@ describe("monitorSignalProvider tool results", () => { expect(sendMock.mock.calls[0][1]).toBe("PFX tool update"); expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + + it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => { + config = { + ...config, + signal: { autoStart: false, dmPolicy: "pairing", allowFrom: [] }, + }; + + streamMock.mockImplementation(async ({ onEvent }) => { + const payload = { + envelope: { + sourceNumber: "+15550001111", + sourceName: "Ada", + timestamp: 1, + dataMessage: { + message: "hello", + }, + }, + }; + await onEvent({ + event: "receive", + data: JSON.stringify(payload), + }); + }); + + await monitorSignalProvider({ + autoStart: false, + baseUrl: "http://127.0.0.1:8080", + }); + + await flush(); + + expect(replyMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Pairing code: PAIRCODE", + ); + }); }); diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 3cf92a6bd..8f785f164 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -1,13 +1,18 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; -import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { mediaKindFromMime } from "../media/constants.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeE164 } from "../utils.js"; import { signalCheck, signalRpcRequest, streamSignalEvents } from "./client.js"; @@ -55,6 +60,7 @@ export type MonitorSignalOpts = { ignoreStories?: boolean; sendReadReceipts?: boolean; allowFrom?: Array; + groupAllowFrom?: Array; mediaMaxMb?: number; }; @@ -97,8 +103,19 @@ function resolveAllowFrom(opts: MonitorSignalOpts): string[] { return raw.map((entry) => String(entry).trim()).filter(Boolean); } +function resolveGroupAllowFrom(opts: MonitorSignalOpts): string[] { + const cfg = loadConfig(); + const raw = + opts.groupAllowFrom ?? + cfg.signal?.groupAllowFrom ?? + (cfg.signal?.allowFrom && cfg.signal.allowFrom.length > 0 + ? cfg.signal.allowFrom + : []); + return raw.map((entry) => String(entry).trim()).filter(Boolean); +} + function isAllowedSender(sender: string, allowFrom: string[]): boolean { - if (allowFrom.length === 0) return true; + if (allowFrom.length === 0) return false; if (allowFrom.includes("*")) return true; const normalizedAllow = allowFrom .map((entry) => entry.replace(/^signal:/i, "")) @@ -107,6 +124,18 @@ function isAllowedSender(sender: string, allowFrom: string[]): boolean { return normalizedAllow.includes(normalizedSender); } +export function isSignalGroupAllowed(params: { + groupPolicy: "open" | "disabled" | "allowlist"; + allowFrom: string[]; + sender: string; +}): boolean { + const { groupPolicy, allowFrom, sender } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + if (allowFrom.length === 0) return false; + return isAllowedSender(sender, allowFrom); +} + async function waitForSignalDaemonReady(params: { baseUrl: string; abortSignal?: AbortSignal; @@ -221,7 +250,10 @@ export async function monitorSignalProvider( const textLimit = resolveTextChunkLimit(cfg, "signal"); const baseUrl = resolveBaseUrl(opts); const account = resolveAccount(opts); + const dmPolicy = cfg.signal?.dmPolicy ?? "pairing"; const allowFrom = resolveAllowFrom(opts); + const groupAllowFrom = resolveGroupAllowFrom(opts); + const groupPolicy = cfg.signal?.groupPolicy ?? "open"; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.signal?.mediaMaxMb ?? 8) * 1024 * 1024; const ignoreAttachments = @@ -288,15 +320,82 @@ export async function monitorSignalProvider( if (account && normalizeE164(sender) === normalizeE164(account)) { return; } - const commandAuthorized = isAllowedSender(sender, allowFrom); - if (!commandAuthorized) { - logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`); - return; - } - const groupId = dataMessage.groupInfo?.groupId ?? undefined; const groupName = dataMessage.groupInfo?.groupName ?? undefined; const isGroup = Boolean(groupId); + const storeAllowFrom = await readProviderAllowFromStore("signal").catch( + () => [], + ); + const effectiveDmAllow = [...allowFrom, ...storeAllowFrom]; + const effectiveGroupAllow = [...groupAllowFrom, ...storeAllowFrom]; + const dmAllowed = + dmPolicy === "open" ? true : isAllowedSender(sender, effectiveDmAllow); + + if (!isGroup) { + if (dmPolicy === "disabled") return; + if (!dmAllowed) { + if (dmPolicy === "pairing") { + const senderId = normalizeE164(sender); + const { code } = await upsertProviderPairingRequest({ + provider: "signal", + id: senderId, + meta: { + name: envelope.sourceName ?? undefined, + }, + }); + logVerbose( + `signal pairing request sender=${senderId} code=${code}`, + ); + try { + await sendMessageSignal( + senderId, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider signal ", + ].join("\n"), + { baseUrl, account, maxBytes: mediaMaxBytes }, + ); + } catch (err) { + logVerbose( + `signal pairing reply failed for ${senderId}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked signal sender ${sender} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + } + if (isGroup && groupPolicy === "disabled") { + logVerbose("Blocked signal group message (groupPolicy: disabled)"); + return; + } + if (isGroup && groupPolicy === "allowlist") { + if (effectiveGroupAllow.length === 0) { + logVerbose( + "Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)", + ); + return; + } + if (!isAllowedSender(sender, effectiveGroupAllow)) { + logVerbose( + `Blocked signal group sender ${sender} (not in groupAllowFrom)`, + ); + return; + } + } + + const commandAuthorized = isGroup + ? effectiveGroupAllow.length > 0 + ? isAllowedSender(sender, effectiveGroupAllow) + : true + : dmAllowed; const messageText = (dataMessage.message ?? "").trim(); let mediaPath: string | undefined; @@ -338,21 +437,31 @@ export async function monitorSignalProvider( ? `${groupName ?? "Signal Group"} id:${groupId}` : `${envelope.sourceName ?? sender} id:${sender}`; const body = formatAgentEnvelope({ - surface: "Signal", + provider: "Signal", from: fromLabel, timestamp: envelope.timestamp ?? undefined, body: bodyText, }); + const route = resolveAgentRoute({ + cfg, + provider: "signal", + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender), + }, + }); const ctxPayload = { Body: body, - From: isGroup ? `group:${groupId}` : `signal:${sender}`, - To: isGroup ? `group:${groupId}` : `signal:${sender}`, + From: isGroup ? `group:${groupId ?? "unknown"}` : `signal:${sender}`, + To: isGroup ? `group:${groupId ?? "unknown"}` : `signal:${sender}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (groupName ?? undefined) : undefined, SenderName: envelope.sourceName ?? sender, SenderId: sender, - Surface: "signal" as const, + Provider: "signal" as const, MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined, Timestamp: envelope.timestamp ?? undefined, MediaPath: mediaPath, @@ -363,13 +472,15 @@ export async function monitorSignalProvider( if (!isGroup) { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); await updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "signal", + sessionKey: route.mainSessionKey, + provider: "signal", to: normalizeE164(sender), + accountId: route.accountId, }); } @@ -400,28 +511,11 @@ export async function monitorSignalProvider( }, }); - const replyResult = await getReplyFromConfig( - ctxPayload, - { - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); - }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); - }, - }, + const { queuedFinal } = await dispatchReplyFromConfig({ + ctx: ctxPayload, cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; - } - await dispatcher.waitForIdle(); + dispatcher, + }); if (!queuedFinal) return; }; diff --git a/src/slack/monitor.test.ts b/src/slack/monitor.test.ts new file mode 100644 index 000000000..baa5a7397 --- /dev/null +++ b/src/slack/monitor.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { isSlackRoomAllowedByPolicy } from "./monitor.js"; + +describe("slack groupPolicy gating", () => { + it("allows when policy is open", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "open", + channelAllowlistConfigured: false, + channelAllowed: false, + }), + ).toBe(true); + }); + + it("blocks when policy is disabled", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "disabled", + channelAllowlistConfigured: true, + channelAllowed: true, + }), + ).toBe(false); + }); + + it("blocks allowlist when no channel allowlist configured", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: false, + channelAllowed: true, + }), + ).toBe(false); + }); + + it("allows allowlist when channel is allowed", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: true, + channelAllowed: true, + }), + ).toBe(true); + }); + + it("blocks allowlist when channel is not allowed", () => { + expect( + isSlackRoomAllowedByPolicy({ + groupPolicy: "allowlist", + channelAllowlistConfigured: true, + channelAllowed: false, + }), + ).toBe(false); + }); +}); diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 4a19ca8fc..8042c3744 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -5,13 +5,18 @@ import { monitorSlackProvider } from "./monitor.js"; const sendMock = vi.fn(); const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); +const reactMock = vi.fn(); let config: Record = {}; +const readAllowFromStoreMock = vi.fn(); +const upsertPairingRequestMock = vi.fn(); const getSlackHandlers = () => ( globalThis as { __slackHandlers?: Map Promise>; } ).__slackHandlers; +const getSlackClient = () => + (globalThis as { __slackClient?: Record }).__slackClient; vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -29,6 +34,13 @@ vi.mock("./send.js", () => ({ sendMessageSlack: (...args: unknown[]) => sendMock(...args), })); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), @@ -39,20 +51,30 @@ vi.mock("@slack/bolt", () => { const handlers = new Map Promise>(); (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; + const client = { + auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, + conversations: { + info: vi.fn().mockResolvedValue({ + channel: { name: "dm", is_im: true }, + }), + }, + users: { + info: vi.fn().mockResolvedValue({ + user: { profile: { display_name: "Ada" } }, + }), + }, + assistant: { + threads: { + setStatus: vi.fn().mockResolvedValue({ ok: true }), + }, + }, + reactions: { + add: (...args: unknown[]) => reactMock(...args), + }, + }; + (globalThis as { __slackClient?: typeof client }).__slackClient = client; class App { - client = { - auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, - conversations: { - info: vi.fn().mockResolvedValue({ - channel: { name: "dm", is_im: true }, - }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { profile: { display_name: "Ada" } }, - }), - }, - }; + client = client; event(name: string, handler: (args: unknown) => Promise) { handlers.set(name, handler); } @@ -62,7 +84,7 @@ vi.mock("@slack/bolt", () => { start = vi.fn().mockResolvedValue(undefined); stop = vi.fn().mockResolvedValue(undefined); } - return { default: { App } }; + return { App, default: { App } }; }); const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -76,13 +98,22 @@ async function waitForEvent(name: string) { beforeEach(() => { config = { - messages: { responsePrefix: "PFX" }, - slack: { dm: { enabled: true }, groupDm: { enabled: false } }, + messages: { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } }, routing: { allowFrom: [] }, }; sendMock.mockReset().mockResolvedValue(undefined); replyMock.mockReset(); updateLastRouteMock.mockReset(); + reactMock.mockReset(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock + .mockReset() + .mockResolvedValue({ code: "PAIRCODE", created: true }); }); describe("monitorSlackProvider tool results", () => { @@ -123,12 +154,62 @@ describe("monitorSlackProvider tool results", () => { expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + it("updates assistant thread status when replies start", async () => { + replyMock.mockImplementation(async (_ctx, opts) => { + await opts?.onReplyStart?.(); + return { text: "final reply" }; + }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + const client = getSlackClient() as { + assistant?: { threads?: { setStatus?: ReturnType } }; + }; + const setStatus = client.assistant?.threads?.setStatus; + expect(setStatus).toHaveBeenCalledTimes(2); + expect(setStatus).toHaveBeenNthCalledWith(1, { + token: "bot-token", + channel_id: "C1", + thread_ts: "123", + status: "is typing...", + }); + expect(setStatus).toHaveBeenNthCalledWith(2, { + token: "bot-token", + channel_id: "C1", + thread_ts: "123", + status: "", + }); + }); + it("accepts channel messages when mentionPatterns match", async () => { config = { messages: { responsePrefix: "PFX" }, slack: { - dm: { enabled: true }, - groupDm: { enabled: false }, + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, channels: { C1: { allow: true, requireMention: true } }, }, routing: { @@ -201,4 +282,121 @@ describe("monitorSlackProvider tool results", () => { expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" }); }); + + it("keeps replies in channel root when message is not threaded", async () => { + replyMock.mockResolvedValue({ text: "root reply" }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "789", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined }); + }); + + it("reacts to mention-gated room messages when ackReaction is enabled", async () => { + replyMock.mockResolvedValue(undefined); + const client = getSlackClient(); + if (!client) throw new Error("Slack client not registered"); + const conversations = client.conversations as { + info: ReturnType; + }; + conversations.info.mockResolvedValueOnce({ + channel: { name: "general", is_channel: true }, + }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "<@bot-user> hello", + ts: "456", + channel: "C1", + channel_type: "channel", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(reactMock).toHaveBeenCalledWith({ + channel: "C1", + timestamp: "456", + name: "👀", + }); + }); + + it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => { + config = { + ...config, + slack: { dm: { enabled: true, policy: "pairing", allowFrom: [] } }, + }; + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Pairing code: PAIRCODE", + ); + }); }); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index e8509f774..f98841d41 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -1,16 +1,25 @@ -import type { - SlackCommandMiddlewareArgs, - SlackEventMiddlewareArgs, +import { + App, + type SlackCommandMiddlewareArgs, + type SlackEventMiddlewareArgs, } from "@slack/bolt"; -import bolt from "@slack/bolt"; -import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { + chunkMarkdownText, + resolveTextChunkLimit, +} from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; +import { + buildCommandText, + listNativeCommandSpecs, + shouldHandleTextCommands, +} from "../auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { buildMentionRegexes, matchesMentionPatterns, } from "../auto-reply/reply/mentions.js"; -import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; +import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -29,7 +38,13 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; +import { reactSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; @@ -341,7 +356,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const chatType = isRoom ? "room" : isGroup ? "group" : "direct"; return resolveSessionKey( sessionScope, - { From: from, ChatType: chatType, Surface: "slack" }, + { From: from, ChatType: chatType, Provider: "slack" }, mainKey, ); }; @@ -372,11 +387,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }; const dmConfig = cfg.slack?.dm; + const dmPolicy = dmConfig?.policy ?? "pairing"; const allowFrom = normalizeAllowList(dmConfig?.allowFrom); const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels); const channelsConfig = cfg.slack?.channels; const dmEnabled = dmConfig?.enabled ?? true; + const groupPolicy = cfg.slack?.groupPolicy ?? "open"; + const useAccessGroups = cfg.commands?.useAccessGroups !== false; const reactionMode = cfg.slack?.reactionNotifications ?? "own"; const reactionAllowlist = cfg.slack?.reactionAllowlist ?? []; const slashCommand = resolveSlackSlashCommandConfig( @@ -384,6 +402,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ); const textLimit = resolveTextChunkLimit(cfg, "slack"); const mentionRegexes = buildMentionRegexes(cfg); + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024; @@ -413,7 +433,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { return false; }; - const { App } = bolt; const app = new App({ token: botToken, appToken, @@ -421,9 +440,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }); let botUserId = ""; + let teamId = ""; try { const auth = await app.client.auth.test({ token: botToken }); botUserId = auth.user_id ?? ""; + teamId = auth.team_id ?? ""; } catch (err) { runtime.error?.(danger(`slack auth failed: ${String(err)}`)); } @@ -478,6 +499,41 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } }; + const setSlackThreadStatus = async (params: { + channelId: string; + threadTs?: string; + status: string; + }) => { + if (!params.threadTs) return; + const payload = { + token: botToken, + channel_id: params.channelId, + thread_ts: params.threadTs, + status: params.status, + }; + const client = app.client as unknown as { + assistant?: { + threads?: { + setStatus?: (args: typeof payload) => Promise; + }; + }; + apiCall?: (method: string, args: typeof payload) => Promise; + }; + try { + if (client.assistant?.threads?.setStatus) { + await client.assistant.threads.setStatus(payload); + return; + } + if (typeof client.apiCall === "function") { + await client.apiCall("assistant.threads.setStatus", payload); + } + } catch (err) { + logVerbose( + `slack status update failed for channel ${params.channelId}: ${String(err)}`, + ); + } + }; + const isChannelAllowed = (params: { channelId?: string; channelName?: string; @@ -513,7 +569,19 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { channelName: params.channelName, channels: channelsConfig, }); - if (channelConfig?.allowed === false) return false; + const channelAllowed = channelConfig?.allowed !== false; + const channelAllowlistConfigured = + Boolean(channelsConfig) && Object.keys(channelsConfig ?? {}).length > 0; + if ( + !isSlackRoomAllowedByPolicy({ + groupPolicy, + channelAllowlistConfigured, + channelAllowed, + }) + ) { + return false; + } + if (!channelAllowed) return false; } return true; @@ -562,17 +630,63 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { return; } - if (isDirectMessage && allowFrom.length > 0) { - const permitted = allowListMatches({ - allowList: normalizeAllowListLower(allowFrom), - id: message.user, - }); - if (!permitted) { - logVerbose( - `Blocked unauthorized slack sender ${message.user} (not in allowFrom)`, - ); + const storeAllowFrom = await readProviderAllowFromStore("slack").catch( + () => [], + ); + const effectiveAllowFrom = normalizeAllowList([ + ...allowFrom, + ...storeAllowFrom, + ]); + const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom); + + if (isDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { + logVerbose("slack: drop dm (dms disabled)"); return; } + if (dmPolicy !== "open") { + const permitted = allowListMatches({ + allowList: effectiveAllowFromLower, + id: message.user, + }); + if (!permitted) { + if (dmPolicy === "pairing") { + const sender = await resolveUserName(message.user); + const senderName = sender?.name ?? undefined; + const { code } = await upsertProviderPairingRequest({ + provider: "slack", + id: message.user, + meta: { name: senderName }, + }); + logVerbose( + `slack pairing request sender=${message.user} name=${senderName ?? "unknown"} code=${code}`, + ); + try { + await sendMessageSlack( + message.channel, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider slack ", + ].join("\n"), + { token: botToken, client: app.client }, + ); + } catch (err) { + logVerbose( + `slack pairing reply failed for ${message.user}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked unauthorized slack sender ${message.user} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + } } const channelConfig = isRoom @@ -590,7 +704,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { matchesMentionPatterns(message.text ?? "", mentionRegexes))); const sender = await resolveUserName(message.user); const senderName = sender?.name ?? message.user; - const allowList = normalizeAllowListLower(allowFrom); + const allowList = effectiveAllowFromLower; const commandAuthorized = allowList.length === 0 || allowListMatches({ @@ -599,7 +713,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { name: senderName, }); const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: "slack", + }); const shouldBypassMention = + allowTextCommands && isRoom && channelConfig?.requireMention && !wasMentioned && @@ -628,6 +747,30 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }); const rawBody = (message.text ?? "").trim() || media?.placeholder || ""; if (!rawBody) return; + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return isDirectMessage; + const isGroupChat = isRoom || isGroupDm; + if (ackReactionScope === "group-all") return isGroupChat; + if (ackReactionScope === "group-mentions") { + if (!isRoom) return false; + if (!channelConfig?.requireMention) return false; + if (!canDetectMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction() && message.ts) { + reactSlackMessage(message.channel, message.ts, ackReaction, { + token: botToken, + client: app.client, + }).catch((err) => { + logVerbose( + `slack react failed for channel ${message.channel}: ${String(err)}`, + ); + }); + } const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`; @@ -640,15 +783,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { : isRoom ? `slack:channel:${message.channel}` : `slack:group:${message.channel}`; - const sessionKey = resolveSessionKey( - sessionScope, - { - From: slackFrom, - ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", - Surface: "slack", + const route = resolveAgentRoute({ + cfg, + provider: "slack", + teamId: teamId || undefined, + peer: { + kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group", + id: isDirectMessage ? (message.user ?? "unknown") : message.channel, }, - mainKey, - ); + }); + const sessionKey = route.sessionKey; enqueueSystemEvent(`${inboundLabel}: ${preview}`, { sessionKey, contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`, @@ -656,7 +800,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; const body = formatAgentEnvelope({ - surface: "Slack", + provider: "Slack", from: senderName, timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, body: textWithId, @@ -669,11 +813,13 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { To: isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", GroupSubject: isRoomish ? roomLabel : undefined, SenderName: senderName, SenderId: message.user, - Surface: "slack" as const, + Provider: "slack" as const, MessageSid: message.ts, ReplyToId: message.thread_ts ?? message.ts, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, @@ -692,13 +838,15 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { if (isDirectMessage) { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); await updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "slack", + sessionKey: route.mainSessionKey, + provider: "slack", to: `user:${message.user}`, + accountId: route.accountId, }); } @@ -710,51 +858,61 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { // Only thread replies if the incoming message was in a thread. const incomingThreadTs = message.thread_ts; - - const dispatcher = createReplyDispatcher({ - responsePrefix: cfg.messages?.responsePrefix, - deliver: async (payload) => { - await deliverReplies({ - replies: [payload], - target: replyTarget, - token: botToken, - runtime, - textLimit, - threadTs: incomingThreadTs, - }); - }, - onError: (err, info) => { - runtime.error?.( - danger(`slack ${info.kind} reply failed: ${String(err)}`), - ); - }, - }); - - const replyResult = await getReplyFromConfig( - ctxPayload, - { - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); + const statusThreadTs = message.thread_ts ?? message.ts; + let didSetStatus = false; + const onReplyStart = async () => { + didSetStatus = true; + await setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "is typing...", + }); + }; + const { dispatcher, replyOptions, markDispatchIdle } = + createReplyDispatcherWithTyping({ + responsePrefix: cfg.messages?.responsePrefix, + deliver: async (payload) => { + await deliverReplies({ + replies: [payload], + target: replyTarget, + token: botToken, + runtime, + textLimit, + threadTs: incomingThreadTs, + }); }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); + onError: (err, info) => { + runtime.error?.( + danger(`slack ${info.kind} reply failed: ${String(err)}`), + ); + if (didSetStatus) { + void setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "", + }); + } }, - }, + onReplyStart, + }); + + const { queuedFinal, counts } = await dispatchReplyFromConfig({ + ctx: ctxPayload, cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; + dispatcher, + replyOptions, + }); + markDispatchIdle(); + if (didSetStatus) { + await setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "", + }); } - await dispatcher.waitForIdle(); if (!queuedFinal) return; if (shouldLogVerbose()) { - const finalCount = dispatcher.getQueuedCounts().final; + const finalCount = counts.final; logVerbose( `slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); @@ -1217,150 +1375,242 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }, ); - if (slashCommand.enabled) { - app.command( - slashCommand.name, - async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => { - try { - const prompt = command.text?.trim(); - if (!prompt) { - await ack({ - text: "Message required.", - response_type: "ephemeral", - }); - return; - } - await ack(); + const handleSlashCommand = async (params: { + command: SlackCommandMiddlewareArgs["command"]; + ack: SlackCommandMiddlewareArgs["ack"]; + respond: SlackCommandMiddlewareArgs["respond"]; + prompt: string; + }) => { + const { command, ack, respond, prompt } = params; + try { + if (!prompt.trim()) { + await ack({ + text: "Message required.", + response_type: "ephemeral", + }); + return; + } + await ack(); - if (botUserId && command.user_id === botUserId) return; + if (botUserId && command.user_id === botUserId) return; - const channelInfo = await resolveChannelName(command.channel_id); - const channelType = - channelInfo?.type ?? - (command.channel_name === "directmessage" ? "im" : undefined); - const isDirectMessage = channelType === "im"; - const isGroupDm = channelType === "mpim"; - const isRoom = channelType === "channel" || channelType === "group"; + const channelInfo = await resolveChannelName(command.channel_id); + const channelType = + channelInfo?.type ?? + (command.channel_name === "directmessage" ? "im" : undefined); + const isDirectMessage = channelType === "im"; + const isGroupDm = channelType === "mpim"; + const isRoom = channelType === "channel" || channelType === "group"; - if (isDirectMessage && !dmEnabled) { - await respond({ - text: "Slack DMs are disabled.", - response_type: "ephemeral", - }); - return; - } - if (isGroupDm && !groupDmEnabled) { - await respond({ - text: "Slack group DMs are disabled.", - response_type: "ephemeral", - }); - return; - } - if (isGroupDm && groupDmChannels.length > 0) { - const allowList = normalizeAllowListLower(groupDmChannels); - const channelName = channelInfo?.name; - const candidates = [ - command.channel_id, - channelName ? `#${channelName}` : undefined, - channelName, - channelName ? normalizeSlackSlug(channelName) : undefined, - ] - .filter((value): value is string => Boolean(value)) - .map((value) => value.toLowerCase()); - const permitted = - allowList.includes("*") || - candidates.some((candidate) => allowList.includes(candidate)); - if (!permitted) { + if (isDirectMessage && !dmEnabled) { + await respond({ + text: "Slack DMs are disabled.", + response_type: "ephemeral", + }); + return; + } + if (isGroupDm && !groupDmEnabled) { + await respond({ + text: "Slack group DMs are disabled.", + response_type: "ephemeral", + }); + return; + } + if (isGroupDm && groupDmChannels.length > 0) { + const allowList = normalizeAllowListLower(groupDmChannels); + const channelName = channelInfo?.name; + const candidates = [ + command.channel_id, + channelName ? `#${channelName}` : undefined, + channelName, + channelName ? normalizeSlackSlug(channelName) : undefined, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()); + const permitted = + allowList.includes("*") || + candidates.some((candidate) => allowList.includes(candidate)); + if (!permitted) { + await respond({ + text: "This group DM is not allowed.", + response_type: "ephemeral", + }); + return; + } + } + + const storeAllowFrom = await readProviderAllowFromStore("slack").catch( + () => [], + ); + const effectiveAllowFrom = normalizeAllowList([ + ...allowFrom, + ...storeAllowFrom, + ]); + const effectiveAllowFromLower = + normalizeAllowListLower(effectiveAllowFrom); + + let commandAuthorized = true; + if (isDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { + await respond({ + text: "Slack DMs are disabled.", + response_type: "ephemeral", + }); + return; + } + if (dmPolicy !== "open") { + const sender = await resolveUserName(command.user_id); + const senderName = sender?.name ?? undefined; + const permitted = allowListMatches({ + allowList: effectiveAllowFromLower, + id: command.user_id, + name: senderName, + }); + if (!permitted) { + if (dmPolicy === "pairing") { + const { code } = await upsertProviderPairingRequest({ + provider: "slack", + id: command.user_id, + meta: { name: senderName }, + }); await respond({ - text: "This group DM is not allowed.", + text: [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider slack ", + ].join("\n"), response_type: "ephemeral", }); - return; - } - } - - if (isDirectMessage && allowFrom.length > 0) { - const sender = await resolveUserName(command.user_id); - const permitted = allowListMatches({ - allowList: normalizeAllowListLower(allowFrom), - id: command.user_id, - name: sender?.name ?? undefined, - }); - if (!permitted) { + } else { await respond({ text: "You are not authorized to use this command.", response_type: "ephemeral", }); - return; } + return; } + commandAuthorized = true; + } + } - if (isRoom) { - const channelConfig = resolveSlackChannelConfig({ - channelId: command.channel_id, - channelName: channelInfo?.name, - channels: channelsConfig, - }); - if (channelConfig?.allowed === false) { - await respond({ - text: "This channel is not allowed.", - response_type: "ephemeral", - }); - return; - } - } - - const sender = await resolveUserName(command.user_id); - const senderName = - sender?.name ?? command.user_name ?? command.user_id; - const channelName = channelInfo?.name; - const roomLabel = channelName - ? `#${channelName}` - : `#${command.channel_id}`; - const isRoomish = isRoom || isGroupDm; - - const ctxPayload = { - Body: prompt, - From: isDirectMessage - ? `slack:${command.user_id}` - : isRoom - ? `slack:channel:${command.channel_id}` - : `slack:group:${command.channel_id}`, - To: `slash:${command.user_id}`, - ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", - GroupSubject: isRoomish ? roomLabel : undefined, - SenderName: senderName, - Surface: "slack" as const, - WasMentioned: true, - MessageSid: command.trigger_id, - Timestamp: Date.now(), - SessionKey: `${slashCommand.sessionPrefix}:${command.user_id}`, - }; - - const replyResult = await getReplyFromConfig( - ctxPayload, - undefined, - cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - - await deliverSlackSlashReplies({ - replies, - respond, - ephemeral: slashCommand.ephemeral, - textLimit, - }); - } catch (err) { - runtime.error?.(danger(`slack slash handler failed: ${String(err)}`)); + if (isRoom) { + const channelConfig = resolveSlackChannelConfig({ + channelId: command.channel_id, + channelName: channelInfo?.name, + channels: channelsConfig, + }); + if ( + useAccessGroups && + !isSlackRoomAllowedByPolicy({ + groupPolicy, + channelAllowlistConfigured: + Boolean(channelsConfig) && + Object.keys(channelsConfig ?? {}).length > 0, + channelAllowed: channelConfig?.allowed !== false, + }) + ) { await respond({ - text: "Sorry, something went wrong handling that command.", + text: "This channel is not allowed.", response_type: "ephemeral", }); + return; } + if (useAccessGroups && channelConfig?.allowed === false) { + await respond({ + text: "This channel is not allowed.", + response_type: "ephemeral", + }); + return; + } + } + + const sender = await resolveUserName(command.user_id); + const senderName = sender?.name ?? command.user_name ?? command.user_id; + const channelName = channelInfo?.name; + const roomLabel = channelName + ? `#${channelName}` + : `#${command.channel_id}`; + const isRoomish = isRoom || isGroupDm; + const route = resolveAgentRoute({ + cfg, + provider: "slack", + teamId: teamId || undefined, + peer: { + kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group", + id: isDirectMessage ? command.user_id : command.channel_id, + }, + }); + + const ctxPayload = { + Body: prompt, + From: isDirectMessage + ? `slack:${command.user_id}` + : isRoom + ? `slack:channel:${command.channel_id}` + : `slack:group:${command.channel_id}`, + To: `slash:${command.user_id}`, + ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", + GroupSubject: isRoomish ? roomLabel : undefined, + SenderName: senderName, + SenderId: command.user_id, + Provider: "slack" as const, + Surface: "slack" as const, + WasMentioned: true, + MessageSid: command.trigger_id, + Timestamp: Date.now(), + SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`, + AccountId: route.accountId, + CommandSource: "native" as const, + CommandAuthorized: commandAuthorized, + }; + + const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + + await deliverSlackSlashReplies({ + replies, + respond, + ephemeral: slashCommand.ephemeral, + textLimit, + }); + } catch (err) { + runtime.error?.(danger(`slack slash handler failed: ${String(err)}`)); + await respond({ + text: "Sorry, something went wrong handling that command.", + response_type: "ephemeral", + }); + } + }; + + const nativeCommands = + cfg.commands?.native === true ? listNativeCommandSpecs() : []; + if (nativeCommands.length > 0) { + for (const command of nativeCommands) { + app.command( + `/${command.name}`, + async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => { + const prompt = buildCommandText(command.name, cmd.text); + await handleSlashCommand({ command: cmd, ack, respond, prompt }); + }, + ); + } + } else if (slashCommand.enabled) { + app.command( + slashCommand.name, + async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => { + await handleSlashCommand({ + command, + ack, + respond, + prompt: command.text?.trim() ?? "", + }); }, ); } @@ -1401,7 +1651,7 @@ async function deliverReplies(params: { if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { - for (const chunk of chunkText(text, chunkLimit)) { + for (const chunk of chunkMarkdownText(text, chunkLimit)) { const trimmed = chunk.trim(); if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue; await sendMessageSlack(params.target, trimmed, { @@ -1430,6 +1680,18 @@ type SlackRespondFn = (payload: { response_type?: "ephemeral" | "in_channel"; }) => Promise; +export function isSlackRoomAllowedByPolicy(params: { + groupPolicy: "open" | "disabled" | "allowlist"; + channelAllowlistConfigured: boolean; + channelAllowed: boolean; +}): boolean { + const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + if (!channelAllowlistConfigured) return false; + return channelAllowed; +} + async function deliverSlackSlashReplies(params: { replies: ReplyPayload[]; respond: SlackRespondFn; @@ -1451,7 +1713,7 @@ async function deliverSlackSlashReplies(params: { .filter(Boolean) .join("\n"); if (!combined) continue; - for (const chunk of chunkText(combined, chunkLimit)) { + for (const chunk of chunkMarkdownText(combined, chunkLimit)) { messages.push(chunk); } } diff --git a/src/slack/send.ts b/src/slack/send.ts index 3f1970f10..b53a0d4e2 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -1,6 +1,9 @@ import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api"; -import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { + chunkMarkdownText, + resolveTextChunkLimit, +} from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; import { loadWebMedia } from "../web/media.js"; import { resolveSlackBotToken } from "./token.js"; @@ -144,7 +147,7 @@ export async function sendMessageSlack( const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "slack"); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); - const chunks = chunkText(trimmedMessage, chunkLimit); + const chunks = chunkMarkdownText(trimmedMessage, chunkLimit); const mediaMaxBytes = typeof cfg.slack?.mediaMaxMb === "number" ? cfg.slack.mediaMaxMb * 1024 * 1024 diff --git a/src/telegram/bot.media.test.ts b/src/telegram/bot.media.test.ts index 0157d0831..068f0fa7c 100644 --- a/src/telegram/bot.media.test.ts +++ b/src/telegram/bot.media.test.ts @@ -35,10 +35,18 @@ vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadConfig: () => ({}), + loadConfig: () => ({ telegram: { dmPolicy: "open", allowFrom: ["*"] } }), }; }); +vi.mock("./pairing-store.js", () => ({ + readTelegramAllowFromStore: vi.fn(async () => [] as string[]), + upsertTelegramPairingRequest: vi.fn(async () => ({ + code: "PAIRCODE", + created: true, + })), +})); + vi.mock("../auto-reply/reply.js", () => { const replySpy = vi.fn(async (_ctx, opts) => { await opts?.onReplyStart?.(); @@ -209,3 +217,216 @@ describe("telegram inbound media", () => { fetchSpy.mockRestore(); }); }); + +describe("telegram media groups", () => { + const waitForMediaGroupProcessing = () => + new Promise((resolve) => setTimeout(resolve, 600)); + + it("buffers messages with same media_group_id and processes them together", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + + const runtimeError = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch" as never).mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/png" }, + arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, + } as Response); + + createTelegramBot({ + token: "tok", + runtime: { + log: vi.fn(), + error: runtimeError, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 1, + caption: "Here are my photos", + date: 1736380800, + media_group_id: "album123", + photo: [{ file_id: "photo1" }], + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "photos/photo1.jpg" }), + }); + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 2, + date: 1736380801, + media_group_id: "album123", + photo: [{ file_id: "photo2" }], + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "photos/photo2.jpg" }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + await waitForMediaGroupProcessing(); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("Here are my photos"); + expect(payload.MediaPaths).toHaveLength(2); + + fetchSpy.mockRestore(); + }, 2000); + + it("processes separate media groups independently", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + + const fetchSpy = vi.spyOn(globalThis, "fetch" as never).mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/png" }, + arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, + } as Response); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 1, + caption: "Album A", + date: 1736380800, + media_group_id: "albumA", + photo: [{ file_id: "photoA1" }], + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "photos/photoA1.jpg" }), + }); + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 2, + caption: "Album B", + date: 1736380801, + media_group_id: "albumB", + photo: [{ file_id: "photoB1" }], + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "photos/photoB1.jpg" }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + await waitForMediaGroupProcessing(); + + expect(replySpy).toHaveBeenCalledTimes(2); + + fetchSpy.mockRestore(); + }, 2000); +}); + +describe("telegram location parsing", () => { + it("includes location text and ctx fields for pins", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0]?.[1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 5, + caption: "Meet here", + date: 1736380800, + location: { + latitude: 48.858844, + longitude: 2.294351, + horizontal_accuracy: 12, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "unused" }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("Meet here"); + expect(payload.Body).toContain("48.858844"); + expect(payload.LocationLat).toBe(48.858844); + expect(payload.LocationLon).toBe(2.294351); + expect(payload.LocationSource).toBe("pin"); + expect(payload.LocationIsLive).toBe(false); + }); + + it("captures venue fields for named places", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0]?.[1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 6, + date: 1736380800, + venue: { + title: "Eiffel Tower", + address: "Champ de Mars, Paris", + location: { latitude: 48.858844, longitude: 2.294351 }, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "unused" }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("Eiffel Tower"); + expect(payload.LocationName).toBe("Eiffel Tower"); + expect(payload.LocationAddress).toBe("Champ de Mars, Paris"); + expect(payload.LocationSource).toBe("place"); + }); +}); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index b74a25a76..e9553508e 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -21,16 +21,35 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted( + () => ({ + readTelegramAllowFromStore: vi.fn(async () => [] as string[]), + upsertTelegramPairingRequest: vi.fn(async () => ({ + code: "PAIRCODE", + created: true, + })), + }), +); + +vi.mock("./pairing-store.js", () => ({ + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +})); + const useSpy = vi.fn(); const onSpy = vi.fn(); const stopSpy = vi.fn(); const sendChatActionSpy = vi.fn(); +const setMessageReactionSpy = vi.fn(async () => undefined); +const setMyCommandsSpy = vi.fn(async () => undefined); const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); type ApiStub = { config: { use: (arg: unknown) => void }; sendChatAction: typeof sendChatActionSpy; + setMessageReaction: typeof setMessageReactionSpy; + setMyCommands: typeof setMyCommandsSpy; sendMessage: typeof sendMessageSpy; sendAnimation: typeof sendAnimationSpy; sendPhoto: typeof sendPhotoSpy; @@ -38,6 +57,8 @@ type ApiStub = { const apiStub: ApiStub = { config: { use: useSpy }, sendChatAction: sendChatActionSpy, + setMessageReaction: setMessageReactionSpy, + setMyCommands: setMyCommandsSpy, sendMessage: sendMessageSpy, sendAnimation: sendAnimationSpy, sendPhoto: sendPhotoSpy, @@ -70,10 +91,14 @@ vi.mock("../auto-reply/reply.js", () => { describe("createTelegramBot", () => { beforeEach(() => { - loadConfig.mockReturnValue({}); + loadConfig.mockReturnValue({ + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }); loadWebMedia.mockReset(); sendAnimationSpy.mockReset(); sendPhotoSpy.mockReset(); + setMessageReactionSpy.mockReset(); + setMyCommandsSpy.mockReset(); }); it("installs grammY throttler", () => { @@ -126,6 +151,46 @@ describe("createTelegramBot", () => { } }); + it("requests pairing by default for unknown DM senders", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ telegram: { dmPolicy: "pairing" } }); + readTelegramAllowFromStore.mockResolvedValue([]); + upsertTelegramPairingRequest.mockResolvedValue({ + code: "PAIRME12", + created: true, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello", + date: 1736380800, + from: { id: 999, username: "random" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain( + "Pairing code:", + ); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("PAIRME12"); + }); + it("triggers typing cue via onReplyStart", async () => { onSpy.mockReset(); sendChatActionSpy.mockReset(); @@ -178,6 +243,52 @@ describe("createTelegramBot", () => { expect(payload.WasMentioned).toBe(true); }); + it("reacts to mention-gated group messages when ackReaction is enabled", async () => { + onSpy.mockReset(); + setMessageReactionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert hello", + date: 1736380800, + message_id: 123, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [ + { type: "emoji", emoji: "👀" }, + ]); + }); + + it("clears native commands when disabled", () => { + loadConfig.mockReturnValue({ + commands: { native: false }, + }); + + createTelegramBot({ token: "tok" }); + + expect(setMyCommandsSpy).toHaveBeenCalledWith([]); + }); + it("skips group messages when requireMention is enabled and no mention matches", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType< @@ -357,7 +468,10 @@ describe("createTelegramBot", () => { await opts?.onToolResult?.({ text: "tool result" }); return { text: "final reply" }; }); - loadConfig.mockReturnValue({ messages: { responsePrefix: "PFX" } }); + loadConfig.mockReturnValue({ + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + messages: { responsePrefix: "PFX" }, + }); createTelegramBot({ token: "tok" }); const handler = onSpy.mock.calls[0][1] as ( @@ -411,6 +525,38 @@ describe("createTelegramBot", () => { } }); + it("blocks group messages when telegram.groups is set without a wildcard", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groups: { + "123": { requireMention: false }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("skips group messages without mention when requireMention is enabled", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType< @@ -565,11 +711,565 @@ describe("createTelegramBot", () => { }); expect(sendAnimationSpy).toHaveBeenCalledTimes(1); - expect(sendAnimationSpy).toHaveBeenCalledWith( - "1234", - expect.anything(), - { caption: "caption", reply_to_message_id: undefined }, - ); + expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), { + caption: "caption", + reply_to_message_id: undefined, + }); expect(sendPhotoSpy).not.toHaveBeenCalled(); }); + + // groupPolicy tests + it("blocks all group messages when groupPolicy is 'disabled'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "disabled", + allowFrom: ["123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should NOT call getReplyFromConfig because groupPolicy is disabled + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], // Does not include sender 999999 + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "notallowed" }, // Not in allowFrom + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + groups: { "*": { requireMention: false } }, // Skip mention check + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // In allowFrom + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from senders in allowFrom (by username) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@testuser"], // By username + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, // Username matches @testuser + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from telegram:-prefixed allowFrom entries when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:77112533"], + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from tg:-prefixed allowFrom entries case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:77112533"], + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows all group messages when groupPolicy is 'open' (default)", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + // groupPolicy not set, should default to "open" + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, // Random sender + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("matches usernames case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@TestUser"], // Uppercase in config + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, // Lowercase in message + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages regardless of groupPolicy", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "disabled", // Even with disabled, DMs should work + allowFrom: ["123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + allowFrom: [" TG:123456789 "], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with telegram:-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + allowFrom: ["telegram:123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["*"], // Wildcard allows everyone + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, // Random sender, but wildcard allows + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("blocks group messages with no sender ID when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + // No `from` field (e.g., channel post or anonymous admin) + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("matches telegram:-prefixed allowFrom entries in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:123456789"], // Prefixed format + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // Matches after stripping prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping telegram: prefix + expect(replySpy).toHaveBeenCalled(); + }); + + it("matches tg:-prefixed allowFrom entries case-insensitively in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:123456789"], // Prefixed format (case-insensitive) + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // Matches after stripping tg: prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping tg: prefix + expect(replySpy).toHaveBeenCalled(); + }); + + it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows control commands with TG-prefixed groupAllowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: [" TG:123456789 "], + groups: { "*": { requireMention: true } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "/status", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 952550148..296f5b15c 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -4,18 +4,29 @@ import { Buffer } from "node:buffer"; import { apiThrottler } from "@grammyjs/transformer-throttler"; import type { ApiClientOptions, Message } from "grammy"; import { Bot, InputFile, webhookCallback } from "grammy"; -import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { + chunkMarkdownText, + resolveTextChunkLimit, +} from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; +import { + buildCommandText, + listNativeCommandSpecs, +} from "../auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { buildMentionRegexes, matchesMentionPatterns, } from "../auto-reply/reply/mentions.js"; -import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; -import { getReplyFromConfig } from "../auto-reply/reply.js"; +import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { + resolveProviderGroupPolicy, + resolveProviderGroupRequireMention, +} from "../config/group-policy.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -23,14 +34,56 @@ import { getChildLogger } from "../logging.js"; import { mediaKindFromMime } from "../media/constants.js"; import { detectMime, isGifMedia } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + formatLocationText, + type NormalizedLocation, + toLocationContext, +} from "../providers/location.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; +import { + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +} from "./pairing-store.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; +// Media group aggregation - Telegram sends multi-image messages as separate updates +// with a shared media_group_id. We buffer them and process as a single message after a short delay. +const MEDIA_GROUP_TIMEOUT_MS = 500; + type TelegramMessage = Message.CommonMessage; +type MediaGroupEntry = { + messages: Array<{ + msg: TelegramMessage; + ctx: TelegramContext; + }>; + timer: ReturnType; +}; + +/** Telegram Location object */ +interface TelegramLocation { + latitude: number; + longitude: number; + horizontal_accuracy?: number; + live_period?: number; + heading?: number; +} + +/** Telegram Venue object */ +interface TelegramVenue { + location: TelegramLocation; + title: string; + address: string; + foursquare_id?: string; + foursquare_type?: string; + google_place_id?: string; + google_place_type?: string; +} + type TelegramContext = { message: TelegramMessage; me?: { username?: string }; @@ -44,6 +97,7 @@ export type TelegramBotOptions = { runtime?: RuntimeEnv; requireMention?: boolean; allowFrom?: Array; + groupAllowFrom?: Array; mediaMaxMb?: number; replyToMode?: ReplyToMode; proxyFetch?: typeof fetch; @@ -64,178 +118,340 @@ export function createTelegramBot(opts: TelegramBotOptions) { const bot = new Bot(opts.token, { client }); bot.api.config.use(apiThrottler()); + const mediaGroupBuffer = new Map(); + const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "telegram"); + const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing"; const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; + const groupAllowFrom = + opts.groupAllowFrom ?? + cfg.telegram?.groupAllowFrom ?? + (cfg.telegram?.allowFrom && cfg.telegram.allowFrom.length > 0 + ? cfg.telegram.allowFrom + : undefined) ?? + (opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined); + const normalizeAllowFrom = (list?: Array) => { + const entries = (list ?? []) + .map((value) => String(value).trim()) + .filter(Boolean); + const hasWildcard = entries.includes("*"); + const normalized = entries + .filter((value) => value !== "*") + .map((value) => value.replace(/^(telegram|tg):/i, "")); + const normalizedLower = normalized.map((value) => value.toLowerCase()); + return { + entries: normalized, + entriesLower: normalizedLower, + hasWildcard, + hasEntries: entries.length > 0, + }; + }; + const isSenderAllowed = (params: { + allow: ReturnType; + senderId?: string; + senderUsername?: string; + }) => { + const { allow, senderId, senderUsername } = params; + if (!allow.hasEntries) return true; + if (allow.hasWildcard) return true; + if (senderId && allow.entries.includes(senderId)) return true; + const username = senderUsername?.toLowerCase(); + if (!username) return false; + return allow.entriesLower.some( + (entry) => entry === username || entry === `@${username}`, + ); + }; const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off"; + const nativeEnabled = cfg.commands?.native === true; + const nativeDisabledExplicit = cfg.commands?.native === false; + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024; const logger = getChildLogger({ module: "telegram-auto-reply" }); const mentionRegexes = buildMentionRegexes(cfg); - const resolveGroupRequireMention = (chatId: string | number) => { - const groupId = String(chatId); - const groupConfig = cfg.telegram?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - if (typeof opts.requireMention === "boolean") return opts.requireMention; - return true; - }; + const resolveGroupPolicy = (chatId: string | number) => + resolveProviderGroupPolicy({ + cfg, + provider: "telegram", + groupId: String(chatId), + }); + const resolveGroupRequireMention = (chatId: string | number) => + resolveProviderGroupRequireMention({ + cfg, + provider: "telegram", + groupId: String(chatId), + requireMentionOverride: opts.requireMention, + overrideOrder: "after-config", + }); - bot.on("message", async (ctx) => { - try { - const msg = ctx.message; - if (!msg) return; - const chatId = msg.chat.id; - const isGroup = - msg.chat.type === "group" || msg.chat.type === "supergroup"; + const processMessage = async ( + primaryCtx: TelegramContext, + allMedia: Array<{ path: string; contentType?: string }>, + storeAllowFrom: string[], + ) => { + const msg = primaryCtx.message; + const chatId = msg.chat.id; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const effectiveDmAllow = normalizeAllowFrom([ + ...(allowFrom ?? []), + ...storeAllowFrom, + ]); + const effectiveGroupAllow = normalizeAllowFrom([ + ...(groupAllowFrom ?? []), + ...storeAllowFrom, + ]); - const sendTyping = async () => { - try { - await bot.api.sendChatAction(chatId, "typing"); - } catch (err) { - logVerbose( - `telegram typing cue failed for chat ${chatId}: ${String(err)}`, - ); - } - }; + const sendTyping = async () => { + try { + await bot.api.sendChatAction(chatId, "typing"); + } catch (err) { + logVerbose( + `telegram typing cue failed for chat ${chatId}: ${String(err)}`, + ); + } + }; - // allowFrom for direct chats - if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) { + // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled" + if (!isGroup) { + if (dmPolicy === "disabled") return; + + if (dmPolicy !== "open") { const candidate = String(chatId); - const allowed = allowFrom.map(String); - const allowedWithPrefix = allowFrom.map((v) => `telegram:${String(v)}`); - const permitted = - allowed.includes(candidate) || - allowedWithPrefix.includes(`telegram:${candidate}`) || - allowed.includes("*"); - if (!permitted) { - logVerbose( - `Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`, - ); + const senderUsername = msg.from?.username ?? ""; + const allowed = + effectiveDmAllow.hasWildcard || + (effectiveDmAllow.hasEntries && + isSenderAllowed({ + allow: effectiveDmAllow, + senderId: candidate, + senderUsername, + })); + if (!allowed) { + if (dmPolicy === "pairing") { + try { + const from = msg.from as + | { + first_name?: string; + last_name?: string; + username?: string; + } + | undefined; + const { code } = await upsertTelegramPairingRequest({ + chatId: candidate, + username: from?.username, + firstName: from?.first_name, + lastName: from?.last_name, + }); + logger.info( + { + chatId: candidate, + username: from?.username, + firstName: from?.first_name, + lastName: from?.last_name, + code, + }, + "telegram pairing request", + ); + await bot.api.sendMessage( + chatId, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot telegram pairing approve ", + ].join("\n"), + ); + } catch (err) { + logVerbose( + `telegram pairing reply failed for chat ${chatId}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy})`, + ); + } return; } } + } - const botUsername = ctx.me?.username?.toLowerCase(); - const allowFromList = Array.isArray(allowFrom) - ? allowFrom.map((entry) => String(entry).trim()).filter(Boolean) - : []; - const senderId = msg.from?.id ? String(msg.from.id) : ""; - const senderUsername = msg.from?.username ?? ""; - const commandAuthorized = - allowFromList.length === 0 || - allowFromList.includes("*") || - (senderId && allowFromList.includes(senderId)) || - (senderId && allowFromList.includes(`telegram:${senderId}`)) || - (senderUsername && - allowFromList.some( - (entry) => - entry.toLowerCase() === senderUsername.toLowerCase() || - entry.toLowerCase() === `@${senderUsername.toLowerCase()}`, - )); - const wasMentioned = - (Boolean(botUsername) && hasBotMention(msg, botUsername)) || - matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes); - const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( - (ent) => ent.type === "mention", - ); - const shouldBypassMention = - isGroup && - resolveGroupRequireMention(chatId) && - !wasMentioned && - !hasAnyMention && - commandAuthorized && - hasControlCommand(msg.text ?? msg.caption ?? ""); - const canDetectMention = - Boolean(botUsername) || mentionRegexes.length > 0; - if (isGroup && resolveGroupRequireMention(chatId) && canDetectMention) { - if (!wasMentioned && !shouldBypassMention) { - logger.info( - { chatId, reason: "no-mention" }, - "skipping group message", - ); - return; - } + const botUsername = primaryCtx.me?.username?.toLowerCase(); + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const senderUsername = msg.from?.username ?? ""; + const commandAuthorized = isSenderAllowed({ + allow: isGroup ? effectiveGroupAllow : effectiveDmAllow, + senderId, + senderUsername, + }); + const wasMentioned = + (Boolean(botUsername) && hasBotMention(msg, botUsername)) || + matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes); + const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( + (ent) => ent.type === "mention", + ); + const requireMention = resolveGroupRequireMention(chatId); + const shouldBypassMention = + isGroup && + requireMention && + !wasMentioned && + !hasAnyMention && + commandAuthorized && + hasControlCommand(msg.text ?? msg.caption ?? ""); + const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; + if (isGroup && requireMention && canDetectMention) { + if (!wasMentioned && !shouldBypassMention) { + logger.info({ chatId, reason: "no-mention" }, "skipping group message"); + return; } + } - const media = await resolveMedia( - ctx, - mediaMaxBytes, - opts.token, - opts.proxyFetch, - ); - const replyTarget = describeReplyTarget(msg); - const rawBody = ( - msg.text ?? - msg.caption ?? - media?.placeholder ?? - "" - ).trim(); - if (!rawBody) return; - const replySuffix = replyTarget - ? `\n\n[Replying to ${replyTarget.sender}${replyTarget.id ? ` id:${replyTarget.id}` : ""}]\n${replyTarget.body}\n[/Replying]` - : ""; - const body = formatAgentEnvelope({ - surface: "Telegram", - from: isGroup - ? buildGroupLabel(msg, chatId) - : buildSenderLabel(msg, chatId), - timestamp: msg.date ? msg.date * 1000 : undefined, - body: `${rawBody}${replySuffix}`, - }); - - const ctxPayload = { - Body: body, - From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, - To: `telegram:${chatId}`, - ChatType: isGroup ? "group" : "direct", - GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, - SenderName: buildSenderName(msg), - SenderId: senderId || undefined, - SenderUsername: senderUsername || undefined, - Surface: "telegram", - MessageSid: String(msg.message_id), - ReplyToId: replyTarget?.id, - ReplyToBody: replyTarget?.body, - ReplyToSender: replyTarget?.sender, - Timestamp: msg.date ? msg.date * 1000 : undefined, - WasMentioned: isGroup ? wasMentioned : undefined, - MediaPath: media?.path, - MediaType: media?.contentType, - MediaUrl: media?.path, - CommandAuthorized: commandAuthorized, + // ACK reactions + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return !isGroup; + if (ackReactionScope === "group-all") return isGroup; + if (ackReactionScope === "group-mentions") { + if (!isGroup) return false; + if (!requireMention) return false; + if (!canDetectMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction() && msg.message_id) { + const api = bot.api as unknown as { + setMessageReaction?: ( + chatId: number | string, + messageId: number, + reactions: Array<{ type: "emoji"; emoji: string }>, + ) => Promise; }; - - if (replyTarget && shouldLogVerbose()) { - const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120); - logVerbose( - `telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`, - ); + if (typeof api.setMessageReaction === "function") { + api + .setMessageReaction(chatId, msg.message_id, [ + { type: "emoji", emoji: ackReaction }, + ]) + .catch((err) => { + logVerbose( + `telegram react failed for chat ${chatId}: ${String(err)}`, + ); + }); } + } - if (!isGroup) { - const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); - await updateLastRoute({ - storePath, - sessionKey: mainKey, - channel: "telegram", - to: String(chatId), - }); - } + let placeholder = ""; + if (msg.photo) placeholder = ""; + else if (msg.video) placeholder = ""; + else if (msg.audio || msg.voice) placeholder = ""; + else if (msg.document) placeholder = ""; - if (shouldLogVerbose()) { - const preview = body.slice(0, 200).replace(/\n/g, "\\n"); - logVerbose( - `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length} preview="${preview}"`, - ); - } + const replyTarget = describeReplyTarget(msg); + const locationData = extractTelegramLocation(msg); + const locationText = locationData + ? formatLocationText(locationData) + : undefined; + const rawText = (msg.text ?? msg.caption ?? "").trim(); + let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim(); + if (!rawBody) rawBody = placeholder; + if (!rawBody && allMedia.length === 0) return; - const dispatcher = createReplyDispatcher({ + let bodyText = rawBody; + if (!bodyText && allMedia.length > 0) { + bodyText = `${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`; + } + + const replySuffix = replyTarget + ? `\n\n[Replying to ${replyTarget.sender}${ + replyTarget.id ? ` id:${replyTarget.id}` : "" + }]\n${replyTarget.body}\n[/Replying]` + : ""; + const body = formatAgentEnvelope({ + provider: "Telegram", + from: isGroup + ? buildGroupLabel(msg, chatId) + : buildSenderLabel(msg, chatId), + timestamp: msg.date ? msg.date * 1000 : undefined, + body: `${bodyText}${replySuffix}`, + }); + + const route = resolveAgentRoute({ + cfg, + provider: "telegram", + peer: { + kind: isGroup ? "group" : "dm", + id: String(chatId), + }, + }); + const ctxPayload = { + Body: body, + From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, + To: `telegram:${chatId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, + SenderName: buildSenderName(msg), + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + Provider: "telegram", + MessageSid: String(msg.message_id), + ReplyToId: replyTarget?.id, + ReplyToBody: replyTarget?.body, + ReplyToSender: replyTarget?.sender, + Timestamp: msg.date ? msg.date * 1000 : undefined, + WasMentioned: isGroup ? wasMentioned : undefined, + MediaPath: allMedia[0]?.path, + MediaType: allMedia[0]?.contentType, + MediaUrl: allMedia[0]?.path, + MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, + MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, + MediaTypes: + allMedia.length > 0 + ? (allMedia.map((m) => m.contentType).filter(Boolean) as string[]) + : undefined, + ...(locationData ? toLocationContext(locationData) : undefined), + CommandAuthorized: commandAuthorized, + }; + + if (replyTarget && shouldLogVerbose()) { + const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120); + logVerbose( + `telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`, + ); + } + + if (!isGroup) { + const sessionCfg = cfg.session; + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); + await updateLastRoute({ + storePath, + sessionKey: route.mainSessionKey, + provider: "telegram", + to: String(chatId), + accountId: route.accountId, + }); + } + + if (shouldLogVerbose()) { + const preview = body.slice(0, 200).replace(/\n/g, "\\n"); + const mediaInfo = + allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : ""; + logVerbose( + `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`, + ); + } + + const { dispatcher, replyOptions, markDispatchIdle } = + createReplyDispatcherWithTyping({ responsePrefix: cfg.messages?.responsePrefix, deliver: async (payload) => { await deliverReplies({ @@ -253,33 +469,303 @@ export function createTelegramBot(opts: TelegramBotOptions) { danger(`telegram ${info.kind} reply failed: ${String(err)}`), ); }, + onReplyStart: sendTyping, }); - const replyResult = await getReplyFromConfig( - ctxPayload, - { - onReplyStart: sendTyping, - onToolResult: dispatcher.sendToolResult, - onBlockReply: dispatcher.sendBlockReply, - }, - cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; + const { queuedFinal } = await dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }); + markDispatchIdle(); + if (!queuedFinal) return; + }; + + const nativeCommands = nativeEnabled ? listNativeCommandSpecs() : []; + if (nativeCommands.length > 0) { + bot.api + .setMyCommands( + nativeCommands.map((command) => ({ + command: command.name, + description: command.description, + })), + ) + .catch((err) => { + runtime.error?.( + danger(`telegram setMyCommands failed: ${String(err)}`), + ); + }); + + for (const command of nativeCommands) { + bot.command(command.name, async (ctx) => { + const msg = ctx.message; + if (!msg) return; + const chatId = msg.chat.id; + const isGroup = + msg.chat.type === "group" || msg.chat.type === "supergroup"; + + if (isGroup && useAccessGroups) { + const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + if (groupPolicy === "disabled") { + await bot.api.sendMessage( + chatId, + "Telegram group commands are disabled.", + ); + return; + } + if (groupPolicy === "allowlist") { + const senderId = msg.from?.id; + if (senderId == null) { + await bot.api.sendMessage( + chatId, + "You are not authorized to use this command.", + ); + return; + } + const senderUsername = msg.from?.username ?? ""; + if ( + !isSenderAllowed({ + allow: groupAllow, + senderId: String(senderId), + senderUsername, + }) + ) { + await bot.api.sendMessage( + chatId, + "You are not authorized to use this command.", + ); + return; + } + } + const groupAllowlist = resolveGroupPolicy(chatId); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { + await bot.api.sendMessage(chatId, "This group is not allowed."); + return; + } + } + + const allowFromList = Array.isArray(allowFrom) + ? allowFrom.map((entry) => String(entry).trim()).filter(Boolean) + : []; + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const senderUsername = msg.from?.username ?? ""; + const commandAuthorized = + allowFromList.length === 0 || + allowFromList.includes("*") || + (senderId && allowFromList.includes(senderId)) || + (senderId && allowFromList.includes(`telegram:${senderId}`)) || + (senderUsername && + allowFromList.some( + (entry) => + entry.toLowerCase() === senderUsername.toLowerCase() || + entry.toLowerCase() === `@${senderUsername.toLowerCase()}`, + )); + if (!commandAuthorized) { + await bot.api.sendMessage( + chatId, + "You are not authorized to use this command.", + ); + return; + } + + const prompt = buildCommandText(command.name, ctx.match ?? ""); + const ctxPayload = { + Body: prompt, + From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, + To: `slash:${senderId || chatId}`, + ChatType: isGroup ? "group" : "direct", + GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, + SenderName: buildSenderName(msg), + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + Surface: "telegram", + MessageSid: String(msg.message_id), + Timestamp: msg.date ? msg.date * 1000 : undefined, + WasMentioned: true, + CommandAuthorized: commandAuthorized, + CommandSource: "native" as const, + SessionKey: `telegram:slash:${senderId || chatId}`, + }; + + const replyResult = await getReplyFromConfig( + ctxPayload, + undefined, + cfg, + ); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + await deliverReplies({ + replies, + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + }); + }); + } + } else if (nativeDisabledExplicit) { + bot.api.setMyCommands([]).catch((err) => { + runtime.error?.(danger(`telegram clear commands failed: ${String(err)}`)); + }); + } + + bot.on("message", async (ctx) => { + try { + const msg = ctx.message; + if (!msg) return; + + const chatId = msg.chat.id; + const isGroup = + msg.chat.type === "group" || msg.chat.type === "supergroup"; + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + + if (isGroup) { + // Group policy filtering: controls how group messages are handled + // - "open" (default): groups bypass allowFrom, only mention-gating applies + // - "disabled": block all group messages entirely + // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + if (groupPolicy === "disabled") { + logVerbose(`Blocked telegram group message (groupPolicy: disabled)`); + return; + } + if (groupPolicy === "allowlist") { + const effectiveGroupAllow = normalizeAllowFrom([ + ...(groupAllowFrom ?? []), + ...storeAllowFrom, + ]); + // For allowlist mode, the sender (msg.from.id) must be in allowFrom + const senderId = msg.from?.id; + if (senderId == null) { + logVerbose( + `Blocked telegram group message (no sender ID, groupPolicy: allowlist)`, + ); + return; + } + if (!effectiveGroupAllow.hasEntries) { + logVerbose( + "Blocked telegram group message (groupPolicy: allowlist, no groupAllowFrom)", + ); + return; + } + const senderUsername = msg.from?.username ?? ""; + if ( + !isSenderAllowed({ + allow: effectiveGroupAllow, + senderId: String(senderId), + senderUsername, + }) + ) { + logVerbose( + `Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`, + ); + return; + } + } + + // Group allowlist based on configured group IDs. + const groupAllowlist = resolveGroupPolicy(chatId); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { + logger.info( + { chatId, title: msg.chat.title, reason: "not-allowed" }, + "skipping group message", + ); + return; + } } - await dispatcher.waitForIdle(); - if (!queuedFinal) return; + + // Media group handling - buffer multi-image messages + const mediaGroupId = (msg as { media_group_id?: string }).media_group_id; + if (mediaGroupId) { + const existing = mediaGroupBuffer.get(mediaGroupId); + if (existing) { + clearTimeout(existing.timer); + existing.messages.push({ msg, ctx }); + existing.timer = setTimeout(async () => { + mediaGroupBuffer.delete(mediaGroupId); + await processMediaGroup(existing); + }, MEDIA_GROUP_TIMEOUT_MS); + } else { + const entry: MediaGroupEntry = { + messages: [{ msg, ctx }], + timer: setTimeout(async () => { + mediaGroupBuffer.delete(mediaGroupId); + await processMediaGroup(entry); + }, MEDIA_GROUP_TIMEOUT_MS), + }; + mediaGroupBuffer.set(mediaGroupId, entry); + } + return; + } + + let media: Awaited> = null; + try { + media = await resolveMedia( + ctx, + mediaMaxBytes, + opts.token, + opts.proxyFetch, + ); + } catch (mediaErr) { + const errMsg = String(mediaErr); + if (errMsg.includes("exceeds") && errMsg.includes("MB limit")) { + const limitMb = Math.round(mediaMaxBytes / (1024 * 1024)); + await bot.api + .sendMessage( + chatId, + `⚠️ File too large. Maximum size is ${limitMb}MB.`, + { reply_to_message_id: msg.message_id }, + ) + .catch(() => {}); + logger.warn({ chatId, error: errMsg }, "media exceeds size limit"); + return; + } + throw mediaErr; + } + const allMedia = media + ? [{ path: media.path, contentType: media.contentType }] + : []; + await processMessage(ctx, allMedia, storeAllowFrom); } catch (err) { runtime.error?.(danger(`handler failed: ${String(err)}`)); } }); + const processMediaGroup = async (entry: MediaGroupEntry) => { + try { + entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id); + + const captionMsg = entry.messages.find( + (m) => m.msg.caption || m.msg.text, + ); + const primaryEntry = captionMsg ?? entry.messages[0]; + + const allMedia: Array<{ path: string; contentType?: string }> = []; + for (const { ctx } of entry.messages) { + const media = await resolveMedia( + ctx, + mediaMaxBytes, + opts.token, + opts.proxyFetch, + ); + if (media) { + allMedia.push({ path: media.path, contentType: media.contentType }); + } + } + + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom); + } catch (err) { + runtime.error?.(danger(`media group handler failed: ${String(err)}`)); + } + }; + return bot; } @@ -316,7 +802,7 @@ async function deliverReplies(params: { ? [reply.mediaUrl] : []; if (mediaList.length === 0) { - for (const chunk of chunkText(reply.text || "", textLimit)) { + for (const chunk of chunkMarkdownText(reply.text || "", textLimit)) { await sendTelegramText(bot, chatId, chunk, runtime, { replyToMessageId: replyToId && (replyToMode === "all" || !hasReplied) @@ -510,6 +996,10 @@ function describeReplyTarget(msg: TelegramMessage) { else if (reply.video) body = ""; else if (reply.audio || reply.voice) body = ""; else if (reply.document) body = ""; + else { + const locationData = extractTelegramLocation(reply); + if (locationData) body = formatLocationText(locationData); + } } if (!body) return null; const sender = buildSenderName(reply); @@ -520,3 +1010,39 @@ function describeReplyTarget(msg: TelegramMessage) { body, }; } + +function extractTelegramLocation( + msg: TelegramMessage, +): NormalizedLocation | null { + const msgWithLocation = msg as { + location?: TelegramLocation; + venue?: TelegramVenue; + }; + const { venue, location } = msgWithLocation; + + if (venue) { + return { + latitude: venue.location.latitude, + longitude: venue.location.longitude, + accuracy: venue.location.horizontal_accuracy, + name: venue.title, + address: venue.address, + source: "place", + isLive: false, + }; + } + + if (location) { + const isLive = + typeof location.live_period === "number" && location.live_period > 0; + return { + latitude: location.latitude, + longitude: location.longitude, + accuracy: location.horizontal_accuracy, + source: isLive ? "live" : "pin", + isLive, + }; + } + + return null; +} diff --git a/src/telegram/pairing-store.test.ts b/src/telegram/pairing-store.test.ts new file mode 100644 index 000000000..ce5e04b29 --- /dev/null +++ b/src/telegram/pairing-store.test.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + approveTelegramPairingCode, + listTelegramPairingRequests, + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +} from "./pairing-store.js"; + +async function withTempStateDir(fn: (stateDir: string) => Promise) { + const previous = process.env.CLAWDBOT_STATE_DIR; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-pairing-")); + process.env.CLAWDBOT_STATE_DIR = dir; + try { + return await fn(dir); + } finally { + if (previous === undefined) delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previous; + await fs.rm(dir, { recursive: true, force: true }); + } +} + +describe("telegram pairing store", () => { + it("creates pairing request and approves it into allow store", async () => { + await withTempStateDir(async () => { + const created = await upsertTelegramPairingRequest({ + chatId: "123456789", + username: "ada", + }); + expect(created.code).toBeTruthy(); + + const list = await listTelegramPairingRequests(); + expect(list).toHaveLength(1); + expect(list[0]?.chatId).toBe("123456789"); + expect(list[0]?.code).toBe(created.code); + + const approved = await approveTelegramPairingCode({ code: created.code }); + expect(approved?.chatId).toBe("123456789"); + + const listAfter = await listTelegramPairingRequests(); + expect(listAfter).toHaveLength(0); + + const allow = await readTelegramAllowFromStore(); + expect(allow).toContain("123456789"); + }); + }); +}); diff --git a/src/telegram/pairing-store.ts b/src/telegram/pairing-store.ts new file mode 100644 index 000000000..7d8c6e542 --- /dev/null +++ b/src/telegram/pairing-store.ts @@ -0,0 +1,122 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { + addProviderAllowFromStoreEntry, + approveProviderPairingCode, + listProviderPairingRequests, + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; + +export type TelegramPairingListEntry = { + chatId: string; + username?: string; + firstName?: string; + lastName?: string; + code: string; + createdAt: string; + lastSeenAt: string; +}; + +const PROVIDER = "telegram" as const; + +export async function readTelegramAllowFromStore( + env: NodeJS.ProcessEnv = process.env, +): Promise { + return readProviderAllowFromStore(PROVIDER, env); +} + +export async function addTelegramAllowFromStoreEntry(params: { + entry: string | number; + env?: NodeJS.ProcessEnv; +}): Promise<{ changed: boolean; allowFrom: string[] }> { + return addProviderAllowFromStoreEntry({ + provider: PROVIDER, + entry: params.entry, + env: params.env, + }); +} + +export async function listTelegramPairingRequests( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const list = await listProviderPairingRequests(PROVIDER, env); + return list.map((r) => ({ + chatId: r.id, + code: r.code, + createdAt: r.createdAt, + lastSeenAt: r.lastSeenAt, + username: r.meta?.username, + firstName: r.meta?.firstName, + lastName: r.meta?.lastName, + })); +} + +export async function upsertTelegramPairingRequest(params: { + chatId: string | number; + username?: string; + firstName?: string; + lastName?: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ code: string; created: boolean }> { + return upsertProviderPairingRequest({ + provider: PROVIDER, + id: String(params.chatId), + env: params.env, + meta: { + username: params.username, + firstName: params.firstName, + lastName: params.lastName, + }, + }); +} + +export async function approveTelegramPairingCode(params: { + code: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ chatId: string; entry?: TelegramPairingListEntry } | null> { + const res = await approveProviderPairingCode({ + provider: PROVIDER, + code: params.code, + env: params.env, + }); + if (!res) return null; + const entry = res.entry + ? { + chatId: res.entry.id, + code: res.entry.code, + createdAt: res.entry.createdAt, + lastSeenAt: res.entry.lastSeenAt, + username: res.entry.meta?.username, + firstName: res.entry.meta?.firstName, + lastName: res.entry.meta?.lastName, + } + : undefined; + return { chatId: res.id, entry }; +} + +export async function resolveTelegramEffectiveAllowFrom(params: { + cfg: ClawdbotConfig; + env?: NodeJS.ProcessEnv; +}): Promise<{ dm: string[]; group: string[] }> { + const env = params.env ?? process.env; + const cfgAllowFrom = (params.cfg.telegram?.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean) + .map((v) => v.replace(/^(telegram|tg):/i, "")) + .filter((v) => v !== "*"); + const cfgGroupAllowFrom = (params.cfg.telegram?.groupAllowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean) + .map((v) => v.replace(/^(telegram|tg):/i, "")) + .filter((v) => v !== "*"); + const storeAllowFrom = await readTelegramAllowFromStore(env); + + const dm = Array.from(new Set([...cfgAllowFrom, ...storeAllowFrom])); + const group = Array.from( + new Set([ + ...(cfgGroupAllowFrom.length > 0 ? cfgGroupAllowFrom : cfgAllowFrom), + ...storeAllowFrom, + ]), + ); + return { dm, group }; +} diff --git a/src/tui/commands.test.ts b/src/tui/commands.test.ts new file mode 100644 index 000000000..2c0fde55d --- /dev/null +++ b/src/tui/commands.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { parseCommand } from "./commands.js"; + +describe("tui slash commands", () => { + it("treats /elev as an alias for /elevated", () => { + expect(parseCommand("/elev on")).toEqual({ name: "elevated", args: "on" }); + }); + + it("normalizes alias case", () => { + expect(parseCommand("/ELEV off")).toEqual({ + name: "elevated", + args: "off", + }); + }); +}); diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 60b299779..032b04ce6 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -11,11 +11,19 @@ export type ParsedCommand = { args: string; }; +const COMMAND_ALIASES: Record = { + elev: "elevated", +}; + export function parseCommand(input: string): ParsedCommand { const trimmed = input.replace(/^\//, "").trim(); if (!trimmed) return { name: "", args: "" }; const [name, ...rest] = trimmed.split(/\s+/); - return { name: name.toLowerCase(), args: rest.join(" ").trim() }; + const normalized = name.toLowerCase(); + return { + name: COMMAND_ALIASES[normalized] ?? normalized, + args: rest.join(" ").trim(), + }; } export function getSlashCommands(): SlashCommand[] { @@ -53,6 +61,14 @@ export function getSlashCommands(): SlashCommand[] { (value) => ({ value, label: value }), ), }, + { + name: "elev", + description: "Alias for /elevated", + getArgumentCompletions: (prefix) => + ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map( + (value) => ({ value, label: value }), + ), + }, { name: "activation", description: "Set group activation", @@ -88,6 +104,7 @@ export function helpText(): string { "/think ", "/verbose ", "/elevated ", + "/elev ", "/activation ", "/deliver ", "/new or /reset", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index b55af013a..1fe903050 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -45,13 +45,14 @@ export type GatewaySessionList = { contextTokens?: number | null; totalTokens?: number | null; displayName?: string; - surface?: string; + provider?: string; room?: string; space?: string; subject?: string; chatType?: string; - lastChannel?: string; + lastProvider?: string; lastTo?: string; + lastAccountId?: string; }>; }; diff --git a/src/types/proper-lockfile.d.ts b/src/types/proper-lockfile.d.ts new file mode 100644 index 000000000..37641a1bb --- /dev/null +++ b/src/types/proper-lockfile.d.ts @@ -0,0 +1,26 @@ +declare module "proper-lockfile" { + export type RetryOptions = { + retries?: number; + factor?: number; + minTimeout?: number; + maxTimeout?: number; + randomize?: boolean; + }; + + export type LockOptions = { + retries?: number | RetryOptions; + stale?: number; + update?: number; + realpath?: boolean; + }; + + export type ReleaseFn = () => Promise; + + export function lock(path: string, options?: LockOptions): Promise; + + const lockfile: { + lock: typeof lock; + }; + + export default lockfile; +} diff --git a/src/web/accounts.ts b/src/web/accounts.ts new file mode 100644 index 000000000..a9fcffaad --- /dev/null +++ b/src/web/accounts.ts @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveOAuthDir } from "../config/paths.js"; +import type { GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { resolveUserPath } from "../utils.js"; + +export type ResolvedWhatsAppAccount = { + accountId: string; + enabled: boolean; + authDir: string; + isLegacyAuthDir: boolean; + allowFrom?: string[]; + groupAllowFrom?: string[]; + groupPolicy?: GroupPolicy; + textChunkLimit?: number; + groups?: WhatsAppAccountConfig["groups"]; +}; + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = cfg.whatsapp?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listWhatsAppAccountIds(cfg: ClawdbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultWhatsAppAccountId(cfg: ClawdbotConfig): string { + const ids = listWhatsAppAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): WhatsAppAccountConfig | undefined { + const accounts = cfg.whatsapp?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + const entry = accounts[accountId] as WhatsAppAccountConfig | undefined; + return entry; +} + +function resolveDefaultAuthDir(accountId: string): string { + return path.join(resolveOAuthDir(), "whatsapp", accountId); +} + +function resolveLegacyAuthDir(): string { + // Legacy Baileys creds lived in the same directory as OAuth tokens. + return resolveOAuthDir(); +} + +function legacyAuthExists(authDir: string): boolean { + try { + return fs.existsSync(path.join(authDir, "creds.json")); + } catch { + return false; + } +} + +export function resolveWhatsAppAuthDir(params: { + cfg: ClawdbotConfig; + accountId: string; +}): { authDir: string; isLegacy: boolean } { + const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID; + const account = resolveAccountConfig(params.cfg, accountId); + const configured = account?.authDir?.trim(); + if (configured) { + return { authDir: resolveUserPath(configured), isLegacy: false }; + } + + const defaultDir = resolveDefaultAuthDir(accountId); + if (accountId === DEFAULT_ACCOUNT_ID) { + const legacyDir = resolveLegacyAuthDir(); + if (legacyAuthExists(legacyDir) && !legacyAuthExists(defaultDir)) { + return { authDir: legacyDir, isLegacy: true }; + } + } + + return { authDir: defaultDir, isLegacy: false }; +} + +export function resolveWhatsAppAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedWhatsAppAccount { + const accountId = + params.accountId?.trim() || resolveDefaultWhatsAppAccountId(params.cfg); + const accountCfg = resolveAccountConfig(params.cfg, accountId); + const enabled = accountCfg?.enabled !== false; + const { authDir, isLegacy } = resolveWhatsAppAuthDir({ + cfg: params.cfg, + accountId, + }); + return { + accountId, + enabled, + authDir, + isLegacyAuthDir: isLegacy, + allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom, + groupAllowFrom: + accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom, + groupPolicy: accountCfg?.groupPolicy ?? params.cfg.whatsapp?.groupPolicy, + textChunkLimit: + accountCfg?.textChunkLimit ?? params.cfg.whatsapp?.textChunkLimit, + groups: accountCfg?.groups ?? params.cfg.whatsapp?.groups, + }; +} + +export function listEnabledWhatsAppAccounts( + cfg: ClawdbotConfig, +): ResolvedWhatsAppAccount[] { + return listWhatsAppAccountIds(cfg) + .map((accountId) => resolveWhatsAppAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/src/web/active-listener.ts b/src/web/active-listener.ts index bdcac6b85..76c7016db 100644 --- a/src/web/active-listener.ts +++ b/src/web/active-listener.ts @@ -1,3 +1,6 @@ +import type { PollInput } from "../polls.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; + export type ActiveWebSendOptions = { gifPlayback?: boolean; }; @@ -10,16 +13,46 @@ export type ActiveWebListener = { mediaType?: string, options?: ActiveWebSendOptions, ) => Promise<{ messageId: string }>; + sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>; sendComposingTo: (to: string) => Promise; close?: () => Promise; }; -let currentListener: ActiveWebListener | null = null; +let _currentListener: ActiveWebListener | null = null; -export function setActiveWebListener(listener: ActiveWebListener | null) { - currentListener = listener; +const listeners = new Map(); + +export function setActiveWebListener(listener: ActiveWebListener | null): void; +export function setActiveWebListener( + accountId: string | null | undefined, + listener: ActiveWebListener | null, +): void; +export function setActiveWebListener( + accountIdOrListener: string | ActiveWebListener | null | undefined, + maybeListener?: ActiveWebListener | null, +): void { + const { accountId, listener } = + typeof accountIdOrListener === "string" + ? { accountId: accountIdOrListener, listener: maybeListener ?? null } + : { + accountId: DEFAULT_ACCOUNT_ID, + listener: accountIdOrListener ?? null, + }; + + const id = (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID; + if (!listener) { + listeners.delete(id); + } else { + listeners.set(id, listener); + } + if (id === DEFAULT_ACCOUNT_ID) { + _currentListener = listener; + } } -export function getActiveWebListener(): ActiveWebListener | null { - return currentListener; +export function getActiveWebListener( + accountId?: string | null, +): ActiveWebListener | null { + const id = (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID; + return listeners.get(id) ?? null; } diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 02f77c23d..4cf7ec565 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -105,7 +105,7 @@ const makeSessionStore = async ( }; describe("partial reply gating", () => { - it("does not send partial replies for WhatsApp surface", async () => { + it("does not send partial replies for WhatsApp provider", async () => { const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn().mockResolvedValue(undefined); const sendMedia = vi.fn().mockResolvedValue(undefined); @@ -153,8 +153,9 @@ describe("partial reply gating", () => { it("updates last-route for direct chats without senderE164", async () => { const now = Date.now(); + const mainSessionKey = "agent:main:main"; const store = await makeSessionStore({ - main: { sessionId: "sid", updatedAt: now - 1 }, + [mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, }); const replyResolver = vi.fn().mockResolvedValue(undefined); @@ -163,7 +164,7 @@ describe("partial reply gating", () => { whatsapp: { allowFrom: ["*"], }, - session: { store: store.storePath, mainKey: "main" }, + session: { store: store.storePath }, }; setLoadConfigMock(mockConfig); @@ -190,18 +191,95 @@ describe("partial reply gating", () => { replyResolver, ); - let stored: { main?: { lastChannel?: string; lastTo?: string } } | null = - null; + let stored: Record< + string, + { lastProvider?: string; lastTo?: string } + > | null = null; for (let attempt = 0; attempt < 50; attempt += 1) { - stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as { - main?: { lastChannel?: string; lastTo?: string }; - }; - if (stored.main?.lastChannel && stored.main?.lastTo) break; + stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< + string, + { lastProvider?: string; lastTo?: string } + >; + if ( + stored[mainSessionKey]?.lastProvider && + stored[mainSessionKey]?.lastTo + ) + break; await new Promise((resolve) => setTimeout(resolve, 5)); } if (!stored) throw new Error("store not loaded"); - expect(stored.main?.lastChannel).toBe("whatsapp"); - expect(stored.main?.lastTo).toBe("+1000"); + expect(stored[mainSessionKey]?.lastProvider).toBe("whatsapp"); + expect(stored[mainSessionKey]?.lastTo).toBe("+1000"); + + resetLoadConfigMock(); + await store.cleanup(); + }); + + it("updates last-route for group chats with account id", async () => { + const now = Date.now(); + const groupSessionKey = "agent:main:whatsapp:group:123@g.us"; + const store = await makeSessionStore({ + [groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, + }); + + const replyResolver = vi.fn().mockResolvedValue(undefined); + + const mockConfig: ClawdbotConfig = { + whatsapp: { + allowFrom: ["*"], + }, + session: { store: store.storePath }, + }; + + setLoadConfigMock(mockConfig); + + await monitorWebProvider( + false, + async ({ onMessage }) => { + await onMessage({ + id: "g1", + from: "123@g.us", + conversationId: "123@g.us", + to: "+2000", + body: "hello", + timestamp: now, + chatType: "group", + chatId: "123@g.us", + accountId: "work", + senderE164: "+1000", + senderName: "Alice", + selfE164: "+2000", + sendComposing: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + sendMedia: vi.fn().mockResolvedValue(undefined), + }); + return { close: vi.fn().mockResolvedValue(undefined) }; + }, + false, + replyResolver, + ); + + let stored: Record< + string, + { lastProvider?: string; lastTo?: string; lastAccountId?: string } + > | null = null; + for (let attempt = 0; attempt < 50; attempt += 1) { + stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< + string, + { lastProvider?: string; lastTo?: string; lastAccountId?: string } + >; + if ( + stored[groupSessionKey]?.lastProvider && + stored[groupSessionKey]?.lastTo && + stored[groupSessionKey]?.lastAccountId + ) + break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + if (!stored) throw new Error("store not loaded"); + expect(stored[groupSessionKey]?.lastProvider).toBe("whatsapp"); + expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us"); + expect(stored[groupSessionKey]?.lastAccountId).toBe("work"); resetLoadConfigMock(); await store.cleanup(); @@ -244,6 +322,63 @@ describe("partial reply gating", () => { }); }); +describe("typing controller idle", () => { + it("marks dispatch idle after replies flush", async () => { + const markDispatchIdle = vi.fn(); + const typingMock = { + onReplyStart: vi.fn(async () => {}), + startTypingLoop: vi.fn(async () => {}), + startTypingOnText: vi.fn(async () => {}), + refreshTypingTtl: vi.fn(), + markRunComplete: vi.fn(), + markDispatchIdle, + cleanup: vi.fn(), + }; + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn().mockResolvedValue(undefined); + const sendMedia = vi.fn().mockResolvedValue(undefined); + + const replyResolver = vi.fn().mockImplementation(async (_ctx, opts) => { + opts?.onTypingController?.(typingMock); + return { text: "final reply" }; + }); + + const mockConfig: ClawdbotConfig = { + whatsapp: { + allowFrom: ["*"], + }, + }; + + setLoadConfigMock(mockConfig); + + await monitorWebProvider( + false, + async ({ onMessage }) => { + await onMessage({ + id: "m1", + from: "+1000", + conversationId: "+1000", + to: "+2000", + body: "hello", + timestamp: Date.now(), + chatType: "direct", + chatId: "direct:+1000", + sendComposing, + reply, + sendMedia, + }); + return { close: vi.fn().mockResolvedValue(undefined) }; + }, + false, + replyResolver, + ); + + resetLoadConfigMock(); + + expect(markDispatchIdle).toHaveBeenCalled(); + }); +}); + describe("web auto-reply", () => { beforeEach(() => { vi.clearAllMocks(); @@ -1045,6 +1180,57 @@ describe("web auto-reply", () => { resetLoadConfigMock(); }); + it("blocks group messages when whatsapp groups is set without a wildcard", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "ok" }); + + setLoadConfigMock(() => ({ + whatsapp: { + allowFrom: ["*"], + groups: { "999@g.us": { requireMention: false } }, + }, + routing: { groupChat: { mentionPatterns: ["@clawd"] } }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "@clawd hello", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g-allowlist-block", + senderE164: "+111", + senderName: "Alice", + mentionedJids: ["999@s.whatsapp.net"], + selfE164: "+999", + selfJid: "999@s.whatsapp.net", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).not.toHaveBeenCalled(); + resetLoadConfigMock(); + }); + it("honors per-group mention overrides when conversationId uses session key", async () => { const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); @@ -1107,7 +1293,7 @@ describe("web auto-reply", () => { .mockResolvedValueOnce({ text: "ok" }); const { storePath, cleanup } = await makeSessionStore({ - "whatsapp:group:123@g.us": { + "agent:main:whatsapp:group:123@g.us": { sessionId: "g-1", updatedAt: Date.now(), groupActivation: "always", diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index c46afcf66..2532923df 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1,4 +1,7 @@ -import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { + chunkMarkdownText, + resolveTextChunkLimit, +} from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { normalizeGroupActivation, @@ -7,18 +10,24 @@ import { import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, HEARTBEAT_PROMPT, + resolveHeartbeatPrompt, stripHeartbeatToken, } from "../auto-reply/heartbeat.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { buildMentionRegexes, normalizeMentionText, } from "../auto-reply/reply/mentions.js"; -import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; +import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; +import { + resolveProviderGroupPolicy, + resolveProviderGroupRequireMention, +} from "../config/group-policy.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, @@ -33,8 +42,11 @@ import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; +import { toLocationContext } from "../providers/location.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js"; +import { resolveWhatsAppAccount } from "./accounts.js"; import { setActiveWebListener } from "./active-listener.js"; import { monitorWebInbox } from "./inbound.js"; import { loadWebMedia } from "./media.js"; @@ -116,6 +128,8 @@ export type WebMonitorTuning = { heartbeatSeconds?: number; sleep?: (ms: number, signal?: AbortSignal) => Promise; statusSink?: (status: WebProviderStatus) => void; + /** WhatsApp account id. Default: "default". */ + accountId?: string; }; const formatDuration = (ms: number) => @@ -326,7 +340,7 @@ export async function runWebHeartbeatOnce(opts: { const replyResult = await replyResolver( { - Body: HEARTBEAT_PROMPT, + Body: resolveHeartbeatPrompt(cfg.agent?.heartbeat?.prompt), From: to, To: to, MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId, @@ -451,7 +465,7 @@ function getSessionRecipients(cfg: ReturnType) { .filter(([key]) => !isGroupKey(key) && !isCronKey(key)) .map(([_, entry]) => ({ to: - entry?.lastChannel === "whatsapp" && entry?.lastTo + entry?.lastProvider === "whatsapp" && entry?.lastTo ? normalizeE164(entry.lastTo) : "", updatedAt: entry?.updatedAt ?? 0, @@ -545,7 +559,7 @@ async function deliverWebReply(params: { skipLog, } = params; const replyStarted = Date.now(); - const textChunks = chunkText(replyResult.text || "", textLimit); + const textChunks = chunkMarkdownText(replyResult.text || "", textLimit); const mediaList = replyResult.mediaUrls?.length ? replyResult.mediaUrls : replyResult.mediaUrl @@ -755,7 +769,22 @@ export async function monitorWebProvider( }); }; emitStatus(); - const cfg = loadConfig(); + const baseCfg = loadConfig(); + const account = resolveWhatsAppAccount({ + cfg: baseCfg, + accountId: tuning.accountId, + }); + const cfg = { + ...baseCfg, + whatsapp: { + ...baseCfg.whatsapp, + allowFrom: account.allowFrom, + groupAllowFrom: account.groupAllowFrom, + groupPolicy: account.groupPolicy, + textChunkLimit: account.textChunkLimit, + groups: account.groups, + }, + } satisfies ReturnType; const configuredMaxMb = cfg.agent?.mediaMaxMb; const maxMediaBytes = typeof configuredMaxMb === "number" && configuredMaxMb > 0 @@ -767,7 +796,6 @@ export async function monitorWebProvider( ); const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect); const mentionConfig = buildMentionConfig(cfg); - const sessionStorePath = resolveStorePath(cfg.session?.store); const groupHistoryLimit = cfg.routing?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT; const groupHistories = new Map< @@ -846,30 +874,40 @@ export async function monitorWebProvider( resolveGroupSessionKey({ From: conversationId, ChatType: "group", - Surface: "whatsapp", + Provider: "whatsapp", }); + const resolveGroupPolicyFor = (conversationId: string) => { + const groupId = + resolveGroupResolution(conversationId)?.id ?? conversationId; + return resolveProviderGroupPolicy({ + cfg, + provider: "whatsapp", + groupId, + }); + }; + const resolveGroupRequireMentionFor = (conversationId: string) => { const groupId = resolveGroupResolution(conversationId)?.id ?? conversationId; - const groupConfig = cfg.whatsapp?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; + return resolveProviderGroupRequireMention({ + cfg, + provider: "whatsapp", + groupId, + }); }; - const resolveGroupActivationFor = (conversationId: string) => { - const key = - resolveGroupResolution(conversationId)?.key ?? - (conversationId.startsWith("group:") - ? conversationId - : `whatsapp:group:${conversationId}`); - const store = loadSessionStore(sessionStorePath); - const entry = store[key]; - const requireMention = resolveGroupRequireMentionFor(conversationId); + const resolveGroupActivationFor = (params: { + agentId: string; + sessionKey: string; + conversationId: string; + }) => { + const storePath = resolveStorePath(cfg.session?.store, { + agentId: params.agentId, + }); + const store = loadSessionStore(storePath); + const entry = store[params.sessionKey]; + const requireMention = resolveGroupRequireMentionFor(params.conversationId); const defaultActivation = requireMention === false ? "always" : "mention"; return ( normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation @@ -1005,7 +1043,7 @@ export async function monitorWebProvider( // Wrap with standardized envelope for the agent. return formatAgentEnvelope({ - surface: "WhatsApp", + provider: "WhatsApp", from: msg.chatType === "group" ? msg.from @@ -1015,7 +1053,10 @@ export async function monitorWebProvider( }); }; - const processMessage = async (msg: WebInboundMsg) => { + const processMessage = async ( + msg: WebInboundMsg, + route: ReturnType, + ) => { status.lastMessageAt = Date.now(); status.lastEventAt = status.lastMessageAt; emitStatus(); @@ -1024,14 +1065,14 @@ export async function monitorWebProvider( let shouldClearGroupHistory = false; if (msg.chatType === "group") { - const history = groupHistories.get(conversationId) ?? []; + const history = groupHistories.get(route.sessionKey) ?? []; const historyWithoutCurrent = history.length > 0 ? history.slice(0, -1) : []; if (historyWithoutCurrent.length > 0) { const historyText = historyWithoutCurrent .map((m) => formatAgentEnvelope({ - surface: "WhatsApp", + provider: "WhatsApp", from: conversationId, timestamp: m.timestamp, body: `${m.sender}: ${m.body}`, @@ -1081,8 +1122,9 @@ export async function monitorWebProvider( if (msg.chatType !== "group") { const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); const to = (() => { if (msg.senderE164) return normalizeE164(msg.senderE164); // In direct chats, `msg.from` is already the canonical conversation id, @@ -1094,12 +1136,18 @@ export async function monitorWebProvider( if (to) { const task = updateLastRoute({ storePath, - sessionKey: mainKey, - channel: "whatsapp", + sessionKey: route.mainSessionKey, + provider: "whatsapp", to, + accountId: route.accountId, }).catch((err) => { replyLogger.warn( - { error: formatError(err), storePath, sessionKey: mainKey, to }, + { + error: formatError(err), + storePath, + sessionKey: route.mainSessionKey, + to, + }, "failed updating last route", ); }); @@ -1113,74 +1161,78 @@ export async function monitorWebProvider( const textLimit = resolveTextChunkLimit(cfg, "whatsapp"); let didLogHeartbeatStrip = false; let didSendReply = false; - const dispatcher = createReplyDispatcher({ - responsePrefix: cfg.messages?.responsePrefix, - onHeartbeatStrip: () => { - if (!didLogHeartbeatStrip) { - didLogHeartbeatStrip = true; - logVerbose("Stripped stray HEARTBEAT_OK token from web reply"); - } - }, - deliver: async (payload, info) => { - await deliverWebReply({ - replyResult: payload, - msg, - maxMediaBytes, - textLimit, - replyLogger, - connectionId, - // Tool + block updates are noisy; skip their log lines. - skipLog: info.kind !== "final", - }); - didSendReply = true; - if (info.kind === "tool") { - rememberSentText(payload.text, { combinedBody: "" }); - return; - } - const shouldLog = - info.kind === "final" && payload.text ? true : undefined; - rememberSentText(payload.text, { - combinedBody, - logVerboseMessage: shouldLog, - }); - if (info.kind === "final") { - const fromDisplay = - msg.chatType === "group" - ? conversationId - : (msg.from ?? "unknown"); - const hasMedia = Boolean( - payload.mediaUrl || payload.mediaUrls?.length, - ); - whatsappOutboundLog.info( - `Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`, - ); - if (shouldLogVerbose()) { - const preview = - payload.text != null ? elide(payload.text, 400) : ""; - whatsappOutboundLog.debug( - `Reply body: ${preview}${hasMedia ? " (media)" : ""}`, - ); + const { dispatcher, replyOptions, markDispatchIdle } = + createReplyDispatcherWithTyping({ + responsePrefix: cfg.messages?.responsePrefix, + onHeartbeatStrip: () => { + if (!didLogHeartbeatStrip) { + didLogHeartbeatStrip = true; + logVerbose("Stripped stray HEARTBEAT_OK token from web reply"); } - } - }, - onError: (err, info) => { - const label = - info.kind === "tool" - ? "tool update" - : info.kind === "block" - ? "block update" - : "auto-reply"; - whatsappOutboundLog.error( - `Failed sending web ${label} to ${msg.from ?? conversationId}: ${formatError(err)}`, - ); - }, - }); + }, + deliver: async (payload, info) => { + await deliverWebReply({ + replyResult: payload, + msg, + maxMediaBytes, + textLimit, + replyLogger, + connectionId, + // Tool + block updates are noisy; skip their log lines. + skipLog: info.kind !== "final", + }); + didSendReply = true; + if (info.kind === "tool") { + rememberSentText(payload.text, { combinedBody: "" }); + return; + } + const shouldLog = + info.kind === "final" && payload.text ? true : undefined; + rememberSentText(payload.text, { + combinedBody, + logVerboseMessage: shouldLog, + }); + if (info.kind === "final") { + const fromDisplay = + msg.chatType === "group" + ? conversationId + : (msg.from ?? "unknown"); + const hasMedia = Boolean( + payload.mediaUrl || payload.mediaUrls?.length, + ); + whatsappOutboundLog.info( + `Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`, + ); + if (shouldLogVerbose()) { + const preview = + payload.text != null ? elide(payload.text, 400) : ""; + whatsappOutboundLog.debug( + `Reply body: ${preview}${hasMedia ? " (media)" : ""}`, + ); + } + } + }, + onError: (err, info) => { + const label = + info.kind === "tool" + ? "tool update" + : info.kind === "block" + ? "block update" + : "auto-reply"; + whatsappOutboundLog.error( + `Failed sending web ${label} to ${msg.from ?? conversationId}: ${formatError(err)}`, + ); + }, + onReplyStart: msg.sendComposing, + }); - const replyResult = await (replyResolver ?? getReplyFromConfig)( - { + const { queuedFinal } = await dispatchReplyFromConfig({ + ctx: { Body: combinedBody, From: msg.from, To: msg.to, + SessionKey: route.sessionKey, + AccountId: route.accountId, MessageSid: msg.id, ReplyToId: msg.replyToId, ReplyToBody: msg.replyToBody, @@ -1192,39 +1244,24 @@ export async function monitorWebProvider( GroupSubject: msg.groupSubject, GroupMembers: formatGroupMembers( msg.groupParticipants, - groupMemberNames.get(conversationId), + groupMemberNames.get(route.sessionKey), msg.senderE164, ), SenderName: msg.senderName, SenderE164: msg.senderE164, WasMentioned: msg.wasMentioned, - Surface: "whatsapp", + ...(msg.location ? toLocationContext(msg.location) : {}), + Provider: "whatsapp", }, - { - onReplyStart: msg.sendComposing, - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); - }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); - }, - }, - ); - - const replyList = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - - let queuedFinal = false; - for (const replyPayload of replyList) { - queuedFinal = dispatcher.sendFinalReply(replyPayload) || queuedFinal; - } - await dispatcher.waitForIdle(); + cfg, + dispatcher, + replyResolver, + replyOptions, + }); + markDispatchIdle(); if (!queuedFinal) { if (shouldClearGroupHistory && didSendReply) { - groupHistories.set(conversationId, []); + groupHistories.set(route.sessionKey, []); } logVerbose( "Skipping auto-reply: silent token or no text/media returned from resolver", @@ -1233,12 +1270,14 @@ export async function monitorWebProvider( } if (shouldClearGroupHistory && didSendReply) { - groupHistories.set(conversationId, []); + groupHistories.set(route.sessionKey, []); } }; const listener = await (listenerFactory ?? monitorWebInbox)({ verbose, + accountId: account.accountId, + authDir: account.authDir, onMessage: async (msg) => { handledMessages += 1; lastMessageAt = Date.now(); @@ -1247,6 +1286,28 @@ export async function monitorWebProvider( emitStatus(); _lastInboundMsg = msg; const conversationId = msg.conversationId ?? msg.from; + const peerId = + msg.chatType === "group" + ? conversationId + : (() => { + if (msg.senderE164) { + return normalizeE164(msg.senderE164) ?? msg.senderE164; + } + if (msg.from.includes("@")) { + return jidToE164(msg.from) ?? msg.from; + } + return normalizeE164(msg.from) ?? msg.from; + })(); + const route = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: msg.accountId, + peer: { + kind: msg.chatType === "group" ? "group" : "dm", + id: peerId, + }, + }); + const groupHistoryKey = route.sessionKey; // Same-phone mode logging retained if (msg.from === msg.to) { @@ -1266,7 +1327,40 @@ export async function monitorWebProvider( } if (msg.chatType === "group") { - noteGroupMember(conversationId, msg.senderE164, msg.senderName); + const groupPolicy = resolveGroupPolicyFor(conversationId); + if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + logVerbose( + `Skipping group message ${conversationId} (not in allowlist)`, + ); + return; + } + { + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const task = updateLastRoute({ + storePath, + sessionKey: route.sessionKey, + provider: "whatsapp", + to: conversationId, + accountId: route.accountId, + }).catch((err) => { + replyLogger.warn( + { + error: formatError(err), + storePath, + sessionKey: route.sessionKey, + to: conversationId, + }, + "failed updating last route", + ); + }); + backgroundTasks.add(task); + void task.finally(() => { + backgroundTasks.delete(task); + }); + } + noteGroupMember(groupHistoryKey, msg.senderE164, msg.senderName); const commandBody = stripMentionsForCommand(msg.body, msg.selfE164); const activationCommand = parseActivationCommand(commandBody); const isOwner = isOwnerSender(msg); @@ -1283,7 +1377,7 @@ export async function monitorWebProvider( if (!shouldBypassMention) { const history = - groupHistories.get(conversationId) ?? + groupHistories.get(groupHistoryKey) ?? ([] as Array<{ sender: string; body: string; @@ -1295,7 +1389,7 @@ export async function monitorWebProvider( timestamp: msg.timestamp, }); while (history.length > groupHistoryLimit) history.shift(); - groupHistories.set(conversationId, history); + groupHistories.set(groupHistoryKey, history); } const mentionDebug = debugMention(msg, mentionConfig); @@ -1309,7 +1403,11 @@ export async function monitorWebProvider( ); const wasMentioned = mentionDebug.wasMentioned; msg.wasMentioned = wasMentioned; - const activation = resolveGroupActivationFor(conversationId); + const activation = resolveGroupActivationFor({ + agentId: route.agentId, + sessionKey: route.sessionKey, + conversationId, + }); const requireMention = activation !== "always"; if (!shouldBypassMention && requireMention && !wasMentioned) { logVerbose( @@ -1319,7 +1417,7 @@ export async function monitorWebProvider( } } - return processMessage(msg); + return processMessage(msg, route); }, }); @@ -1330,12 +1428,18 @@ export async function monitorWebProvider( emitStatus(); // Surface a concise connection event for the next main-session turn/heartbeat. - const { e164: selfE164 } = readWebSelfId(); + const { e164: selfE164 } = readWebSelfId(account.authDir); + const connectRoute = resolveAgentRoute({ + cfg, + provider: "whatsapp", + accountId: account.accountId, + }); enqueueSystemEvent( `WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`, + { sessionKey: connectRoute.sessionKey }, ); - setActiveWebListener(listener); + setActiveWebListener(account.accountId, listener); unregisterUnhandled = registerUnhandledRejectionHandler((reason) => { if (!isLikelyWhatsAppCryptoError(reason)) return false; const errorStr = formatError(reason); @@ -1352,7 +1456,7 @@ export async function monitorWebProvider( }); const closeListener = async () => { - setActiveWebListener(null); + setActiveWebListener(account.accountId, null); if (unregisterUnhandled) { unregisterUnhandled(); unregisterUnhandled = null; @@ -1372,7 +1476,7 @@ export async function monitorWebProvider( if (keepAlive) { heartbeat = setInterval(() => { - const authAgeMs = getWebAuthAgeMs(); + const authAgeMs = getWebAuthAgeMs(account.authDir); const minutesSinceLastMessage = lastMessageAt ? Math.floor((Date.now() - lastMessageAt) / 60000) : null; diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index fb4fd78ec..ee4ef47e0 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -5,6 +5,11 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +const readAllowFromStoreMock = vi.fn().mockResolvedValue([]); +const upsertPairingRequestMock = vi + .fn() + .mockResolvedValue({ code: "PAIRCODE", created: true }); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -21,6 +26,13 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + const HOME = path.join( os.tmpdir(), `clawdbot-inbound-media-${crypto.randomUUID()}`, diff --git a/src/web/inbound.test.ts b/src/web/inbound.test.ts index 161b0d62b..6efcfa9e0 100644 --- a/src/web/inbound.test.ts +++ b/src/web/inbound.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { extractMediaPlaceholder, extractText } from "./inbound.js"; +import { + extractLocationData, + extractMediaPlaceholder, + extractText, +} from "./inbound.js"; describe("web inbound helpers", () => { it("prefers the main conversation body", () => { @@ -45,4 +49,46 @@ describe("web inbound helpers", () => { } as unknown as import("@whiskeysockets/baileys").proto.IMessage), ).toBe(""); }); + + it("extracts WhatsApp location messages", () => { + const location = extractLocationData({ + locationMessage: { + degreesLatitude: 48.858844, + degreesLongitude: 2.294351, + name: "Eiffel Tower", + address: "Champ de Mars, Paris", + accuracyInMeters: 12, + comment: "Meet here", + }, + } as unknown as import("@whiskeysockets/baileys").proto.IMessage); + expect(location).toEqual({ + latitude: 48.858844, + longitude: 2.294351, + accuracy: 12, + name: "Eiffel Tower", + address: "Champ de Mars, Paris", + caption: "Meet here", + source: "place", + isLive: false, + }); + }); + + it("extracts WhatsApp live location messages", () => { + const location = extractLocationData({ + liveLocationMessage: { + degreesLatitude: 37.819929, + degreesLongitude: -122.478255, + accuracyInMeters: 20, + caption: "On the move", + }, + } as unknown as import("@whiskeysockets/baileys").proto.IMessage); + expect(location).toEqual({ + latitude: 37.819929, + longitude: -122.478255, + accuracy: 20, + caption: "On the move", + source: "live", + isLive: true, + }); + }); }); diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 2043b0d39..c72ff43d2 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -16,12 +16,21 @@ import { loadConfig } from "../config/config.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; +import { + formatLocationText, + type NormalizedLocation, +} from "../providers/location.js"; import { isSelfChatMode, jidToE164, normalizeE164, toWhatsappJid, } from "../utils.js"; +import { resolveWhatsAppAccount } from "./accounts.js"; import type { ActiveWebSendOptions } from "./active-listener.js"; import { createWaSocket, @@ -40,6 +49,7 @@ export type WebInboundMessage = { from: string; // conversation id: E.164 for direct chats, group JID for groups conversationId: string; // alias for clarity (same as from) to: string; + accountId: string; body: string; pushName?: string; timestamp?: number; @@ -56,6 +66,7 @@ export type WebInboundMessage = { mentionedJids?: string[]; selfJid?: string | null; selfE164?: string | null; + location?: NormalizedLocation; sendComposing: () => Promise; reply: (text: string) => Promise; sendMedia: (payload: AnyMessageContent) => Promise; @@ -67,13 +78,17 @@ export type WebInboundMessage = { export async function monitorWebInbox(options: { verbose: boolean; + accountId: string; + authDir: string; onMessage: (msg: WebInboundMessage) => Promise; }) { const inboundLogger = getChildLogger({ module: "web-inbound" }); const inboundConsoleLog = createSubsystemLogger( "gateway/providers/whatsapp", ).child("inbound"); - const sock = await createWaSocket(false, options.verbose); + const sock = await createWaSocket(false, options.verbose, { + authDir: options.authDir, + }); await waitForWaConnection(sock); let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null; const onClose = new Promise((resolve) => { @@ -163,29 +178,119 @@ export async function monitorWebInbox(options: { // Filter unauthorized senders early to prevent wasted processing // and potential session corruption from Bad MAC errors const cfg = loadConfig(); - const configuredAllowFrom = cfg.whatsapp?.allowFrom; + const account = resolveWhatsAppAccount({ + cfg, + accountId: options.accountId, + }); + const dmPolicy = cfg.whatsapp?.dmPolicy ?? "pairing"; + const configuredAllowFrom = account.allowFrom; + const storeAllowFrom = await readProviderAllowFromStore("whatsapp").catch( + () => [], + ); // Without user config, default to self-only DM access so the owner can talk to themselves + const combinedAllowFrom = Array.from( + new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]), + ); const defaultAllowFrom = - (!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164 - ? [selfE164] - : undefined; + combinedAllowFrom.length === 0 && selfE164 ? [selfE164] : undefined; const allowFrom = - configuredAllowFrom && configuredAllowFrom.length > 0 + combinedAllowFrom.length > 0 ? combinedAllowFrom : defaultAllowFrom; + const groupAllowFrom = + account.groupAllowFrom ?? + (configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom - : defaultAllowFrom; + : undefined); const isSamePhone = from === selfE164; const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom); - const allowlistEnabled = - !group && Array.isArray(allowFrom) && allowFrom.length > 0; - if (!isSamePhone && allowlistEnabled) { - const candidate = from; - const allowedList = allowFrom.map(normalizeE164); - if (!allowFrom.includes("*") && !allowedList.includes(candidate)) { + // Pre-compute normalized allowlists for filtering + const dmHasWildcard = allowFrom?.includes("*") ?? false; + const normalizedAllowFrom = + allowFrom && allowFrom.length > 0 + ? allowFrom.filter((entry) => entry !== "*").map(normalizeE164) + : []; + const groupHasWildcard = groupAllowFrom?.includes("*") ?? false; + const normalizedGroupAllowFrom = + groupAllowFrom && groupAllowFrom.length > 0 + ? groupAllowFrom.filter((entry) => entry !== "*").map(normalizeE164) + : []; + + // Group policy filtering: controls how group messages are handled + // - "open" (default): groups bypass allowFrom, only mention-gating applies + // - "disabled": block all group messages entirely + // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + const groupPolicy = account.groupPolicy ?? "open"; + if (group && groupPolicy === "disabled") { + logVerbose(`Blocked group message (groupPolicy: disabled)`); + continue; + } + if (group && groupPolicy === "allowlist") { + // For allowlist mode, the sender (participant) must be in allowFrom + // If we can't resolve the sender E164, block the message for safety + if (!groupAllowFrom || groupAllowFrom.length === 0) { logVerbose( - `Blocked unauthorized sender ${candidate} (not in allowFrom list)`, + "Blocked group message (groupPolicy: allowlist, no groupAllowFrom)", ); - continue; // Skip processing entirely + continue; + } + const senderAllowed = + groupHasWildcard || + (senderE164 != null && normalizedGroupAllowFrom.includes(senderE164)); + if (!senderAllowed) { + logVerbose( + `Blocked group message from ${senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`, + ); + continue; + } + } + + // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled" + if (!group) { + if (dmPolicy === "disabled") { + logVerbose("Blocked dm (dmPolicy: disabled)"); + continue; + } + if (dmPolicy !== "open" && !isSamePhone) { + const candidate = from; + const allowed = + dmHasWildcard || + (normalizedAllowFrom.length > 0 && + normalizedAllowFrom.includes(candidate)); + if (!allowed) { + if (dmPolicy === "pairing") { + const { code } = await upsertProviderPairingRequest({ + provider: "whatsapp", + id: candidate, + meta: { + name: (msg.pushName ?? "").trim() || undefined, + }, + }); + logVerbose( + `whatsapp pairing request sender=${candidate} name=${msg.pushName ?? "unknown"} code=${code}`, + ); + try { + await sock.sendMessage(remoteJid, { + text: [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider whatsapp ", + ].join("\n"), + }); + } catch (err) { + logVerbose( + `whatsapp pairing reply failed for ${candidate}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`, + ); + } + continue; + } } } @@ -213,7 +318,12 @@ export async function monitorWebInbox(options: { // but we skip triggering the auto-reply logic to avoid spamming old context. if (upsert.type === "append") continue; + const location = extractLocationData(msg.message ?? undefined); + const locationText = location ? formatLocationText(location) : undefined; let body = extractText(msg.message ?? undefined); + if (locationText) { + body = [body, locationText].filter(Boolean).join("\n").trim(); + } if (!body) { body = extractMediaPlaceholder(msg.message ?? undefined); if (!body) continue; @@ -275,6 +385,7 @@ export async function monitorWebInbox(options: { from, conversationId: from, to: selfE164 ?? "me", + accountId: account.accountId, body, pushName: senderName, timestamp, @@ -291,6 +402,7 @@ export async function monitorWebInbox(options: { mentionedJids: mentionedJids ?? undefined, selfJid, selfE164, + location: location ?? undefined, sendComposing, reply, sendMedia, @@ -428,6 +540,24 @@ export async function monitorWebInbox(options: { const result = await sock.sendMessage(jid, payload); return { messageId: result?.key?.id ?? "unknown" }; }, + /** + * Send a poll message through this connection's socket. + * Used by IPC to create WhatsApp polls in groups or chats. + */ + sendPoll: async ( + to: string, + poll: { question: string; options: string[]; maxSelections?: number }, + ): Promise<{ messageId: string }> => { + const jid = toWhatsappJid(to); + const result = await sock.sendMessage(jid, { + poll: { + name: poll.question, + values: poll.options, + selectableCount: poll.maxSelections ?? 1, + }, + }); + return { messageId: result?.key?.id ?? "unknown" }; + }, /** * Send typing indicator ("composing") to a chat. * Used after IPC send to show more messages are coming. @@ -552,6 +682,62 @@ export function extractMediaPlaceholder( return undefined; } +export function extractLocationData( + rawMessage: proto.IMessage | undefined, +): NormalizedLocation | null { + const message = unwrapMessage(rawMessage); + if (!message) return null; + + const live = message.liveLocationMessage ?? undefined; + if (live) { + const latitudeRaw = live.degreesLatitude; + const longitudeRaw = live.degreesLongitude; + if (latitudeRaw != null && longitudeRaw != null) { + const latitude = Number(latitudeRaw); + const longitude = Number(longitudeRaw); + if (Number.isFinite(latitude) && Number.isFinite(longitude)) { + return { + latitude, + longitude, + accuracy: live.accuracyInMeters ?? undefined, + caption: live.caption ?? undefined, + source: "live", + isLive: true, + }; + } + } + } + + const location = message.locationMessage ?? undefined; + if (location) { + const latitudeRaw = location.degreesLatitude; + const longitudeRaw = location.degreesLongitude; + if (latitudeRaw != null && longitudeRaw != null) { + const latitude = Number(latitudeRaw); + const longitude = Number(longitudeRaw); + if (Number.isFinite(latitude) && Number.isFinite(longitude)) { + const isLive = Boolean(location.isLive); + return { + latitude, + longitude, + accuracy: location.accuracyInMeters ?? undefined, + name: location.name ?? undefined, + address: location.address ?? undefined, + caption: location.comment ?? undefined, + source: isLive + ? "live" + : location.name || location.address + ? "place" + : "pin", + isLive, + }; + } + } + } + + return null; +} + function describeReplyContext(rawMessage: proto.IMessage | undefined): { id?: string; body: string; @@ -564,7 +750,14 @@ function describeReplyContext(rawMessage: proto.IMessage | undefined): { contextInfo?.quotedMessage as proto.IMessage | undefined, ) as proto.IMessage | undefined; if (!quoted) return null; - const body = extractText(quoted) ?? extractMediaPlaceholder(quoted); + const location = extractLocationData(quoted); + const locationText = location ? formatLocationText(location) : undefined; + const text = extractText(quoted); + let body: string | undefined = [text, locationText] + .filter(Boolean) + .join("\n") + .trim(); + if (!body) body = extractMediaPlaceholder(quoted); if (!body) { const quotedType = quoted ? getContentType(quoted) : undefined; logVerbose( diff --git a/src/web/login-qr.ts b/src/web/login-qr.ts index 5c70b79e2..60cde83ad 100644 --- a/src/web/login-qr.ts +++ b/src/web/login-qr.ts @@ -1,10 +1,11 @@ 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 { resolveWhatsAppAccount } from "./accounts.js"; import { renderQrPngBase64 } from "./qr-image.js"; import { createWaSocket, @@ -19,6 +20,9 @@ import { type WaSocket = Awaited>; type ActiveLogin = { + accountId: string; + authDir: string; + isLegacyAuthDir: boolean; id: string; sock: WaSocket; startedAt: number; @@ -33,7 +37,7 @@ type ActiveLogin = { }; const ACTIVE_LOGIN_TTL_MS = 3 * 60_000; -let activeLogin: ActiveLogin | null = null; +const activeLogins = new Map(); function closeSocket(sock: WaSocket) { try { @@ -43,10 +47,11 @@ function closeSocket(sock: WaSocket) { } } -async function resetActiveLogin(reason?: string) { - if (activeLogin) { - closeSocket(activeLogin.sock); - activeLogin = null; +async function resetActiveLogin(accountId: string, reason?: string) { + const login = activeLogins.get(accountId); + if (login) { + closeSocket(login.sock); + activeLogins.delete(accountId); } if (reason) { logInfo(reason); @@ -57,18 +62,17 @@ function isLoginFresh(login: ActiveLogin) { return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS; } -function attachLoginWaiter(login: ActiveLogin) { +function attachLoginWaiter(accountId: string, login: ActiveLogin) { login.waitPromise = waitForWaConnection(login.sock) .then(() => { - if (activeLogin?.id === login.id) { - activeLogin.connected = true; - } + const current = activeLogins.get(accountId); + if (current?.id === login.id) current.connected = true; }) .catch((err) => { - if (activeLogin?.id === login.id) { - activeLogin.error = formatError(err); - activeLogin.errorStatus = getStatusCode(err); - } + const current = activeLogins.get(accountId); + if (current?.id !== login.id) return; + current.error = formatError(err); + current.errorStatus = getStatusCode(err); }); } @@ -82,12 +86,14 @@ async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) { ); closeSocket(login.sock); try { - const sock = await createWaSocket(false, login.verbose); + const sock = await createWaSocket(false, login.verbose, { + authDir: login.authDir, + }); login.sock = sock; login.connected = false; login.error = undefined; login.errorStatus = undefined; - attachLoginWaiter(login); + attachLoginWaiter(login.accountId, login); return true; } catch (err) { login.error = formatError(err); @@ -101,12 +107,15 @@ export async function startWebLoginWithQr( verbose?: boolean; timeoutMs?: number; force?: boolean; + accountId?: string; runtime?: RuntimeEnv; } = {}, ): Promise<{ qrDataUrl?: string; message: string }> { const runtime = opts.runtime ?? defaultRuntime; - const hasWeb = await webAuthExists(); - const selfId = readWebSelfId(); + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); + const hasWeb = await webAuthExists(account.authDir); + const selfId = readWebSelfId(account.authDir); if (hasWeb && !opts.force) { const who = selfId.e164 ?? selfId.jid ?? "unknown"; return { @@ -114,14 +123,15 @@ export async function startWebLoginWithQr( }; } - if (activeLogin && isLoginFresh(activeLogin) && activeLogin.qrDataUrl) { + const existing = activeLogins.get(account.accountId); + if (existing && isLoginFresh(existing) && existing.qrDataUrl) { return { - qrDataUrl: activeLogin.qrDataUrl, + qrDataUrl: existing.qrDataUrl, message: "QR already active. Scan it in WhatsApp → Linked Devices.", }; } - await resetActiveLogin(); + await resetActiveLogin(account.accountId); let resolveQr: ((qr: string) => void) | null = null; let rejectQr: ((err: Error) => void) | null = null; @@ -138,11 +148,15 @@ export async function startWebLoginWithQr( ); let sock: WaSocket; + let pendingQr: string | null = null; try { sock = await createWaSocket(false, Boolean(opts.verbose), { + authDir: account.authDir, onQr: (qr: string) => { - if (!activeLogin || activeLogin.qr) return; - activeLogin.qr = qr; + if (pendingQr) return; + pendingQr = qr; + const current = activeLogins.get(account.accountId); + if (current && !current.qr) current.qr = qr; clearTimeout(qrTimer); runtime.log(info("WhatsApp QR received.")); resolveQr?.(qr); @@ -150,12 +164,15 @@ export async function startWebLoginWithQr( }); } catch (err) { clearTimeout(qrTimer); - await resetActiveLogin(); + await resetActiveLogin(account.accountId); return { message: `Failed to start WhatsApp login: ${String(err)}`, }; } const login: ActiveLogin = { + accountId: account.accountId, + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, id: randomUUID(), sock, startedAt: Date.now(), @@ -164,15 +181,16 @@ export async function startWebLoginWithQr( restartAttempted: false, verbose: Boolean(opts.verbose), }; - activeLogin = login; - attachLoginWaiter(login); + activeLogins.set(account.accountId, login); + if (pendingQr && !login.qr) login.qr = pendingQr; + attachLoginWaiter(account.accountId, login); let qr: string; try { qr = await qrPromise; } catch (err) { clearTimeout(qrTimer); - await resetActiveLogin(); + await resetActiveLogin(account.accountId); return { message: `Failed to get QR: ${String(err)}`, }; @@ -187,9 +205,12 @@ export async function startWebLoginWithQr( } export async function waitForWebLogin( - opts: { timeoutMs?: number; runtime?: RuntimeEnv } = {}, + opts: { timeoutMs?: number; runtime?: RuntimeEnv; accountId?: string } = {}, ): Promise<{ connected: boolean; message: string }> { const runtime = opts.runtime ?? defaultRuntime; + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); + const activeLogin = activeLogins.get(account.accountId); if (!activeLogin) { return { connected: false, @@ -199,7 +220,7 @@ export async function waitForWebLogin( const login = activeLogin; if (!isLoginFresh(login)) { - await resetActiveLogin(); + await resetActiveLogin(account.accountId); return { connected: false, message: "The login QR expired. Ask me to generate a new one.", @@ -235,10 +256,14 @@ export async function waitForWebLogin( if (login.error) { if (login.errorStatus === DisconnectReason.loggedOut) { - await logoutWeb(runtime); + await logoutWeb({ + authDir: login.authDir, + isLegacyAuthDir: login.isLegacyAuthDir, + runtime, + }); const message = "WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR."; - await resetActiveLogin(message); + await resetActiveLogin(account.accountId, message); runtime.log(danger(message)); return { connected: false, message }; } @@ -249,7 +274,7 @@ export async function waitForWebLogin( } } const message = `WhatsApp login failed: ${login.error}`; - await resetActiveLogin(message); + await resetActiveLogin(account.accountId, message); runtime.log(danger(message)); return { connected: false, message }; } @@ -257,7 +282,7 @@ export async function waitForWebLogin( if (login.connected) { const message = "✅ Linked! WhatsApp is ready."; runtime.log(success(message)); - await resetActiveLogin(); + await resetActiveLogin(account.accountId); return { connected: true, message }; } diff --git a/src/web/login.coverage.test.ts b/src/web/login.coverage.test.ts index 2025eae9f..6933feff9 100644 --- a/src/web/login.coverage.test.ts +++ b/src/web/login.coverage.test.ts @@ -7,20 +7,36 @@ vi.useFakeTimers(); const rmMock = vi.spyOn(fs, "rm"); +vi.mock("../config/config.js", () => ({ + loadConfig: () => + ({ + whatsapp: { + accounts: { + default: { enabled: true, authDir: "/tmp/wa-creds" }, + }, + }, + }) as never, +})); + vi.mock("./session.js", () => { const sockA = { ws: { close: vi.fn() } }; const sockB = { ws: { close: vi.fn() } }; - const createWaSocket = vi.fn(async () => - createWaSocket.mock.calls.length === 0 ? sockA : sockB, - ); + let call = 0; + const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB)); const waitForWaConnection = vi.fn(); const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`); return { createWaSocket, waitForWaConnection, formatError, - resolveWebAuthDir: () => "/tmp/wa-creds", WA_WEB_AUTH_DIR: "/tmp/wa-creds", + logoutWeb: vi.fn(async (params: { authDir?: string }) => { + await fs.rm(params.authDir ?? "/tmp/wa-creds", { + recursive: true, + force: true, + }); + return true; + }), }; }); diff --git a/src/web/login.ts b/src/web/login.ts index 9abbf4909..8ed15978f 100644 --- a/src/web/login.ts +++ b/src/web/login.ts @@ -1,30 +1,35 @@ -import fs from "node:fs/promises"; - 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 { resolveWhatsAppAccount } from "./accounts.js"; import { createWaSocket, formatError, - resolveWebAuthDir, + logoutWeb, waitForWaConnection, } from "./session.js"; export async function loginWeb( verbose: boolean, provider = "whatsapp", - waitForConnection: typeof waitForWaConnection = waitForWaConnection, + waitForConnection?: typeof waitForWaConnection, runtime: RuntimeEnv = defaultRuntime, + accountId?: string, ) { if (provider !== "whatsapp" && provider !== "web") { throw new Error(`Unsupported provider: ${provider}`); } - const sock = await createWaSocket(true, verbose); + const wait = waitForConnection ?? waitForWaConnection; + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg, accountId }); + const sock = await createWaSocket(true, verbose, { + authDir: account.authDir, + }); logInfo("Waiting for WhatsApp connection...", runtime); try { - await waitForConnection(sock); + await wait(sock); console.log(success("✅ Linked! Credentials saved for future sends.")); } catch (err) { const code = @@ -42,9 +47,11 @@ export async function loginWeb( } catch { // ignore } - const retry = await createWaSocket(false, verbose); + const retry = await createWaSocket(false, verbose, { + authDir: account.authDir, + }); try { - await waitForConnection(retry); + await wait(retry); console.log( success( "✅ Linked after restart; web session ready. You can now send with provider=web.", @@ -56,7 +63,11 @@ export async function loginWeb( } } if (code === DisconnectReason.loggedOut) { - await fs.rm(resolveWebAuthDir(), { recursive: true, force: true }); + await logoutWeb({ + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, + runtime, + }); console.error( danger( "WhatsApp reported the session is logged out. Cleared cached web session; please rerun clawdbot login and scan the QR again.", diff --git a/src/web/logout.test.ts b/src/web/logout.test.ts index af8dea03e..83f88e287 100644 --- a/src/web/logout.test.ts +++ b/src/web/logout.test.ts @@ -45,32 +45,41 @@ describe("web logout", () => { "deletes cached credentials when present", { timeout: 15_000 }, async () => { - const credsDir = path.join(tmpDir, ".clawdbot", "credentials"); - fs.mkdirSync(credsDir, { recursive: true }); - fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); - const sessionsPath = path.join( - tmpDir, - ".clawdbot", - "sessions", - "sessions.json", - ); - fs.mkdirSync(path.dirname(sessionsPath), { recursive: true }); - fs.writeFileSync(sessionsPath, "{}"); const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true); - const result = await logoutWeb(runtime as never); + fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true }); + fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}"); + const result = await logoutWeb({ runtime: runtime as never }); expect(result).toBe(true); - expect(fs.existsSync(credsDir)).toBe(false); - expect(fs.existsSync(sessionsPath)).toBe(false); + expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false); }, ); it("no-ops when nothing to delete", { timeout: 15_000 }, async () => { const { logoutWeb } = await import("./session.js"); - const result = await logoutWeb(runtime as never); + const result = await logoutWeb({ runtime: runtime as never }); expect(result).toBe(false); expect(runtime.log).toHaveBeenCalled(); }); + + it("keeps shared oauth.json when using legacy auth dir", async () => { + const { logoutWeb } = await import("./session.js"); + const credsDir = path.join(tmpDir, ".clawdbot", "credentials"); + fs.mkdirSync(credsDir, { recursive: true }); + fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); + fs.writeFileSync(path.join(credsDir, "oauth.json"), '{"token":true}'); + fs.writeFileSync(path.join(credsDir, "session-abc.json"), "{}"); + + const result = await logoutWeb({ + authDir: credsDir, + isLegacyAuthDir: true, + runtime: runtime as never, + }); + expect(result).toBe(true); + expect(fs.existsSync(path.join(credsDir, "oauth.json"))).toBe(true); + expect(fs.existsSync(path.join(credsDir, "creds.json"))).toBe(false); + expect(fs.existsSync(path.join(credsDir, "session-abc.json"))).toBe(false); + }); }); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 3f285b6b0..bd9946b4b 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -19,6 +19,11 @@ const mockLoadConfig = vi.fn().mockReturnValue({ }, }); +const readAllowFromStoreMock = vi.fn().mockResolvedValue([]); +const upsertPairingRequestMock = vi + .fn() + .mockResolvedValue({ code: "PAIRCODE", created: true }); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -27,6 +32,13 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + vi.mock("./session.js", () => { const { EventEmitter } = require("node:events"); const ev = new EventEmitter(); @@ -64,6 +76,11 @@ import { monitorWebInbox } from "./inbound.js"; describe("web monitor inbox", () => { beforeEach(() => { vi.clearAllMocks(); + readAllowFromStoreMock.mockResolvedValue([]); + upsertPairingRequestMock.mockResolvedValue({ + code: "PAIRCODE", + created: true, + }); }); afterEach(() => { @@ -564,6 +581,10 @@ describe("web monitor inbox", () => { expect(onMessage).not.toHaveBeenCalled(); // Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn). expect(sock.readMessages).not.toHaveBeenCalled(); + expect(sock.sendMessage).toHaveBeenCalledTimes(1); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: expect.stringContaining("Pairing code: PAIRCODE"), + }); // Reset mock for other tests mockLoadConfig.mockReturnValue({ @@ -670,6 +691,214 @@ describe("web monitor inbox", () => { await listener.close(); }); + it("blocks all group messages when groupPolicy is 'disabled'", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["+1234"], + groupPolicy: "disabled", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-disabled", + fromMe: false, + remoteJid: "11111@g.us", + participant: "999@s.whatsapp.net", + }, + message: { conversation: "group message should be blocked" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should NOT call onMessage because groupPolicy is disabled + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("blocks group messages from senders not in groupAllowFrom when groupPolicy is 'allowlist'", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + groupAllowFrom: ["+1234"], // Does not include +999 + groupPolicy: "allowlist", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-allowlist-blocked", + fromMe: false, + remoteJid: "11111@g.us", + participant: "999@s.whatsapp.net", + }, + message: { conversation: "unauthorized group sender" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should NOT call onMessage because sender +999 not in groupAllowFrom + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("allows group messages from senders in groupAllowFrom when groupPolicy is 'allowlist'", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + groupAllowFrom: ["+15551234567"], // Includes the sender + groupPolicy: "allowlist", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-allowlist-allowed", + fromMe: false, + remoteJid: "11111@g.us", + participant: "15551234567@s.whatsapp.net", + }, + message: { conversation: "authorized group sender" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should call onMessage because sender is in groupAllowFrom + expect(onMessage).toHaveBeenCalledTimes(1); + const payload = onMessage.mock.calls[0][0]; + expect(payload.chatType).toBe("group"); + expect(payload.senderE164).toBe("+15551234567"); + + await listener.close(); + }); + + it("allows all group senders with wildcard in groupPolicy allowlist", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + groupAllowFrom: ["*"], // Wildcard allows everyone + groupPolicy: "allowlist", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-wildcard-test", + fromMe: false, + remoteJid: "22222@g.us", + participant: "9999999999@s.whatsapp.net", // Random sender + }, + message: { conversation: "wildcard group sender" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should call onMessage because wildcard allows all senders + expect(onMessage).toHaveBeenCalledTimes(1); + const payload = onMessage.mock.calls[0][0]; + expect(payload.chatType).toBe("group"); + + await listener.close(); + }); + + it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + groupPolicy: "allowlist", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-allowlist-empty", + fromMe: false, + remoteJid: "11111@g.us", + participant: "999@s.whatsapp.net", + }, + message: { conversation: "blocked by empty allowlist" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + it("allows messages from senders in allowFrom list", async () => { mockLoadConfig.mockReturnValue({ whatsapp: { @@ -772,105 +1001,118 @@ describe("web monitor inbox", () => { await listener.close(); }); -}); -it("defaults to self-only when no config is present", async () => { - // No config file => allowFrom should be derived from selfE164 - mockLoadConfig.mockReturnValue({}); - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = await createWaSocket(); + it("locks down when no config is present (pairing for unknown senders)", async () => { + // No config file => locked-down defaults apply (pairing for unknown senders) + mockLoadConfig.mockReturnValue({}); - // Message from someone else should be blocked - const upsertBlocked = { - type: "notify", - messages: [ - { - key: { - id: "no-config-1", - fromMe: false, - remoteJid: "999@s.whatsapp.net", + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + // Message from someone else should be blocked + const upsertBlocked = { + type: "notify", + messages: [ + { + key: { + id: "no-config-1", + fromMe: false, + remoteJid: "999@s.whatsapp.net", + }, + message: { conversation: "ping" }, + messageTimestamp: 1_700_000_000, }, - message: { conversation: "ping" }, - messageTimestamp: 1_700_000_000, - }, - ], - }; + ], + }; - sock.ev.emit("messages.upsert", upsertBlocked); - await new Promise((resolve) => setImmediate(resolve)); - expect(onMessage).not.toHaveBeenCalled(); + sock.ev.emit("messages.upsert", upsertBlocked); + await new Promise((resolve) => setImmediate(resolve)); + expect(onMessage).not.toHaveBeenCalled(); + expect(sock.sendMessage).toHaveBeenCalledTimes(1); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: expect.stringContaining("Pairing code: PAIRCODE"), + }); - // Message from self should be allowed - const upsertSelf = { - type: "notify", - messages: [ - { - key: { - id: "no-config-2", - fromMe: false, - remoteJid: "123@s.whatsapp.net", + // Message from self should be allowed + const upsertSelf = { + type: "notify", + messages: [ + { + key: { + id: "no-config-2", + fromMe: false, + remoteJid: "123@s.whatsapp.net", + }, + message: { conversation: "self ping" }, + messageTimestamp: 1_700_000_001, }, - message: { conversation: "self ping" }, - messageTimestamp: 1_700_000_001, + ], + }; + + sock.ev.emit("messages.upsert", upsertSelf); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledTimes(1); + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + body: "self ping", + from: "+123", + to: "+123", + }), + ); + + // Reset mock for other tests + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["*"], }, - ], - }; + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + }, + }); - sock.ev.emit("messages.upsert", upsertSelf); - await new Promise((resolve) => setImmediate(resolve)); - - expect(onMessage).toHaveBeenCalledTimes(1); - expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ body: "self ping", from: "+123", to: "+123" }), - ); - - // Reset mock for other tests - mockLoadConfig.mockReturnValue({ - whatsapp: { - allowFrom: ["*"], - }, - messages: { - messagePrefix: undefined, - responsePrefix: undefined, - }, + await listener.close(); }); - await listener.close(); -}); + it("handles append messages by marking them read but skipping auto-reply", async () => { + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); -it("handles append messages by marking them read but skipping auto-reply", async () => { - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = await createWaSocket(); + const upsert = { + type: "append", + messages: [ + { + key: { + id: "history1", + fromMe: false, + remoteJid: "999@s.whatsapp.net", + }, + message: { conversation: "old message" }, + messageTimestamp: 1_700_000_000, + pushName: "History Sender", + }, + ], + }; - const upsert = { - type: "append", - messages: [ + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Verify it WAS marked as read + expect(sock.readMessages).toHaveBeenCalledWith([ { - key: { id: "history1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "old message" }, - messageTimestamp: 1_700_000_000, - pushName: "History Sender", + remoteJid: "999@s.whatsapp.net", + id: "history1", + participant: undefined, + fromMe: false, }, - ], - }; + ]); - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); + // Verify it WAS NOT passed to onMessage + expect(onMessage).not.toHaveBeenCalled(); - // Verify it WAS marked as read - expect(sock.readMessages).toHaveBeenCalledWith([ - { - remoteJid: "999@s.whatsapp.net", - id: "history1", - participant: undefined, - fromMe: false, - }, - ]); - - // Verify it WAS NOT passed to onMessage - expect(onMessage).not.toHaveBeenCalled(); - - await listener.close(); + await listener.close(); + }); }); diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index d36a51f66..e7c3a2ba1 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -8,15 +8,16 @@ vi.mock("./media.js", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), })); -import { sendMessageWhatsApp } from "./outbound.js"; +import { sendMessageWhatsApp, sendPollWhatsApp } from "./outbound.js"; describe("web outbound", () => { const sendComposingTo = vi.fn(async () => {}); const sendMessage = vi.fn(async () => ({ messageId: "msg123" })); + const sendPoll = vi.fn(async () => ({ messageId: "poll123" })); beforeEach(() => { vi.clearAllMocks(); - setActiveWebListener({ sendComposingTo, sendMessage }); + setActiveWebListener({ sendComposingTo, sendMessage, sendPoll }); }); afterEach(() => { @@ -137,4 +138,22 @@ describe("web outbound", () => { "application/pdf", ); }); + + it("sends polls via active listener", async () => { + const result = await sendPollWhatsApp( + "+1555", + { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2 }, + { verbose: false }, + ); + expect(result).toEqual({ + messageId: "poll123", + toJid: "1555@s.whatsapp.net", + }); + expect(sendPoll).toHaveBeenCalledWith("+1555", { + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 2, + durationHours: undefined, + }); + }); }); diff --git a/src/web/outbound.ts b/src/web/outbound.ts index a8c3c076b..120184c5f 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; +import { normalizePollInput, type PollInput } from "../polls.js"; import { toWhatsappJid } from "../utils.js"; import { type ActiveWebSendOptions, @@ -15,12 +16,17 @@ const outboundLog = createSubsystemLogger("gateway/providers/whatsapp").child( export async function sendMessageWhatsApp( to: string, body: string, - options: { verbose: boolean; mediaUrl?: string; gifPlayback?: boolean }, + options: { + verbose: boolean; + mediaUrl?: string; + gifPlayback?: boolean; + accountId?: string; + }, ): Promise<{ messageId: string; toJid: string }> { let text = body; const correlationId = randomUUID(); const startedAt = Date.now(); - const active = getActiveWebListener(); + const active = getActiveWebListener(options.accountId); if (!active) { throw new Error( "No active gateway listener. Start the gateway before sending WhatsApp messages.", @@ -85,3 +91,49 @@ export async function sendMessageWhatsApp( throw err; } } +export async function sendPollWhatsApp( + to: string, + poll: PollInput, + options: { verbose: boolean; accountId?: string }, +): Promise<{ messageId: string; toJid: string }> { + const correlationId = randomUUID(); + const startedAt = Date.now(); + const active = getActiveWebListener(options.accountId); + if (!active) { + throw new Error( + "No active gateway listener. Start the gateway before sending WhatsApp polls.", + ); + } + const logger = getChildLogger({ + module: "web-outbound", + correlationId, + to, + }); + try { + const jid = toWhatsappJid(to); + const normalized = normalizePollInput(poll, { maxOptions: 12 }); + outboundLog.info(`Sending poll -> ${jid}: "${normalized.question}"`); + logger.info( + { + jid, + question: normalized.question, + optionCount: normalized.options.length, + maxSelections: normalized.maxSelections, + }, + "sending poll", + ); + const result = await active.sendPoll(to, normalized); + const messageId = + (result as { messageId?: string })?.messageId ?? "unknown"; + const durationMs = Date.now() - startedAt; + outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`); + logger.info({ jid, messageId }, "sent poll"); + return { messageId, toJid: jid }; + } catch (err) { + logger.error( + { err: String(err), to, question: poll.question }, + "failed to send poll via web session", + ); + throw err; + } +} diff --git a/src/web/session.test.ts b/src/web/session.test.ts index 1f2c29725..04efe1c56 100644 --- a/src/web/session.test.ts +++ b/src/web/session.test.ts @@ -70,22 +70,28 @@ describe("web session", () => { }); it("logWebSelfId prints cached E.164 when creds exist", () => { - const existsSpy = vi - .spyOn(fsSync, "existsSync") - .mockReturnValue(true as never); - const readSpy = vi - .spyOn(fsSync, "readFileSync") - .mockReturnValue(JSON.stringify({ me: { id: "12345@s.whatsapp.net" } })); + const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => { + if (typeof p !== "string") return false; + return p.endsWith("creds.json"); + }); + const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => { + if (typeof p === "string" && p.endsWith("creds.json")) { + return JSON.stringify({ me: { id: "12345@s.whatsapp.net" } }); + } + throw new Error(`unexpected readFileSync path: ${String(p)}`); + }); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; - logWebSelfId(runtime as never, true); + logWebSelfId("/tmp/wa-creds", runtime as never, true); expect(runtime.log).toHaveBeenCalledWith( - "Web Provider: +12345 (jid 12345@s.whatsapp.net)", + expect.stringContaining( + "Web Provider: +12345 (jid 12345@s.whatsapp.net)", + ), ); existsSpy.mockRestore(); readSpy.mockRestore(); @@ -111,7 +117,13 @@ describe("web session", () => { }); it("does not clobber creds backup when creds.json is corrupted", async () => { - const credsSuffix = path.join(".clawdbot", "credentials", "creds.json"); + const credsSuffix = path.join( + ".clawdbot", + "credentials", + "whatsapp", + "default", + "creds.json", + ); const copySpy = vi .spyOn(fsSync, "copyFileSync") @@ -191,10 +203,18 @@ describe("web session", () => { }); it("rotates creds backup when creds.json is valid JSON", async () => { - const credsSuffix = path.join(".clawdbot", "credentials", "creds.json"); + const credsSuffix = path.join( + ".clawdbot", + "credentials", + "whatsapp", + "default", + "creds.json", + ); const backupSuffix = path.join( ".clawdbot", "credentials", + "whatsapp", + "default", "creds.json.bak", ); diff --git a/src/web/session.ts b/src/web/session.ts index 6b69aa2cf..906f00875 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -10,41 +10,37 @@ import { useMultiFileAuthState, } from "@whiskeysockets/baileys"; import qrcode from "qrcode-terminal"; - -import { resolveDefaultSessionStorePath } from "../config/sessions.js"; +import { resolveOAuthDir } from "../config/paths.js"; import { danger, info, success } from "../globals.js"; import { getChildLogger, toPinoLikeLogger } from "../logging.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import type { Provider } from "../utils.js"; -import { - CONFIG_DIR, - ensureDir, - jidToE164, - resolveConfigDir, -} from "../utils.js"; +import { ensureDir, jidToE164, resolveUserPath } from "../utils.js"; import { VERSION } from "../version.js"; -export function resolveWebAuthDir() { - return path.join(resolveConfigDir(), "credentials"); +function resolveDefaultWebAuthDir(): string { + return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID); } -function resolveWebCredsPath() { - return path.join(resolveWebAuthDir(), "creds.json"); +export const WA_WEB_AUTH_DIR = resolveDefaultWebAuthDir(); + +function resolveWebCredsPath(authDir: string) { + return path.join(authDir, "creds.json"); } -function resolveWebCredsBackupPath() { - return path.join(resolveWebAuthDir(), "creds.json.bak"); +function resolveWebCredsBackupPath(authDir: string) { + return path.join(authDir, "creds.json.bak"); } -export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials"); - let credsSaveQueue: Promise = Promise.resolve(); function enqueueSaveCreds( + authDir: string, saveCreds: () => Promise | void, logger: ReturnType, ): void { credsSaveQueue = credsSaveQueue - .then(() => safeSaveCreds(saveCreds, logger)) + .then(() => safeSaveCreds(authDir, saveCreds, logger)) .catch((err) => { logger.warn({ error: String(err) }, "WhatsApp creds save queue error"); }); @@ -62,11 +58,12 @@ function readCredsJsonRaw(filePath: string): string | null { } function maybeRestoreCredsFromBackup( + authDir: string, logger: ReturnType, ): void { try { - const credsPath = resolveWebCredsPath(); - const backupPath = resolveWebCredsBackupPath(); + const credsPath = resolveWebCredsPath(authDir); + const backupPath = resolveWebCredsBackupPath(authDir); const raw = readCredsJsonRaw(credsPath); if (raw) { // Validate that creds.json is parseable. @@ -90,14 +87,15 @@ function maybeRestoreCredsFromBackup( } async function safeSaveCreds( + authDir: string, saveCreds: () => Promise | void, logger: ReturnType, ): Promise { try { // Best-effort backup so we can recover after abrupt restarts. // Important: don't clobber a good backup with a corrupted/truncated creds.json. - const credsPath = resolveWebCredsPath(); - const backupPath = resolveWebCredsBackupPath(); + const credsPath = resolveWebCredsPath(authDir); + const backupPath = resolveWebCredsBackupPath(authDir); const raw = readCredsJsonRaw(credsPath); if (raw) { try { @@ -124,7 +122,7 @@ async function safeSaveCreds( export async function createWaSocket( printQr: boolean, verbose: boolean, - opts: { onQr?: (qr: string) => void } = {}, + opts: { authDir?: string; onQr?: (qr: string) => void } = {}, ) { const baseLogger = getChildLogger( { module: "baileys" }, @@ -133,10 +131,10 @@ export async function createWaSocket( }, ); const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent"); - const authDir = resolveWebAuthDir(); + const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir()); await ensureDir(authDir); const sessionLogger = getChildLogger({ module: "web-session" }); - maybeRestoreCredsFromBackup(sessionLogger); + maybeRestoreCredsFromBackup(authDir, sessionLogger); const { state, saveCreds } = await useMultiFileAuthState(authDir); const { version } = await fetchLatestBaileysVersion(); const sock = makeWASocket({ @@ -152,7 +150,9 @@ export async function createWaSocket( markOnlineOnConnect: false, }); - sock.ev.on("creds.update", () => enqueueSaveCreds(saveCreds, sessionLogger)); + sock.ev.on("creds.update", () => + enqueueSaveCreds(authDir, saveCreds, sessionLogger), + ); sock.ev.on( "connection.update", (update: Partial) => { @@ -330,13 +330,15 @@ export function formatError(err: unknown): string { return safeStringify(err); } -export async function webAuthExists() { +export async function webAuthExists( + authDir: string = resolveDefaultWebAuthDir(), +) { const sessionLogger = getChildLogger({ module: "web-session" }); - maybeRestoreCredsFromBackup(sessionLogger); - const authDir = resolveWebAuthDir(); - const credsPath = resolveWebCredsPath(); + const resolvedAuthDir = resolveUserPath(authDir); + maybeRestoreCredsFromBackup(resolvedAuthDir, sessionLogger); + const credsPath = resolveWebCredsPath(resolvedAuthDir); try { - await fs.access(authDir); + await fs.access(resolvedAuthDir); } catch { return false; } @@ -351,23 +353,50 @@ export async function webAuthExists() { } } -export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) { - const exists = await webAuthExists(); +async function clearLegacyBaileysAuthState(authDir: string) { + const entries = await fs.readdir(authDir, { withFileTypes: true }); + const shouldDelete = (name: string) => { + if (name === "oauth.json") return false; + if (name === "creds.json" || name === "creds.json.bak") return true; + if (!name.endsWith(".json")) return false; + return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); + }; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isFile()) return; + if (!shouldDelete(entry.name)) return; + await fs.rm(path.join(authDir, entry.name), { force: true }); + }), + ); +} + +export async function logoutWeb(params: { + authDir?: string; + isLegacyAuthDir?: boolean; + runtime?: RuntimeEnv; +}) { + const runtime = params.runtime ?? defaultRuntime; + const resolvedAuthDir = resolveUserPath( + params.authDir ?? resolveDefaultWebAuthDir(), + ); + const exists = await webAuthExists(resolvedAuthDir); if (!exists) { runtime.log(info("No WhatsApp Web session found; nothing to delete.")); return false; } - await fs.rm(resolveWebAuthDir(), { recursive: true, force: true }); - // Also drop session store to clear lingering per-sender state after logout. - await fs.rm(resolveDefaultSessionStorePath(), { force: true }); + if (params.isLegacyAuthDir) { + await clearLegacyBaileysAuthState(resolvedAuthDir); + } else { + await fs.rm(resolvedAuthDir, { recursive: true, force: true }); + } runtime.log(success("Cleared WhatsApp Web credentials.")); return true; } -export function readWebSelfId() { +export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) { // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. try { - const credsPath = resolveWebCredsPath(); + const credsPath = resolveWebCredsPath(resolveUserPath(authDir)); if (!fsSync.existsSync(credsPath)) { return { e164: null, jid: null } as const; } @@ -385,9 +414,13 @@ export function readWebSelfId() { * Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing. * Helpful for heartbeats/observability to spot stale credentials. */ -export function getWebAuthAgeMs(): number | null { +export function getWebAuthAgeMs( + authDir: string = resolveDefaultWebAuthDir(), +): number | null { try { - const stats = fsSync.statSync(resolveWebCredsPath()); + const stats = fsSync.statSync( + resolveWebCredsPath(resolveUserPath(authDir)), + ); return Date.now() - stats.mtimeMs; } catch { return null; @@ -399,11 +432,12 @@ export function newConnectionId() { } export function logWebSelfId( + authDir: string = resolveDefaultWebAuthDir(), runtime: RuntimeEnv = defaultRuntime, includeProviderPrefix = false, ) { // Human-friendly log of the currently linked personal web session. - const { e164, jid } = readWebSelfId(); + const { e164, jid } = readWebSelfId(authDir); const details = e164 || jid ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` @@ -412,9 +446,12 @@ export function logWebSelfId( runtime.log(info(`${prefix}${details}`)); } -export async function pickProvider(pref: Provider | "auto"): Promise { +export async function pickProvider( + pref: Provider | "auto", + authDir: string = resolveDefaultWebAuthDir(), +): Promise { const choice: Provider = pref === "auto" ? "web" : pref; - const hasWeb = await webAuthExists(); + const hasWeb = await webAuthExists(authDir); if (!hasWeb) { throw new Error( "No WhatsApp Web session found. Run `clawdbot login --verbose` to link.", diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 9f99c62c7..7536ab44b 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -52,6 +52,10 @@ import type { OnboardOptions, ResetScope, } from "../commands/onboard-types.js"; +import { + applyOpenAICodexModelDefault, + OPENAI_CODEX_DEFAULT_MODEL, +} from "../commands/openai-codex-model-default.js"; import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js"; import type { ClawdbotConfig } from "../config/config.js"; import { @@ -60,7 +64,6 @@ import { resolveGatewayPort, writeConfigFile, } from "../config/config.js"; -import type { AgentModelListConfig } from "../config/types.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; @@ -70,50 +73,6 @@ import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; import type { WizardPrompter } from "./prompts.js"; -const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.2"; - -function shouldSetOpenAICodexModel(model?: string): boolean { - const trimmed = model?.trim(); - if (!trimmed) return true; - const normalized = trimmed.toLowerCase(); - if (normalized.startsWith("openai-codex/")) return false; - if (normalized.startsWith("openai/")) return true; - return normalized === "gpt" || normalized === "gpt-mini"; -} - -function resolvePrimaryModel( - model?: AgentModelListConfig | string, -): string | undefined { - if (typeof model === "string") return model; - if (model && typeof model === "object" && typeof model.primary === "string") { - return model.primary; - } - return undefined; -} - -function applyOpenAICodexModelDefault(cfg: ClawdbotConfig): { - next: ClawdbotConfig; - changed: boolean; -} { - const current = resolvePrimaryModel(cfg.agent?.model); - if (!shouldSetOpenAICodexModel(current)) { - return { next: cfg, changed: false }; - } - return { - next: { - ...cfg, - agent: { - ...cfg.agent, - model: - cfg.agent?.model && typeof cfg.agent.model === "object" - ? { ...cfg.agent.model, primary: OPENAI_CODEX_DEFAULT_MODEL } - : { primary: OPENAI_CODEX_DEFAULT_MODEL }, - }, - }, - changed: true, - }; -} - async function warnIfModelConfigLooksOff( config: ClawdbotConfig, prompter: WizardPrompter, @@ -356,12 +315,19 @@ export async function runOnboardingWizard( "OpenAI Codex OAuth", ); const spin = prompter.progress("Starting OAuth flow…"); + let manualCodePromise: Promise | undefined; try { const creds = await loginOpenAICodex({ onAuth: async ({ url }) => { if (isRemote) { spin.stop("OAuth URL ready"); runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); + manualCodePromise = prompter + .text({ + message: "Paste the redirect URL (or authorization code)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }) + .then((value) => String(value)); } else { spin.update("Complete sign-in in browser…"); await openUrl(url); @@ -369,6 +335,9 @@ export async function runOnboardingWizard( } }, onPrompt: async (prompt) => { + if (manualCodePromise) { + return manualCodePromise; + } const code = await prompter.text({ message: prompt.message, placeholder: prompt.placeholder, @@ -438,7 +407,7 @@ export async function runOnboardingWizard( if (oauthCreds) { await writeOAuthCredentials("google-antigravity", oauthCreds); nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "google-antigravity:default", + profileId: `google-antigravity:${oauthCreds.email ?? "default"}`, provider: "google-antigravity", mode: "oauth", }); @@ -635,7 +604,9 @@ export async function runOnboardingWizard( await writeConfigFile(nextConfig); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - await ensureWorkspaceAndSessions(workspaceDir, runtime); + await ensureWorkspaceAndSessions(workspaceDir, runtime, { + skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap), + }); nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter); nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); diff --git a/test/gateway.multi.e2e.test.ts b/test/gateway.multi.e2e.test.ts index f3510f6be..7e15e96c0 100644 --- a/test/gateway.multi.e2e.test.ts +++ b/test/gateway.multi.e2e.test.ts @@ -118,10 +118,8 @@ const spawnGatewayInstance = async (name: string): Promise => { try { child = spawn( - process.execPath, + "bun", [ - "--import", - "tsx", "src/index.ts", "gateway", "--port", @@ -218,15 +216,11 @@ const runCliJson = async ( ): Promise => { const stdout: string[] = []; const stderr: string[] = []; - const child = spawn( - process.execPath, - ["--import", "tsx", "src/index.ts", ...args], - { - cwd: process.cwd(), - env: { ...process.env, ...env }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); + const child = spawn("bun", ["src/index.ts", ...args], { + cwd: process.cwd(), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); child.stdout?.setEncoding("utf8"); child.stderr?.setEncoding("utf8"); child.stdout?.on("data", (d) => stdout.push(String(d))); diff --git a/ui/index.html b/ui/index.html index 6c71e342f..411354ea1 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,6 +5,7 @@ Clawdbot Control + diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 000000000..ec5665f56 Binary files /dev/null and b/ui/public/favicon.ico differ diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 12dbdbec9..1e94013bb 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -200,6 +200,11 @@ background: rgba(245, 159, 74, 0.2); } +.btn.active { + border-color: rgba(245, 159, 74, 0.55); + background: rgba(245, 159, 74, 0.16); +} + .btn.danger { border-color: rgba(255, 107, 107, 0.45); background: rgba(255, 107, 107, 0.18); @@ -820,6 +825,13 @@ border-top: 1px solid var(--border); } +.shell--chat-focus .chat-compose { + bottom: calc(var(--shell-pad) + 8px); + padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); + border-bottom-left-radius: 18px; + border-bottom-right-radius: 18px; +} + .chat-compose__field { gap: 4px; } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index b311c1447..37e7ef6d1 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1,16 +1,33 @@ .shell { --shell-pad: 18px; --shell-gap: 18px; + --shell-nav-col: minmax(220px, 280px); + --shell-topbar-row: auto; + --shell-focus-duration: 220ms; + --shell-focus-ease: cubic-bezier(0.2, 0.85, 0.25, 1); min-height: 100vh; display: grid; - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); - grid-template-rows: auto 1fr; + grid-template-columns: var(--shell-nav-col) minmax(0, 1fr); + grid-template-rows: var(--shell-topbar-row) 1fr; grid-template-areas: "topbar topbar" "nav content"; gap: var(--shell-gap); padding: var(--shell-pad); animation: dashboard-enter 0.6s ease-out; + transition: padding var(--shell-focus-duration) var(--shell-focus-ease); +} + +.shell--chat-focus { + --shell-pad: 8px; + --shell-gap: 0px; + --shell-nav-col: 0px; + --shell-topbar-row: 0px; +} + +.shell--chat-focus .content { + padding-top: 0; + gap: 0; } .topbar { @@ -27,6 +44,23 @@ background: linear-gradient(135deg, var(--chrome), rgba(255, 255, 255, 0.02)); backdrop-filter: blur(18px); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28); + overflow: hidden; + transform-origin: top center; + transition: opacity var(--shell-focus-duration) var(--shell-focus-ease), + transform var(--shell-focus-duration) var(--shell-focus-ease), + max-height var(--shell-focus-duration) var(--shell-focus-ease), + padding var(--shell-focus-duration) var(--shell-focus-ease), + border-width var(--shell-focus-duration) var(--shell-focus-ease); + max-height: max(0px, var(--topbar-height, 92px)); +} + +.shell--chat-focus .topbar { + opacity: 0; + transform: translateY(-10px); + max-height: 0px; + padding: 0; + border-width: 0; + pointer-events: none; } .brand { @@ -72,6 +106,23 @@ background: var(--panel); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); + transform-origin: left center; + transition: opacity var(--shell-focus-duration) var(--shell-focus-ease), + transform var(--shell-focus-duration) var(--shell-focus-ease), + max-width var(--shell-focus-duration) var(--shell-focus-ease), + padding var(--shell-focus-duration) var(--shell-focus-ease), + border-width var(--shell-focus-duration) var(--shell-focus-ease); + max-width: 320px; +} + +.shell--chat-focus .nav { + opacity: 0; + transform: translateX(-12px); + max-width: 0px; + padding: 0; + border-width: 0; + overflow: hidden; + pointer-events: none; } .nav-group { @@ -163,6 +214,21 @@ justify-content: space-between; gap: 12px; padding: 0 6px; + overflow: hidden; + transform-origin: top center; + transition: opacity var(--shell-focus-duration) var(--shell-focus-ease), + transform var(--shell-focus-duration) var(--shell-focus-ease), + max-height var(--shell-focus-duration) var(--shell-focus-ease), + padding var(--shell-focus-duration) var(--shell-focus-ease); + max-height: 90px; +} + +.shell--chat-focus .content-header { + opacity: 0; + transform: translateY(-10px); + max-height: 0px; + padding: 0; + pointer-events: none; } .page-title { @@ -229,6 +295,7 @@ .shell { --shell-pad: 12px; --shell-gap: 12px; + --shell-nav-col: 1fr; grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; grid-template-areas: diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index de9654498..eb2d48cc8 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -185,9 +185,10 @@ export function renderApp(state: AppViewState) { const cronNext = state.cronStatus?.nextWakeAtMs ?? null; const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; const isChat = state.tab === "chat"; + const chatFocus = isChat && state.settings.chatFocusMode; return html` -
+
Clawdbot Control
@@ -398,10 +399,16 @@ export function renderApp(state: AppViewState) { disabledReason: chatDisabledReason, error: state.lastError, sessions: state.sessionsResult, + focusMode: state.settings.chatFocusMode, onRefresh: () => { state.resetToolStream(); return loadChatHistory(state); }, + onToggleFocusMode: () => + state.applySettings({ + ...state.settings, + chatFocusMode: !state.settings.chatFocusMode, + }), onDraftChange: (next) => (state.chatMessage = next), onSend: () => state.handleSendChat(), }) diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 2c0dab719..660fe5ec4 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -161,7 +161,7 @@ const DEFAULT_CRON_FORM: CronFormState = { payloadKind: "systemEvent", payloadText: "", deliver: false, - channel: "last", + provider: "last", to: "", timeoutSeconds: "", postToMainPrefix: "", @@ -437,25 +437,41 @@ export class ClawdbotApp extends LitElement { clearTimeout(this.chatScrollTimeout); this.chatScrollTimeout = null; } - this.chatScrollFrame = requestAnimationFrame(() => { - this.chatScrollFrame = null; + const pickScrollTarget = () => { const container = this.querySelector(".chat-thread") as HTMLElement | null; - if (!container) return; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - const shouldStick = force || distanceFromBottom < 140; - if (!shouldStick) return; - if (force) this.chatHasAutoScrolled = true; - container.scrollTop = container.scrollHeight; - this.chatScrollTimeout = window.setTimeout(() => { - this.chatScrollTimeout = null; - const latest = this.querySelector(".chat-thread") as HTMLElement | null; - if (!latest) return; - const latestDistanceFromBottom = - latest.scrollHeight - latest.scrollTop - latest.clientHeight; - if (!force && latestDistanceFromBottom >= 180) return; - latest.scrollTop = latest.scrollHeight; - }, 120); + if (container) { + const overflowY = getComputedStyle(container).overflowY; + const canScroll = + overflowY === "auto" || + overflowY === "scroll" || + container.scrollHeight - container.clientHeight > 1; + if (canScroll) return container; + } + return (document.scrollingElement ?? document.documentElement) as HTMLElement | null; + }; + // Wait for Lit render to complete, then scroll + void this.updateComplete.then(() => { + this.chatScrollFrame = requestAnimationFrame(() => { + this.chatScrollFrame = null; + const target = pickScrollTarget(); + if (!target) return; + const distanceFromBottom = + target.scrollHeight - target.scrollTop - target.clientHeight; + const shouldStick = force || distanceFromBottom < 200; + if (!shouldStick) return; + if (force) this.chatHasAutoScrolled = true; + target.scrollTop = target.scrollHeight; + const retryDelay = force ? 150 : 120; + this.chatScrollTimeout = window.setTimeout(() => { + this.chatScrollTimeout = null; + const latest = pickScrollTarget(); + if (!latest) return; + const latestDistanceFromBottom = + latest.scrollHeight - latest.scrollTop - latest.clientHeight; + if (!force && latestDistanceFromBottom >= 250) return; + latest.scrollTop = latest.scrollHeight; + }, retryDelay); + }); }); } @@ -689,7 +705,7 @@ export class ClawdbotApp extends LitElement { if (this.tab === "nodes") await loadNodes(this); if (this.tab === "chat") { await Promise.all([loadChatHistory(this), loadSessions(this)]); - this.scheduleChatScroll(); + this.scheduleChatScroll(!this.chatHasAutoScrolled); } if (this.tab === "config") { await loadConfigSchema(this); diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 8de0b25ab..8d012e488 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -70,10 +70,19 @@ describe("config form renderer", () => { ); const select = container.querySelector("select") as HTMLSelectElement | null; - expect(select).not.toBeNull(); - if (!select) return; - select.value = "token"; - select.dispatchEvent(new Event("change", { bubbles: true })); + const selects = Array.from(container.querySelectorAll("select")); + const modeSelect = selects.find((el) => + Array.from(el.options).some((opt) => opt.textContent?.trim() === "token"), + ) as HTMLSelectElement | undefined; + expect(modeSelect).not.toBeUndefined(); + if (!modeSelect) return; + const tokenOption = Array.from(modeSelect.options).find( + (opt) => opt.textContent?.trim() === "token", + ); + expect(tokenOption).not.toBeUndefined(); + if (!tokenOption) return; + modeSelect.value = tokenOption.value; + modeSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["mode"], "token"); const checkbox = container.querySelector( @@ -133,11 +142,16 @@ describe("config form renderer", () => { const selects = Array.from(container.querySelectorAll("select")); const bindSelect = selects.find((el) => - Array.from(el.options).some((opt) => opt.value === "tailnet"), + Array.from(el.options).some((opt) => opt.textContent?.trim() === "tailnet"), ) as HTMLSelectElement | undefined; expect(bindSelect).not.toBeUndefined(); if (!bindSelect) return; - bindSelect.value = "tailnet"; + const tailnetOption = Array.from(bindSelect.options).find( + (opt) => opt.textContent?.trim() === "tailnet", + ); + expect(tailnetOption).not.toBeUndefined(); + if (!tailnetOption) return; + bindSelect.value = tailnetOption.value; bindSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet"); }); @@ -181,7 +195,7 @@ describe("config form renderer", () => { type: "object", properties: { mixed: { - anyOf: [{ type: "string" }, { type: "number" }], + anyOf: [{ type: "string" }, { type: "object", properties: {} }], }, }, }; diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index f0d24d472..342a1c8f0 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -73,7 +73,7 @@ export function buildCronPayload(form: CronFormState) { kind: "agentTurn"; message: string; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" @@ -85,7 +85,7 @@ export function buildCronPayload(form: CronFormState) { timeoutSeconds?: number; } = { kind: "agentTurn", message }; if (form.deliver) payload.deliver = true; - if (form.channel) payload.channel = form.channel; + if (form.provider) payload.provider = form.provider; if (form.to.trim()) payload.to = form.to.trim(); const timeoutSeconds = toNumber(form.timeoutSeconds, 0); if (timeoutSeconds > 0) payload.timeoutSeconds = timeoutSeconds; diff --git a/ui/src/ui/focus-mode.browser.test.ts b/ui/src/ui/focus-mode.browser.test.ts new file mode 100644 index 000000000..334dde30e --- /dev/null +++ b/ui/src/ui/focus-mode.browser.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ClawdbotApp } from "./app"; + +const originalConnect = ClawdbotApp.prototype.connect; + +function mountApp(pathname: string) { + window.history.replaceState({}, "", pathname); + const app = document.createElement("clawdbot-app") as ClawdbotApp; + document.body.append(app); + return app; +} + +beforeEach(() => { + ClawdbotApp.prototype.connect = () => { + // no-op: avoid real gateway WS connections in browser tests + }; + window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +afterEach(() => { + ClawdbotApp.prototype.connect = originalConnect; + window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +describe("chat focus mode", () => { + it("collapses header + sidebar on chat tab only", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const shell = app.querySelector(".shell"); + expect(shell).not.toBeNull(); + expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + + const toggle = app.querySelector( + 'button[title^="Toggle focus mode"]', + ); + expect(toggle).not.toBeNull(); + toggle?.click(); + + await app.updateComplete; + expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + + const link = app.querySelector('a.nav-item[href="/connections"]'); + expect(link).not.toBeNull(); + link?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), + ); + + await app.updateComplete; + expect(app.tab).toBe("connections"); + expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + + const chatLink = app.querySelector('a.nav-item[href="/chat"]'); + chatLink?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), + ); + + await app.updateComplete; + expect(app.tab).toBe("chat"); + expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + }); +}); + diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index f7b522b4c..6c3b68b0c 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -22,12 +22,14 @@ beforeEach(() => { // no-op: avoid real gateway WS connections in browser tests }; window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); document.body.innerHTML = ""; }); afterEach(() => { ClawdbotApp.prototype.connect = originalConnect; window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); document.body.innerHTML = ""; }); @@ -102,13 +104,19 @@ describe("control UI routing", () => { })); await app.updateComplete; - await nextFrame(); + for (let i = 0; i < 6; i++) { + await nextFrame(); + } const container = app.querySelector(".chat-thread") as HTMLElement | null; expect(container).not.toBeNull(); if (!container) return; const maxScroll = container.scrollHeight - container.clientHeight; expect(maxScroll).toBeGreaterThan(0); + for (let i = 0; i < 10; i++) { + if (container.scrollTop === maxScroll) break; + await nextFrame(); + } expect(container.scrollTop).toBe(maxScroll); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 2cb066ace..b77dd96d4 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -7,6 +7,7 @@ export type UiSettings = { token: string; sessionKey: string; theme: ThemeMode; + chatFocusMode: boolean; }; export function loadSettings(): UiSettings { @@ -20,6 +21,7 @@ export function loadSettings(): UiSettings { token: "", sessionKey: "main", theme: "system", + chatFocusMode: false, }; try { @@ -42,6 +44,10 @@ export function loadSettings(): UiSettings { parsed.theme === "system" ? parsed.theme : defaults.theme, + chatFocusMode: + typeof parsed.chatFocusMode === "boolean" + ? parsed.chatFocusMode + : defaults.chatFocusMode, }; } catch { return defaults; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index bd3a002f1..7611a93d3 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -271,7 +271,7 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - channel?: + provider?: | "last" | "whatsapp" | "telegram" diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 90a1372d8..e78c584cf 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -162,7 +162,7 @@ export type CronFormState = { payloadKind: "systemEvent" | "agentTurn"; payloadText: string; deliver: boolean; - channel: + provider: | "last" | "whatsapp" | "telegram" diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index dd20de44b..59d9fc1aa 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -22,13 +22,14 @@ export type ChatProps = { disabledReason: string | null; error: string | null; sessions: SessionsListResult | null; + focusMode: boolean; onRefresh: () => void; + onToggleFocusMode: () => void; onDraftChange: (next: string) => void; onSend: () => void; }; export function renderChat(props: ChatProps) { - const canInteract = props.connected; const canCompose = props.connected && !props.sending; const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions); const composePlaceholder = props.connected @@ -43,7 +44,7 @@ export function renderChat(props: ChatProps) { Session Key = 0 ? String(currentIndex) : ""} + ?disabled=${disabled} + @change=${(e: Event) => { + const idx = (e.target as HTMLSelectElement).value; + onPatch(path, idx === "" ? undefined : literals[Number(idx)]); + }} + > + + ${literals.map( + (opt, i) => html``, + )} + + + `; + } + + const primitiveTypes = ["string", "number", "integer", "boolean"]; + const allPrimitive = nonNull.every((v) => v.type && primitiveTypes.includes(String(v.type))); + if (allPrimitive) { + const typeHint = nonNull.map((v) => v.type).join(" | "); + const hasBoolean = nonNull.some((v) => v.type === "boolean"); + const hasNumber = nonNull.some((v) => v.type === "number" || v.type === "integer"); + const isInteger = nonNull.every((v) => v.type !== "number"); + return html` + + `; + } + + return html`
+ ${label}: unsupported schema node. Use Raw. +
`; + } + + if (schema.allOf) { return html`
${label}: unsupported schema node. Use Raw.
`; @@ -182,18 +270,26 @@ function renderNode(params: { } if (schema.enum) { + const enumValues = schema.enum; + const currentIndex = enumValues.findIndex( + (v) => v === value || String(v) === String(value), + ); + const unsetValue = "__unset__"; return html` @@ -327,7 +423,7 @@ function renderMapField(params: { disabled: boolean; reservedKeys: Set; onPatch: (path: Array, value: unknown) => void; -}) { +}): TemplateResult { const { schema, value, @@ -517,7 +613,8 @@ function normalizeUnion( schema: JsonSchema, path: Array, ): ConfigSchemaAnalysis | null { - const variants = schema.anyOf ?? schema.oneOf ?? schema.allOf; + if (schema.allOf) return null; + const variants = schema.anyOf ?? schema.oneOf; if (!variants) return null; const values: unknown[] = []; const nonLiteral: JsonSchema[] = []; @@ -568,11 +665,22 @@ function normalizeUnion( if (nonLiteral.length === 1) { const result = normalizeSchemaNode(nonLiteral[0], path); if (result.schema) { - result.schema.nullable = true; + result.schema.nullable = nullable || result.schema.nullable; } return result; } + const primitiveTypes = ["string", "number", "integer", "boolean"]; + const allPrimitive = nonLiteral.every( + (v) => v.type && primitiveTypes.includes(String(v.type)), + ); + if (allPrimitive && nonLiteral.length > 0 && values.length === 0) { + return { + schema: { ...schema, nullable }, + unsupportedPaths: [], + }; + } + return null; } diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index c975c4631..78a8b41cc 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -17,7 +17,9 @@ describe("config view", () => { schema: { type: "object", properties: { - mixed: { anyOf: [{ type: "string" }, { type: "number" }] }, + mixed: { + anyOf: [{ type: "string" }, { type: "object", properties: {} }], + }, }, }, schemaLoading: false, diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index cbb2efc25..53d7bf199 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -174,12 +174,12 @@ export function renderCron(props: CronProps) { />