Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke
2026-01-08 16:22:25 -05:00
committed by GitHub
35 changed files with 847 additions and 201 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 Gateways 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 dont 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
servers local timezone is used.
Cron expressions use `croner`. If a timezone is omitted, the Gateway hosts
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 shouldnt 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 sessions “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 its explicit and unambiguous.
- If you see `telegram:...` prefixes in logs or stored “last route” targets, thats normal;
cron delivery accepts them and still parses topic IDs correctly.

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View 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 doesnt 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)

View File

@@ -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

View File

@@ -55,6 +55,10 @@ the Gateway likely refused to bind.
- If they dont, youre 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.

View File

@@ -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**: youre 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

View File

@@ -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

View File

@@ -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 youre on any other Linux VPS, the same steps apply — you just wont 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

View File

@@ -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
```

View File

@@ -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`

View File

@@ -36,12 +36,6 @@ clawdbot daemon install
Or:
```
clawdbot daemon install
```
Or:
```
clawdbot configure
```

View File

@@ -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?

View File

@@ -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)

View File

@@ -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");

View File

@@ -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;

View File

@@ -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)"}`;

View File

@@ -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:

View File

@@ -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),
});

View File

@@ -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 =

View File

@@ -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,
};
}

View File

@@ -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>();

View File

@@ -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);

View File

@@ -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,
});
});
});

View File

@@ -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,
});
}
}

View File

@@ -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",
});
}
}

View File

@@ -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({

View File

@@ -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);

View 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
View 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 };
}

View File

@@ -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;

View File

@@ -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 () => {