Merge branch 'main' into commands-list-clean
This commit is contained in:
@@ -81,6 +81,8 @@
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Doctor/Daemon: audit supervisor configs, recommend doctor from daemon status, and document user vs system services. (#?) — thanks @steipete
|
||||
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
|
||||
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
|
||||
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
|
||||
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
|
||||
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
|
||||
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
|
||||
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
|
||||
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
|
||||
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
|
||||
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
|
||||
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
|
||||
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
|
||||
@@ -19,6 +22,7 @@
|
||||
- Control UI: add Docs link, remove chat composer divider, and add New session button.
|
||||
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
|
||||
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
|
||||
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
|
||||
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
|
||||
- Onboarding: tighten QuickStart hint copy for configuring later.
|
||||
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
|
||||
|
||||
@@ -6,62 +6,85 @@ read_when:
|
||||
---
|
||||
# Cron jobs (Gateway scheduler)
|
||||
|
||||
Cron runs inside the Gateway and schedules background work so Clawdbot can
|
||||
wake itself up, run isolated agent jobs, and deliver reminders on time.
|
||||
Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at
|
||||
the right time, and can optionally deliver output back to a chat.
|
||||
|
||||
## Update checklist (internal)
|
||||
- [x] Audit cron + heartbeat behavior in code
|
||||
- [x] Rewrite cron doc as user-facing feature
|
||||
- [x] Update heartbeat docs + templates
|
||||
- [x] Update cron links in docs
|
||||
- [x] Update changelog
|
||||
- [x] Run full gate (lint/build/test/docs)
|
||||
If you want *“run this every morning”* or *“poke the agent in 20 minutes”*,
|
||||
cron is the mechanism.
|
||||
|
||||
## What cron is
|
||||
- **Gateway-owned scheduler** that persists jobs under `~/.clawdbot/cron/`.
|
||||
- **Two execution modes**:
|
||||
- **Main session jobs** enqueue `System:` events and rely on the heartbeat runner.
|
||||
- **Isolated jobs** run a dedicated agent turn in `cron:<jobId>` sessions.
|
||||
- **Wakeups** are first-class: a job can trigger the next heartbeat or run it now.
|
||||
## TL;DR
|
||||
- Cron runs **inside the Gateway** (not inside the model).
|
||||
- Jobs persist under `~/.clawdbot/cron/` so restarts don’t lose schedules.
|
||||
- Two execution styles:
|
||||
- **Main session**: enqueue a system event, then run on the next heartbeat.
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
|
||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||
|
||||
## When to use it
|
||||
- Recurring reminders: “every weekday at 7:30” or “every 2h.”
|
||||
- Background chores: summarize inboxes, check dashboards, watch logs.
|
||||
- Automation that should not pollute the main chat history.
|
||||
- Scheduled wakeups that drive the heartbeat pipeline.
|
||||
## Concepts
|
||||
|
||||
## Schedules
|
||||
### Jobs
|
||||
A cron job is a stored record with:
|
||||
- a **schedule** (when it should run),
|
||||
- a **payload** (what it should do),
|
||||
- optional **delivery** (where output should be sent).
|
||||
|
||||
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
|
||||
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
|
||||
|
||||
### Schedules
|
||||
Cron supports three schedule kinds:
|
||||
- `at`: one-shot timestamp in ms.
|
||||
- `at`: one-shot timestamp (ms since epoch).
|
||||
- `every`: fixed interval (ms).
|
||||
- `cron`: 5-field cron expression, optional IANA timezone.
|
||||
- `cron`: 5-field cron expression with optional IANA timezone.
|
||||
|
||||
Cron expressions use `croner` under the hood. If a timezone is omitted, the
|
||||
server’s local timezone is used.
|
||||
Cron expressions use `croner`. If a timezone is omitted, the Gateway host’s
|
||||
local timezone is used.
|
||||
|
||||
## Job types
|
||||
### Main vs isolated execution
|
||||
|
||||
### Main session jobs
|
||||
#### Main session jobs (system events)
|
||||
Main jobs enqueue a system event and optionally wake the heartbeat runner.
|
||||
They **must** use `payload.kind = "systemEvent"`.
|
||||
They must use `payload.kind = "systemEvent"`.
|
||||
|
||||
- **`wakeMode: "next-heartbeat"`** (default): the event waits for the next
|
||||
scheduled heartbeat.
|
||||
- **`wakeMode: "now"`**: the event triggers an immediate heartbeat run.
|
||||
- `wakeMode: "next-heartbeat"` (default): event waits for the next scheduled heartbeat.
|
||||
- `wakeMode: "now"`: event triggers an immediate heartbeat run.
|
||||
|
||||
### Isolated jobs
|
||||
Isolated jobs run a dedicated agent turn in session `cron:<jobId>` and can
|
||||
optionally deliver a message.
|
||||
This is the best fit when you want the normal heartbeat prompt + main-session context.
|
||||
See [Heartbeat](/gateway/heartbeat).
|
||||
|
||||
#### Isolated jobs (dedicated cron sessions)
|
||||
Isolated jobs run a dedicated agent turn in session `cron:<jobId>`.
|
||||
|
||||
Key behaviors:
|
||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||
- A summary is posted to the main session with prefix `Cron` (or
|
||||
`isolation.postToMainPrefix`).
|
||||
- A summary is posted to the main session (prefix `Cron`, configurable).
|
||||
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
|
||||
- `payload.deliver: true` sends output to a provider; otherwise it stays internal.
|
||||
- If `payload.deliver: true`, output is delivered to a provider; otherwise it stays internal.
|
||||
|
||||
Use isolated jobs for noisy, frequent, or “background chores” that shouldn’t spam
|
||||
your main chat history.
|
||||
|
||||
### Delivery (provider + target)
|
||||
Isolated jobs can deliver output to a provider. The job payload can specify:
|
||||
- `provider`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
|
||||
- `to`: provider-specific recipient target
|
||||
|
||||
If `provider` or `to` is omitted, cron can fall back to the main session’s “last route”
|
||||
(the last place the agent replied).
|
||||
|
||||
#### Telegram delivery targets (topics / forum threads)
|
||||
Telegram supports forum topics via `message_thread_id`. For cron delivery, you can encode
|
||||
the topic/thread into the `to` field:
|
||||
|
||||
- `-1001234567890` (chat id only)
|
||||
- `-1001234567890:topic:123` (preferred: explicit topic marker)
|
||||
- `-1001234567890:123` (shorthand: numeric suffix)
|
||||
|
||||
Internal prefixes like `telegram:...` / `telegram:group:...` are also accepted:
|
||||
- `telegram:group:-1001234567890:topic:123`
|
||||
|
||||
## Storage & history
|
||||
- Job store: `~/.clawdbot/cron/jobs.json` (JSON, Gateway-managed).
|
||||
- Job store: `~/.clawdbot/cron/jobs.json` (Gateway-managed JSON).
|
||||
- Run history: `~/.clawdbot/cron/runs/<jobId>.jsonl` (JSONL, auto-pruned).
|
||||
- Override store path: `cron.store` in config.
|
||||
|
||||
@@ -70,16 +93,16 @@ Key behaviors:
|
||||
```json5
|
||||
{
|
||||
cron: {
|
||||
enabled: true, // default true
|
||||
enabled: true, // default true
|
||||
store: "~/.clawdbot/cron/jobs.json",
|
||||
maxConcurrentRuns: 1 // default 1
|
||||
maxConcurrentRuns: 1 // default 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Disable cron entirely:
|
||||
- `cron.enabled: false` (config)
|
||||
- or `CLAWDBOT_SKIP_CRON=1` (env)
|
||||
- `CLAWDBOT_SKIP_CRON=1` (env)
|
||||
|
||||
## CLI quickstart
|
||||
|
||||
@@ -106,6 +129,19 @@ clawdbot cron add \
|
||||
--to "+15551234567"
|
||||
```
|
||||
|
||||
Recurring isolated job (deliver to a Telegram topic):
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Nightly summary (topic)" \
|
||||
--cron "0 22 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize today; send to the nightly topic." \
|
||||
--deliver \
|
||||
--provider telegram \
|
||||
--to "-1001234567890:topic:123"
|
||||
```
|
||||
|
||||
Manual run (debug):
|
||||
```bash
|
||||
clawdbot cron run <jobId> --force
|
||||
@@ -121,12 +157,19 @@ Immediate wake without creating a job:
|
||||
clawdbot wake --mode now --text "Next heartbeat: check battery."
|
||||
```
|
||||
|
||||
## API surface (Gateway)
|
||||
## Gateway API surface
|
||||
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
||||
- `cron.run` (force or due), `cron.runs`
|
||||
- `wake` (enqueue system event + optional heartbeat)
|
||||
|
||||
## Tips
|
||||
- Use **main session jobs** when you want the heartbeat prompt + existing context.
|
||||
- Use **isolated jobs** for noisy, frequent, or long-running work.
|
||||
- Keep messages short; cron turns are full agent runs and can burn tokens.
|
||||
## Troubleshooting
|
||||
|
||||
### “Nothing runs”
|
||||
- Check cron is enabled: `cron.enabled` and `CLAWDBOT_SKIP_CRON`.
|
||||
- Check the Gateway is running continuously (cron runs inside the Gateway process).
|
||||
- For `cron` schedules: confirm timezone (`--tz`) vs the host timezone.
|
||||
|
||||
### Telegram delivers to the wrong place
|
||||
- For forum topics, use `-100…:topic:<id>` so it’s explicit and unambiguous.
|
||||
- If you see `telegram:...` prefixes in logs or stored “last route” targets, that’s normal;
|
||||
cron delivery accepts them and still parses topic IDs correctly.
|
||||
|
||||
@@ -1176,6 +1176,8 @@ per session key at a time). Default: 1.
|
||||
Optional **Docker sandboxing** for the embedded agent. Intended for non-main
|
||||
sessions so they cannot access your host system.
|
||||
|
||||
Details: [Sandboxing](/gateway/sandboxing)
|
||||
|
||||
Defaults (if enabled):
|
||||
- scope: `"agent"` (one container + workspace per agent)
|
||||
- Debian bookworm-slim based image
|
||||
|
||||
@@ -23,11 +23,24 @@ clawdbot doctor --yes
|
||||
|
||||
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
|
||||
|
||||
```bash
|
||||
clawdbot doctor --repair
|
||||
```
|
||||
|
||||
Apply recommended repairs without prompting (repairs + restarts where safe).
|
||||
|
||||
```bash
|
||||
clawdbot doctor --repair --force
|
||||
```
|
||||
|
||||
Apply aggressive repairs too (overwrites custom supervisor configs).
|
||||
|
||||
```bash
|
||||
clawdbot doctor --non-interactive
|
||||
```
|
||||
|
||||
Run without prompts and only apply safe migrations (config normalization + on-disk state moves). Skips restart/service/sandbox actions that require human confirmation.
|
||||
Legacy state migrations run automatically when detected.
|
||||
|
||||
```bash
|
||||
clawdbot doctor --deep
|
||||
@@ -47,6 +60,7 @@ cat ~/.clawdbot/clawdbot.json
|
||||
- Legacy config migration and normalization.
|
||||
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
|
||||
- State integrity and permissions checks (sessions, transcripts, state dir).
|
||||
- Config file permission checks (chmod 600) when running locally.
|
||||
- Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`).
|
||||
- Sandbox image repair when sandboxing is enabled.
|
||||
- Legacy service migration and extra gateway detection.
|
||||
@@ -117,6 +131,8 @@ Doctor checks:
|
||||
split between installs).
|
||||
- **Remote mode reminder**: if `gateway.mode=remote`, doctor reminds you to run
|
||||
it on the remote host (the state lives there).
|
||||
- **Config file permissions**: warns if `~/.clawdbot/clawdbot.json` is
|
||||
group/world readable and offers to tighten to `600`.
|
||||
|
||||
### 5) Sandbox image repair
|
||||
When sandboxing is enabled, doctor checks Docker images and offers to build or
|
||||
@@ -150,6 +166,13 @@ missing or outdated defaults (e.g., systemd network-online dependencies and
|
||||
restart delay). When it finds a mismatch, it recommends an update and can
|
||||
rewrite the service file/task to the current defaults.
|
||||
|
||||
Notes:
|
||||
- `clawdbot doctor` prompts before rewriting supervisor config.
|
||||
- `clawdbot doctor --yes` accepts the default repair prompts.
|
||||
- `clawdbot doctor --repair` applies recommended fixes without prompts.
|
||||
- `clawdbot doctor --repair --force` overwrites custom supervisor configs.
|
||||
- You can always force a full rewrite via `clawdbot daemon install --force`.
|
||||
|
||||
### 12) Gateway runtime + port diagnostics
|
||||
Doctor inspects the daemon runtime (PID, last exit status) and warns when the
|
||||
service is installed but not actually running. It also checks for port collisions
|
||||
|
||||
@@ -156,6 +156,8 @@ See also: [`docs/presence.md`](/concepts/presence) for how presence is produced/
|
||||
- StandardOut/Err: file paths or `syslog`
|
||||
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
|
||||
- LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
|
||||
- `clawdbot daemon install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist`.
|
||||
- `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults.
|
||||
|
||||
## Daemon management (CLI)
|
||||
|
||||
|
||||
94
docs/gateway/sandboxing.md
Normal file
94
docs/gateway/sandboxing.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
summary: "How Clawdbot sandboxing works: modes, scopes, workspace access, and images"
|
||||
title: Sandboxing
|
||||
read_when: "You want a dedicated explanation of sandboxing or need to tune agent.sandbox."
|
||||
status: active
|
||||
---
|
||||
|
||||
# Sandboxing
|
||||
|
||||
Clawdbot can run **tools inside Docker containers** to reduce blast radius.
|
||||
This is **optional** and controlled by configuration (`agent.sandbox` or
|
||||
`routing.agents[id].sandbox`). If sandboxing is off, tools run on the host.
|
||||
The Gateway stays on the host; tool execution runs in an isolated sandbox
|
||||
when enabled.
|
||||
|
||||
This is not a perfect security boundary, but it materially limits filesystem
|
||||
and process access when the model does something dumb.
|
||||
|
||||
## What gets sandboxed
|
||||
- Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.).
|
||||
- Optional sandboxed browser (`agent.sandbox.browser`).
|
||||
|
||||
Not sandboxed:
|
||||
- The Gateway process itself.
|
||||
- Any tool explicitly allowed to run on the host (e.g. `agent.elevated`).
|
||||
|
||||
## Modes
|
||||
`agent.sandbox.mode` controls **when** sandboxing is used:
|
||||
- `"off"`: no sandboxing.
|
||||
- `"non-main"`: sandbox only **non-main** sessions (default if you want normal chats on host).
|
||||
- `"all"`: every session runs in a sandbox.
|
||||
|
||||
## Scope
|
||||
`agent.sandbox.scope` controls **how many containers** are created:
|
||||
- `"session"` (default): one container per session.
|
||||
- `"agent"`: one container per agent.
|
||||
- `"shared"`: one container shared by all sandboxed sessions.
|
||||
|
||||
## Workspace access
|
||||
`agent.sandbox.workspaceAccess` controls **what the sandbox can see**:
|
||||
- `"none"` (default): tools see a sandbox workspace under `~/.clawdbot/sandboxes`.
|
||||
- `"ro"`: mounts the agent workspace read-only at `/agent` (disables `write`/`edit`).
|
||||
- `"rw"`: mounts the agent workspace read/write at `/workspace`.
|
||||
|
||||
Inbound media is copied into the active sandbox workspace (`media/inbound/*`).
|
||||
|
||||
## Images + setup
|
||||
Default image: `clawdbot-sandbox:bookworm-slim`
|
||||
|
||||
Build it once:
|
||||
```bash
|
||||
scripts/sandbox-setup.sh
|
||||
```
|
||||
|
||||
Sandboxed browser image:
|
||||
```bash
|
||||
scripts/sandbox-browser-setup.sh
|
||||
```
|
||||
|
||||
By default, sandbox containers run with **no network**.
|
||||
Override with `agent.sandbox.docker.network`.
|
||||
|
||||
Docker installs and the containerized gateway live here:
|
||||
[Docker](/install/docker)
|
||||
|
||||
## Tool policy + escape hatches
|
||||
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
||||
globally or per-agent, sandboxing doesn’t bring it back.
|
||||
|
||||
`agent.elevated` is an explicit escape hatch that runs `bash` on the host.
|
||||
Keep it locked down.
|
||||
|
||||
## Multi-agent overrides
|
||||
Each agent can override sandbox + tools:
|
||||
`routing.agents[id].sandbox` and `routing.agents[id].tools`.
|
||||
See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for precedence.
|
||||
|
||||
## Minimal enable example
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
sandbox: {
|
||||
mode: "non-main",
|
||||
scope: "session",
|
||||
workspaceAccess: "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related docs
|
||||
- [Sandbox Configuration](/gateway/configuration#agent-sandbox)
|
||||
- [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools)
|
||||
- [Security](/gateway/security)
|
||||
@@ -95,6 +95,14 @@ This is social engineering 101. Create distrust, encourage snooping.
|
||||
|
||||
## Configuration Hardening (examples)
|
||||
|
||||
### 0) File permissions
|
||||
|
||||
Keep config + state private on the gateway host:
|
||||
- `~/.clawdbot/clawdbot.json`: `600` (user read/write only)
|
||||
- `~/.clawdbot`: `700` (user only)
|
||||
|
||||
`clawdbot doctor` can warn and offer to tighten these permissions.
|
||||
|
||||
### 1) DMs: pairing by default
|
||||
|
||||
```json5
|
||||
@@ -138,10 +146,12 @@ We may add a single `readOnlyMode` flag later to simplify this configuration.
|
||||
|
||||
## Sandboxing (recommended)
|
||||
|
||||
Dedicated doc: [Sandboxing](/gateway/sandboxing)
|
||||
|
||||
Two complementary approaches:
|
||||
|
||||
- **Run the full Gateway in Docker** (container boundary): [Docker](/install/docker)
|
||||
- **Tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): [Configuration](/gateway/configuration)
|
||||
- **Tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): [Sandboxing](/gateway/sandboxing)
|
||||
|
||||
Note: to prevent cross-agent access, keep `sandbox.scope` at `"agent"` (default)
|
||||
or `"session"` for stricter per-session isolation. `scope: "shared"` uses a
|
||||
|
||||
@@ -55,6 +55,10 @@ the Gateway likely refused to bind.
|
||||
- If they don’t, you’re almost certainly editing one config while the daemon is running another.
|
||||
- Fix: rerun `clawdbot daemon install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the daemon to use.
|
||||
|
||||
**If `clawdbot daemon status` reports service config issues**
|
||||
- The supervisor config (launchd/systemd/schtasks) is missing current defaults.
|
||||
- Fix: run `clawdbot doctor` to update it (or `clawdbot daemon install --force` for a full rewrite).
|
||||
|
||||
**If `Last gateway error:` mentions “refusing to bind … without auth”**
|
||||
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`auto`) but left auth off.
|
||||
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the daemon.
|
||||
|
||||
@@ -9,10 +9,18 @@ read_when:
|
||||
|
||||
Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow.
|
||||
|
||||
## Is Docker right for me?
|
||||
|
||||
- **Yes**: you want an isolated, throwaway gateway environment or to run Clawdbot on a host without local installs.
|
||||
- **No**: you’re running on your own machine and just want the fastest dev loop. Use the normal install flow instead.
|
||||
- **Sandboxing note**: agent sandboxing uses Docker too, but it does **not** require the full gateway to run in Docker. See [Sandboxing](/gateway/sandboxing).
|
||||
|
||||
This guide covers:
|
||||
- Containerized Gateway (full Clawdbot in Docker)
|
||||
- Per-session Agent Sandbox (host gateway + Docker-isolated agent tools)
|
||||
|
||||
Sandboxing details: [Sandboxing](/gateway/sandboxing)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker Desktop (or Docker Engine) + Docker Compose v2
|
||||
@@ -33,6 +41,11 @@ This script:
|
||||
- runs the onboarding wizard
|
||||
- prints optional provider setup hints
|
||||
- starts the gateway via Docker Compose
|
||||
- generates a gateway token and writes it to `.env`
|
||||
|
||||
After it finishes:
|
||||
- Open `http://127.0.0.1:18789/` in your browser.
|
||||
- Paste the token into the Control UI (Settings → token).
|
||||
|
||||
It writes config/workspace on the host:
|
||||
- `~/.clawdbot/`
|
||||
@@ -92,6 +105,8 @@ pnpm test:docker:qr
|
||||
|
||||
## Agent Sandbox (host gateway + Docker tools)
|
||||
|
||||
Deep dive: [Sandboxing](/gateway/sandboxing)
|
||||
|
||||
### What it does
|
||||
|
||||
When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker
|
||||
|
||||
@@ -18,6 +18,8 @@ This allows you to run multiple agents with different security profiles:
|
||||
- Family/work agents with restricted tools
|
||||
- Public-facing agents in sandboxes
|
||||
|
||||
For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
|
||||
|
||||
---
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
@@ -13,6 +13,15 @@ Goal: Clawdbot Gateway running on an exe.dev VM, reachable from your laptop via:
|
||||
|
||||
This page assumes **Ubuntu/Debian**. If you picked a different distro, map packages accordingly.
|
||||
|
||||
If you’re on any other Linux VPS, the same steps apply — you just won’t use the exe.dev proxy commands.
|
||||
|
||||
## Beginner quick path
|
||||
|
||||
1) Create VM → install Node 22 → install Clawdbot
|
||||
2) Run `clawdbot onboard --install-daemon`
|
||||
3) Tunnel from laptop (`ssh -N -L 18789:127.0.0.1:18789 …`)
|
||||
4) Open `http://127.0.0.1:18789/` and paste your token
|
||||
|
||||
## What you need
|
||||
|
||||
- exe.dev account + `ssh exe.dev` working on your laptop
|
||||
|
||||
@@ -10,6 +10,16 @@ Clawdbot core is fully supported on Linux. The core is written in TypeScript, so
|
||||
|
||||
We do not have a Linux companion app yet. It is planned, and we would love contributions to make it happen.
|
||||
|
||||
## Beginner quick path (VPS)
|
||||
|
||||
1) Install Node 22+
|
||||
2) `npm i -g clawdbot@latest`
|
||||
3) `clawdbot onboard --install-daemon`
|
||||
4) From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
|
||||
5) Open `http://127.0.0.1:18789/` and paste your token
|
||||
|
||||
Step-by-step VPS guide: [exe.dev](/platforms/exe-dev)
|
||||
|
||||
## Install
|
||||
- [Getting Started](/start/getting-started)
|
||||
- [Install & updates](/install/updating)
|
||||
@@ -35,12 +45,6 @@ clawdbot daemon install
|
||||
|
||||
Or:
|
||||
|
||||
```
|
||||
clawdbot daemon install
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```
|
||||
clawdbot configure
|
||||
```
|
||||
|
||||
@@ -63,6 +63,8 @@ Manager:
|
||||
Behavior:
|
||||
- “Clawdbot Active” enables/disables the LaunchAgent.
|
||||
- App quit does **not** stop the gateway (launchd keeps it alive).
|
||||
- CLI install (`clawdbot daemon install`) writes the same LaunchAgent; `clawdbot daemon install --force` rewrites it.
|
||||
- `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults.
|
||||
|
||||
Logging:
|
||||
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
||||
|
||||
@@ -36,12 +36,6 @@ clawdbot daemon install
|
||||
|
||||
Or:
|
||||
|
||||
```
|
||||
clawdbot daemon install
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```
|
||||
clawdbot configure
|
||||
```
|
||||
|
||||
@@ -89,6 +89,10 @@ It also warns if your configured model is unknown or missing auth.
|
||||
|
||||
Bun is supported for faster TypeScript execution, but **WhatsApp requires Node** in this ecosystem. The wizard lets you pick the runtime; choose **Node** if you use WhatsApp.
|
||||
|
||||
### Is there a dedicated sandboxing doc?
|
||||
|
||||
Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker).
|
||||
|
||||
## Where things live on disk
|
||||
|
||||
### Where does Clawdbot store its data?
|
||||
|
||||
@@ -80,6 +80,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [Heartbeat](https://docs.clawd.bot/gateway/heartbeat)
|
||||
- [Doctor](https://docs.clawd.bot/gateway/doctor)
|
||||
- [Logging](https://docs.clawd.bot/gateway/logging)
|
||||
- [Sandboxing](https://docs.clawd.bot/gateway/sandboxing)
|
||||
- [Dashboard](https://docs.clawd.bot/web/dashboard)
|
||||
- [Control UI](https://docs.clawd.bot/web/control-ui)
|
||||
- [Remote access](https://docs.clawd.bot/gateway/remote)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isMessagingToolDuplicate,
|
||||
normalizeTextForComparison,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
sanitizeSessionMessagesImages,
|
||||
validateGeminiTurns,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
@@ -250,6 +251,77 @@ describe("sanitizeGoogleTurnOrdering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSessionMessagesImages", () => {
|
||||
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const content = (out[0] as { content?: unknown }).content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content).toHaveLength(1);
|
||||
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
|
||||
});
|
||||
|
||||
it("filters whitespace-only assistant text blocks", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: " " },
|
||||
{ type: "text", text: "ok" },
|
||||
],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const content = (out[0] as { content?: unknown }).content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content).toHaveLength(1);
|
||||
expect((content as Array<{ text?: string }>)[0]?.text).toBe("ok");
|
||||
});
|
||||
|
||||
it("drops assistant messages that only contain empty text", async () => {
|
||||
const input = [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "" }] },
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.role).toBe("user");
|
||||
});
|
||||
|
||||
it("leaves non-assistant messages unchanged", async () => {
|
||||
const input = [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "toolResult",
|
||||
toolUseId: "tool-1",
|
||||
content: [{ type: "text", text: "result" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]?.role).toBe("user");
|
||||
expect(out[1]?.role).toBe("toolResult");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTextForComparison", () => {
|
||||
it("lowercases text", () => {
|
||||
expect(normalizeTextForComparison("Hello World")).toBe("hello world");
|
||||
|
||||
@@ -99,6 +99,28 @@ export async function sanitizeSessionMessagesImages(
|
||||
}
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const content = assistantMsg.content;
|
||||
if (Array.isArray(content)) {
|
||||
const filteredContent = content.filter((block) => {
|
||||
if (!block || typeof block !== "object") return true;
|
||||
const rec = block as { type?: unknown; text?: unknown };
|
||||
if (rec.type !== "text" || typeof rec.text !== "string") return true;
|
||||
return rec.text.trim().length > 0;
|
||||
});
|
||||
const sanitizedContent = (await sanitizeContentBlocksImages(
|
||||
filteredContent as unknown as ContentBlock[],
|
||||
label,
|
||||
)) as unknown as typeof assistantMsg.content;
|
||||
if (sanitizedContent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
out.push({ ...assistantMsg, content: sanitizedContent });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out.push(msg);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -547,14 +547,14 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
||||
defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`);
|
||||
}
|
||||
if (service.configAudit?.issues.length) {
|
||||
defaultRuntime.error(
|
||||
"Service config looks out of date or non-standard.",
|
||||
);
|
||||
defaultRuntime.error("Service config looks out of date or non-standard.");
|
||||
for (const issue of service.configAudit.issues) {
|
||||
const detail = issue.detail ? ` (${issue.detail})` : "";
|
||||
defaultRuntime.error(`Service config issue: ${issue.message}${detail}`);
|
||||
}
|
||||
defaultRuntime.error('Recommendation: run "clawdbot doctor".');
|
||||
defaultRuntime.error(
|
||||
'Recommendation: run "clawdbot doctor" (or "clawdbot doctor --repair").',
|
||||
);
|
||||
}
|
||||
if (status.config) {
|
||||
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
||||
|
||||
@@ -74,6 +74,13 @@ function parsePort(raw: unknown): number | null {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const toOptionString = (value: unknown): string | undefined => {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "bigint")
|
||||
return value.toString();
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function describeUnknownError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === "string") return err;
|
||||
@@ -338,9 +345,10 @@ async function runGatewayCommand(
|
||||
}
|
||||
}
|
||||
if (opts.token) {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token);
|
||||
const token = toOptionString(opts.token);
|
||||
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
}
|
||||
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
|
||||
const authModeRaw = toOptionString(opts.auth);
|
||||
const authMode: GatewayAuthMode | null =
|
||||
authModeRaw === "token" || authModeRaw === "password" ? authModeRaw : null;
|
||||
if (authModeRaw && !authMode) {
|
||||
@@ -348,7 +356,7 @@ async function runGatewayCommand(
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
|
||||
const tailscaleRaw = toOptionString(opts.tailscale);
|
||||
const tailscaleMode =
|
||||
tailscaleRaw === "off" ||
|
||||
tailscaleRaw === "serve" ||
|
||||
@@ -362,6 +370,8 @@ async function runGatewayCommand(
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const passwordRaw = toOptionString(opts.password);
|
||||
const tokenRaw = toOptionString(opts.token);
|
||||
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
|
||||
const mode = cfg.gateway?.mode;
|
||||
if (!opts.allowUnconfigured && mode !== "local") {
|
||||
@@ -377,7 +387,7 @@ async function runGatewayCommand(
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
|
||||
const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback";
|
||||
const bind =
|
||||
bindRaw === "loopback" ||
|
||||
bindRaw === "tailnet" ||
|
||||
@@ -398,8 +408,8 @@ async function runGatewayCommand(
|
||||
const authConfig = {
|
||||
...cfg.gateway?.auth,
|
||||
...(authMode ? { mode: authMode } : {}),
|
||||
...(opts.password ? { password: String(opts.password) } : {}),
|
||||
...(opts.token ? { token: String(opts.token) } : {}),
|
||||
...(passwordRaw ? { password: passwordRaw } : {}),
|
||||
...(tokenRaw ? { token: tokenRaw } : {}),
|
||||
};
|
||||
const resolvedAuth = resolveGatewayAuth({
|
||||
authConfig,
|
||||
@@ -467,11 +477,11 @@ async function runGatewayCommand(
|
||||
await startGatewayServer(port, {
|
||||
bind,
|
||||
auth:
|
||||
authMode || opts.password || opts.token || authModeRaw
|
||||
authMode || passwordRaw || tokenRaw || authModeRaw
|
||||
? {
|
||||
mode: authMode ?? undefined,
|
||||
token: opts.token ? String(opts.token) : undefined,
|
||||
password: opts.password ? String(opts.password) : undefined,
|
||||
token: tokenRaw,
|
||||
password: passwordRaw,
|
||||
}
|
||||
: undefined,
|
||||
tailscale:
|
||||
|
||||
@@ -328,6 +328,12 @@ export function buildProgram() {
|
||||
false,
|
||||
)
|
||||
.option("--yes", "Accept defaults without prompting", false)
|
||||
.option("--repair", "Apply recommended repairs without prompting", false)
|
||||
.option(
|
||||
"--force",
|
||||
"Apply aggressive repairs (overwrites custom service config)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Run without prompts (safe migrations only)",
|
||||
@@ -339,6 +345,8 @@ export function buildProgram() {
|
||||
await doctorCommand(defaultRuntime, {
|
||||
workspaceSuggestions: opts.workspaceSuggestions,
|
||||
yes: Boolean(opts.yes),
|
||||
repair: Boolean(opts.repair),
|
||||
force: Boolean(opts.force),
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
deep: Boolean(opts.deep),
|
||||
});
|
||||
|
||||
@@ -142,7 +142,12 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
}
|
||||
|
||||
const service = resolveGatewayService();
|
||||
const command = await service.readCommand(process.env).catch(() => null);
|
||||
let command: Awaited<ReturnType<typeof service.readCommand>> | null = null;
|
||||
try {
|
||||
command = await service.readCommand(process.env);
|
||||
} catch {
|
||||
command = null;
|
||||
}
|
||||
if (!command) return;
|
||||
|
||||
const audit = await auditGatewayServiceConfig({
|
||||
@@ -154,16 +159,39 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
note(
|
||||
audit.issues
|
||||
.map((issue) =>
|
||||
issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`,
|
||||
issue.detail
|
||||
? `- ${issue.message} (${issue.detail})`
|
||||
: `- ${issue.message}`,
|
||||
)
|
||||
.join("\n"),
|
||||
"Gateway service config",
|
||||
);
|
||||
|
||||
const repair = await prompter.confirmSkipInNonInteractive({
|
||||
message: "Update gateway service config to the recommended defaults now?",
|
||||
initialValue: true,
|
||||
});
|
||||
const aggressiveIssues = audit.issues.filter(
|
||||
(issue) => issue.level === "aggressive",
|
||||
);
|
||||
const _recommendedIssues = audit.issues.filter(
|
||||
(issue) => issue.level !== "aggressive",
|
||||
);
|
||||
const needsAggressive = aggressiveIssues.length > 0;
|
||||
|
||||
if (needsAggressive && !prompter.shouldForce) {
|
||||
note(
|
||||
"Custom or unexpected service edits detected. Rerun with --force to overwrite.",
|
||||
"Gateway service config",
|
||||
);
|
||||
}
|
||||
|
||||
const repair = needsAggressive
|
||||
? await prompter.confirmAggressive({
|
||||
message: "Overwrite gateway service config with current defaults now?",
|
||||
initialValue: Boolean(prompter.shouldForce),
|
||||
})
|
||||
: await prompter.confirmRepair({
|
||||
message:
|
||||
"Update gateway service config to the recommended defaults now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!repair) return;
|
||||
|
||||
const devMode =
|
||||
|
||||
@@ -8,14 +8,22 @@ export type DoctorOptions = {
|
||||
yes?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
deep?: boolean;
|
||||
repair?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type DoctorPrompter = {
|
||||
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
|
||||
confirmRepair: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
|
||||
confirmAggressive: (
|
||||
params: Parameters<typeof confirm>[0],
|
||||
) => Promise<boolean>;
|
||||
confirmSkipInNonInteractive: (
|
||||
params: Parameters<typeof confirm>[0],
|
||||
) => Promise<boolean>;
|
||||
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
|
||||
shouldRepair: boolean;
|
||||
shouldForce: boolean;
|
||||
};
|
||||
|
||||
export function createDoctorPrompter(params: {
|
||||
@@ -24,24 +32,42 @@ export function createDoctorPrompter(params: {
|
||||
}): DoctorPrompter {
|
||||
const yes = params.options.yes === true;
|
||||
const requestedNonInteractive = params.options.nonInteractive === true;
|
||||
const shouldRepair = params.options.repair === true || yes;
|
||||
const shouldForce = params.options.force === true;
|
||||
const isTty = Boolean(process.stdin.isTTY);
|
||||
const nonInteractive = requestedNonInteractive || (!isTty && !yes);
|
||||
|
||||
const canPrompt = isTty && !yes && !nonInteractive;
|
||||
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
|
||||
if (nonInteractive) return false;
|
||||
if (shouldRepair) return true;
|
||||
if (!canPrompt) return Boolean(p.initialValue ?? false);
|
||||
return guardCancel(await confirm(p), params.runtime) === true;
|
||||
};
|
||||
|
||||
return {
|
||||
confirm: confirmDefault,
|
||||
confirmSkipInNonInteractive: async (p) => {
|
||||
confirmRepair: async (p) => {
|
||||
if (nonInteractive) return false;
|
||||
return confirmDefault(p);
|
||||
},
|
||||
confirmAggressive: async (p) => {
|
||||
if (nonInteractive) return false;
|
||||
if (shouldRepair && shouldForce) return true;
|
||||
if (shouldRepair && !shouldForce) return false;
|
||||
if (!canPrompt) return Boolean(p.initialValue ?? false);
|
||||
return guardCancel(await confirm(p), params.runtime) === true;
|
||||
},
|
||||
confirmSkipInNonInteractive: async (p) => {
|
||||
if (nonInteractive) return false;
|
||||
if (shouldRepair) return true;
|
||||
return confirmDefault(p);
|
||||
},
|
||||
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
|
||||
if (!canPrompt) return fallback;
|
||||
if (!canPrompt || shouldRepair) return fallback;
|
||||
return guardCancel(await select(p), params.runtime) as T;
|
||||
},
|
||||
shouldRepair,
|
||||
shouldForce,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ function findOtherStateDirs(stateDir: string): string[] {
|
||||
export async function noteStateIntegrity(
|
||||
cfg: ClawdbotConfig,
|
||||
prompter: DoctorPrompterLike,
|
||||
configPath?: string,
|
||||
) {
|
||||
const warnings: string[] = [];
|
||||
const changes: string[] = [];
|
||||
@@ -186,6 +187,49 @@ export async function noteStateIntegrity(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stateDirExists && process.platform !== "win32") {
|
||||
try {
|
||||
const stat = fs.statSync(stateDir);
|
||||
if ((stat.mode & 0o077) !== 0) {
|
||||
warnings.push(
|
||||
`- State directory permissions are too open (${stateDir}). Recommend chmod 700.`,
|
||||
);
|
||||
const tighten = await prompter.confirmSkipInNonInteractive({
|
||||
message: `Tighten permissions on ${stateDir} to 700?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (tighten) {
|
||||
fs.chmodSync(stateDir, 0o700);
|
||||
changes.push(`- Tightened permissions on ${stateDir} to 700`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`- Failed to read ${stateDir} permissions: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (configPath && existsFile(configPath) && process.platform !== "win32") {
|
||||
try {
|
||||
const stat = fs.statSync(configPath);
|
||||
if ((stat.mode & 0o077) !== 0) {
|
||||
warnings.push(
|
||||
`- Config file is group/world readable (${configPath}). Recommend chmod 600.`,
|
||||
);
|
||||
const tighten = await prompter.confirmSkipInNonInteractive({
|
||||
message: `Tighten permissions on ${configPath} to 600?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (tighten) {
|
||||
fs.chmodSync(configPath, 0o600);
|
||||
changes.push(`- Tightened permissions on ${configPath} to 600`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`- Failed to read config permissions (${configPath}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (stateDirExists) {
|
||||
const dirCandidates = new Map<string, string>();
|
||||
|
||||
@@ -129,10 +129,13 @@ export async function doctorCommand(
|
||||
const legacyState = await detectLegacyStateMigrations({ cfg });
|
||||
if (legacyState.preview.length > 0) {
|
||||
note(legacyState.preview.join("\n"), "Legacy state detected");
|
||||
const migrate = await prompter.confirm({
|
||||
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
|
||||
initialValue: true,
|
||||
});
|
||||
const migrate =
|
||||
options.nonInteractive === true
|
||||
? true
|
||||
: await prompter.confirm({
|
||||
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (migrate) {
|
||||
const migrated = await runLegacyStateMigrations({
|
||||
detected: legacyState,
|
||||
@@ -146,7 +149,11 @@ export async function doctorCommand(
|
||||
}
|
||||
}
|
||||
|
||||
await noteStateIntegrity(cfg, prompter);
|
||||
await noteStateIntegrity(
|
||||
cfg,
|
||||
prompter,
|
||||
snapshot.path ?? CONFIG_PATH_CLAWDBOT,
|
||||
);
|
||||
|
||||
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
|
||||
noteSandboxScopeWarnings(cfg);
|
||||
|
||||
@@ -20,10 +20,7 @@ vi.mock("../agents/model-catalog.js", () => ({
|
||||
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import {
|
||||
parseTelegramTarget,
|
||||
runCronIsolatedAgentTurn,
|
||||
} from "./isolated-agent.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-"));
|
||||
@@ -408,6 +405,51 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram topic targets with messageThreadId", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "-1001234567890",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
provider: "telegram",
|
||||
to: "telegram:group:-1001234567890:topic:321",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"-1001234567890",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ messageThreadId: 321 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers via discord when configured", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
@@ -673,63 +715,3 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTelegramTarget", () => {
|
||||
it("parses plain chatId", () => {
|
||||
expect(parseTelegramTarget("-1001234567890")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses @username", () => {
|
||||
expect(parseTelegramTarget("@mychannel")).toEqual({
|
||||
chatId: "@mychannel",
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses chatId:topicId format", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses chatId:topic:topicId format", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: 99,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat non-numeric suffix as topicId", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
|
||||
chatId: "-1001234567890:abc",
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips internal telegram prefix", () => {
|
||||
expect(parseTelegramTarget("telegram:123")).toEqual({
|
||||
chatId: "123",
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips internal telegram + group prefixes before parsing topic", () => {
|
||||
expect(
|
||||
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
|
||||
).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: 456,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,49 +46,11 @@ import {
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { parseTelegramTarget } from "../telegram/targets.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse a Telegram delivery target into chatId and optional topicId.
|
||||
* Supports formats:
|
||||
* - `chatId` (plain chat ID or @username)
|
||||
* - `chatId:topicId` (chat ID with topic/thread ID)
|
||||
* - `chatId:topic:topicId` (alternative format with explicit "topic" marker)
|
||||
*/
|
||||
export function parseTelegramTarget(to: string): {
|
||||
chatId: string;
|
||||
topicId: number | undefined;
|
||||
} {
|
||||
let trimmed = to.trim();
|
||||
|
||||
// Cron "lastTo" values can include internal prefixes like `telegram:...` or
|
||||
// `telegram:group:...` (see normalizeChatId in telegram/send.ts).
|
||||
// Strip these before parsing `:topic:` / `:<topicId>` suffixes.
|
||||
while (true) {
|
||||
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||
if (next === trimmed) break;
|
||||
trimmed = next;
|
||||
}
|
||||
|
||||
// Try format: chatId:topic:topicId
|
||||
const topicMatch = /^(.+?):topic:(\d+)$/.exec(trimmed);
|
||||
if (topicMatch) {
|
||||
return { chatId: topicMatch[1], topicId: parseInt(topicMatch[2], 10) };
|
||||
}
|
||||
|
||||
// Try format: chatId:topicId (where topicId is numeric)
|
||||
// Be careful not to match @username or other non-numeric suffixes
|
||||
const colonMatch = /^(.+):(\d+)$/.exec(trimmed);
|
||||
if (colonMatch) {
|
||||
return { chatId: colonMatch[1], topicId: parseInt(colonMatch[2], 10) };
|
||||
}
|
||||
|
||||
// Plain chatId, no topic
|
||||
return { chatId: trimmed, topicId: undefined };
|
||||
}
|
||||
|
||||
export type RunCronAgentTurnResult = {
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
@@ -526,7 +488,9 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
summary: "Delivery skipped (no Telegram chatId).",
|
||||
};
|
||||
}
|
||||
const { chatId, topicId } = parseTelegramTarget(resolvedDelivery.to);
|
||||
const telegramTarget = parseTelegramTarget(resolvedDelivery.to);
|
||||
const chatId = telegramTarget.chatId;
|
||||
const messageThreadId = telegramTarget.messageThreadId;
|
||||
const textLimit = resolveTextChunkLimit(params.cfg, "telegram");
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
@@ -540,7 +504,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
await params.deps.sendMessageTelegram(chatId, chunk, {
|
||||
verbose: false,
|
||||
token: telegramToken || undefined,
|
||||
messageThreadId: topicId,
|
||||
messageThreadId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -552,7 +516,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
token: telegramToken || undefined,
|
||||
messageThreadId: topicId,
|
||||
messageThreadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type ServiceConfigIssue = {
|
||||
code: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
level?: "recommended" | "aggressive";
|
||||
};
|
||||
|
||||
export type ServiceConfigAudit = {
|
||||
@@ -84,6 +85,7 @@ async function auditSystemdUnit(
|
||||
code: "systemd-after-network-online",
|
||||
message: "Missing systemd After=network-online.target",
|
||||
detail: unitPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
if (!parsed.wants.has("network-online.target")) {
|
||||
@@ -91,6 +93,7 @@ async function auditSystemdUnit(
|
||||
code: "systemd-wants-network-online",
|
||||
message: "Missing systemd Wants=network-online.target",
|
||||
detail: unitPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
if (!isRestartSecPreferred(parsed.restartSec)) {
|
||||
@@ -98,6 +101,7 @@ async function auditSystemdUnit(
|
||||
code: "systemd-restart-sec",
|
||||
message: "RestartSec does not match the recommended 5s",
|
||||
detail: unitPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -121,6 +125,7 @@ async function auditLaunchdPlist(
|
||||
code: "launchd-run-at-load",
|
||||
message: "LaunchAgent is missing RunAtLoad=true",
|
||||
detail: plistPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
if (!hasKeepAlive) {
|
||||
@@ -128,6 +133,7 @@ async function auditLaunchdPlist(
|
||||
code: "launchd-keep-alive",
|
||||
message: "LaunchAgent is missing KeepAlive=true",
|
||||
detail: plistPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -141,6 +147,7 @@ function auditGatewayCommand(
|
||||
issues.push({
|
||||
code: "gateway-command-missing",
|
||||
message: "Service command does not include the gateway subcommand",
|
||||
level: "aggressive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,31 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 55,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await sendMessageTelegram(
|
||||
`telegram:group:${chatId}:topic:271`,
|
||||
"hello forum",
|
||||
{
|
||||
token: "tok",
|
||||
api,
|
||||
},
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes reply_to_message_id for threaded replies", async () => {
|
||||
const chatId = "123";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -11,6 +11,10 @@ import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
import { markdownToTelegramHtml } from "./format.js";
|
||||
import {
|
||||
parseTelegramTarget,
|
||||
stripTelegramInternalPrefixes,
|
||||
} from "./targets.js";
|
||||
|
||||
type TelegramSendOpts = {
|
||||
token?: string;
|
||||
@@ -65,7 +69,7 @@ function normalizeChatId(to: string): string {
|
||||
// Common internal prefixes that sometimes leak into outbound sends.
|
||||
// - ctx.To uses `telegram:<id>`
|
||||
// - group sessions often use `telegram:group:<id>`
|
||||
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||
let normalized = stripTelegramInternalPrefixes(trimmed);
|
||||
|
||||
// Accept t.me links for public chats/channels.
|
||||
// (Invite links like `t.me/+...` are not resolvable via Bot API.)
|
||||
@@ -110,7 +114,8 @@ export async function sendMessageTelegram(
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken(opts.token, account);
|
||||
const chatId = normalizeChatId(to);
|
||||
const target = parseTelegramTarget(to);
|
||||
const chatId = normalizeChatId(target.chatId);
|
||||
// Use provided api or create a new Bot instance. The nullish coalescing
|
||||
// operator ensures api is always defined (Bot.api is always non-null).
|
||||
const fetchImpl = resolveTelegramFetch();
|
||||
@@ -123,8 +128,12 @@ export async function sendMessageTelegram(
|
||||
// Build optional params for forum topics and reply threading.
|
||||
// Only include these if actually provided to keep API calls clean.
|
||||
const threadParams: Record<string, number> = {};
|
||||
if (opts.messageThreadId != null) {
|
||||
threadParams.message_thread_id = Math.trunc(opts.messageThreadId);
|
||||
const messageThreadId =
|
||||
opts.messageThreadId != null
|
||||
? opts.messageThreadId
|
||||
: target.messageThreadId;
|
||||
if (messageThreadId != null) {
|
||||
threadParams.message_thread_id = Math.trunc(messageThreadId);
|
||||
}
|
||||
if (opts.replyToMessageId != null) {
|
||||
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
|
||||
|
||||
72
src/telegram/targets.test.ts
Normal file
72
src/telegram/targets.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseTelegramTarget,
|
||||
stripTelegramInternalPrefixes,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("stripTelegramInternalPrefixes", () => {
|
||||
it("strips telegram prefix", () => {
|
||||
expect(stripTelegramInternalPrefixes("telegram:123")).toBe("123");
|
||||
});
|
||||
|
||||
it("strips telegram+group prefixes", () => {
|
||||
expect(stripTelegramInternalPrefixes("telegram:group:-100123")).toBe(
|
||||
"-100123",
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
expect(stripTelegramInternalPrefixes("@mychannel")).toBe("@mychannel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTelegramTarget", () => {
|
||||
it("parses plain chatId", () => {
|
||||
expect(parseTelegramTarget("-1001234567890")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses @username", () => {
|
||||
expect(parseTelegramTarget("@mychannel")).toEqual({
|
||||
chatId: "@mychannel",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses chatId:topicId format", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses chatId:topic:topicId format", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 99,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat non-numeric suffix as topicId", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
|
||||
chatId: "-1001234567890:abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips internal prefixes before parsing", () => {
|
||||
expect(
|
||||
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
|
||||
).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 456,
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/telegram/targets.ts
Normal file
43
src/telegram/targets.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type TelegramTarget = {
|
||||
chatId: string;
|
||||
messageThreadId?: number;
|
||||
};
|
||||
|
||||
export function stripTelegramInternalPrefixes(to: string): string {
|
||||
let trimmed = to.trim();
|
||||
while (true) {
|
||||
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||
if (next === trimmed) return trimmed;
|
||||
trimmed = next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Telegram delivery target into chatId and optional topic/thread ID.
|
||||
*
|
||||
* Supported formats:
|
||||
* - `chatId` (plain chat ID, t.me link, @username, or internal prefixes like `telegram:...`)
|
||||
* - `chatId:topicId` (numeric topic/thread ID)
|
||||
* - `chatId:topic:topicId` (explicit topic marker; preferred)
|
||||
*/
|
||||
export function parseTelegramTarget(to: string): TelegramTarget {
|
||||
const normalized = stripTelegramInternalPrefixes(to);
|
||||
|
||||
const topicMatch = /^(.+?):topic:(\d+)$/.exec(normalized);
|
||||
if (topicMatch) {
|
||||
return {
|
||||
chatId: topicMatch[1],
|
||||
messageThreadId: Number.parseInt(topicMatch[2], 10),
|
||||
};
|
||||
}
|
||||
|
||||
const colonMatch = /^(.+):(\d+)$/.exec(normalized);
|
||||
if (colonMatch) {
|
||||
return {
|
||||
chatId: colonMatch[1],
|
||||
messageThreadId: Number.parseInt(colonMatch[2], 10),
|
||||
};
|
||||
}
|
||||
|
||||
return { chatId: normalized };
|
||||
}
|
||||
@@ -118,6 +118,25 @@ export async function monitorWebInbox(options: {
|
||||
{ subject?: string; participants?: string[]; expires: number }
|
||||
>();
|
||||
const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const lidLookup = sock.signalRepository?.lidMapping;
|
||||
|
||||
const resolveJidToE164 = async (
|
||||
jid: string | null | undefined,
|
||||
): Promise<string | null> => {
|
||||
if (!jid) return null;
|
||||
const direct = jidToE164(jid);
|
||||
if (direct) return direct;
|
||||
if (!/(@lid|@hosted\.lid)$/.test(jid)) return null;
|
||||
if (!lidLookup?.getPNForLID) return null;
|
||||
try {
|
||||
const pnJid = await lidLookup.getPNForLID(jid);
|
||||
if (!pnJid) return null;
|
||||
return jidToE164(pnJid);
|
||||
} catch (err) {
|
||||
logVerbose(`LID mapping lookup failed for ${jid}: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getGroupMeta = async (jid: string) => {
|
||||
const cached = groupMetaCache.get(jid);
|
||||
@@ -125,9 +144,14 @@ export async function monitorWebInbox(options: {
|
||||
try {
|
||||
const meta = await sock.groupMetadata(jid);
|
||||
const participants =
|
||||
meta.participants
|
||||
?.map((p) => jidToE164(p.id) ?? p.id)
|
||||
.filter(Boolean) ?? [];
|
||||
(
|
||||
await Promise.all(
|
||||
meta.participants?.map(async (p) => {
|
||||
const mapped = await resolveJidToE164(p.id);
|
||||
return mapped ?? p.id;
|
||||
}) ?? [],
|
||||
)
|
||||
).filter(Boolean) ?? [];
|
||||
const entry = {
|
||||
subject: meta.subject,
|
||||
participants,
|
||||
@@ -159,12 +183,12 @@ export async function monitorWebInbox(options: {
|
||||
continue;
|
||||
const group = isJidGroup(remoteJid);
|
||||
const participantJid = msg.key?.participant ?? undefined;
|
||||
const from = group ? remoteJid : jidToE164(remoteJid);
|
||||
const from = group ? remoteJid : await resolveJidToE164(remoteJid);
|
||||
// Skip if we still can't resolve an id to key conversation
|
||||
if (!from) continue;
|
||||
const senderE164 = group
|
||||
? participantJid
|
||||
? jidToE164(participantJid)
|
||||
? await resolveJidToE164(participantJid)
|
||||
: null
|
||||
: from;
|
||||
let groupSubject: string | undefined;
|
||||
|
||||
@@ -50,6 +50,11 @@ vi.mock("./session.js", () => {
|
||||
readMessages: vi.fn().mockResolvedValue(undefined),
|
||||
updateMediaMessage: vi.fn(),
|
||||
logger: {},
|
||||
signalRepository: {
|
||||
lidMapping: {
|
||||
getPNForLID: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
};
|
||||
return {
|
||||
@@ -136,6 +141,89 @@ describe("web monitor inbox", () => {
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("resolves LID JIDs using Baileys LID mapping store", async () => {
|
||||
const onMessage = vi.fn(async () => {
|
||||
return;
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const getPNForLID = vi.spyOn(
|
||||
sock.signalRepository.lidMapping,
|
||||
"getPNForLID",
|
||||
);
|
||||
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce(
|
||||
"999:0@s.whatsapp.net",
|
||||
);
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@lid" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(getPNForLID).toHaveBeenCalledWith("999@lid");
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: "ping", from: "+999", to: "+123" }),
|
||||
);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("resolves group participant LID JIDs via Baileys mapping", async () => {
|
||||
const onMessage = vi.fn(async () => {
|
||||
return;
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const getPNForLID = vi.spyOn(
|
||||
sock.signalRepository.lidMapping,
|
||||
"getPNForLID",
|
||||
);
|
||||
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce(
|
||||
"444:0@s.whatsapp.net",
|
||||
);
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "abc",
|
||||
fromMe: false,
|
||||
remoteJid: "123@g.us",
|
||||
participant: "444@lid",
|
||||
},
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(getPNForLID).toHaveBeenCalledWith("444@lid");
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "ping",
|
||||
from: "123@g.us",
|
||||
senderE164: "+444",
|
||||
chatType: "group",
|
||||
}),
|
||||
);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("does not block follow-up messages when handler is pending", async () => {
|
||||
let resolveFirst: (() => void) | null = null;
|
||||
const onMessage = vi.fn(async () => {
|
||||
|
||||
Reference in New Issue
Block a user