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/CHANGELOG.md b/CHANGELOG.md index 0ce0c6d0a..2da301e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - 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 - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. @@ -85,6 +86,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. @@ -108,6 +110,11 @@ - 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/README.md b/README.md index 3ec3328be..06007d86b 100644 --- a/README.md +++ b/README.md @@ -48,35 +48,43 @@ pnpm clawdbot onboard ## Quick start (from source) -Runtime: **Node ≥22** + **pnpm**. +Runtime: **Node ≥22**. + +From source, **pnpm** is the default workflow. Bun is supported as an optional local workflow; see [`docs/bun.md`](docs/bun.md). ```bash -pnpm install -pnpm build -pnpm ui:build +# Install deps (no Bun lockfile) +bun install --no-save + +# Build TypeScript +bun run build + +# Build Control UI +bun install --cwd ui --no-save +bun run --cwd ui build # Recommended: run the onboarding wizard -pnpm clawdbot onboard +bun run clawdbot onboard # Link WhatsApp (stores creds in ~/.clawdbot/credentials) -pnpm clawdbot login +bun run 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`. -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`). ## Highlights diff --git a/docs/bun.md b/docs/bun.md new file mode 100644 index 000000000..a3350357a --- /dev/null +++ b/docs/bun.md @@ -0,0 +1,56 @@ +# Bun (optional) + +Goal: allow running this repo with Bun without maintaining a Bun lockfile or losing pnpm patch behavior. + +## Status + +- pnpm remains the primary package manager/runtime for this repo. +- Bun can be used for local installs/builds/tests, but Bun currently **cannot use** `pnpm-lock.yaml` and will ignore it. + +## Install (no Bun lockfile) + +Use Bun without writing `bun.lock`/`bun.lockb`: + +```sh +bun install --no-save +``` + +This avoids maintaining two lockfiles. (`bun.lock`/`bun.lockb` are gitignored.) + +## 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` 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/configuration.md b/docs/configuration.md index 70eabc4e7..bde5740f9 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`) @@ -218,7 +218,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 diff --git a/docs/grammy.md b/docs/grammy.md index fb212f3ec..7e0c3366a 100644 --- a/docs/grammy.md +++ b/docs/grammy.md @@ -18,7 +18,7 @@ Updated: 2025-12-07 - **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`. +- **Config knobs:** `telegram.botToken`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `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..254439124 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -10,8 +10,8 @@ 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. +- 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 allowlist: `whatsapp.groups` gates which group JIDs are allowed; `whatsapp.allowFrom` still gates participants for direct chats. - 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. - 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. diff --git a/docs/health.md b/docs/health.md index d880e8955..761dcd3aa 100644 --- a/docs/health.md +++ b/docs/health.md @@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## 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/imessage.md b/docs/imessage.md index f602f6de7..611858f6f 100644 --- a/docs/imessage.md +++ b/docs/imessage.md @@ -55,7 +55,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/telegram.md b/docs/telegram.md index 058a5c36e..45c83afc4 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -24,7 +24,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - 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`). +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) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). ## Capabilities & limits (Bot API) @@ -37,7 +37,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - 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). - 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.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. - Mention gating precedence (most specific wins): `telegram.groups..requireMention` → `telegram.groups."*".requireMention` → default `true`. Example config: @@ -48,7 +48,7 @@ Example config: botToken: "123:abc", replyToMode: "off", groups: { - "*": { requireMention: true }, + "*": { requireMention: true }, // allow all groups "123456789": { requireMention: false } // group chat id }, allowFrom: ["123456789"], // direct chat ids allowed (or "*") @@ -65,7 +65,7 @@ Example config: ## 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`) or use a `routing.groupChat.mentionPatterns` trigger; 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/whatsapp.md b/docs/whatsapp.md index 0594fb583..454e83a21 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -118,7 +118,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Config quick map - `whatsapp.allowFrom` (DM allowlist). -- `whatsapp.groups` (group mention gating defaults/overrides) +- `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `routing.groupChat.mentionPatterns` - `routing.groupChat.historyLimit` - `messages.messagePrefix` (inbound prefix) diff --git a/package.json b/package.json index 5ddc0c3a7..ce18123ce 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ ], "scripts": { "dev": "tsx src/entry.ts", + "postinstall": "node scripts/postinstall.js", "docs:list": "tsx scripts/docs-list.ts", "docs:dev": "cd docs && mint dev", "docs:build": "cd docs && pnpm dlx mint broken-links", diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 000000000..8dd5e8b2d --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,106 @@ +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}`); + } + + const targetDir = path.resolve(opts.targetDir); + if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) { + console.warn(`[postinstall] skip missing target: ${targetDir}`); + return; + } + + 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); +}