Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
747277d914 | ||
|
|
068dca3366 | ||
|
|
336a1ad9cf | ||
|
|
d42f767d0c | ||
|
|
9b8ae62399 | ||
|
|
af370ab23e | ||
|
|
1210657fda | ||
|
|
12afec953f | ||
|
|
8e2707e232 | ||
|
|
8befe7f8a7 | ||
|
|
a70fcc8ae0 | ||
|
|
fa521154ff | ||
|
|
dffc1a4dcd | ||
|
|
61e385b331 | ||
|
|
f8f319713f | ||
|
|
bb665bf22c | ||
|
|
f17dcb6213 | ||
|
|
d54c101100 | ||
|
|
96daa51d45 | ||
|
|
6084421ec6 | ||
|
|
bca5c0d569 | ||
|
|
8c3cdba21c | ||
|
|
e274b5a040 | ||
|
|
a139d35fa2 | ||
|
|
8312a19f02 | ||
|
|
fe8b28cdd9 | ||
|
|
10eb1beccf | ||
|
|
a4b347b454 | ||
|
|
688a0ce439 | ||
|
|
6146acbb69 | ||
|
|
35492f8513 | ||
|
|
db9be87d94 | ||
|
|
4a99b9b651 | ||
|
|
7dea403302 | ||
|
|
10731dfee3 | ||
|
|
d00f2d9c0c | ||
|
|
8b89980a89 | ||
|
|
634a429c50 | ||
|
|
bf90815b9e | ||
|
|
2b113c4d6c | ||
|
|
7f6a288bd3 | ||
|
|
daf471c450 | ||
|
|
cb78fa46a1 | ||
|
|
316e8b2eb2 | ||
|
|
4de81ed6c4 | ||
|
|
b6fb24f6d2 | ||
|
|
2b1c26f900 | ||
|
|
47ea9356d1 | ||
|
|
f12c1b391f | ||
|
|
05658b6609 | ||
|
|
dfb6630de1 | ||
|
|
eb7656d68c | ||
|
|
0e1dcf9cb4 | ||
|
|
d05c3d0659 | ||
|
|
36292d3fbb | ||
|
|
1ae344d8a6 | ||
|
|
01c43b0b0c | ||
|
|
fc4aa9a683 | ||
|
|
c043e9767f | ||
|
|
8b48299d8f | ||
|
|
0f27cff247 | ||
|
|
2d1ae0916f | ||
|
|
d0455f2683 | ||
|
|
f25fe032c4 | ||
|
|
0a5b1fbcd1 | ||
|
|
8daab932a2 | ||
|
|
3b7b45c29e | ||
|
|
3a446dd400 | ||
|
|
876efb11e7 | ||
|
|
c7615aa559 | ||
|
|
6042485367 | ||
|
|
725c340257 | ||
|
|
9abd7ceda2 | ||
|
|
cf81cb942b | ||
|
|
9097ef90b7 | ||
|
|
4f1a4ab072 | ||
|
|
0facc63019 | ||
|
|
95735c3978 | ||
|
|
d5d33d4848 | ||
|
|
84e9401d53 | ||
|
|
84bee3d7d0 | ||
|
|
6ff3c39989 | ||
|
|
5c8239a2a9 | ||
|
|
f9170c5d02 | ||
|
|
6ccb19e274 | ||
|
|
52f876bfbc | ||
|
|
415ff7f483 | ||
|
|
74bc5bfd7c | ||
|
|
7e1e7ba2d8 | ||
|
|
aac5b4673f | ||
|
|
c86b257d38 | ||
|
|
4291d56e0b | ||
|
|
5599603bdb | ||
|
|
e0f69a2294 | ||
|
|
510915a801 | ||
|
|
609d029e20 | ||
|
|
4275ed68a2 | ||
|
|
75d2785d20 | ||
|
|
b77b47bb98 | ||
|
|
5f87f7bbf5 | ||
|
|
1afdb850f3 | ||
|
|
725a6b71dc | ||
|
|
f4bb5b381d | ||
|
|
11d4fc101e | ||
|
|
4e6fb47a3f | ||
|
|
a39bb4310c | ||
|
|
f1ac18933c | ||
|
|
2bae4d2dba | ||
|
|
1797233989 | ||
|
|
3171781d58 | ||
|
|
35ddd8db5e | ||
|
|
c269d5f258 | ||
|
|
627cc1f04b | ||
|
|
53d0bf653a | ||
|
|
a5a9788b20 | ||
|
|
9c04a79c0a | ||
|
|
f3519d895c | ||
|
|
0ac5480034 | ||
|
|
5cd48bbe7d | ||
|
|
8dacafce7f | ||
|
|
8c4b8f2c38 | ||
|
|
fee8bcc50b | ||
|
|
cb9f4a0485 | ||
|
|
79f340a410 | ||
|
|
081e5ef572 | ||
|
|
f5577ad78e | ||
|
|
b2b1c1de9c | ||
|
|
3e6917c8ae | ||
|
|
564b6aa2aa | ||
|
|
eb3eb3c39e | ||
|
|
2579609922 | ||
|
|
946b0229e8 | ||
|
|
0a1eeedc10 | ||
|
|
5a58feefdc | ||
|
|
48b15bd099 | ||
|
|
1c96477686 | ||
|
|
04ebf8d67a | ||
|
|
82d83a9d54 | ||
|
|
66de8aedeb | ||
|
|
154b8e3e0e | ||
|
|
d9f2ee40f7 | ||
|
|
6bcb89cf38 | ||
|
|
3475ecde6f |
No files matched your search
@@ -6,6 +6,7 @@
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
@@ -59,6 +60,9 @@
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
## Shorthand Commands
|
||||
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
||||
@@ -74,6 +78,7 @@
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
|
||||
+60
-13
@@ -1,14 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.14 (unreleased)
|
||||
## 2026.1.15 (unreleased)
|
||||
|
||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||
- TUI: show provider/model labels for the active session and default model.
|
||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
### Highlights
|
||||
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
|
||||
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
|
||||
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
|
||||
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
||||
|
||||
### Changes
|
||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
||||
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
|
||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
|
||||
- Security: add `SECURITY.md` reporting policy.
|
||||
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||
@@ -16,22 +52,12 @@
|
||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
||||
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
||||
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
||||
- Tools: return a setup hint (docs link) when web_search runs without a Brave API key.
|
||||
- Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).
|
||||
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
|
||||
- CLI/Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`.
|
||||
- Browser: copy the installed Chrome extension path to clipboard after `clawdbot browser extension install/path`.
|
||||
- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz.
|
||||
|
||||
### Fixes
|
||||
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
||||
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
||||
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
|
||||
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
@@ -60,7 +86,28 @@
|
||||
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
||||
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
||||
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
||||
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
|
||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||
|
||||
#### Agents / Auth / Tools / Sandbox
|
||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
|
||||
#### macOS / Apps
|
||||
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
||||
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
||||
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
|
||||
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
|
||||
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
||||
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: 95ad7532c1...5c195f5e46.
@@ -474,22 +474,23 @@ Core contributors:
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
|
||||
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
|
||||
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a>
|
||||
<a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
||||
<a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||
<a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
||||
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a>
|
||||
<a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
|
||||
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a>
|
||||
<a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
|
||||
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a>
|
||||
<a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
|
||||
<a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
||||
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a>
|
||||
<a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
|
||||
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
|
||||
<a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
|
||||
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
|
||||
<a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
||||
<a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
|
||||
<a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
||||
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
|
||||
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
|
||||
<a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
|
||||
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
|
||||
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
||||
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a>
|
||||
</p>
|
||||
+172
-17
@@ -2,6 +2,178 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.14-1</title>
|
||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5825</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<h4>Web Tools</h4>
|
||||
<ul>
|
||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
||||
</ul>
|
||||
<h4>Browser / Control UI</h4>
|
||||
<ul>
|
||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
||||
</ul>
|
||||
<h4>Plugins</h4>
|
||||
<ul>
|
||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
||||
</ul>
|
||||
<h4>Security</h4>
|
||||
<ul>
|
||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
||||
</ul>
|
||||
<h4>Onboarding / Daemon</h4>
|
||||
<ul>
|
||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
||||
</ul>
|
||||
<h4>Auth / Usage / Config</h4>
|
||||
<ul>
|
||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
||||
</ul>
|
||||
<h4>Channels</h4>
|
||||
<ul>
|
||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
||||
</ul>
|
||||
<h4>Docs</h4>
|
||||
<ul>
|
||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<h4>Gateway / Daemon / Sessions</h4>
|
||||
<ul>
|
||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
||||
</ul>
|
||||
<h4>CLI / Onboarding</h4>
|
||||
<ul>
|
||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
||||
</ul>
|
||||
<h4>Control UI / TUI</h4>
|
||||
<ul>
|
||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
||||
</ul>
|
||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
||||
<ul>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
</ul>
|
||||
<h4>macOS / Apps</h4>
|
||||
<ul>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h4>Channels / Messaging</h4>
|
||||
<ul>
|
||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
||||
<li>WhatsApp: harden owner command auth.</li>
|
||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
||||
</ul>
|
||||
<h4>Config / Doctor / Packaging</h4>
|
||||
<ul>
|
||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.12-2</title>
|
||||
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
|
||||
@@ -35,22 +207,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.11-2</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5210</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li>
|
||||
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -27,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
@@ -138,7 +139,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
fun loadChat(sessionKey: String) {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class NodeRuntime(context: Context) {
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(command))
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.value))
|
||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
@@ -142,12 +142,13 @@ class NodeRuntime(context: Context) {
|
||||
private val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { name, remote ->
|
||||
onConnected = { name, remote, mainSessionKey ->
|
||||
_statusText.value = "Connected"
|
||||
_serverName.value = name
|
||||
_remoteAddress.value = remote
|
||||
_isConnected.value = true
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
applyMainSessionKey(mainSessionKey)
|
||||
scope.launch { refreshBrandingFromGateway() }
|
||||
scope.launch { refreshWakeWordsFromGateway() }
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
@@ -172,11 +173,31 @@ class NodeRuntime(context: Context) {
|
||||
_remoteAddress.value = null
|
||||
_isConnected.value = false
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
_mainSessionKey.value = "main"
|
||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
||||
_mainSessionKey.value = "main"
|
||||
}
|
||||
val mainKey = resolveMainSessionKey()
|
||||
talkMode.setMainSessionKey(mainKey)
|
||||
chat.applyMainSessionKey(mainKey)
|
||||
chat.onDisconnected(message)
|
||||
showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
private fun applyMainSessionKey(candidate: String?) {
|
||||
val trimmed = candidate?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
||||
if (_mainSessionKey.value == trimmed) return
|
||||
_mainSessionKey.value = trimmed
|
||||
talkMode.setMainSessionKey(trimmed)
|
||||
chat.applyMainSessionKey(trimmed)
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
val trimmed = _mainSessionKey.value.trim()
|
||||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
@@ -559,7 +580,7 @@ class NodeRuntime(context: Context) {
|
||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||
|
||||
val sessionKey = "main"
|
||||
val sessionKey = resolveMainSessionKey()
|
||||
val message =
|
||||
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = name,
|
||||
@@ -607,8 +628,9 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
chat.load(sessionKey)
|
||||
fun loadChat(sessionKey: String) {
|
||||
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
|
||||
chat.load(key)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
@@ -701,7 +723,7 @@ class NodeRuntime(context: Context) {
|
||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
_mainSessionKey.value = mainKey
|
||||
applyMainSessionKey(mainKey)
|
||||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
|
||||
@@ -4,3 +4,10 @@ internal fun normalizeMainKey(raw: String?): String {
|
||||
val trimmed = raw?.trim()
|
||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
||||
}
|
||||
|
||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return false
|
||||
if (trimmed == "global") return true
|
||||
return trimmed.startsWith("agent:")
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class BridgeSession(
|
||||
private val scope: CoroutineScope,
|
||||
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
|
||||
private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||
@@ -64,6 +64,7 @@ class BridgeSession(
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
@Volatile private var mainSessionKey: String? = null
|
||||
|
||||
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||
private var job: Job? = null
|
||||
@@ -90,11 +91,13 @@ class BridgeSession(
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
canvasHostUrl = null
|
||||
mainSessionKey = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
fun currentMainSessionKey(): String? = mainSessionKey
|
||||
|
||||
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||
val conn = currentConnection ?: return
|
||||
@@ -212,7 +215,9 @@ class BridgeSession(
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
mainSessionKey = rawMainSessionKey
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
@@ -222,7 +227,7 @@ class BridgeSession(
|
||||
)
|
||||
}
|
||||
}
|
||||
onConnected(name, conn.remoteAddress)
|
||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
||||
}
|
||||
"error" -> {
|
||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
|
||||
@@ -71,12 +71,21 @@ class ChatController(
|
||||
_sessionId.value = null
|
||||
}
|
||||
|
||||
fun load(sessionKey: String = "main") {
|
||||
fun load(sessionKey: String) {
|
||||
val key = sessionKey.trim().ifEmpty { "main" }
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun applyMainSessionKey(mainSessionKey: String) {
|
||||
val trimmed = mainSessionKey.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (_sessionKey.value == trimmed) return
|
||||
if (_sessionKey.value != "main") return
|
||||
_sessionKey.value = trimmed
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.clawdbot.android.chat.ChatSessionEntry
|
||||
fun ChatComposer(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
@@ -61,7 +62,7 @@ fun ChatComposer(
|
||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||
var showSessionMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
||||
val currentSessionLabel =
|
||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||
|
||||
|
||||
@@ -33,13 +33,14 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat("main")
|
||||
LaunchedEffect(mainSessionKey) {
|
||||
viewModel.loadChat(mainSessionKey)
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
ChatComposer(
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
|
||||
@@ -2,20 +2,23 @@ package com.clawdbot.android.ui.chat
|
||||
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
|
||||
private const val MAIN_SESSION_KEY = "main"
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
fun resolveSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<ChatSessionEntry> {
|
||||
val current = currentSessionKey.trim()
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
val aliasKey = if (mainKey == "main") null else "main"
|
||||
val cutoff = nowMs - RECENT_WINDOW_MS
|
||||
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||
val recent = mutableListOf<ChatSessionEntry>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (entry in sorted) {
|
||||
if (aliasKey != null && entry.key == aliasKey) continue
|
||||
if (!seen.add(entry.key)) continue
|
||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
||||
recent.add(entry)
|
||||
@@ -23,13 +26,13 @@ fun resolveSessionChoices(
|
||||
|
||||
val result = mutableListOf<ChatSessionEntry>()
|
||||
val included = mutableSetOf<String>()
|
||||
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
|
||||
val mainEntry = sorted.firstOrNull { it.key == mainKey }
|
||||
if (mainEntry != null) {
|
||||
result.add(mainEntry)
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
} else if (current == MAIN_SESSION_KEY) {
|
||||
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
included.add(mainKey)
|
||||
} else if (current == mainKey) {
|
||||
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
|
||||
included.add(mainKey)
|
||||
}
|
||||
|
||||
for (entry in recent) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.isCanonicalMainSessionKey
|
||||
import com.clawdbot.android.normalizeMainKey
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
@@ -116,6 +117,13 @@ class TalkModeManager(
|
||||
chatSubscribedSessionKey = null
|
||||
}
|
||||
|
||||
fun setMainSessionKey(sessionKey: String?) {
|
||||
val trimmed = sessionKey?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (isCanonicalMainSessionKey(mainSessionKey)) return
|
||||
mainSessionKey = trimmed
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
if (_isEnabled.value == enabled) return
|
||||
_isEnabled.value = enabled
|
||||
@@ -827,7 +835,9 @@ class TalkModeManager(
|
||||
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||
|
||||
mainSessionKey = mainKey
|
||||
if (!isCanonicalMainSessionKey(mainSessionKey)) {
|
||||
mainSessionKey = mainKey
|
||||
}
|
||||
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
voiceAliases = aliases
|
||||
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
||||
|
||||
@@ -30,7 +30,7 @@ class BridgeSessionTest {
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||
@@ -97,7 +97,7 @@ class BridgeSessionTest {
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||
@@ -167,7 +167,7 @@ class BridgeSessionTest {
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
||||
@@ -239,7 +239,7 @@ class BridgeSessionTest {
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.countDown() },
|
||||
onConnected = { _, _, _ -> connected.countDown() },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||
|
||||
@@ -19,7 +19,7 @@ class SessionFiltersTest {
|
||||
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
|
||||
)
|
||||
|
||||
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
|
||||
val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "recent-1", "recent-2"), result)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class SessionFiltersTest {
|
||||
val recent = now - 10 * 60 * 1000L
|
||||
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
|
||||
|
||||
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
|
||||
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "custom"), result)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ actor BridgeSession {
|
||||
|
||||
private(set) var state: State = .idle
|
||||
private var canvasHostUrl: String?
|
||||
private var mainSessionKey: String?
|
||||
|
||||
func currentCanvasHostUrl() -> String? {
|
||||
self.canvasHostUrl
|
||||
@@ -68,7 +69,7 @@ actor BridgeSession {
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
{
|
||||
@@ -107,7 +108,9 @@ actor BridgeSession {
|
||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||
self.state = .connected(serverName: ok.serverName)
|
||||
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
await onConnected?(ok.serverName)
|
||||
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
|
||||
await onConnected?(ok.serverName, self.mainSessionKey)
|
||||
} else if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||
@@ -217,6 +220,7 @@ actor BridgeSession {
|
||||
self.queue = nil
|
||||
self.buffer = Data()
|
||||
self.canvasHostUrl = nil
|
||||
self.mainSessionKey = nil
|
||||
|
||||
let pending = self.pendingRPC.values
|
||||
self.pendingRPC.removeAll()
|
||||
@@ -234,6 +238,10 @@ actor BridgeSession {
|
||||
self.state = .idle
|
||||
}
|
||||
|
||||
func currentMainSessionKey() -> String? {
|
||||
self.mainSessionKey
|
||||
}
|
||||
|
||||
private func beginRPC(
|
||||
id: String,
|
||||
request: BridgeRPCRequest,
|
||||
|
||||
@@ -6,7 +6,7 @@ struct ChatSheet: View {
|
||||
@State private var viewModel: ClawdbotChatViewModel
|
||||
private let userAccent: Color?
|
||||
|
||||
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
|
||||
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
|
||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||
self._viewModel = State(
|
||||
initialValue: ClawdbotChatViewModel(
|
||||
|
||||
@@ -109,7 +109,7 @@ final class NodeAppModel {
|
||||
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
||||
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
||||
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
let sessionKey = "main"
|
||||
let sessionKey = self.mainSessionKey
|
||||
|
||||
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
|
||||
actionName: name,
|
||||
@@ -232,12 +232,15 @@ final class NodeAppModel {
|
||||
try await self.bridge.connect(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
onConnected: { [weak self] serverName in
|
||||
onConnected: { [weak self] serverName, mainSessionKey in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Connected"
|
||||
self.bridgeServerName = serverName
|
||||
}
|
||||
await MainActor.run {
|
||||
self.applyMainSessionKey(mainSessionKey)
|
||||
}
|
||||
if let addr = await self.bridge.currentRemoteAddress() {
|
||||
await MainActor.run {
|
||||
self.bridgeRemoteAddress = addr
|
||||
@@ -286,7 +289,10 @@ final class NodeAppModel {
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionKey = "main"
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
}
|
||||
@@ -303,10 +309,23 @@ final class NodeAppModel {
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionKey = "main"
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
private func applyMainSessionKey(_ key: String?) {
|
||||
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if SessionKey.isCanonicalMainSessionKey(current) { return }
|
||||
if trimmed == current { return }
|
||||
self.mainSessionKey = trimmed
|
||||
self.talkMode.updateMainSessionKey(trimmed)
|
||||
}
|
||||
|
||||
var seamColor: Color {
|
||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||
}
|
||||
@@ -335,7 +354,10 @@ final class NodeAppModel {
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
await MainActor.run {
|
||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionKey = mainKey
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = mainKey
|
||||
self.talkMode.updateMainSessionKey(mainKey)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@@ -5,4 +5,11 @@ enum SessionKey {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
}
|
||||
|
||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return false }
|
||||
if trimmed == "global" { return true }
|
||||
return trimmed.hasPrefix("agent:")
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,13 @@ final class TalkModeManager: NSObject {
|
||||
self.bridge = bridge
|
||||
}
|
||||
|
||||
func updateMainSessionKey(_ sessionKey: String?) {
|
||||
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return }
|
||||
self.mainSessionKey = trimmed
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
self.isEnabled = enabled
|
||||
if enabled {
|
||||
@@ -649,7 +656,10 @@ final class TalkModeManager: NSObject {
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
let session = config["session"] as? [String: Any]
|
||||
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = mainKey
|
||||
}
|
||||
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let aliases = talk?["voiceAliases"] as? [String: Any] {
|
||||
var resolved: [String: String] = [:]
|
||||
|
||||
@@ -282,7 +282,12 @@ actor BridgeConnectionHandler {
|
||||
do {
|
||||
try await self.send(BridgePairOk(type: "pair-ok", token: token))
|
||||
self.isAuthenticated = true
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
try await self.send(
|
||||
BridgeHelloOk(
|
||||
type: "hello-ok",
|
||||
serverName: serverName,
|
||||
mainSessionKey: mainSessionKey))
|
||||
} catch {
|
||||
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
@@ -298,7 +303,12 @@ actor BridgeConnectionHandler {
|
||||
case .ok:
|
||||
self.isAuthenticated = true
|
||||
do {
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
try await self.send(
|
||||
BridgeHelloOk(
|
||||
type: "hello-ok",
|
||||
serverName: serverName,
|
||||
mainSessionKey: mainSessionKey))
|
||||
} catch {
|
||||
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@ import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension ConnectionsStore {
|
||||
var isTelegramTokenLocked: Bool {
|
||||
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||
.tokenSource == "env"
|
||||
}
|
||||
|
||||
var isDiscordTokenLocked: Bool {
|
||||
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.tokenSource == "env"
|
||||
}
|
||||
|
||||
func loadConfig() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
@@ -37,7 +47,8 @@ extension ConnectionsStore {
|
||||
|
||||
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
|
||||
if let channels = snap.config?["channels"]?.dictionaryValue,
|
||||
let entry = channels[key]?.dictionaryValue {
|
||||
let entry = channels[key]?.dictionaryValue
|
||||
{
|
||||
return entry
|
||||
}
|
||||
return snap.config?[key]?.dictionaryValue
|
||||
|
||||
@@ -233,7 +233,18 @@ actor GatewayConnection {
|
||||
|
||||
func canvasHostUrl() async -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
|
||||
(defaults?[key]?.stringValue ?? "")
|
||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func cachedMainSessionKey() -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
@@ -268,12 +279,35 @@ actor GatewayConnection {
|
||||
private func broadcast(_ push: GatewayPush) {
|
||||
if case let .snapshot(snapshot) = push {
|
||||
self.lastSnapshot = snapshot
|
||||
if let mainSessionKey = self.cachedMainSessionKey() {
|
||||
Task { @MainActor in
|
||||
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, continuation) in self.subscribers {
|
||||
continuation.yield(push)
|
||||
}
|
||||
}
|
||||
|
||||
private func canonicalizeSessionKey(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
|
||||
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
|
||||
guard !mainSessionKey.isEmpty else { return trimmed }
|
||||
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
|
||||
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
|
||||
let isMainAlias =
|
||||
trimmed == "main" ||
|
||||
(!mainKey.isEmpty && trimmed == mainKey) ||
|
||||
trimmed == mainSessionKey ||
|
||||
(!defaultAgentId.isEmpty &&
|
||||
(trimmed == "agent:\(defaultAgentId):main" ||
|
||||
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
|
||||
return isMainAlias ? mainSessionKey : trimmed
|
||||
}
|
||||
|
||||
private func configure(url: URL, token: String?, password: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
||||
self.configuredPassword == password
|
||||
@@ -332,6 +366,9 @@ extension GatewayConnection {
|
||||
}
|
||||
|
||||
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
||||
if let cached = self.cachedMainSessionKey() {
|
||||
return cached
|
||||
}
|
||||
do {
|
||||
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
|
||||
return try Self.mainSessionKey(fromConfigGetData: data)
|
||||
@@ -362,10 +399,11 @@ extension GatewayConnection {
|
||||
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
|
||||
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return (false, "message empty") }
|
||||
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"message": AnyCodable(trimmed),
|
||||
"sessionKey": AnyCodable(invocation.sessionKey),
|
||||
"sessionKey": AnyCodable(sessionKey),
|
||||
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||
"deliver": AnyCodable(invocation.deliver),
|
||||
"to": AnyCodable(invocation.to ?? ""),
|
||||
@@ -469,7 +507,8 @@ extension GatewayConnection {
|
||||
limit: Int? = nil,
|
||||
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
|
||||
{
|
||||
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)]
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
|
||||
if let limit { params["limit"] = AnyCodable(limit) }
|
||||
let timeout = timeoutMs.map { Double($0) }
|
||||
return try await self.requestDecoded(
|
||||
@@ -486,8 +525,9 @@ extension GatewayConnection {
|
||||
attachments: [ClawdbotChatAttachmentPayload],
|
||||
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
|
||||
{
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionKey": AnyCodable(sessionKey),
|
||||
"sessionKey": AnyCodable(resolvedKey),
|
||||
"message": AnyCodable(message),
|
||||
"thinking": AnyCodable(thinking),
|
||||
"idempotencyKey": AnyCodable(idempotencyKey),
|
||||
@@ -513,10 +553,11 @@ extension GatewayConnection {
|
||||
}
|
||||
|
||||
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
|
||||
let res: AbortResponse = try await self.requestDecoded(
|
||||
method: .chatAbort,
|
||||
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
|
||||
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
|
||||
return res.aborted ?? false
|
||||
}
|
||||
|
||||
|
||||
@@ -33,14 +33,16 @@ actor GatewayEndpointStore {
|
||||
return GatewayEndpointStore.resolveGatewayToken(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
},
|
||||
password: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
},
|
||||
localPort: { GatewayEnvironment.gatewayPort() },
|
||||
localHost: {
|
||||
@@ -60,7 +62,8 @@ actor GatewayEndpointStore {
|
||||
private static func resolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
|
||||
{
|
||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -88,13 +91,19 @@ actor GatewayEndpointStore {
|
||||
return pw
|
||||
}
|
||||
}
|
||||
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!password.isEmpty
|
||||
{
|
||||
return password
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveGatewayToken(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
|
||||
{
|
||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -122,6 +131,11 @@ actor GatewayEndpointStore {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -430,9 +444,19 @@ extension GatewayEndpointStore {
|
||||
static func _testResolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
|
||||
{
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
|
||||
}
|
||||
|
||||
static func _testResolveGatewayToken(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
|
||||
{
|
||||
self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
|
||||
}
|
||||
|
||||
static func _testResolveGatewayBindMode(
|
||||
|
||||
@@ -306,6 +306,10 @@ enum GatewayLaunchAgentManager {
|
||||
password: snapshot.password)
|
||||
}
|
||||
|
||||
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
||||
LaunchAgentPlist.snapshot(url: self.plistURL)
|
||||
}
|
||||
|
||||
private static func ensureEnabled() async {
|
||||
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
guard result.status != 0 else { return }
|
||||
|
||||
@@ -102,11 +102,14 @@ struct MenuContent: View {
|
||||
}
|
||||
if self.state.canvasEnabled {
|
||||
Button {
|
||||
if self.state.canvasPanelVisible {
|
||||
CanvasManager.shared.hideAll()
|
||||
} else {
|
||||
// Don't force a navigation on re-open: preserve the current web view state.
|
||||
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||
Task { @MainActor in
|
||||
if self.state.canvasPanelVisible {
|
||||
CanvasManager.shared.hideAll()
|
||||
} else {
|
||||
let sessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
// Don't force a navigation on re-open: preserve the current web view state.
|
||||
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
|
||||
@@ -104,6 +104,8 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Injection
|
||||
|
||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||
|
||||
private func inject(into menu: NSMenu) {
|
||||
// Remove any previous injected items.
|
||||
for item in menu.items where item.tag == self.tag {
|
||||
@@ -120,13 +122,15 @@ extension MenuSessionsInjector {
|
||||
|
||||
if let snapshot = self.cachedSnapshot {
|
||||
let now = Date()
|
||||
let mainKey = self.mainSessionKey
|
||||
let rows = snapshot.rows.filter { row in
|
||||
if row.key == "main" { return true }
|
||||
if row.key == "main", mainKey != "main" { return false }
|
||||
if row.key == mainKey { return true }
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}.sorted { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
if lhs.key == mainKey { return true }
|
||||
if rhs.key == mainKey { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
|
||||
@@ -645,7 +649,7 @@ extension MenuSessionsInjector {
|
||||
compact.representedObject = row.key
|
||||
menu.addItem(compact)
|
||||
|
||||
if row.key != "main", row.key != "global" {
|
||||
if row.key != self.mainSessionKey, row.key != "global" {
|
||||
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
||||
del.target = self
|
||||
del.representedObject = row.key
|
||||
|
||||
@@ -36,7 +36,7 @@ actor MacNodeBridgeSession {
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
@@ -98,7 +98,8 @@ actor MacNodeBridgeSession {
|
||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||
self.state = .connected(serverName: ok.serverName)
|
||||
self.startPingLoop()
|
||||
await onConnected?(ok.serverName)
|
||||
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
|
||||
} else if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||
|
||||
@@ -67,8 +67,11 @@ final class MacNodeModeCoordinator {
|
||||
try await self.session.connect(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
onConnected: { [weak self] serverName in
|
||||
onConnected: { [weak self] serverName, mainSessionKey in
|
||||
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
||||
if let mainSessionKey {
|
||||
await self?.runtime.updateMainSessionKey(mainSessionKey)
|
||||
}
|
||||
},
|
||||
onDisconnected: { reason in
|
||||
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
||||
@@ -309,9 +312,23 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
|
||||
let remotePort = Self.remoteBridgePort()
|
||||
let preferredLocalPort = Self.loopbackBridgePort()
|
||||
if let preferredLocalPort {
|
||||
self.logger.info(
|
||||
"mac node bridge tunnel starting " +
|
||||
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
} else {
|
||||
self.logger.info(
|
||||
"mac node bridge tunnel starting " +
|
||||
"preferredLocalPort=none " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
}
|
||||
self.tunnel = try await RemotePortTunnel.create(
|
||||
remotePort: remotePort,
|
||||
allowRemoteUrlOverride: false)
|
||||
preferredLocalPort: preferredLocalPort,
|
||||
allowRemoteUrlOverride: false,
|
||||
allowRandomLocalPort: true)
|
||||
if let localPort = self.tunnel?.localPort,
|
||||
let port = NWEndpoint.Port(rawValue: localPort)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ actor MacNodeRuntime {
|
||||
private let cameraCapture = CameraCaptureService()
|
||||
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||
private var mainSessionKey: String = "main"
|
||||
|
||||
init(
|
||||
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||
@@ -16,6 +17,12 @@ actor MacNodeRuntime {
|
||||
self.makeMainActorServices = makeMainActorServices
|
||||
}
|
||||
|
||||
func updateMainSessionKey(_ sessionKey: String) {
|
||||
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.mainSessionKey = trimmed
|
||||
}
|
||||
|
||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
let command = req.command
|
||||
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
||||
@@ -72,28 +79,32 @@ actor MacNodeRuntime {
|
||||
let placement = params.placement.map {
|
||||
CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height)
|
||||
}
|
||||
let sessionKey = self.mainSessionKey
|
||||
try await MainActor.run {
|
||||
_ = try CanvasManager.shared.showDetailed(
|
||||
sessionKey: "main",
|
||||
sessionKey: sessionKey,
|
||||
target: url,
|
||||
placement: placement)
|
||||
}
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
case ClawdbotCanvasCommand.hide.rawValue:
|
||||
let sessionKey = self.mainSessionKey
|
||||
await MainActor.run {
|
||||
CanvasManager.shared.hide(sessionKey: "main")
|
||||
CanvasManager.shared.hide(sessionKey: sessionKey)
|
||||
}
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
case ClawdbotCanvasCommand.navigate.rawValue:
|
||||
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
|
||||
let sessionKey = self.mainSessionKey
|
||||
try await MainActor.run {
|
||||
_ = try CanvasManager.shared.show(sessionKey: "main", path: params.url)
|
||||
_ = try CanvasManager.shared.show(sessionKey: sessionKey, path: params.url)
|
||||
}
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
case ClawdbotCanvasCommand.evalJS.rawValue:
|
||||
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
|
||||
let sessionKey = self.mainSessionKey
|
||||
let result = try await CanvasManager.shared.eval(
|
||||
sessionKey: "main",
|
||||
sessionKey: sessionKey,
|
||||
javaScript: params.javaScript)
|
||||
let payload = try Self.encodePayload(["result": result] as [String: String])
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
@@ -109,7 +120,8 @@ actor MacNodeRuntime {
|
||||
}()
|
||||
let quality = params?.quality ?? 0.9
|
||||
|
||||
let path = try await CanvasManager.shared.snapshot(sessionKey: "main", outPath: nil)
|
||||
let sessionKey = self.mainSessionKey
|
||||
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
|
||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
guard let image = NSImage(data: data) else {
|
||||
@@ -319,7 +331,8 @@ actor MacNodeRuntime {
|
||||
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
try await self.ensureA2UIHost()
|
||||
|
||||
let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
|
||||
let sessionKey = self.mainSessionKey
|
||||
let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
|
||||
(() => {
|
||||
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
|
||||
return JSON.stringify(globalThis.clawdbotA2UI.reset());
|
||||
@@ -358,7 +371,8 @@ actor MacNodeRuntime {
|
||||
}
|
||||
})()
|
||||
"""
|
||||
let resultJSON = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: js)
|
||||
let sessionKey = self.mainSessionKey
|
||||
let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||
}
|
||||
|
||||
@@ -369,8 +383,9 @@ actor MacNodeRuntime {
|
||||
NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
])
|
||||
}
|
||||
let sessionKey = self.mainSessionKey
|
||||
_ = try await MainActor.run {
|
||||
try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl)
|
||||
try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl)
|
||||
}
|
||||
if await self.isA2UIReady(poll: true) { return }
|
||||
throw NSError(domain: "Canvas", code: 31, userInfo: [
|
||||
@@ -389,7 +404,8 @@ actor MacNodeRuntime {
|
||||
let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
|
||||
while true {
|
||||
do {
|
||||
let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
|
||||
let sessionKey = self.mainSessionKey
|
||||
let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
|
||||
(() => String(Boolean(globalThis.clawdbotA2UI)))()
|
||||
""")
|
||||
let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -58,6 +58,13 @@ final class OnboardingWizardModel {
|
||||
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
|
||||
guard self.sessionId == nil, !self.isStarting else { return }
|
||||
guard mode == .local else { return }
|
||||
if self.shouldSkipWizard() {
|
||||
self.sessionId = nil
|
||||
self.currentStep = nil
|
||||
self.status = "done"
|
||||
self.errorMessage = nil
|
||||
return
|
||||
}
|
||||
self.isStarting = true
|
||||
self.errorMessage = nil
|
||||
self.lastStartMode = mode
|
||||
@@ -177,6 +184,33 @@ final class OnboardingWizardModel {
|
||||
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
|
||||
return true
|
||||
}
|
||||
|
||||
private func shouldSkipWizard() -> Bool {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
|
||||
return true
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any]
|
||||
{
|
||||
if let mode = auth["mode"] as? String,
|
||||
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let token = auth["token"] as? String,
|
||||
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let password = auth["password"] as? String,
|
||||
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingWizardStepView: View {
|
||||
|
||||
@@ -28,9 +28,11 @@ final class WorkActivityStore {
|
||||
private var currentSessionKey: String?
|
||||
private var toolSeqBySession: [String: Int] = [:]
|
||||
|
||||
private let mainSessionKey = "main"
|
||||
private var mainSessionKeyStorage = "main"
|
||||
private let toolResultGrace: TimeInterval = 2.0
|
||||
|
||||
var mainSessionKey: String { self.mainSessionKeyStorage }
|
||||
|
||||
func handleJob(sessionKey: String, state: String) {
|
||||
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
|
||||
if isStart {
|
||||
@@ -129,6 +131,17 @@ final class WorkActivityStore {
|
||||
self.refreshDerivedState()
|
||||
}
|
||||
|
||||
func setMainSessionKey(_ sessionKey: String) {
|
||||
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard trimmed != self.mainSessionKeyStorage else { return }
|
||||
self.mainSessionKeyStorage = trimmed
|
||||
if let current = self.currentSessionKey, !self.isActive(sessionKey: current) {
|
||||
self.pickNextSession()
|
||||
}
|
||||
self.refreshDerivedState()
|
||||
}
|
||||
|
||||
private func clearJob(sessionKey: String) {
|
||||
guard self.jobs[sessionKey] != nil else { return }
|
||||
self.jobs.removeValue(forKey: sessionKey)
|
||||
@@ -151,8 +164,8 @@ final class WorkActivityStore {
|
||||
|
||||
private func pickNextSession() {
|
||||
// Prefer main if present.
|
||||
if self.isActive(sessionKey: self.mainSessionKey) {
|
||||
self.currentSessionKey = self.mainSessionKey
|
||||
if self.isActive(sessionKey: self.mainSessionKeyStorage) {
|
||||
self.currentSessionKey = self.mainSessionKeyStorage
|
||||
return
|
||||
}
|
||||
|
||||
@@ -163,7 +176,7 @@ final class WorkActivityStore {
|
||||
}
|
||||
|
||||
private func role(for sessionKey: String) -> SessionRole {
|
||||
sessionKey == self.mainSessionKey ? .main : .other
|
||||
sessionKey == self.mainSessionKeyStorage ? .main : .other
|
||||
}
|
||||
|
||||
private func isActive(sessionKey: String) -> Bool {
|
||||
|
||||
@@ -252,6 +252,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
public let uptimems: Int
|
||||
public let configpath: String?
|
||||
public let statedir: String?
|
||||
public let sessiondefaults: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
presence: [PresenceEntry],
|
||||
@@ -259,7 +260,8 @@ public struct Snapshot: Codable, Sendable {
|
||||
stateversion: StateVersion,
|
||||
uptimems: Int,
|
||||
configpath: String?,
|
||||
statedir: String?
|
||||
statedir: String?,
|
||||
sessiondefaults: [String: AnyCodable]?
|
||||
) {
|
||||
self.presence = presence
|
||||
self.health = health
|
||||
@@ -267,6 +269,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
self.uptimems = uptimems
|
||||
self.configpath = configpath
|
||||
self.statedir = statedir
|
||||
self.sessiondefaults = sessiondefaults
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case presence
|
||||
@@ -275,6 +278,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
case uptimems = "uptimeMs"
|
||||
case configpath = "configPath"
|
||||
case statedir = "stateDir"
|
||||
case sessiondefaults = "sessionDefaults"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,63 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayEndpointStoreTests {
|
||||
@Test func resolvesLocalHostFromBindModes() {
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "loopback",
|
||||
tailscaleIP: "100.64.0.10") == "127.0.0.1")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "lan",
|
||||
tailscaleIP: "100.64.0.10") == "127.0.0.1")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "tailnet",
|
||||
tailscaleIP: "100.64.0.10") == "100.64.0.10")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "tailnet",
|
||||
tailscaleIP: nil) == "127.0.0.1")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "auto",
|
||||
tailscaleIP: "100.64.0.10") == "100.64.0.10")
|
||||
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||
bindMode: "auto",
|
||||
tailscaleIP: nil) == "127.0.0.1")
|
||||
@Suite struct GatewayEndpointStoreTests {
|
||||
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: "launchd-token",
|
||||
password: nil)
|
||||
|
||||
let envToken = GatewayEndpointStore._testResolveGatewayToken(
|
||||
isRemote: false,
|
||||
root: [:],
|
||||
env: ["CLAWDBOT_GATEWAY_TOKEN": "env-token"],
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(envToken == "env-token")
|
||||
|
||||
let fallbackToken = GatewayEndpointStore._testResolveGatewayToken(
|
||||
isRemote: false,
|
||||
root: [:],
|
||||
env: [:],
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(fallbackToken == "launchd-token")
|
||||
}
|
||||
|
||||
@Test func resolvesBindModeFromEnvOrConfig() {
|
||||
let root: [String: Any] = ["gateway": ["bind": "tailnet"]]
|
||||
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
|
||||
root: root,
|
||||
env: [:]) == "tailnet")
|
||||
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
|
||||
root: root,
|
||||
env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan")
|
||||
@Test func resolveGatewayTokenIgnoresLaunchdInRemoteMode() {
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: "launchd-token",
|
||||
password: nil)
|
||||
|
||||
let token = GatewayEndpointStore._testResolveGatewayToken(
|
||||
isRemote: true,
|
||||
root: [:],
|
||||
env: [:],
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(token == nil)
|
||||
}
|
||||
|
||||
@Test func resolveGatewayPasswordFallsBackToLaunchd() {
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: nil,
|
||||
password: "launchd-pass")
|
||||
|
||||
let password = GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: [:],
|
||||
env: [:],
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(password == "launchd-pass")
|
||||
}
|
||||
}
|
||||
@@ -100,11 +100,18 @@ public struct BridgeHelloOk: Codable, Sendable {
|
||||
public let type: String
|
||||
public let serverName: String
|
||||
public let canvasHostUrl: String?
|
||||
public let mainSessionKey: String?
|
||||
|
||||
public init(type: String = "hello-ok", serverName: String, canvasHostUrl: String? = nil) {
|
||||
public init(
|
||||
type: String = "hello-ok",
|
||||
serverName: String,
|
||||
canvasHostUrl: String? = nil,
|
||||
mainSessionKey: String? = nil)
|
||||
{
|
||||
self.type = type
|
||||
self.serverName = serverName
|
||||
self.canvasHostUrl = canvasHostUrl
|
||||
self.mainSessionKey = mainSessionKey
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ Notes:
|
||||
- Your config requires mentions and you didn’t mention it, or
|
||||
- Your guild/channel allowlist denies the channel/user.
|
||||
- **`requireMention: false` but still no replies**:
|
||||
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or explicitly list channels under `channels.discord.guilds.<id>.channels`.
|
||||
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
|
||||
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
|
||||
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
|
||||
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
|
||||
@@ -352,6 +352,7 @@ Allowlist matching notes:
|
||||
- Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported.
|
||||
- Use `*` to allow any sender/channel.
|
||||
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
||||
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
|
||||
|
||||
Native command notes:
|
||||
- The registered commands mirror Clawdbot’s chat commands.
|
||||
@@ -362,6 +363,7 @@ The agent can call `discord` with actions like:
|
||||
- `react` / `reactions` (add or list reactions)
|
||||
- `sticker`, `poll`, `permissions`
|
||||
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
|
||||
- Read/search/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Discord `timestamp`.
|
||||
- `threadCreate`, `threadList`, `threadReply`
|
||||
- `pinMessage`, `unpinMessage`, `listPins`
|
||||
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
|
||||
|
||||
@@ -18,6 +18,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support.
|
||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
summary: "Matrix support status, capabilities, and configuration"
|
||||
read_when:
|
||||
- Working on Matrix channel features
|
||||
---
|
||||
# Matrix (plugin)
|
||||
|
||||
Status: supported via plugin (matrix-js-sdk). Direct messages, rooms, threads, media, reactions, and polls.
|
||||
|
||||
## Plugin required
|
||||
Matrix ships as a plugin and is not bundled with the core install.
|
||||
|
||||
Install via CLI (npm registry):
|
||||
```bash
|
||||
clawdbot plugins install @clawdbot/matrix
|
||||
```
|
||||
|
||||
Local checkout (when running from a git repo):
|
||||
```bash
|
||||
clawdbot plugins install ./extensions/matrix
|
||||
```
|
||||
|
||||
If you choose Matrix during configure/onboarding and a git checkout is detected,
|
||||
Clawdbot will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
|
||||
## Quick setup (beginner)
|
||||
1) Install the Matrix plugin:
|
||||
- From npm: `clawdbot plugins install @clawdbot/matrix`
|
||||
- From a local checkout: `clawdbot plugins install ./extensions/matrix`
|
||||
2) Configure credentials:
|
||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
|
||||
- Or config: `channels.matrix.*`
|
||||
3) Restart the gateway (or finish onboarding).
|
||||
4) DM access defaults to pairing; approve the pairing code on first contact.
|
||||
|
||||
Runtime note: Matrix requires Node.js (Bun is not supported).
|
||||
|
||||
Minimal config:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@clawdbot:example.org",
|
||||
accessToken: "syt_***",
|
||||
dm: { policy: "pairing" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Encryption (E2EE)
|
||||
End-to-end encrypted rooms are **not** supported.
|
||||
- Use unencrypted rooms or disable encryption when creating the room.
|
||||
- If a room is E2EE, the bot will receive encrypted events and won’t reply.
|
||||
|
||||
## What it is
|
||||
Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and listens to DMs and rooms.
|
||||
- A Matrix user account owned by the Gateway.
|
||||
- Deterministic routing: replies go back to Matrix.
|
||||
- DMs share the agent's main session; rooms map to group sessions.
|
||||
|
||||
## Access control (DMs)
|
||||
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
|
||||
- Approve via:
|
||||
- `clawdbot pairing list matrix`
|
||||
- `clawdbot pairing approve matrix <CODE>`
|
||||
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
||||
|
||||
## Rooms (groups)
|
||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated).
|
||||
- Allowlist rooms with `channels.matrix.rooms`:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
rooms: {
|
||||
"!roomId:example.org": { requireMention: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- `requireMention: false` enables auto-reply in that room.
|
||||
|
||||
## Threads
|
||||
- Reply threading is supported.
|
||||
- `channels.matrix.replyToMode` controls replies when tagged:
|
||||
- `off` (default), `first`, `all`
|
||||
|
||||
## Capabilities
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Direct messages | ✅ Supported |
|
||||
| Rooms | ✅ Supported |
|
||||
| Threads | ✅ Supported |
|
||||
| Media | ✅ Supported |
|
||||
| Reactions | ✅ Supported |
|
||||
| Polls | ✅ Supported |
|
||||
| Native commands | ✅ Supported |
|
||||
|
||||
## Configuration reference (Matrix)
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
Provider options:
|
||||
- `channels.matrix.enabled`: enable/disable channel startup.
|
||||
- `channels.matrix.homeserver`: homeserver URL.
|
||||
- `channels.matrix.userId`: Matrix user ID.
|
||||
- `channels.matrix.accessToken`: access token.
|
||||
- `channels.matrix.password`: password for login (token stored).
|
||||
- `channels.matrix.deviceName`: device display name.
|
||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist. `open` requires `"*"`.
|
||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
||||
- `channels.matrix.rooms`: per-room settings and allowlist.
|
||||
- `channels.matrix.replyToMode`: reply-to mode for threads/tags.
|
||||
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
||||
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
|
||||
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).
|
||||
+88
-20
@@ -27,17 +27,17 @@ Minimal config:
|
||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
4) **Event Subscriptions** → enable events and subscribe to:
|
||||
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
|
||||
5) **Event Subscriptions** → enable events and subscribe to:
|
||||
- `message.*` (includes edits/deletes/thread broadcasts)
|
||||
- `app_mention`
|
||||
- `reaction_added`, `reaction_removed`
|
||||
- `member_joined_channel`, `member_left_channel`
|
||||
- `channel_id_changed`
|
||||
- `channel_rename`
|
||||
- `pin_added`, `pin_removed`
|
||||
5) Invite the bot to channels you want it to read.
|
||||
6) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
|
||||
7) App Home → enable the **Messages Tab** so users can DM the bot.
|
||||
6) Invite the bot to channels you want it to read.
|
||||
7) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
|
||||
8) App Home → enable the **Messages Tab** so users can DM the bot.
|
||||
|
||||
Use the manifest below so scopes and events stay in sync.
|
||||
|
||||
@@ -63,27 +63,59 @@ Or via config:
|
||||
}
|
||||
```
|
||||
|
||||
## History context
|
||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
|
||||
## User token (optional)
|
||||
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
||||
pins, reactions, emoji, member info). By default this stays read-only: reads
|
||||
prefer the user token when present, and writes still use the bot token unless
|
||||
you explicitly opt in. Even with `userTokenReadOnly: false`, the bot token stays
|
||||
preferred for writes when it is available.
|
||||
|
||||
## Config writes
|
||||
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
|
||||
User tokens are configured in the config file (no env var support). For
|
||||
multi-account, set `channels.slack.accounts.<id>.userToken`.
|
||||
|
||||
This happens when:
|
||||
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
|
||||
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
Example with bot + app + user tokens:
|
||||
```json5
|
||||
{
|
||||
channels: { slack: { configWrites: false } }
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
appToken: "xapp-...",
|
||||
botToken: "xoxb-...",
|
||||
userToken: "xoxp-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example with userTokenReadOnly explicitly set (allow user token writes):
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
appToken: "xapp-...",
|
||||
botToken: "xoxb-...",
|
||||
userToken: "xoxp-...",
|
||||
userTokenReadOnly: false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Token usage
|
||||
- Read operations (history, reactions list, pins list, emoji list, member info,
|
||||
search) prefer the user token when configured, otherwise the bot token.
|
||||
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||
no bot token is available, Clawdbot falls back to the user token.
|
||||
|
||||
## History context
|
||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
|
||||
## Manifest (optional)
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||
user scopes if you plan to configure a user token.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -133,6 +165,21 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
|
||||
"commands",
|
||||
"files:read",
|
||||
"files:write"
|
||||
],
|
||||
"user": [
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
"im:read",
|
||||
"mpim:history",
|
||||
"mpim:read",
|
||||
"users:read",
|
||||
"reactions:read",
|
||||
"pins:read",
|
||||
"emoji:read",
|
||||
"search:read"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -149,7 +196,6 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
|
||||
"reaction_removed",
|
||||
"member_joined_channel",
|
||||
"member_left_channel",
|
||||
"channel_id_changed",
|
||||
"channel_rename",
|
||||
"pin_added",
|
||||
"pin_removed"
|
||||
@@ -166,7 +212,7 @@ Slack's Conversations API is type-scoped: you only need the scopes for the
|
||||
conversation types you actually touch (channels, groups, im, mpim). See
|
||||
https://api.channels.slack.com/docs/conversations-api for the overview.
|
||||
|
||||
### Required scopes
|
||||
### Bot token scopes (required)
|
||||
- `chat:write` (send/update/delete messages via `chat.postMessage`)
|
||||
https://api.channels.slack.com/methods/chat.postMessage
|
||||
- `im:write` (open DMs via `conversations.open` for user DMs)
|
||||
@@ -188,6 +234,17 @@ https://api.channels.slack.com/docs/conversations-api for the overview.
|
||||
- `files:write` (uploads via `files.uploadV2`)
|
||||
https://api.channels.slack.com/messaging/files/uploading
|
||||
|
||||
### User token scopes (optional, read-only by default)
|
||||
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
||||
|
||||
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
||||
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
|
||||
- `users:read`
|
||||
- `reactions:read`
|
||||
- `pins:read`
|
||||
- `emoji:read`
|
||||
- `search:read`
|
||||
|
||||
### Not needed today (but likely future)
|
||||
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
|
||||
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
|
||||
@@ -315,6 +372,17 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
|
||||
| memberInfo | enabled | Member info |
|
||||
| emojiList | enabled | Custom emoji list |
|
||||
|
||||
## Security notes
|
||||
- Writes default to the bot token so state-changing actions stay scoped to the
|
||||
app's bot permissions and identity.
|
||||
- Setting `userTokenReadOnly: false` allows the user token to be used for write
|
||||
operations when a bot token is unavailable, which means actions run with the
|
||||
installing user's access. Treat the user token as highly privileged and keep
|
||||
action gates and allowlists tight.
|
||||
- If you enable user-token writes, make sure the user token includes the write
|
||||
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
|
||||
`files:write`) or those operations will fail.
|
||||
|
||||
## Notes
|
||||
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
|
||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||
|
||||
@@ -188,6 +188,7 @@ Disable with:
|
||||
Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
||||
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
|
||||
- Sends typing indicators and replies with `message_thread_id` so responses stay in the topic.
|
||||
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
|
||||
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
||||
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
||||
|
||||
@@ -297,6 +298,49 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
|
||||
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
||||
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
|
||||
|
||||
## Reaction notifications
|
||||
|
||||
**How reactions work:**
|
||||
Telegram reactions arrive as **separate `message_reaction` events**, not as properties in message payloads. When a user adds a reaction, Clawdbot:
|
||||
|
||||
1. Receives the `message_reaction` update from Telegram API
|
||||
2. Converts it to a **system event** with format: `"Telegram reaction added: {emoji} by {user} on msg {id}"`
|
||||
3. Enqueues the system event using the **same session key** as regular messages
|
||||
4. When the next message arrives in that conversation, system events are drained and prepended to the agent's context
|
||||
|
||||
The agent sees reactions as **system notifications** in the conversation history, not as message metadata.
|
||||
|
||||
**Configuration:**
|
||||
- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications
|
||||
- `"off"` — ignore all reactions (default when not set)
|
||||
- `"own"` — notify when users react to bot messages (best-effort; in-memory)
|
||||
- `"all"` — notify for all reactions
|
||||
|
||||
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
|
||||
- `"off"` — agent cannot react to messages
|
||||
- `"ack"` — bot sends acknowledgment reactions (👀 while processing) (default)
|
||||
- `"minimal"` — agent can react sparingly (guideline: 1 per 5-10 exchanges)
|
||||
- `"extensive"` — agent can react liberally when appropriate
|
||||
|
||||
**Forum groups:** Reactions in forum groups include `message_thread_id` and use session keys like `agent:main:telegram:group:{chatId}:topic:{threadId}`. This ensures reactions and messages in the same topic stay together.
|
||||
|
||||
**Example config:**
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
reactionNotifications: "all", // See all reactions
|
||||
reactionLevel: "minimal" // Agent can react sparingly
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Telegram bots must explicitly request `message_reaction` in `allowed_updates` (configured automatically by Clawdbot)
|
||||
- For webhook mode, reactions are included in the webhook `allowed_updates`
|
||||
- For polling mode, reactions are included in the `getUpdates` `allowed_updates`
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- Use a chat id (`123456789`) or a username (`@name`) as the target.
|
||||
- Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`.
|
||||
@@ -324,6 +368,11 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
|
||||
- Make sure your Telegram user ID is authorized (via pairing or `channels.telegram.allowFrom`)
|
||||
- Commands require authorization even in groups with `groupPolicy: "open"`
|
||||
|
||||
**Bot starts, then silently stops responding (or logs `HttpError: Network request ... failed`):**
|
||||
- Some hosts resolve `api.telegram.org` to IPv6 first. If your server does not have working IPv6 egress, grammY can get stuck on IPv6-only requests.
|
||||
- Fix by enabling IPv6 egress **or** forcing IPv4 resolution for `api.telegram.org` (for example, add an `/etc/hosts` entry using the IPv4 A record, or prefer IPv4 in your OS DNS stack), then restart the gateway.
|
||||
- Quick check: `dig +short api.telegram.org A` and `dig +short api.telegram.org AAAA` to confirm what DNS returns.
|
||||
|
||||
## Configuration reference (Telegram)
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
@@ -355,6 +404,8 @@ Provider options:
|
||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `off` when not set).
|
||||
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `ack` when not set).
|
||||
|
||||
Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
|
||||
|
||||
@@ -19,3 +19,6 @@ clawdbot channels status --probe
|
||||
- Discord: [/channels/discord#troubleshooting](/channels/discord#troubleshooting)
|
||||
- Telegram: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
|
||||
- WhatsApp: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick)
|
||||
|
||||
## Telegram quick fixes
|
||||
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
||||
@@ -296,8 +296,9 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
|
||||
## Heartbeats
|
||||
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
||||
- **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session.
|
||||
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior.
|
||||
- **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally
|
||||
via `agents.defaults.heartbeat` (fallback when no per-agent entries are set).
|
||||
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`) + `HEARTBEAT_OK` skip behavior.
|
||||
- Delivery defaults to the last used channel (or configured target).
|
||||
|
||||
## Reconnect behavior
|
||||
@@ -330,6 +331,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
- `agents.defaults.heartbeat.model` (optional override)
|
||||
- `agents.defaults.heartbeat.target`
|
||||
- `agents.defaults.heartbeat.to`
|
||||
- `agents.list[].heartbeat.*` (per-agent overrides)
|
||||
- `session.*` (scope, idle, store, mainKey)
|
||||
- `web.enabled` (disable channel startup when false)
|
||||
- `web.heartbeatSeconds`
|
||||
|
||||
@@ -38,6 +38,8 @@ Minimal config:
|
||||
```
|
||||
|
||||
## What it is
|
||||
Zalo is a Vietnam-focused messaging app; its Bot API lets the Gateway run a bot for 1:1 conversations.
|
||||
It is a good fit for support or notifications where you want deterministic routing back to Zalo.
|
||||
- A Zalo Bot API channel owned by the Gateway.
|
||||
- Deterministic routing: replies go back to Zalo; the model never chooses channels.
|
||||
- DMs share the agent's main session.
|
||||
|
||||
+7
-7
@@ -23,16 +23,17 @@ Related:
|
||||
## Quick start (local)
|
||||
|
||||
```bash
|
||||
clawdbot browser status
|
||||
clawdbot browser start
|
||||
clawdbot browser tabs
|
||||
clawdbot browser open https://example.com
|
||||
clawdbot browser snapshot
|
||||
clawdbot browser --browser-profile chrome tabs
|
||||
clawdbot browser --browser-profile clawd start
|
||||
clawdbot browser --browser-profile clawd open https://example.com
|
||||
clawdbot browser --browser-profile clawd snapshot
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
Profiles are named browser instances with their own data directory and CDP settings.
|
||||
Profiles are named browser routing configs. In practice:
|
||||
- `clawd`: launches/attaches to a dedicated Clawdbot-managed Chrome instance (isolated user data dir).
|
||||
- `chrome`: controls your existing Chrome tab(s) via the Chrome extension relay.
|
||||
|
||||
```bash
|
||||
clawdbot browser profiles
|
||||
@@ -103,4 +104,3 @@ clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
||||
Then point the Gateway at it using `browser.controlUrl` + `browser.controlToken` (or `CLAWDBOT_BROWSER_CONTROL_TOKEN`).
|
||||
|
||||
Security + TLS best-practices: [Browser tool](/tools/browser), [Tailscale](/gateway/tailscale), [Security](/gateway/security)
|
||||
|
||||
@@ -11,10 +11,12 @@ Interactive prompt to set up credentials, devices, and agent defaults.
|
||||
Related:
|
||||
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
||||
|
||||
Notes:
|
||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot configure
|
||||
clawdbot configure --section models --section channels
|
||||
```
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
|
||||
- your configured remote gateway (if set), and
|
||||
- localhost (loopback) **even if remote is configured**.
|
||||
|
||||
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway.
|
||||
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
|
||||
|
||||
```bash
|
||||
clawdbot gateway status
|
||||
|
||||
+7
-1
@@ -22,6 +22,10 @@ clawdbot models set <model-or-alias>
|
||||
clawdbot models scan
|
||||
```
|
||||
|
||||
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
|
||||
When provider usage snapshots are available, the OAuth/token status section includes
|
||||
provider usage headers.
|
||||
|
||||
## Aliases + fallbacks
|
||||
|
||||
```bash
|
||||
@@ -33,7 +37,9 @@ clawdbot models fallbacks list
|
||||
|
||||
```bash
|
||||
clawdbot models auth add
|
||||
clawdbot models auth login --provider <id>
|
||||
clawdbot models auth setup-token
|
||||
clawdbot models auth paste-token
|
||||
```
|
||||
|
||||
`models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use
|
||||
`clawdbot plugins list` to see which providers are installed.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
summary: "Alias for compaction docs"
|
||||
read_when:
|
||||
- You looked for /compaction; canonical doc lives in /concepts/compaction
|
||||
---
|
||||
# Compaction
|
||||
|
||||
Canonical compaction docs live in [Compaction](/concepts/compaction).
|
||||
@@ -16,7 +16,7 @@ Compaction **summarizes older conversation** into a compact summary entry and ke
|
||||
Compaction **persists** in the session’s JSONL history.
|
||||
|
||||
## Configuration
|
||||
See [Compaction config & modes](/compaction) for the `agents.defaults.compaction` settings.
|
||||
See [Compaction config & modes](/concepts/compaction) for the `agents.defaults.compaction` settings.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
When a session nears or exceeds the model’s context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context.
|
||||
|
||||
@@ -33,6 +33,32 @@ Channels can redeliver the same message after reconnects. Clawdbot keeps a
|
||||
short-lived cache keyed by channel/account/peer/session/message id so duplicate
|
||||
deliveries do not trigger another agent run.
|
||||
|
||||
## Inbound debouncing
|
||||
|
||||
Rapid consecutive messages from the **same sender** can be batched into a single
|
||||
agent turn via `messages.inbound`. Debouncing is scoped per channel + conversation
|
||||
and uses the most recent message for reply threading/IDs.
|
||||
|
||||
Config (global default + per-channel overrides):
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
inbound: {
|
||||
debounceMs: 2000,
|
||||
byChannel: {
|
||||
whatsapp: 5000,
|
||||
slack: 1500,
|
||||
discord: 1500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Debounce applies to **text-only** messages; media/attachments flush immediately.
|
||||
- Control commands bypass debouncing so they remain standalone.
|
||||
|
||||
## Sessions and devices
|
||||
|
||||
Sessions are owned by the gateway, not by clients.
|
||||
|
||||
@@ -48,6 +48,17 @@ If no explicit order is configured, Clawdbot uses a round‑robin order:
|
||||
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
|
||||
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
|
||||
|
||||
### Session stickiness (cache-friendly)
|
||||
|
||||
Clawdbot **pins the chosen auth profile per session** to keep provider caches warm.
|
||||
It does **not** rotate on every request. The pinned profile is reused until:
|
||||
- the session is reset (`/new` / `/reset`)
|
||||
- a compaction completes (compaction count increments)
|
||||
- the profile is in cooldown/disabled
|
||||
|
||||
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
||||
and is not auto‑rotated until a new session starts.
|
||||
|
||||
### Why OAuth can “look lost”
|
||||
|
||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||
|
||||
@@ -15,6 +15,13 @@ Clawdbot supports “subscription auth” via OAuth for providers that offer it
|
||||
- how we **reuse external CLI tokens** (Claude Code / Codex CLI)
|
||||
- how to handle **multiple accounts** (profiles + per-session overrides)
|
||||
|
||||
Clawdbot also supports **provider plugins** that ship their own OAuth or API‑key
|
||||
flows. Run them via:
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider <id>
|
||||
```
|
||||
|
||||
## The token sink (why it exists)
|
||||
|
||||
OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app.
|
||||
|
||||
@@ -20,12 +20,28 @@ The prompt is intentionally compact and uses fixed sections:
|
||||
- **Workspace**: working directory (`agents.defaults.workspace`).
|
||||
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
||||
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
||||
- **Time**: UTC default + the user’s local time (already converted).
|
||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
||||
- **Reply Tags**: optional reply tag syntax for supported providers.
|
||||
- **Heartbeats**: heartbeat prompt and ack behavior.
|
||||
- **Runtime**: host, OS, node, model, thinking level (one line).
|
||||
- **Reasoning**: current visibility level + /reasoning toggle hint.
|
||||
|
||||
## Prompt modes
|
||||
|
||||
Clawdbot can render smaller system prompts for sub-agents. The runtime sets a
|
||||
`promptMode` for each run (not a user-facing config):
|
||||
|
||||
- `full` (default): includes all sections above.
|
||||
- `minimal`: used for sub-agents; omits **Skills**, **Memory Recall**, **Clawdbot
|
||||
Self-Update**, **Model Aliases**, **User Identity**, **Reply Tags**,
|
||||
**Messaging**, **Silent Replies**, and **Heartbeats**. Tooling, Workspace,
|
||||
Sandbox, Current Date & Time (when known), Runtime, and injected context stay
|
||||
available.
|
||||
- `none`: returns only the base identity line.
|
||||
|
||||
When `promptMode=minimal`, extra injected prompts are labeled **Subagent
|
||||
Context** instead of **Group Chat Context**.
|
||||
|
||||
## Workspace bootstrap injection
|
||||
|
||||
Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads:
|
||||
@@ -46,12 +62,19 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
|
||||
|
||||
## Time handling
|
||||
|
||||
The Time line is compact and explicit:
|
||||
The system prompt includes a dedicated **Current Date & Time** section when user
|
||||
time or timezone is known. It is explicit about:
|
||||
|
||||
- Assume timestamps are **UTC** unless stated.
|
||||
- The listed **user time** is already converted to `agents.defaults.userTimezone` (if set).
|
||||
- The user’s **local time** (already converted).
|
||||
- The **time zone** used for the conversion.
|
||||
- The **time format** (12-hour / 24-hour).
|
||||
|
||||
Use `agents.defaults.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone.
|
||||
Configure with:
|
||||
|
||||
- `agents.defaults.userTimezone`
|
||||
- `agents.defaults.timeFormat` (`auto` | `12` | `24`)
|
||||
|
||||
See [Date & Time](/date-time) for full behavior details.
|
||||
|
||||
## Skills
|
||||
|
||||
|
||||
@@ -19,10 +19,15 @@ Inbound messages are wrapped in an envelope like:
|
||||
|
||||
The timestamp in the envelope is **always UTC**, with minutes precision.
|
||||
|
||||
## Tool payloads (raw provider data)
|
||||
## Tool payloads (raw provider data + normalized fields)
|
||||
|
||||
Tool calls (`channels.discord.readMessages`, `channels.slack.readMessages`, etc.) return **raw provider timestamps**.
|
||||
These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We do not rewrite them.
|
||||
We also attach normalized fields for consistency:
|
||||
|
||||
- `timestampMs` (UTC epoch milliseconds)
|
||||
- `timestampUtc` (ISO 8601 UTC string)
|
||||
|
||||
Raw provider fields are preserved.
|
||||
|
||||
## User timezone for the system prompt
|
||||
|
||||
@@ -31,10 +36,14 @@ unset, Clawdbot resolves the **host timezone at runtime** (no config write).
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { userTimezone: "America/Chicago" }
|
||||
agents: { defaults: { userTimezone: "America/Chicago" } }
|
||||
}
|
||||
```
|
||||
|
||||
The system prompt includes:
|
||||
- `User timezone: America/Chicago`
|
||||
- `Current user time: 2026-01-05 15:26`
|
||||
- `Current Date & Time` section with local time and timezone
|
||||
- `Time format: 12-hour` or `24-hour`
|
||||
|
||||
You can control the prompt format with `agents.defaults.timeFormat` (`auto` | `12` | `24`).
|
||||
|
||||
See [Date & Time](/date-time) for the full behavior and examples.
|
||||
@@ -44,7 +44,7 @@ Common methods + events:
|
||||
| --- | --- | --- |
|
||||
| Core | `connect`, `health`, `status` | `connect` must be first |
|
||||
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
|
||||
| Chat | `chat.history`, `chat.send`, `chat.abort` | WebChat uses these |
|
||||
| Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these |
|
||||
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
|
||||
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
|
||||
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
|
||||
|
||||
@@ -11,7 +11,7 @@ read_when:
|
||||
- No estimated costs; only the provider-reported windows.
|
||||
|
||||
## Where it shows up
|
||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available.
|
||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
|
||||
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
summary: "Date and time handling across envelopes, prompts, tools, and connectors"
|
||||
read_when:
|
||||
- You are changing how timestamps are shown to the model or users
|
||||
- You are debugging time formatting in messages or system prompt output
|
||||
---
|
||||
|
||||
# Date & Time
|
||||
|
||||
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||
We avoid rewriting provider timestamps so tools keep their native semantics.
|
||||
|
||||
## Message envelopes (UTC)
|
||||
|
||||
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
|
||||
```
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
This envelope timestamp is **always UTC**, regardless of the host timezone.
|
||||
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
If the user timezone or local time is known, the system prompt includes a dedicated
|
||||
**Current Date & Time** section:
|
||||
|
||||
```
|
||||
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
|
||||
Time format: 12-hour
|
||||
```
|
||||
|
||||
If only the timezone is known, we still include the section and instruct the model
|
||||
to assume UTC for unknown time references.
|
||||
|
||||
## System event lines (UTC)
|
||||
|
||||
Queued system events inserted into agent context are prefixed with a UTC timestamp:
|
||||
|
||||
```
|
||||
System: [2026-01-12T20:19:17Z] Model switched.
|
||||
```
|
||||
|
||||
### Configure user timezone + format
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
userTimezone: "America/Chicago",
|
||||
timeFormat: "auto" // auto | 12 | 24
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `userTimezone` sets the **user-local timezone** for prompt context.
|
||||
- `timeFormat` controls **12h/24h display** in the prompt. `auto` follows OS prefs.
|
||||
|
||||
## Time format detection (auto)
|
||||
|
||||
When `timeFormat: "auto"`, Clawdbot inspects the OS preference (macOS/Windows)
|
||||
and falls back to locale formatting. The detected value is **cached per process**
|
||||
to avoid repeated system calls.
|
||||
|
||||
## Tool payloads + connectors (raw provider time + normalized fields)
|
||||
|
||||
Channel tools return **provider-native timestamps** and add normalized fields for consistency:
|
||||
|
||||
- `timestampMs`: epoch milliseconds (UTC)
|
||||
- `timestampUtc`: ISO 8601 UTC string
|
||||
|
||||
Raw provider fields are preserved so nothing is lost.
|
||||
|
||||
- Slack: epoch-like strings from the API
|
||||
- Discord: UTC ISO timestamps
|
||||
- Telegram/WhatsApp: provider-specific numeric/ISO timestamps
|
||||
|
||||
If you need local time, convert it downstream using the known timezone.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [System Prompt](/concepts/system-prompt)
|
||||
- [Timezones](/concepts/timezone)
|
||||
- [Messages](/concepts/messages)
|
||||
+10
-10
@@ -229,6 +229,14 @@
|
||||
"source": "/sandbox/",
|
||||
"destination": "/cli/sandbox"
|
||||
},
|
||||
{
|
||||
"source": "/sandboxing",
|
||||
"destination": "/gateway/sandboxing"
|
||||
},
|
||||
{
|
||||
"source": "/sandboxing/",
|
||||
"destination": "/gateway/sandboxing"
|
||||
},
|
||||
{
|
||||
"source": "/AGENTS.default",
|
||||
"destination": "/reference/AGENTS.default"
|
||||
@@ -851,6 +859,7 @@
|
||||
"gateway/gateway-lock",
|
||||
"environment",
|
||||
"gateway/configuration",
|
||||
"gateway/multiple-gateways",
|
||||
"gateway/configuration-examples",
|
||||
"gateway/authentication",
|
||||
"gateway/openai-http-api",
|
||||
@@ -896,6 +905,7 @@
|
||||
"channels/signal",
|
||||
"channels/imessage",
|
||||
"channels/msteams",
|
||||
"channels/matrix",
|
||||
"channels/zalo",
|
||||
"broadcast-groups",
|
||||
"channels/troubleshooting",
|
||||
@@ -1021,16 +1031,6 @@
|
||||
"reference/templates/TOOLS",
|
||||
"reference/templates/USER"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Experiments & Proposals",
|
||||
"pages": [
|
||||
"experiments/onboarding-config-protocol",
|
||||
"experiments/plans/cron-add-hardening",
|
||||
"experiments/plans/group-policy-hardening",
|
||||
"experiments/proposals/model-config",
|
||||
"experiments/research/memory"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ Behavior:
|
||||
- Output is kept in memory until the session is polled or cleared.
|
||||
- If the `process` tool is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||
|
||||
## Child process bridging
|
||||
|
||||
When spawning long-running child processes outside the exec/process tools (for example, CLI respawns or gateway helpers), attach the child-process bridge helper so termination signals are forwarded and listeners are detached on exit/error. This avoids orphaned processes on systemd and keeps shutdown behavior consistent across platforms.
|
||||
|
||||
Environment overrides:
|
||||
- `PI_BASH_YIELD_MS`: default yield (ms)
|
||||
- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars)
|
||||
|
||||
@@ -455,6 +455,44 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
}
|
||||
```
|
||||
|
||||
### Anthropic subscription + API key, MiniMax fallback
|
||||
```json5
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:subscription": {
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
email: "user@example.com"
|
||||
},
|
||||
"anthropic:api": {
|
||||
provider: "anthropic",
|
||||
mode: "api_key"
|
||||
}
|
||||
},
|
||||
order: {
|
||||
anthropic: ["anthropic:subscription", "anthropic:api"]
|
||||
}
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
apiKey: "${MINIMAX_API_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
agent: {
|
||||
workspace: "~/clawd",
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-5",
|
||||
fallbacks: ["minimax/MiniMax-M2.1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Work bot (restricted access)
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -822,6 +822,31 @@ Controls how inbound messages behave when an agent run is already active.
|
||||
}
|
||||
```
|
||||
|
||||
### `messages.inbound`
|
||||
|
||||
Debounce rapid inbound messages from the **same sender** so multiple back-to-back
|
||||
messages become a single agent turn. Debouncing is scoped per channel + conversation
|
||||
and uses the most recent message for reply threading/IDs.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
inbound: {
|
||||
debounceMs: 2000, // 0 disables
|
||||
byChannel: {
|
||||
whatsapp: 5000,
|
||||
slack: 1500,
|
||||
discord: 1500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Debounce batches **text-only** messages; media/attachments flush immediately.
|
||||
- Control commands (e.g. `/queue`, `/new`) bypass debouncing so they stay standalone.
|
||||
|
||||
### `commands` (chat command handling)
|
||||
|
||||
Controls how chat commands are enabled across connectors.
|
||||
@@ -1050,6 +1075,10 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
reactionNotifications: "own", // off | own | all | allowlist
|
||||
reactionAllowlist: ["U123"],
|
||||
replyToMode: "off", // off | first | all
|
||||
thread: {
|
||||
historyScope: "thread", // thread | channel
|
||||
inheritParent: false
|
||||
},
|
||||
actions: {
|
||||
reactions: true,
|
||||
messages: true,
|
||||
@@ -1083,6 +1112,10 @@ Reaction notification modes:
|
||||
- `all`: all reactions on all messages.
|
||||
- `allowlist`: reactions from `channels.slack.reactionAllowlist` on all messages (empty list disables).
|
||||
|
||||
Thread session isolation:
|
||||
- `channels.slack.thread.historyScope` controls whether thread history is per-thread (`thread`, default) or shared across the channel (`channel`).
|
||||
- `channels.slack.thread.inheritParent` controls whether new thread sessions inherit the parent channel transcript (default: false).
|
||||
|
||||
Slack action groups (gate `slack` tool actions):
|
||||
| Action group | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -1203,6 +1236,17 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime.
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.timeFormat`
|
||||
|
||||
Controls the **time format** shown in the system prompt’s Current Date & Time section.
|
||||
Default: `auto` (OS preference).
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { timeFormat: "auto" } } // auto | 12 | 24
|
||||
}
|
||||
```
|
||||
|
||||
### `messages`
|
||||
|
||||
Controls inbound/outbound prefixes and optional ack reactions.
|
||||
@@ -1541,7 +1585,7 @@ See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
|
||||
|
||||
#### `agents.defaults.compaction` (reserve headroom + memory flush)
|
||||
|
||||
`agents.defaults.compaction.mode` selects the compaction summarization strategy. Defaults to `default`; set `safeguard` to enable chunked summarization for very long histories. See [/compaction](/compaction).
|
||||
`agents.defaults.compaction.mode` selects the compaction summarization strategy. Defaults to `default`; set `safeguard` to enable chunked summarization for very long histories. See [/concepts/compaction](/concepts/compaction).
|
||||
|
||||
`agents.defaults.compaction.reserveTokensFloor` enforces a minimum `reserveTokens`
|
||||
value for Pi compaction (default: `20000`). Set it to `0` to disable the floor.
|
||||
@@ -1631,9 +1675,14 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
|
||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
||||
|
||||
Per-agent heartbeats:
|
||||
- Set `agents.list[].heartbeat` to enable or override heartbeat settings for a specific agent.
|
||||
- If any agent entry defines `heartbeat`, **only those agents** run heartbeats; defaults
|
||||
become the shared baseline for those agents.
|
||||
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
|
||||
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||
|
||||
@@ -2349,7 +2398,7 @@ Defaults:
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
||||
defaultProfile: "clawd",
|
||||
defaultProfile: "chrome",
|
||||
profiles: {
|
||||
clawd: { cdpPort: 18800, color: "#FF4500" },
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
@@ -2504,7 +2553,7 @@ Requires full Gateway restart:
|
||||
|
||||
### Multi-instance isolation
|
||||
|
||||
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
|
||||
To run multiple gateways on one host (for redundancy or a rescue bot), isolate per-instance state + config and use unique ports:
|
||||
- `CLAWDBOT_CONFIG_PATH` (per-instance config)
|
||||
- `CLAWDBOT_STATE_DIR` (sessions/creds)
|
||||
- `agents.defaults.workspace` (memories)
|
||||
@@ -2515,6 +2564,7 @@ Convenience flags (CLI):
|
||||
- `clawdbot --profile <name> …` → uses `~/.clawdbot-<name>` (port via config/env/flags)
|
||||
|
||||
See [Gateway runbook](/gateway) for the derived port mapping (gateway/bridge/browser/canvas).
|
||||
See [Multiple gateways](/gateway/multiple-gateways) for browser/CDP port isolation details.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
|
||||
@@ -16,7 +16,7 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
|
||||
|
||||
## Terms
|
||||
|
||||
- **Gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs channels.
|
||||
- **Gateway**: a single long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. Most setups use one per host; isolated multi-gateway setups are possible.
|
||||
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
|
||||
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
|
||||
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
|
||||
|
||||
@@ -9,7 +9,7 @@ read_when:
|
||||
Last updated: 2025-12-11
|
||||
|
||||
## Why
|
||||
- Ensure only one gateway instance runs per host.
|
||||
- Ensure only one gateway instance runs per base port on the same host; additional gateways must use isolated profiles and unique ports.
|
||||
- Survive crashes/SIGKILL without leaving stale lock files.
|
||||
- Fail fast with a clear error when the control port is already occupied.
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ Example config:
|
||||
|
||||
## Defaults
|
||||
|
||||
- Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable).
|
||||
- Interval: `30m` (set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable).
|
||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
|
||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
||||
prompt includes a “Heartbeat” section and the run is flagged internally.
|
||||
|
||||
@@ -49,8 +49,8 @@ The default prompt is intentionally broad:
|
||||
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
|
||||
|
||||
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
|
||||
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` to a
|
||||
custom body (sent verbatim).
|
||||
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` (or
|
||||
`agents.list[].heartbeat.prompt`) to a custom body (sent verbatim).
|
||||
|
||||
## Response contract
|
||||
|
||||
@@ -77,7 +77,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
||||
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
||||
to: "+15551234567", // optional channel-specific override
|
||||
prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.",
|
||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,39 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
}
|
||||
```
|
||||
|
||||
### Per-agent heartbeats
|
||||
|
||||
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
|
||||
run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat`
|
||||
(so you can set shared defaults once and override per agent).
|
||||
|
||||
Example: two agents, only the second agent runs heartbeats.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "30m",
|
||||
target: "last"
|
||||
}
|
||||
},
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: "ops",
|
||||
heartbeat: {
|
||||
every: "1h",
|
||||
target: "whatsapp",
|
||||
to: "+15551234567",
|
||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field notes
|
||||
|
||||
- `every`: heartbeat interval (duration string; default unit = minutes).
|
||||
@@ -100,7 +133,8 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
|
||||
## Delivery behavior
|
||||
|
||||
- Heartbeats run in the **main session** (`main`, or `global` when scope is global).
|
||||
- Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global`
|
||||
when `session.scope = "global"`.
|
||||
- If the main queue is busy, the heartbeat is skipped and retried later.
|
||||
- If `target` resolves to no external destination, the run still happens but no
|
||||
outbound message is sent.
|
||||
@@ -149,6 +183,9 @@ You can enqueue a system event and trigger an immediate heartbeat with:
|
||||
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
||||
```
|
||||
|
||||
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
||||
agent heartbeats immediately.
|
||||
|
||||
Use `--mode next-heartbeat` to wait for the next scheduled tick.
|
||||
|
||||
## Reasoning delivery (optional)
|
||||
|
||||
@@ -51,7 +51,7 @@ pnpm gateway:watch
|
||||
|
||||
Usually unnecessary: one Gateway can serve multiple messaging channels and agents. Use multiple Gateways only for redundancy or strict isolation (ex: rescue bot).
|
||||
|
||||
Supported if you isolate state + config and use unique ports.
|
||||
Supported if you isolate state + config and use unique ports. Full guide: [Multiple gateways](/gateway/multiple-gateways).
|
||||
|
||||
Service names are profile-aware:
|
||||
- macOS: `com.clawdbot.<profile>`
|
||||
@@ -63,6 +63,8 @@ Install metadata is embedded in the service config:
|
||||
- `CLAWDBOT_SERVICE_KIND=gateway`
|
||||
- `CLAWDBOT_SERVICE_VERSION=<version>`
|
||||
|
||||
Rescue-Bot Pattern: keep a second Gateway isolated with its own profile, state dir, workspace, and base port spacing. Full guide: [Rescue-bot guide](/gateway/multiple-gateways#rescue-bot-guide).
|
||||
|
||||
### Dev profile (`--dev`)
|
||||
|
||||
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup.
|
||||
@@ -205,7 +207,7 @@ Notes:
|
||||
- `daemon status` includes the last gateway error line when the service looks running but the port is closed.
|
||||
- `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed).
|
||||
- If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services.
|
||||
We still recommend **one gateway per machine** unless you need redundant profiles.
|
||||
We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways).
|
||||
- Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
|
||||
- `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes).
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
summary: "Run multiple Clawdbot Gateways on one host (isolation, ports, and profiles)"
|
||||
read_when:
|
||||
- Running more than one Gateway on the same machine
|
||||
- You need isolated config/state/ports per Gateway
|
||||
---
|
||||
# Multiple Gateways (same host)
|
||||
|
||||
Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy (e.g., a rescue bot), run separate Gateways with isolated profiles/ports.
|
||||
|
||||
## Isolation checklist (required)
|
||||
- `CLAWDBOT_CONFIG_PATH` — per-instance config file
|
||||
- `CLAWDBOT_STATE_DIR` — per-instance sessions, creds, caches
|
||||
- `agents.defaults.workspace` — per-instance workspace root
|
||||
- `gateway.port` (or `--port`) — unique per instance
|
||||
- Derived ports (bridge/browser/canvas) must not overlap
|
||||
|
||||
If these are shared, you will hit config races and port conflicts.
|
||||
|
||||
## Recommended: profiles (`--profile`)
|
||||
|
||||
Profiles auto-scope `CLAWDBOT_STATE_DIR` + `CLAWDBOT_CONFIG_PATH` and suffix service names.
|
||||
|
||||
```bash
|
||||
# main
|
||||
clawdbot --profile main setup
|
||||
clawdbot --profile main gateway --port 18789
|
||||
|
||||
# rescue
|
||||
clawdbot --profile rescue setup
|
||||
clawdbot --profile rescue gateway --port 19001
|
||||
```
|
||||
|
||||
Per-profile daemons:
|
||||
```bash
|
||||
clawdbot --profile main daemon install
|
||||
clawdbot --profile rescue daemon install
|
||||
```
|
||||
|
||||
## Rescue-bot guide
|
||||
|
||||
Run a second Gateway on the same host with its own:
|
||||
- profile/config
|
||||
- state dir
|
||||
- workspace
|
||||
- base port (plus derived ports)
|
||||
|
||||
This keeps the rescue bot isolated from the main bot so it can debug or apply config changes if the primary bot is down.
|
||||
|
||||
Port spacing: leave at least 20 ports between base ports so the derived bridge/browser/canvas/CDP ports never collide.
|
||||
|
||||
### How to install (rescue bot)
|
||||
|
||||
```bash
|
||||
# Main bot (existing or fresh, without --profile param)
|
||||
# Runs on port 18789 + Chrome CDC/Canvas/... Ports
|
||||
clawdbot onboard
|
||||
clawdbot daemon install
|
||||
|
||||
# Rescue bot (isolated profile + ports)
|
||||
clawdbot --profile rescue onboard
|
||||
# Notes:
|
||||
# - workspace name will be postfixed with -rescue per default
|
||||
# - Port should be at least 18789 + 20 Ports,
|
||||
# better choose completely different base port, like 19789,
|
||||
# - rest of the onboarding is the same as normal
|
||||
|
||||
# To install the daemon (if not happened automatically during onboarding)
|
||||
clawdbot --profile rescue daemon install
|
||||
```
|
||||
|
||||
## Port mapping (derived)
|
||||
|
||||
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
|
||||
|
||||
- `bridge.port = base + 1`
|
||||
- `browser.controlUrl port = base + 2`
|
||||
- `canvasHost.port = base + 4`
|
||||
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
|
||||
|
||||
If you override any of these in config or env, you must keep them unique per instance.
|
||||
|
||||
## Browser/CDP notes (common footgun)
|
||||
|
||||
- Do **not** pin `browser.controlUrl` or `browser.cdpUrl` to the same values on multiple instances.
|
||||
- Each instance needs its own browser control port and CDP range.
|
||||
- If you need explicit CDP ports, set `browser.profiles.<name>.cdpPort` per instance.
|
||||
- Remote Chrome: use `browser.profiles.<name>.cdpUrl` (per profile, per instance).
|
||||
|
||||
## Manual env example
|
||||
|
||||
```bash
|
||||
CLAWDBOT_CONFIG_PATH=~/.clawdbot/main.json \
|
||||
CLAWDBOT_STATE_DIR=~/.clawdbot-main \
|
||||
clawdbot gateway --port 18789
|
||||
|
||||
CLAWDBOT_CONFIG_PATH=~/.clawdbot/rescue.json \
|
||||
CLAWDBOT_STATE_DIR=~/.clawdbot-rescue \
|
||||
clawdbot gateway --port 19001
|
||||
```
|
||||
|
||||
## Quick checks
|
||||
|
||||
```bash
|
||||
clawdbot --profile main status
|
||||
clawdbot --profile rescue status
|
||||
clawdbot --profile rescue browser status
|
||||
```
|
||||
@@ -26,7 +26,7 @@ Flow example (Telegram → node):
|
||||
- Node returns the result; Gateway replies back out to Telegram.
|
||||
|
||||
Notes:
|
||||
- **Nodes do not run the gateway daemon.** Only one gateway should run per host.
|
||||
- **Nodes do not run the gateway daemon.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
|
||||
- macOS app “node mode” is just a node client over the Bridge.
|
||||
|
||||
## SSH tunnel (CLI + tools)
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
|
||||
|
||||
## Network model
|
||||
|
||||
- **One Gateway per host**: it is the only process allowed to own the WhatsApp Web session.
|
||||
- **One Gateway per host (recommended)**: it is the only process allowed to own the WhatsApp Web session. If you need a rescue bot or strict isolation, run multiple gateways with isolated profiles and ports; see [Multiple gateways](/gateway/multiple-gateways).
|
||||
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
|
||||
- The wizard now generates a gateway token by default (even for loopback).
|
||||
- For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).
|
||||
|
||||
@@ -24,8 +24,8 @@ agent (with a session switcher for other sessions).
|
||||
|
||||
## How it’s wired
|
||||
|
||||
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort` and
|
||||
events `chat`, `agent`, `presence`, `tick`, `health`.
|
||||
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`,
|
||||
`chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`.
|
||||
- Session: defaults to the primary session (`main`, or `global` when scope is
|
||||
global). The UI can switch between sessions.
|
||||
- Onboarding uses a dedicated session to keep first‑run setup separate.
|
||||
|
||||
@@ -110,6 +110,32 @@ Tip: compare against `pnpm clawdbot gateway discover --json` to see whether the
|
||||
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
|
||||
the Node CLI’s `dns-sd` based discovery.
|
||||
|
||||
## Remote connection plumbing (SSH tunnels)
|
||||
|
||||
When the macOS app runs in **Remote** mode, it opens SSH tunnels so local UI
|
||||
components can talk to a remote Gateway as if it were on localhost. There are
|
||||
two independent tunnels:
|
||||
|
||||
### Control tunnel (Gateway control/WebSocket port)
|
||||
- **Purpose:** health checks, status, Web Chat, config, and other control-plane calls.
|
||||
- **Local port:** the Gateway port (default `18789`), always stable.
|
||||
- **Remote port:** the same Gateway port on the remote host.
|
||||
- **Behavior:** no random local port; the app reuses an existing healthy tunnel
|
||||
or restarts it if needed.
|
||||
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
|
||||
ExitOnForwardFailure + keepalive options.
|
||||
|
||||
### Node bridge tunnel (macOS node mode)
|
||||
- **Purpose:** connect the macOS node to the Gateway **Bridge** protocol (TCP JSONL).
|
||||
- **Remote port:** `gatewayPort + 1` (default `18790`), derived from the Gateway port.
|
||||
- **Local port preference:** `CLAWDBOT_BRIDGE_PORT` or the default `18790`.
|
||||
- **Behavior:** prefer the default bridge port for consistency; fall back to a
|
||||
random local port if the preferred one is busy. The node then connects to the
|
||||
resolved local port.
|
||||
|
||||
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
|
||||
details, see [Bridge protocol](/gateway/bridge-protocol).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Gateway runbook](/gateway)
|
||||
|
||||
@@ -33,6 +33,12 @@ clawdbot plugins install @clawdbot/voice-call
|
||||
|
||||
See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
|
||||
## Available plugins (official)
|
||||
|
||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||
|
||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||
register:
|
||||
|
||||
@@ -166,6 +172,56 @@ Plugins export either:
|
||||
- A function: `(api) => { ... }`
|
||||
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
||||
|
||||
## Provider plugins (model auth)
|
||||
|
||||
Plugins can register **model provider auth** flows so users can run OAuth or
|
||||
API-key setup inside Clawdbot (no external scripts needed).
|
||||
|
||||
Register a provider via `api.registerProvider(...)`. Each provider exposes one
|
||||
or more auth methods (OAuth, API key, device code, etc.). These methods power:
|
||||
|
||||
- `clawdbot models auth login --provider <id> [--method <id>]`
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
api.registerProvider({
|
||||
id: "acme",
|
||||
label: "AcmeAI",
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "OAuth",
|
||||
kind: "oauth",
|
||||
run: async (ctx) => {
|
||||
// Run OAuth flow and return auth profiles.
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: "acme:default",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "acme",
|
||||
access: "...",
|
||||
refresh: "...",
|
||||
expires: Date.now() + 3600 * 1000,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: "acme/opus-1",
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `run` receives a `ProviderAuthContext` with `prompter`, `runtime`,
|
||||
`openUrl`, and `oauth.createVpsAwareHandlers` helpers.
|
||||
- Return `configPatch` when you need to add default models or provider config.
|
||||
- Return `defaultModel` so `--set-default` can update agent defaults.
|
||||
|
||||
### Register a messaging channel
|
||||
|
||||
Plugins can register **channel plugins** that behave like built‑in channels
|
||||
|
||||
+51
-193
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Voice Call plugin: outbound and inbound calls via Twilio/Telnyx, with CLI, tools, and streaming"
|
||||
summary: "Voice Call plugin: outbound + inbound calls via Twilio/Telnyx/Plivo (plugin install + config + CLI)"
|
||||
read_when:
|
||||
- You want to place an outbound voice call from Clawdbot
|
||||
- You are configuring or developing the voice-call plugin
|
||||
@@ -7,34 +7,26 @@ read_when:
|
||||
|
||||
# Voice Call (plugin)
|
||||
|
||||
Voice calls for Clawdbot. Use it to place outbound notifications, run multi-turn
|
||||
phone conversations, and accept inbound calls with an explicit policy.
|
||||
Voice calls for Clawdbot via a plugin. Supports outbound notifications and
|
||||
multi-turn conversations with inbound policies.
|
||||
|
||||
Current providers:
|
||||
- `twilio` (Programmable Voice + Media Streams)
|
||||
- `telnyx` (Call Control v2)
|
||||
- `plivo` (Voice API + XML transfer + GetInput speech)
|
||||
- `mock` (dev/no network)
|
||||
|
||||
What you get:
|
||||
- Outbound calls in notify or conversation mode
|
||||
- Inbound calls with allowlist or open policies
|
||||
- Provider webhooks with signature verification
|
||||
- Optional streaming (Twilio Media Streams + OpenAI Realtime STT)
|
||||
- CLI commands, a tool surface, and JSONL call logs
|
||||
|
||||
Quick mental model:
|
||||
1. Install plugin
|
||||
2. Restart Gateway
|
||||
3. Configure `plugins.entries.voice-call.config`
|
||||
4. Expose a public webhook URL
|
||||
5. Call via `clawdbot voicecall ...` or the `voice_call` tool
|
||||
- Install plugin
|
||||
- Restart Gateway
|
||||
- Configure under `plugins.entries.voice-call.config`
|
||||
- Use `clawdbot voicecall ...` or the `voice_call` tool
|
||||
|
||||
## Where it runs (local vs remote)
|
||||
|
||||
The Voice Call plugin runs inside the Gateway process.
|
||||
The Voice Call plugin runs **inside the Gateway process**.
|
||||
|
||||
If you use a remote Gateway, install and configure the plugin on the machine
|
||||
running the Gateway, then restart the Gateway to load it.
|
||||
If you use a remote Gateway, install/configure the plugin on the **machine running the Gateway**, then restart the Gateway to load it.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -55,15 +47,9 @@ cd ./extensions/voice-call && pnpm install
|
||||
|
||||
Restart the Gateway afterwards.
|
||||
|
||||
Note: use `pnpm` for repo work. Bun is not recommended and can cause issues in
|
||||
other Clawdbot channels (especially WhatsApp and Telegram).
|
||||
## Config
|
||||
|
||||
## Config overview
|
||||
|
||||
All config lives under `plugins.entries.voice-call.config`. Phone numbers must
|
||||
be in E.164 format (`+15550001234`).
|
||||
|
||||
Minimal example (Twilio outbound only):
|
||||
Set config under `plugins.entries.voice-call.config`:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -72,16 +58,39 @@ Minimal example (Twilio outbound only):
|
||||
"voice-call": {
|
||||
enabled: true,
|
||||
config: {
|
||||
provider: "twilio",
|
||||
provider: "twilio", // or "telnyx" | "plivo" | "mock"
|
||||
fromNumber: "+15550001234",
|
||||
toNumber: "+15550005678",
|
||||
|
||||
twilio: {
|
||||
accountSid: "ACxxxxxxxx",
|
||||
authToken: "..."
|
||||
},
|
||||
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
|
||||
publicUrl: "https://example.ngrok.app/voice/webhook",
|
||||
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 }
|
||||
|
||||
plivo: {
|
||||
authId: "MAxxxxxxxxxxxxxxxxxxxx",
|
||||
authToken: "..."
|
||||
},
|
||||
|
||||
// Webhook server
|
||||
serve: {
|
||||
port: 3334,
|
||||
path: "/voice/webhook"
|
||||
},
|
||||
|
||||
// Public exposure (pick one)
|
||||
// publicUrl: "https://example.ngrok.app/voice/webhook",
|
||||
// tunnel: { provider: "ngrok" },
|
||||
// tailscale: { mode: "funnel", path: "/voice/webhook" }
|
||||
|
||||
outbound: {
|
||||
defaultMode: "notify" // notify | conversation
|
||||
},
|
||||
|
||||
streaming: {
|
||||
enabled: true,
|
||||
streamPath: "/voice/stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,178 +99,27 @@ Minimal example (Twilio outbound only):
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Twilio/Telnyx require a publicly reachable webhook URL.
|
||||
- Twilio/Telnyx require a **publicly reachable** webhook URL.
|
||||
- Plivo requires a **publicly reachable** webhook URL.
|
||||
- `mock` is a local dev provider (no network calls).
|
||||
- `skipSignatureVerification` is for local testing only.
|
||||
|
||||
## Public URL and webhook exposure
|
||||
## Inbound calls
|
||||
|
||||
Providers send webhooks from the public internet. Your `serve.path` must be
|
||||
reachable from them.
|
||||
|
||||
You have three options:
|
||||
- `publicUrl`: you already have a public HTTPS URL pointing at the Gateway host.
|
||||
- `tunnel`: use ngrok or Tailscale (recommended for quick setup).
|
||||
- `tailscale`: legacy Tailscale serve/funnel config (still supported, but
|
||||
`tunnel` is preferred).
|
||||
|
||||
Example using ngrok:
|
||||
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
||||
|
||||
```json5
|
||||
{
|
||||
tunnel: {
|
||||
provider: "ngrok",
|
||||
ngrokAuthToken: "..."
|
||||
}
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
inboundGreeting: "Hello! How can I help?"
|
||||
}
|
||||
```
|
||||
|
||||
Example using Tailscale Funnel:
|
||||
|
||||
```json5
|
||||
{
|
||||
tunnel: { provider: "tailscale-funnel" }
|
||||
}
|
||||
```
|
||||
|
||||
CLI helper (Tailscale only):
|
||||
|
||||
```bash
|
||||
clawdbot voicecall expose --mode funnel
|
||||
```
|
||||
|
||||
If you use Tailscale Serve without Funnel, the URL is private to your tailnet,
|
||||
so Twilio/Telnyx will not be able to reach it.
|
||||
|
||||
## Providers
|
||||
|
||||
### Twilio
|
||||
|
||||
Twilio uses Programmable Voice with optional Media Streams for real-time audio.
|
||||
|
||||
Required config:
|
||||
- `twilio.accountSid` and `twilio.authToken`
|
||||
- (or `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN`)
|
||||
- A Twilio phone number that can reach your webhook
|
||||
|
||||
Inbound setup:
|
||||
- In the Twilio Console for your phone number, set the Voice webhook to your
|
||||
public `serve.path` URL (HTTP POST).
|
||||
|
||||
Outbound setup:
|
||||
- Outbound calls are created via Twilio API; the plugin supplies the webhook URL
|
||||
per call.
|
||||
|
||||
Streaming (optional, Twilio only):
|
||||
- Enable `streaming.enabled` and set `streaming.streamPath`
|
||||
- Provide `OPENAI_API_KEY` or `streaming.openaiApiKey`
|
||||
- The stream WebSocket URL is derived from your `publicUrl` host + `streamPath`
|
||||
(https -> wss)
|
||||
|
||||
Signature verification:
|
||||
- Webhooks are verified by default.
|
||||
- If you are using ngrok free tier, leave `tunnel.allowNgrokFreeTier` as `true`
|
||||
so URL rewriting does not break verification.
|
||||
- Use `skipSignatureVerification` only for local dev.
|
||||
|
||||
### Telnyx
|
||||
|
||||
Telnyx uses Call Control v2.
|
||||
|
||||
Required config:
|
||||
- `telnyx.apiKey` and `telnyx.connectionId`
|
||||
- (or `TELNYX_API_KEY` / `TELNYX_CONNECTION_ID`)
|
||||
|
||||
Inbound setup:
|
||||
- In your Telnyx Call Control App, set the webhook URL to your public
|
||||
`serve.path`.
|
||||
|
||||
Signature verification:
|
||||
- Set `telnyx.publicKey` to enable Ed25519 signature verification.
|
||||
- If you do not set a public key, webhooks are accepted without verification
|
||||
(not recommended for production).
|
||||
|
||||
Transcription:
|
||||
- Telnyx uses its own transcription events for `continue` responses.
|
||||
|
||||
### Mock (dev)
|
||||
|
||||
`mock` is for local testing and does not make network calls.
|
||||
|
||||
## Call modes
|
||||
|
||||
Outbound calls support two modes:
|
||||
- `notify`: speak a message and auto-hangup after `notifyHangupDelaySec`.
|
||||
- `conversation`: keep the call open and allow back-and-forth.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
clawdbot voicecall call --to "+15555550123" --message "Hello" --mode notify
|
||||
clawdbot voicecall call --to "+15555550123" --message "Ready to talk?" --mode conversation
|
||||
```
|
||||
|
||||
## Inbound calls and policy
|
||||
|
||||
Inbound calls are blocked by default.
|
||||
|
||||
Policies:
|
||||
- `disabled`: block all inbound calls
|
||||
- `allowlist`: allow only numbers in `allowFrom`
|
||||
- `pairing`: currently behaves like `allowlist`
|
||||
- `open`: accept all inbound calls
|
||||
|
||||
Inbound greeting:
|
||||
- `inboundGreeting` controls the first message spoken when a call is accepted.
|
||||
|
||||
## Auto-responses and models
|
||||
|
||||
When a caller speaks, the plugin can auto-respond using the embedded Clawdbot
|
||||
agent.
|
||||
|
||||
Key settings:
|
||||
- `responseModel`: model reference for voice responses (default `openai/gpt-4o-mini`)
|
||||
- `responseSystemPrompt`: optional override for the voice system prompt
|
||||
- `responseTimeoutMs`: response generation timeout
|
||||
|
||||
Responses use the same agent system as messaging, including tool access.
|
||||
The default system prompt keeps replies short and conversational (about 1-2 sentences).
|
||||
|
||||
## Streaming (Twilio only)
|
||||
|
||||
When `streaming.enabled` is on:
|
||||
- The webhook server also accepts WebSocket upgrades at `streaming.streamPath`.
|
||||
- Audio is forwarded to OpenAI Realtime STT.
|
||||
- Final transcripts are fed into the call manager and used by `continue` and
|
||||
auto-responses.
|
||||
|
||||
Required:
|
||||
- A public HTTPS URL for the Gateway (used to derive `wss://...`).
|
||||
- `OPENAI_API_KEY` or `streaming.openaiApiKey`.
|
||||
|
||||
If no OpenAI key is available, streaming does not start and real-time transcripts
|
||||
will not arrive.
|
||||
|
||||
## Limits and timeouts
|
||||
|
||||
These settings are enforced by the call manager:
|
||||
- `maxDurationSeconds`: auto-hangup after this many seconds (starts when answered).
|
||||
- `maxConcurrentCalls`: max simultaneous active calls.
|
||||
- `transcriptTimeoutMs`: how long `continue` waits for a final transcript.
|
||||
|
||||
## Logs and debugging
|
||||
|
||||
Calls are appended as JSONL to:
|
||||
- `${store}/calls.jsonl`, or
|
||||
- `~/clawd/voice-calls/calls.jsonl` by default
|
||||
|
||||
Set `store` if you want a different base directory for call logs.
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
clawdbot voicecall tail
|
||||
```
|
||||
Auto-responses use the agent system. Tune with:
|
||||
- `responseModel`
|
||||
- `responseSystemPrompt`
|
||||
- `responseTimeoutMs`
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -286,7 +144,7 @@ Actions:
|
||||
- `end_call` (callId)
|
||||
- `get_status` (callId)
|
||||
|
||||
If you want a ready-made skill entry, grab it from [ClawdHub.com](https://ClawdHub.com).
|
||||
This repo ships a matching skill doc at `skills/voice-call/SKILL.md`.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
|
||||
7) **GitHub release + appcast**
|
||||
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**.
|
||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
|
||||
- [ ] Attach artifacts: `npm pack` tarball (optional), `Clawdbot-X.Y.Z.zip`, and `Clawdbot-X.Y.Z.dSYM.zip` (if generated).
|
||||
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
|
||||
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
||||
|
||||
@@ -108,7 +108,7 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
|
||||
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
||||
|
||||
Default heartbeat prompt:
|
||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
|
||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ Example:
|
||||
## Heartbeats (proactive mode)
|
||||
|
||||
By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
||||
|
||||
+7
-4
@@ -613,7 +613,7 @@ Gateway process.
|
||||
|
||||
Notes:
|
||||
- If you use allowlists, add `web_search`/`web_fetch` or `group:web`.
|
||||
- In sandboxed sessions, `web_fetch` auto-enables unless explicitly disabled.
|
||||
- `web_fetch` is enabled by default (unless explicitly disabled).
|
||||
- Daemons read env vars from `~/.clawdbot/.env` (or the service environment).
|
||||
|
||||
Docs: [Web tools](/tools/web).
|
||||
@@ -665,7 +665,7 @@ Nodes don’t see inbound provider traffic; they only receive bridge RPC calls.
|
||||
|
||||
### Do nodes run a gateway daemon?
|
||||
|
||||
No. Only **one gateway** should run per host. Nodes are peripherals that connect
|
||||
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
|
||||
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
||||
|
||||
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
||||
@@ -1290,6 +1290,7 @@ Quick setup (recommended):
|
||||
- Install a per-profile daemon: `clawdbot --profile <name> daemon install`.
|
||||
|
||||
Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`).
|
||||
Full guide: [Multiple gateways](/gateway/multiple-gateways).
|
||||
|
||||
### What does “invalid handshake” / code 1008 mean?
|
||||
|
||||
@@ -1412,6 +1413,7 @@ abort
|
||||
esc
|
||||
wait
|
||||
exit
|
||||
interrupt
|
||||
```
|
||||
|
||||
These are abort triggers (not slash commands).
|
||||
@@ -1540,10 +1542,11 @@ See [Streaming](/concepts/streaming).
|
||||
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
|
||||
|
||||
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
|
||||
By default `channels.discord.groupPolicy` is **allowlist**, so guild channels must be explicitly enabled.
|
||||
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
|
||||
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
|
||||
|
||||
Fix checklist:
|
||||
1) Set `channels.discord.groupPolicy: "open"` **or** add the guild/channel allowlist.
|
||||
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
|
||||
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
|
||||
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
|
||||
Top‑level `channels.discord.requireMention` is not a supported key.
|
||||
|
||||
+26
-21
@@ -30,10 +30,10 @@ agent automation and verification.
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
clawdbot browser status
|
||||
clawdbot browser start
|
||||
clawdbot browser open https://example.com
|
||||
clawdbot browser snapshot
|
||||
clawdbot browser --browser-profile clawd status
|
||||
clawdbot browser --browser-profile clawd start
|
||||
clawdbot browser --browser-profile clawd open https://example.com
|
||||
clawdbot browser --browser-profile clawd snapshot
|
||||
```
|
||||
|
||||
If you get “Browser disabled”, enable it in config (see below) and restart the
|
||||
@@ -49,7 +49,7 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
||||
enabled: true, // default: true
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
|
||||
defaultProfile: "clawd",
|
||||
defaultProfile: "chrome",
|
||||
color: "#FF4500",
|
||||
headless: false,
|
||||
noSandbox: false,
|
||||
@@ -197,13 +197,14 @@ Notes:
|
||||
|
||||
## Profiles (multi-browser)
|
||||
|
||||
Clawdbot supports multiple named profiles. Each profile has its own:
|
||||
- user data directory
|
||||
- CDP port (local) or CDP URL (remote)
|
||||
- accent color
|
||||
Clawdbot supports multiple named profiles (routing configs). Profiles can be:
|
||||
- **clawd-managed**: a dedicated Chrome instance with its own user data directory + CDP port
|
||||
- **remote**: an explicit CDP URL (Chrome running elsewhere)
|
||||
- **extension relay**: your existing Chrome tab(s) via the local relay + Chrome extension
|
||||
|
||||
Defaults:
|
||||
- The `clawd` profile is auto-created if missing.
|
||||
- The `chrome` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default).
|
||||
- Local CDP ports allocate from **18800–18899** by default.
|
||||
- Deleting a profile moves its local data directory to Trash.
|
||||
|
||||
@@ -233,26 +234,30 @@ Chrome extension relay takeover requires host browser control, so either:
|
||||
|
||||
### Setup
|
||||
|
||||
1) Create a profile that uses the extension driver:
|
||||
1) Load the extension (dev/unpacked):
|
||||
|
||||
```bash
|
||||
clawdbot browser extension install
|
||||
```
|
||||
|
||||
- Chrome → `chrome://extensions` → enable “Developer mode”
|
||||
- “Load unpacked” → select the directory printed by `clawdbot browser extension path`
|
||||
- Pin the extension, then click it on the tab you want to control (badge shows `ON`).
|
||||
|
||||
2) Use it:
|
||||
- CLI: `clawdbot browser --browser-profile chrome tabs`
|
||||
- Agent tool: `browser` with `profile="chrome"`
|
||||
|
||||
Optional: if you want a different name or relay port, create your own profile:
|
||||
|
||||
```bash
|
||||
clawdbot browser create-profile \
|
||||
--name chrome \
|
||||
--name my-chrome \
|
||||
--driver extension \
|
||||
--cdp-url http://127.0.0.1:18792 \
|
||||
--color "#00AA00"
|
||||
```
|
||||
|
||||
2) Load the extension (dev/unpacked):
|
||||
- Chrome → `chrome://extensions` → enable “Developer mode”
|
||||
- `clawdbot browser extension install`
|
||||
- “Load unpacked” → select the directory printed by `clawdbot browser extension path`
|
||||
- Pin the extension, then click it on the tab you want to control (badge shows `ON`).
|
||||
|
||||
3) Use it:
|
||||
- CLI: `clawdbot browser --browser-profile chrome tabs`
|
||||
- Agent tool: `browser` with `profile="chrome"`
|
||||
|
||||
Notes:
|
||||
- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions).
|
||||
- Detach by clicking the extension icon again.
|
||||
|
||||
@@ -49,20 +49,24 @@ After upgrading Clawdbot:
|
||||
- Re-run `clawdbot browser extension install` to refresh the installed files under your Clawdbot state directory.
|
||||
- Chrome → `chrome://extensions` → click “Reload” on the extension.
|
||||
|
||||
## Create a browser profile for the extension
|
||||
## Use it (no extra config)
|
||||
|
||||
Clawdbot ships with a built-in browser profile named `chrome` that targets the extension relay on the default port.
|
||||
|
||||
Use it:
|
||||
- CLI: `clawdbot browser --browser-profile chrome tabs`
|
||||
- Agent tool: `browser` with `profile="chrome"`
|
||||
|
||||
If you want a different name or a different relay port, create your own profile:
|
||||
|
||||
```bash
|
||||
clawdbot browser create-profile \
|
||||
--name chrome \
|
||||
--name my-chrome \
|
||||
--driver extension \
|
||||
--cdp-url http://127.0.0.1:18792 \
|
||||
--color "#00AA00"
|
||||
```
|
||||
|
||||
Then target it:
|
||||
- CLI: `clawdbot browser --browser-profile chrome tabs`
|
||||
- Agent tool: `browser` with `profile="chrome"`
|
||||
|
||||
## Attach / detach (toolbar button)
|
||||
|
||||
- Open the tab you want Clawdbot to control.
|
||||
@@ -94,7 +98,7 @@ If the Gateway is running on the same machine as Chrome and your `browser.contro
|
||||
you typically **do not** need `clawdbot browser serve`.
|
||||
|
||||
The Gateway’s built-in browser control server will start on `http://127.0.0.1:18791/` and Clawdbot will
|
||||
auto-start the local relay server when you use a profile with `driver="extension"`.
|
||||
auto-start the local relay server on `http://127.0.0.1:18792/`.
|
||||
|
||||
### Remote Gateway (Gateway runs elsewhere) — **yes**
|
||||
|
||||
|
||||
+1
-1
@@ -239,7 +239,7 @@ Notes:
|
||||
- Requires `browser.enabled=true` (default is `true`; set `false` to disable).
|
||||
- Uses `browser.controlUrl` unless `controlUrl` is passed explicitly.
|
||||
- All actions accept optional `profile` parameter for multi-instance support.
|
||||
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
|
||||
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome").
|
||||
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
|
||||
- Port range: 18800-18899 (~100 profiles max).
|
||||
- Remote profiles are attach-only (no start/stop/reset).
|
||||
|
||||
@@ -54,7 +54,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
||||
Text + native (when enabled):
|
||||
- `/help`
|
||||
- `/commands`
|
||||
- `/status` (show current status; includes a short provider usage/quota line when available)
|
||||
- `/status` (show current status; includes provider usage/quota when available, plus OAuth/token status block when OAuth profiles exist)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/usage` (alias: `/status`)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
@@ -69,7 +69,7 @@ Text + native (when enabled):
|
||||
- `/activation mention|always` (groups only)
|
||||
- `/send on|off|inherit` (owner-only)
|
||||
- `/reset` or `/new`
|
||||
- `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only; aliases: `/thinking`, `/t`)
|
||||
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
|
||||
- `/verbose on|off` (alias: `/v`)
|
||||
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
||||
- `/elevated on|off` (alias: `/elev`)
|
||||
@@ -91,10 +91,12 @@ Notes:
|
||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||
- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model).
|
||||
- **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements.
|
||||
- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text.
|
||||
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
|
||||
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
|
||||
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
||||
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
|
||||
|
||||
## Usage vs cost (what shows where)
|
||||
|
||||
@@ -166,4 +168,4 @@ Notes:
|
||||
- Slack: `agent:<agentId>:slack:slash:<userId>` (prefix configurable via `channels.slack.slashCommand.sessionPrefix`)
|
||||
- Telegram: `telegram:slash:<userId>` (targets the chat session via `CommandTargetSessionKey`)
|
||||
- **`/stop`** targets the active chat session so it can abort the current run.
|
||||
- **Slack:** `channels.slack.slashCommand` is still supported for a single `/clawd`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`).
|
||||
- **Slack:** `channels.slack.slashCommand` is still supported for a single `/clawd`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons.
|
||||
@@ -50,8 +50,8 @@ read_when:
|
||||
- Elevated mode docs live in [Elevated mode](/tools/elevated).
|
||||
|
||||
## Heartbeats
|
||||
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true`.
|
||||
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.list[].heartbeat.includeReasoning: true`.
|
||||
|
||||
## Web chat UI
|
||||
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
|
||||
|
||||
+5
-5
@@ -22,12 +22,12 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
- Results are cached by query for 15 minutes (configurable).
|
||||
- `web_fetch` does a plain HTTP GET and extracts readable content
|
||||
(HTML → markdown/text). It does **not** execute JavaScript.
|
||||
- In sandboxed sessions, `web_fetch` is enabled automatically (unless explicitly disabled).
|
||||
- `web_fetch` is enabled by default (unless explicitly disabled).
|
||||
|
||||
## Getting a Brave API key
|
||||
|
||||
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||
2) Generate an API key in the dashboard.
|
||||
2) In the dashboard, choose the **Data for Search** plan (not “Data for AI”) and generate an API key.
|
||||
3) Run `clawdbot configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
|
||||
|
||||
Brave provides a free tier plus paid plans; check the Brave API portal for the
|
||||
@@ -48,7 +48,7 @@ Search the web with Brave’s API.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `tools.web.search.enabled: true`
|
||||
- `tools.web.search.enabled` must not be `false` (default: enabled)
|
||||
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`)
|
||||
|
||||
### Config
|
||||
@@ -80,7 +80,7 @@ Fetch a URL and extract readable content.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `tools.web.fetch.enabled: true`
|
||||
- `tools.web.fetch.enabled` must not be `false` (default: enabled)
|
||||
|
||||
### Config
|
||||
|
||||
@@ -93,7 +93,7 @@ Fetch a URL and extract readable content.
|
||||
maxChars: 50000,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
userAgent: "clawdbot/2026.1.14"
|
||||
userAgent: "clawdbot/2026.1.15"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
|
||||
The onboarding wizard generates a gateway token by default, so paste it here on first connect.
|
||||
|
||||
## What it can do (today)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
@@ -60,9 +60,10 @@ Notes:
|
||||
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
|
||||
- Stop:
|
||||
- Click **Stop** (calls `chat.abort`)
|
||||
- Type `/stop` (or `stop|esc|abort|wait|exit`) to abort out-of-band
|
||||
- Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band
|
||||
- `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session
|
||||
|
||||
## Tailnet access (recommended)
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
3) Ensure gateway auth is configured if you are not on loopback.
|
||||
|
||||
## How it works (behavior)
|
||||
- The UI connects to the Gateway WebSocket and uses `chat.history` + `chat.send`.
|
||||
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
||||
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
|
||||
- History is always fetched from the gateway (no local file watching).
|
||||
- If the gateway is unreachable, WebChat is read-only.
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.14
|
||||
|
||||
### Features
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
- Matrix channel plugin with homeserver + user ID auth (access token or password login with device name).
|
||||
- Direct messages with pairing/allowlist/open/disabled policies and allowFrom support.
|
||||
- Group/room controls: allowlist policy, per-room config, mention gating, auto-reply, per-room skills/system prompts.
|
||||
- Threads: replyToMode controls and thread replies (off/inbound/always).
|
||||
- Messaging: text chunking, media uploads with size caps, reactions, polls, typing, and message edits/deletes.
|
||||
- Actions: read messages, list/remove reactions, pin/unpin/list pins, member info, room info.
|
||||
- Auto-join invites with allowlist support.
|
||||
- Status + probe reporting for health checks.
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
|
||||
const plugin = {
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
description: "Matrix channel plugin (matrix-js-sdk)",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
api.registerChannel({ plugin: matrixPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@clawdbot/matrix",
|
||||
"version": "2026.1.15",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Matrix channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-it": "14.1.0",
|
||||
"matrix-js-sdk": "40.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { createActionGate, readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { handleMatrixAction } from "./tool-actions.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelToolSend,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
|
||||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
|
||||
if (!account.enabled || !account.configured) return [];
|
||||
const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions);
|
||||
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
|
||||
if (gate("reactions")) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
if (gate("messages")) {
|
||||
actions.add("read");
|
||||
actions.add("edit");
|
||||
actions.add("delete");
|
||||
}
|
||||
if (gate("pins")) {
|
||||
actions.add("pin");
|
||||
actions.add("unpin");
|
||||
actions.add("list-pins");
|
||||
}
|
||||
if (gate("memberInfo")) actions.add("member-info");
|
||||
if (gate("channelInfo")) actions.add("channel-info");
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsAction: ({ action }) => action !== "poll",
|
||||
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action !== "sendMessage") return null;
|
||||
const to = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!to) return null;
|
||||
return { to };
|
||||
},
|
||||
handleAction: async (ctx: ChannelMessageActionContext) => {
|
||||
const { action, params, cfg } = ctx;
|
||||
const resolveRoomId = () =>
|
||||
readStringParam(params, "roomId") ??
|
||||
readStringParam(params, "channelId") ??
|
||||
readStringParam(params, "to", { required: true });
|
||||
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "message", {
|
||||
required: true,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to,
|
||||
content,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyToId: replyTo ?? undefined,
|
||||
threadId: threadId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "react",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "reactions",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
limit,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "read") {
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "readMessages",
|
||||
roomId: resolveRoomId(),
|
||||
limit,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const content = readStringParam(params, "message", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
content,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "pin" || action === "unpin" || action === "list-pins") {
|
||||
const messageId =
|
||||
action === "list-pins"
|
||||
? undefined
|
||||
: readStringParam(params, "messageId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action:
|
||||
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "member-info") {
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "memberInfo",
|
||||
userId,
|
||||
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "channel-info") {
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "channelInfo",
|
||||
roomId: resolveRoomId(),
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider matrix.`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../../../src/channels/plugins/config-helpers.js";
|
||||
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
|
||||
import { applyAccountNameToChannelSection } from "../../../src/channels/plugins/setup-helpers.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
resolveDefaultMatrixAccountId,
|
||||
resolveMatrixAccount,
|
||||
type ResolvedMatrixAccount,
|
||||
} from "./matrix/accounts.js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js";
|
||||
import { probeMatrix } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix } from "./matrix/send.js";
|
||||
import { matrixOnboardingAdapter } from "./onboarding.js";
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
|
||||
const meta = {
|
||||
id: "matrix",
|
||||
label: "Matrix",
|
||||
selectionLabel: "Matrix (plugin)",
|
||||
docsPath: "/channels/matrix",
|
||||
docsLabel: "matrix",
|
||||
blurb: "open protocol; configure a homeserver + access token.",
|
||||
order: 70,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
||||
let normalized = raw.trim();
|
||||
if (!normalized) return undefined;
|
||||
if (normalized.toLowerCase().startsWith("matrix:")) {
|
||||
normalized = normalized.slice("matrix:".length).trim();
|
||||
}
|
||||
return normalized ? normalized.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
function buildMatrixConfigUpdate(
|
||||
cfg: CoreConfig,
|
||||
input: {
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
},
|
||||
): CoreConfig {
|
||||
const existing = cfg.channels?.matrix ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...existing,
|
||||
enabled: true,
|
||||
...(input.homeserver ? { homeserver: input.homeserver } : {}),
|
||||
...(input.userId ? { userId: input.userId } : {}),
|
||||
...(input.accessToken ? { accessToken: input.accessToken } : {}),
|
||||
...(input.password ? { password: input.password } : {}),
|
||||
...(input.deviceName ? { deviceName: input.deviceName } : {}),
|
||||
...(typeof input.initialSyncLimit === "number"
|
||||
? { initialSyncLimit: input.initialSyncLimit }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
id: "matrix",
|
||||
meta,
|
||||
onboarding: matrixOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "matrixUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
polls: true,
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.matrix"] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "matrix",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "matrix",
|
||||
accountId,
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceName",
|
||||
"initialSyncLimit",
|
||||
],
|
||||
}),
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
baseUrl: account.homeserver,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg }) =>
|
||||
((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)),
|
||||
formatAllowFrom: ({ allowFrom }) => normalizeAllowListLower(allowFrom),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ account }) => ({
|
||||
policy: account.config.dm?.policy ?? "pairing",
|
||||
allowFrom: account.config.dm?.allowFrom ?? [],
|
||||
policyPath: "channels.matrix.dm.policy",
|
||||
allowFromPath: "channels.matrix.dm.allowFrom",
|
||||
approveHint: formatPairingApproveHint("matrix"),
|
||||
normalizeEntry: (raw) => raw.replace(/^matrix:/i, "").trim().toLowerCase(),
|
||||
}),
|
||||
collectWarnings: ({ account }) => {
|
||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") return [];
|
||||
return [
|
||||
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.",
|
||||
];
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMatrixGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) =>
|
||||
(cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||
},
|
||||
actions: matrixMessageActions,
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "matrix",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ input }) => {
|
||||
if (input.useEnv) return null;
|
||||
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
|
||||
if (!input.userId?.trim()) return "Matrix requires --user-id";
|
||||
if (!input.accessToken?.trim() && !input.password?.trim()) {
|
||||
return "Matrix requires --access-token or --password";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "matrix",
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
name: input.name,
|
||||
});
|
||||
if (input.useEnv) {
|
||||
return {
|
||||
...namedConfig,
|
||||
channels: {
|
||||
...namedConfig.channels,
|
||||
matrix: {
|
||||
...namedConfig.channels?.matrix,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
return buildMatrixConfigUpdate(namedConfig as CoreConfig, {
|
||||
homeserver: input.homeserver?.trim(),
|
||||
userId: input.userId?.trim(),
|
||||
accessToken: input.accessToken?.trim(),
|
||||
password: input.password?.trim(),
|
||||
deviceName: input.deviceName?.trim(),
|
||||
initialSyncLimit: input.initialSyncLimit,
|
||||
});
|
||||
},
|
||||
},
|
||||
outbound: matrixOutbound,
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((account) => {
|
||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||
if (!lastError) return [];
|
||||
return [
|
||||
{
|
||||
channel: "matrix",
|
||||
accountId: account.accountId,
|
||||
kind: "runtime",
|
||||
message: `Channel error: ${lastError}`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
baseUrl: snapshot.baseUrl ?? null,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs, cfg }) => {
|
||||
try {
|
||||
const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig });
|
||||
return await probeMatrix({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
userId: auth.userId,
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
baseUrl: account.homeserver,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastProbeAt: runtime?.lastProbeAt ?? null,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
baseUrl: account.homeserver,
|
||||
});
|
||||
ctx.log?.info(
|
||||
`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`,
|
||||
);
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorMatrixProvider } = await import("./matrix/index.js");
|
||||
return monitorMatrixProvider({
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
mediaMaxMb: account.config.mediaMaxMb,
|
||||
initialSyncLimit: account.config.initialSyncLimit,
|
||||
replyToMode: account.config.replyToMode,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ChannelGroupContext } from "../../../src/channels/plugins/types.js";
|
||||
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
|
||||
const rawGroupId = params.groupId?.trim() ?? "";
|
||||
let roomId = rawGroupId;
|
||||
const lower = roomId.toLowerCase();
|
||||
if (lower.startsWith("matrix:")) {
|
||||
roomId = roomId.slice("matrix:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("channel:")) {
|
||||
roomId = roomId.slice("channel:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupRoom = params.groupRoom?.trim() ?? "";
|
||||
const aliases = groupRoom ? [groupRoom] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupRoom || undefined,
|
||||
}).config;
|
||||
if (resolved) {
|
||||
if (resolved.autoReply === true) return false;
|
||||
if (resolved.autoReply === false) return true;
|
||||
if (typeof resolved.requireMention === "boolean") return resolved.requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../../src/routing/session-key.js";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import { resolveMatrixConfig } from "./client.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
|
||||
|
||||
export type ResolvedMatrixAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
configured: boolean;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
config: MatrixConfig;
|
||||
};
|
||||
|
||||
export function listMatrixAccountIds(_cfg: CoreConfig): string[] {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
|
||||
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
||||
const ids = listMatrixAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function resolveMatrixAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedMatrixAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig;
|
||||
const enabled = base.enabled !== false;
|
||||
const resolved = resolveMatrixConfig(params.cfg, process.env);
|
||||
const hasCore = Boolean(resolved.homeserver && resolved.userId);
|
||||
const hasToken = Boolean(resolved.accessToken || resolved.password);
|
||||
const stored = loadMatrixCredentials(process.env);
|
||||
const hasStored =
|
||||
stored &&
|
||||
resolved.homeserver &&
|
||||
resolved.userId &&
|
||||
credentialsMatchConfig(stored, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
});
|
||||
const configured = hasCore && (hasToken || Boolean(hasStored));
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: base.name?.trim() || undefined,
|
||||
configured,
|
||||
homeserver: resolved.homeserver || undefined,
|
||||
userId: resolved.userId || undefined,
|
||||
config: base,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
|
||||
return listMatrixAccountIds(cfg)
|
||||
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||
import {
|
||||
Direction,
|
||||
EventType,
|
||||
MatrixError,
|
||||
MsgType,
|
||||
RelationType,
|
||||
} from "matrix-js-sdk";
|
||||
import type {
|
||||
ReactionEventContent,
|
||||
RoomMessageEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/events.js";
|
||||
import type {
|
||||
RoomPinnedEventsEventContent,
|
||||
RoomTopicEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/state_events.js";
|
||||
|
||||
import { loadConfig } from "../../../../src/config/config.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
createMatrixClient,
|
||||
isBunRuntime,
|
||||
resolveMatrixAuth,
|
||||
resolveSharedMatrixClient,
|
||||
waitForMatrixSync,
|
||||
} from "./client.js";
|
||||
import {
|
||||
reactMatrixMessage,
|
||||
resolveMatrixRoomId,
|
||||
sendMessageMatrix,
|
||||
} from "./send.js";
|
||||
|
||||
export type MatrixActionClientOpts = {
|
||||
client?: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type MatrixMessageSummary = {
|
||||
eventId?: string;
|
||||
sender?: string;
|
||||
body?: string;
|
||||
msgtype?: string;
|
||||
timestamp?: number;
|
||||
relatesTo?: {
|
||||
relType?: string;
|
||||
eventId?: string;
|
||||
key?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixReactionSummary = {
|
||||
key: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
};
|
||||
|
||||
type MatrixActionClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
};
|
||||
|
||||
function ensureNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<MatrixActionClient> {
|
||||
ensureNodeRuntime();
|
||||
if (opts.client) return { client: opts.client, stopOnDone: false };
|
||||
const active = getActiveMatrixClient();
|
||||
if (active) return { client: active, stopOnDone: false };
|
||||
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
||||
if (shouldShareClient) {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg: loadConfig() as CoreConfig,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ cfg: loadConfig() as CoreConfig });
|
||||
const client = await createMatrixClient({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
localTimeoutMs: opts.timeoutMs,
|
||||
});
|
||||
await client.startClient({
|
||||
initialSyncLimit: 0,
|
||||
lazyLoadMembers: true,
|
||||
threadSupport: true,
|
||||
});
|
||||
await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs });
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
|
||||
function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary {
|
||||
const content = event.getContent<RoomMessageEventContent>();
|
||||
const relates = content["m.relates_to"];
|
||||
let relType: string | undefined;
|
||||
let eventId: string | undefined;
|
||||
if (relates) {
|
||||
if ("rel_type" in relates) {
|
||||
relType = relates.rel_type;
|
||||
eventId = relates.event_id;
|
||||
} else if ("m.in_reply_to" in relates) {
|
||||
eventId = relates["m.in_reply_to"]?.event_id;
|
||||
}
|
||||
}
|
||||
const relatesTo =
|
||||
relType || eventId
|
||||
? {
|
||||
relType,
|
||||
eventId,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
eventId: event.getId() ?? undefined,
|
||||
sender: event.getSender() ?? undefined,
|
||||
body: content.body,
|
||||
msgtype: content.msgtype,
|
||||
timestamp: event.getTs() ?? undefined,
|
||||
relatesTo,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
|
||||
try {
|
||||
const content = (await client.getStateEvent(
|
||||
roomId,
|
||||
EventType.RoomPinnedEvents,
|
||||
"",
|
||||
)) as RoomPinnedEventsEventContent;
|
||||
const pinned = content.pinned;
|
||||
return pinned.filter((id) => id.trim().length > 0);
|
||||
} catch (err) {
|
||||
const httpStatus = err instanceof MatrixError ? err.httpStatus : undefined;
|
||||
const errcode = err instanceof MatrixError ? err.errcode : undefined;
|
||||
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEventSummary(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
): Promise<MatrixMessageSummary | null> {
|
||||
const raw = await client.fetchRoomEvent(roomId, eventId);
|
||||
const mapper = client.getEventMapper();
|
||||
const event = mapper(raw);
|
||||
if (event.isRedacted()) return null;
|
||||
return summarizeMatrixEvent(event);
|
||||
}
|
||||
|
||||
export async function sendMatrixMessage(
|
||||
to: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await sendMessageMatrix(to, content, {
|
||||
mediaUrl: opts.mediaUrl,
|
||||
replyToId: opts.replyToId,
|
||||
threadId: opts.threadId,
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) throw new Error("Matrix edit requires content");
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const newContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: trimmed,
|
||||
} satisfies RoomMessageEventContent;
|
||||
const payload: RoomMessageEventContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: `* ${trimmed}`,
|
||||
"m.new_content": newContent,
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: messageId,
|
||||
},
|
||||
};
|
||||
const response = await client.sendMessage(resolvedRoom, payload);
|
||||
return { eventId: response.event_id ?? null };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
await client.redactEvent(resolvedRoom, messageId, undefined, {
|
||||
reason: opts.reason,
|
||||
});
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMatrixMessages(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
messages: MatrixMessageSummary[];
|
||||
nextBatch?: string | null;
|
||||
prevBatch?: string | null;
|
||||
}> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.max(1, Math.floor(opts.limit))
|
||||
: 20;
|
||||
const token = opts.before?.trim() || opts.after?.trim() || null;
|
||||
const dir = opts.after ? Direction.Forward : Direction.Backward;
|
||||
const res = await client.createMessagesRequest(resolvedRoom, token, limit, dir);
|
||||
const mapper = client.getEventMapper();
|
||||
const events = res.chunk.map(mapper);
|
||||
const messages = events
|
||||
.filter((event) => event.getType() === EventType.RoomMessage)
|
||||
.filter((event) => !event.isRedacted())
|
||||
.map(summarizeMatrixEvent);
|
||||
return {
|
||||
messages,
|
||||
nextBatch: res.end ?? null,
|
||||
prevBatch: res.start ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { limit?: number } = {},
|
||||
): Promise<MatrixReactionSummary[]> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.max(1, Math.floor(opts.limit))
|
||||
: 100;
|
||||
const res = await client.relations(
|
||||
resolvedRoom,
|
||||
messageId,
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction,
|
||||
{ dir: Direction.Backward, limit },
|
||||
);
|
||||
const summaries = new Map<string, MatrixReactionSummary>();
|
||||
for (const event of res.events) {
|
||||
const content = event.getContent<ReactionEventContent>();
|
||||
const key = content["m.relates_to"].key;
|
||||
if (!key) continue;
|
||||
const sender = event.getSender() ?? "";
|
||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||
key,
|
||||
count: 0,
|
||||
users: [],
|
||||
};
|
||||
entry.count += 1;
|
||||
if (sender && !entry.users.includes(sender)) {
|
||||
entry.users.push(sender);
|
||||
}
|
||||
summaries.set(key, entry);
|
||||
}
|
||||
return Array.from(summaries.values());
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { emoji?: string } = {},
|
||||
): Promise<{ removed: number }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const res = await client.relations(
|
||||
resolvedRoom,
|
||||
messageId,
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction,
|
||||
{ dir: Direction.Backward, limit: 200 },
|
||||
);
|
||||
const userId = client.getUserId();
|
||||
if (!userId) return { removed: 0 };
|
||||
const targetEmoji = opts.emoji?.trim();
|
||||
const toRemove = res.events
|
||||
.filter((event) => event.getSender() === userId)
|
||||
.filter((event) => {
|
||||
if (!targetEmoji) return true;
|
||||
const content = event.getContent<ReactionEventContent>();
|
||||
return content["m.relates_to"].key === targetEmoji;
|
||||
})
|
||||
.map((event) => event.getId())
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (toRemove.length === 0) return { removed: 0 };
|
||||
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
||||
return { removed: toRemove.length };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function pinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const current = await readPinnedEvents(client, resolvedRoom);
|
||||
const next = current.includes(messageId) ? current : [...current, messageId];
|
||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
|
||||
return { pinned: next };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const current = await readPinnedEvents(client, resolvedRoom);
|
||||
const next = current.filter((id) => id !== messageId);
|
||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
|
||||
return { pinned: next };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMatrixPins(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const pinned = await readPinnedEvents(client, resolvedRoom);
|
||||
const events = (
|
||||
await Promise.all(
|
||||
pinned.map(async (eventId) => {
|
||||
try {
|
||||
return await fetchEventSummary(client, resolvedRoom, eventId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
||||
return { pinned, events };
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMatrixMemberInfo(
|
||||
userId: string,
|
||||
opts: MatrixActionClientOpts & { roomId?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||
const profile = await client.getProfileInfo(userId);
|
||||
const member = roomId ? client.getRoom(roomId)?.getMember(userId) : undefined;
|
||||
return {
|
||||
userId,
|
||||
profile: {
|
||||
displayName: profile?.displayname ?? null,
|
||||
avatarUrl: profile?.avatar_url ?? null,
|
||||
},
|
||||
membership: member?.membership ?? null,
|
||||
powerLevel: member?.powerLevel ?? null,
|
||||
displayName: member?.name ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const room = client.getRoom(resolvedRoom);
|
||||
const topicEvent = room?.currentState.getStateEvents(EventType.RoomTopic, "");
|
||||
const topicContent = topicEvent?.getContent<RoomTopicEventContent>();
|
||||
const topic = typeof topicContent?.topic === "string" ? topicContent.topic : undefined;
|
||||
return {
|
||||
roomId: resolvedRoom,
|
||||
name: room?.name ?? null,
|
||||
topic: topic ?? null,
|
||||
canonicalAlias: room?.getCanonicalAlias?.() ?? null,
|
||||
altAliases: room?.getAltAliases?.() ?? [],
|
||||
memberCount: room?.getJoinedMemberCount?.() ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) client.stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
export { reactMatrixMessage };
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
let activeClient: MatrixClient | null = null;
|
||||
|
||||
export function setActiveMatrixClient(client: MatrixClient | null): void {
|
||||
activeClient = client;
|
||||
}
|
||||
|
||||
export function getActiveMatrixClient(): MatrixClient | null {
|
||||
return activeClient;
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
|
||||
import { loadConfig } from "../../../../src/config/config.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
|
||||
export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
};
|
||||
|
||||
export type MatrixAuth = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
};
|
||||
|
||||
type MatrixSdk = typeof import("matrix-js-sdk");
|
||||
|
||||
type SharedMatrixClientState = {
|
||||
client: MatrixClient;
|
||||
key: string;
|
||||
started: boolean;
|
||||
};
|
||||
|
||||
let sharedClientState: SharedMatrixClientState | null = null;
|
||||
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
|
||||
let sharedClientStartPromise: Promise<void> | null = null;
|
||||
|
||||
export function isBunRuntime(): boolean {
|
||||
const versions = process.versions as { bun?: string };
|
||||
return typeof versions.bun === "string";
|
||||
}
|
||||
|
||||
async function loadMatrixSdk(): Promise<MatrixSdk> {
|
||||
return (await import("matrix-js-sdk")) as MatrixSdk;
|
||||
}
|
||||
|
||||
function clean(value?: string): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = cfg.channels?.matrix ?? {};
|
||||
const homeserver = clean(env.MATRIX_HOMESERVER) || clean(matrix.homeserver);
|
||||
const userId = clean(env.MATRIX_USER_ID) || clean(matrix.userId);
|
||||
const accessToken =
|
||||
clean(env.MATRIX_ACCESS_TOKEN) || clean(matrix.accessToken) || undefined;
|
||||
const password = clean(env.MATRIX_PASSWORD) || clean(matrix.password) || undefined;
|
||||
const deviceName =
|
||||
clean(env.MATRIX_DEVICE_NAME) || clean(matrix.deviceName) || undefined;
|
||||
const initialSyncLimit =
|
||||
typeof matrix.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||
: undefined;
|
||||
return {
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
password,
|
||||
deviceName,
|
||||
initialSyncLimit,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMatrixAuth(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<MatrixAuth> {
|
||||
const cfg = params?.cfg ?? (loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
if (!resolved.homeserver) {
|
||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||
}
|
||||
if (!resolved.userId) {
|
||||
throw new Error("Matrix userId is required (matrix.userId)");
|
||||
}
|
||||
|
||||
const {
|
||||
loadMatrixCredentials,
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("./credentials.js");
|
||||
|
||||
const cached = loadMatrixCredentials(env);
|
||||
const cachedCredentials =
|
||||
cached &&
|
||||
credentialsMatchConfig(cached, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
})
|
||||
? cached
|
||||
: null;
|
||||
|
||||
if (resolved.accessToken) {
|
||||
if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||
touchMatrixCredentials(env);
|
||||
}
|
||||
return {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
accessToken: resolved.accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
};
|
||||
}
|
||||
|
||||
if (cachedCredentials) {
|
||||
touchMatrixCredentials(env);
|
||||
return {
|
||||
homeserver: cachedCredentials.homeserver,
|
||||
userId: cachedCredentials.userId,
|
||||
accessToken: cachedCredentials.accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolved.password) {
|
||||
throw new Error(
|
||||
"Matrix access token or password is required (matrix.accessToken or matrix.password)",
|
||||
);
|
||||
}
|
||||
|
||||
const sdk = await loadMatrixSdk();
|
||||
const loginClient = sdk.createClient({
|
||||
baseUrl: resolved.homeserver,
|
||||
});
|
||||
const login = await loginClient.loginRequest({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
password: resolved.password,
|
||||
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
|
||||
});
|
||||
const accessToken = login.access_token?.trim();
|
||||
if (!accessToken) {
|
||||
throw new Error("Matrix login did not return an access token");
|
||||
}
|
||||
|
||||
const auth: MatrixAuth = {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: login.user_id ?? resolved.userId,
|
||||
accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
};
|
||||
|
||||
saveMatrixCredentials({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
});
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
export async function createMatrixClient(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
localTimeoutMs?: number;
|
||||
}): Promise<MatrixClient> {
|
||||
const sdk = await loadMatrixSdk();
|
||||
const store = new sdk.MemoryStore();
|
||||
return sdk.createClient({
|
||||
baseUrl: params.homeserver,
|
||||
userId: params.userId,
|
||||
accessToken: params.accessToken,
|
||||
localTimeoutMs: params.localTimeoutMs,
|
||||
store,
|
||||
});
|
||||
}
|
||||
|
||||
function buildSharedClientKey(auth: MatrixAuth): string {
|
||||
return [auth.homeserver, auth.userId, auth.accessToken].join("|");
|
||||
}
|
||||
|
||||
async function createSharedMatrixClient(params: {
|
||||
auth: MatrixAuth;
|
||||
timeoutMs?: number;
|
||||
}): Promise<SharedMatrixClientState> {
|
||||
const client = await createMatrixClient({
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
});
|
||||
return { client, key: buildSharedClientKey(params.auth), started: false };
|
||||
}
|
||||
|
||||
async function ensureSharedClientStarted(params: {
|
||||
state: SharedMatrixClientState;
|
||||
timeoutMs?: number;
|
||||
initialSyncLimit?: number;
|
||||
}): Promise<void> {
|
||||
if (params.state.started) return;
|
||||
if (sharedClientStartPromise) {
|
||||
await sharedClientStartPromise;
|
||||
return;
|
||||
}
|
||||
sharedClientStartPromise = (async () => {
|
||||
const startOpts: Parameters<MatrixClient["startClient"]>[0] = {
|
||||
lazyLoadMembers: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
if (typeof params.initialSyncLimit === "number") {
|
||||
startOpts.initialSyncLimit = params.initialSyncLimit;
|
||||
}
|
||||
await params.state.client.startClient(startOpts);
|
||||
await waitForMatrixSync({
|
||||
client: params.state.client,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
params.state.started = true;
|
||||
})();
|
||||
try {
|
||||
await sharedClientStartPromise;
|
||||
} finally {
|
||||
sharedClientStartPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSharedMatrixClient(
|
||||
params: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
auth?: MatrixAuth;
|
||||
startClient?: boolean;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
|
||||
const key = buildSharedClientKey(auth);
|
||||
const shouldStart = params.startClient !== false;
|
||||
|
||||
if (sharedClientState?.key === key) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: sharedClientState,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
});
|
||||
}
|
||||
return sharedClientState.client;
|
||||
}
|
||||
|
||||
if (sharedClientPromise) {
|
||||
const pending = await sharedClientPromise;
|
||||
if (pending.key === key) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: pending,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
});
|
||||
}
|
||||
return pending.client;
|
||||
}
|
||||
pending.client.stopClient();
|
||||
sharedClientState = null;
|
||||
sharedClientPromise = null;
|
||||
}
|
||||
|
||||
sharedClientPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
try {
|
||||
const created = await sharedClientPromise;
|
||||
sharedClientState = created;
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: created,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
});
|
||||
}
|
||||
return created.client;
|
||||
} finally {
|
||||
sharedClientPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMatrixSync(params: {
|
||||
client: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const timeoutMs = Math.max(1000, params.timeoutMs ?? 15_000);
|
||||
if (params.client.getSyncState() === SyncState.Syncing) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
params.client.removeListener(ClientEvent.Sync, onSync);
|
||||
if (params.abortSignal) {
|
||||
params.abortSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
const onSync = (state: SyncState) => {
|
||||
if (done) return;
|
||||
if (state === SyncState.Prepared || state === SyncState.Syncing) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
if (state === SyncState.Error) {
|
||||
cleanup();
|
||||
reject(new Error("Matrix sync failed"));
|
||||
}
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new Error("Matrix sync aborted"));
|
||||
};
|
||||
params.client.on(ClientEvent.Sync, onSync);
|
||||
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Matrix sync timed out"));
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../../../../src/config/paths.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
const CREDENTIALS_FILENAME = "credentials.json";
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir: string = resolveStateDir(env, os.homedir),
|
||||
): string {
|
||||
return path.join(stateDir, "credentials", "matrix");
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const dir = resolveMatrixCredentialsDir(env);
|
||||
return path.join(dir, CREDENTIALS_FILENAME);
|
||||
}
|
||||
|
||||
export function loadMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixStoredCredentials | null {
|
||||
const credPath = resolveMatrixCredentialsPath(env);
|
||||
try {
|
||||
if (!fs.existsSync(credPath)) return null;
|
||||
const raw = fs.readFileSync(credPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as MatrixStoredCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMatrixCredentials(
|
||||
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
const dir = resolveMatrixCredentialsDir(env);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const credPath = resolveMatrixCredentialsPath(env);
|
||||
|
||||
const existing = loadMatrixCredentials(env);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const toSave: MatrixStoredCredentials = {
|
||||
...credentials,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
lastUsedAt: now,
|
||||
};
|
||||
|
||||
fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
|
||||
const existing = loadMatrixCredentials(env);
|
||||
if (!existing) return;
|
||||
|
||||
existing.lastUsedAt = new Date().toISOString();
|
||||
const credPath = resolveMatrixCredentialsPath(env);
|
||||
fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function clearMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
|
||||
const credPath = resolveMatrixCredentialsPath(env);
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
fs.unlinkSync(credPath);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialsMatchConfig(
|
||||
stored: MatrixStoredCredentials,
|
||||
config: { homeserver: string; userId: string },
|
||||
): boolean {
|
||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { runCommandWithTimeout } from "../../../../src/process/exec.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
|
||||
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
|
||||
|
||||
export function isMatrixSdkAvailable(): boolean {
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
req.resolve(MATRIX_SDK_PACKAGE);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePluginRoot(): string {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(currentDir, "..", "..");
|
||||
}
|
||||
|
||||
export async function ensureMatrixSdkInstalled(params: {
|
||||
runtime: RuntimeEnv;
|
||||
confirm?: (message: string) => Promise<boolean>;
|
||||
}): Promise<void> {
|
||||
if (isMatrixSdkAvailable()) return;
|
||||
const confirm = params.confirm;
|
||||
if (confirm) {
|
||||
const ok = await confirm("Matrix requires matrix-js-sdk. Install now?");
|
||||
if (!ok) {
|
||||
throw new Error("Matrix requires matrix-js-sdk (install dependencies first).");
|
||||
}
|
||||
}
|
||||
|
||||
const root = resolvePluginRoot();
|
||||
const command = fs.existsSync(path.join(root, "pnpm-lock.yaml"))
|
||||
? ["pnpm", "install"]
|
||||
: ["npm", "install", "--omit=dev", "--silent"];
|
||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||
const result = await runCommandWithTimeout(command, {
|
||||
cwd: root,
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.",
|
||||
);
|
||||
}
|
||||
if (!isMatrixSdkAvailable()) {
|
||||
throw new Error("Matrix dependency install completed but matrix-js-sdk is still missing.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { markdownToMatrixHtml } from "./format.js";
|
||||
|
||||
describe("markdownToMatrixHtml", () => {
|
||||
it("renders basic inline formatting", () => {
|
||||
const html = markdownToMatrixHtml("hi _there_ **boss** `code`");
|
||||
expect(html).toContain("<em>there</em>");
|
||||
expect(html).toContain("<strong>boss</strong>");
|
||||
expect(html).toContain("<code>code</code>");
|
||||
});
|
||||
|
||||
it("renders links as HTML", () => {
|
||||
const html = markdownToMatrixHtml("see [docs](https://example.com)");
|
||||
expect(html).toContain('<a href="https://example.com">docs</a>');
|
||||
});
|
||||
|
||||
it("escapes raw HTML", () => {
|
||||
const html = markdownToMatrixHtml("<b>nope</b>");
|
||||
expect(html).toContain("<b>nope</b>");
|
||||
expect(html).not.toContain("<b>nope</b>");
|
||||
});
|
||||
|
||||
it("flattens images into alt text", () => {
|
||||
const html = markdownToMatrixHtml("");
|
||||
expect(html).toContain("alt");
|
||||
expect(html).not.toContain("<img");
|
||||
});
|
||||
|
||||
it("preserves line breaks", () => {
|
||||
const html = markdownToMatrixHtml("line1\nline2");
|
||||
expect(html).toContain("<br");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
typographer: false,
|
||||
});
|
||||
|
||||
md.enable("strikethrough");
|
||||
|
||||
const { escapeHtml } = md.utils;
|
||||
|
||||
md.renderer.rules.image = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
|
||||
|
||||
md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
|
||||
md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
|
||||
|
||||
export function markdownToMatrixHtml(markdown: string): string {
|
||||
const rendered = md.render(markdown ?? "");
|
||||
return rendered.trimEnd();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { monitorMatrixProvider } from "./monitor/index.js";
|
||||
export { probeMatrix } from "./probe.js";
|
||||
export {
|
||||
reactMatrixMessage,
|
||||
resolveMatrixRoomId,
|
||||
sendMessageMatrix,
|
||||
sendPollMatrix,
|
||||
sendTypingMatrix,
|
||||
} from "./send.js";
|
||||
export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js";
|
||||
Loaded 100 of 466 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user