Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f9a96df8e | ||
|
|
e31251293b | ||
|
|
56ed5cc2d9 | ||
|
|
af31e0d969 | ||
|
|
37fa4f7eef | ||
|
|
9aad6dfe1b | ||
|
|
6b8db36a15 | ||
|
|
171060541a | ||
|
|
003547c818 | ||
|
|
c4e1064066 | ||
|
|
d163dbcfcd | ||
|
|
2acfeb1096 | ||
|
|
b3b6d421cc | ||
|
|
cf72b9db3c | ||
|
|
106e308953 | ||
|
|
bf72a126d1 | ||
|
|
28a5d124c3 | ||
|
|
b4045e6adb | ||
|
|
a7bec3340f | ||
|
|
a4e99ecdaf | ||
|
|
dcd20d564f | ||
|
|
59f6ea9b21 | ||
|
|
e44f28bd4f | ||
|
|
929b86e302 | ||
|
|
76d3d58b5c | ||
|
|
548a32c8d4 | ||
|
|
500c75b4f0 | ||
|
|
3567dc4a47 | ||
|
|
7df37c2dbd | ||
|
|
28a4cbc4ef | ||
|
|
21fe4d9ded | ||
|
|
05d149a49b | ||
|
|
97a41a6509 | ||
|
|
a0be85c34c | ||
|
|
390bd11f33 | ||
|
|
16768a9998 | ||
|
|
e9d6869290 | ||
|
|
9072e35f08 | ||
|
|
d887027e95 | ||
|
|
168a0f4998 | ||
|
|
f3a37664d5 | ||
|
|
8bcbe68637 | ||
|
|
beb9eac5f7 | ||
|
|
0dcffcd5b0 | ||
|
|
56efbce31e | ||
|
|
e7c42884fc | ||
|
|
08c0405f0f | ||
|
|
470add877c | ||
|
|
aaa310c047 | ||
|
|
0cd24137e8 | ||
|
|
8ffb8cc363 | ||
|
|
7a9854cb06 | ||
|
|
5ee4456c6e | ||
|
|
de31583021 | ||
|
|
38b49aa0f6 | ||
|
|
69761e8a51 | ||
|
|
3431d3d115 | ||
|
|
fe9e027d58 | ||
|
|
33d17957e5 | ||
|
|
25ae5f897e | ||
|
|
624ff09314 | ||
|
|
c8003ae472 | ||
|
|
6bf627bce8 | ||
|
|
08525435e0 | ||
|
|
78279fb758 | ||
|
|
38cf90f6db | ||
|
|
544ca062a3 | ||
|
|
be9aa5494a |
No files matched your search
@@ -107,6 +107,7 @@
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
|
||||
+30
-1
@@ -11,30 +11,43 @@
|
||||
### Breaking
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||
- **BREAKING:** Discord/Telegram channel tokens now prefer config over env (env is fallback only).
|
||||
- **BREAKING:** Matrix channel credentials now prefer config over env (env is fallback only).
|
||||
|
||||
### Changes
|
||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
|
||||
- Telegram: default reaction notifications to own.
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Channels: inject only pending (mention-gated) group history; clear history on any processed message.
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Skills: add user-invocable skill commands with sanitized names and an opt-out for model invocation.
|
||||
- Skills: add user-invocable skill commands (sanitized + unique), native skill registration gating, and an opt-out for model invocation.
|
||||
- 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.
|
||||
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
||||
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
||||
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
||||
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
||||
- 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.
|
||||
- Docs: add `/help` hub, Node/npm PATH sanity guide, and installer PATH warnings (for “installed but command not found” setups). (#861)
|
||||
- Docs: clarify web_search country defaults to Brave’s region choice. (#1046) — thanks @YuriNachos.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Agents: default to no narration for routine tool calls. (#1008) — thanks @cpojer.
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Tools: fall back to local Codex prompt files for unknown slash commands (supports `$1`, `$2`, `$*`).
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||
- 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.
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding docs. (#1016) — thanks @timolins.
|
||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||
- 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`.
|
||||
@@ -43,19 +56,28 @@
|
||||
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
||||
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
|
||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||
- Telegram: default reaction level to minimal for more delightful baseline reactions.
|
||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||
- Channels/Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) via zca-cli. (#1032) — thanks @suminhthanh.
|
||||
|
||||
### Fixes
|
||||
- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort.
|
||||
- Messages: make `/stop` abort active sub-agent runs spawned from the requester session and report how many were stopped.
|
||||
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
|
||||
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
|
||||
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||
- Sessions: keep session store writes private on disk even when `chmod` fails. (#1049) — thanks @YuriNachos.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
||||
- Agents: drop unsigned Gemini tool calls (and matching results) from context to prevent tool-call markup leakage across models.
|
||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||
- 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.
|
||||
@@ -70,8 +92,15 @@
|
||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||
- Fix: repair orphaned user turns before embedded prompts to avoid role-ordering lockouts. (#1026) — thanks @odrobnik.
|
||||
- Dev: auto-sync Peekaboo submodule on install when clean to avoid dirty checkouts.
|
||||
- Fix: keep background exec aborts from killing backgrounded sessions while honoring timeouts.
|
||||
- Fix: use local auth for gateway security probe unless remote mode has a URL. (#1011) — thanks @ivanrvpereira.
|
||||
- Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr.
|
||||
- macOS: resolve gateway token/password using config mode/remote URL, and warn when `launchctl setenv` overrides config. (#1022, #1021) — thanks @kkarimi.
|
||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||
- Groups: treat replies to the bot as implicit mentions across supported channels.
|
||||
- Security: bump dependency `tar` to 7.5.3 to fix GHSA-8qq5-rm4j-mr97.
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
|
||||
@@ -474,23 +474,24 @@ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></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/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/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/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/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/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/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/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/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/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/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></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/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></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/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></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/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></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/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=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></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/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/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/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/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></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/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></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/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/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></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/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></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/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/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/Szpadel"><img src="https://avatars.githubusercontent.com/u/1857251?v=4&s=48" width="48" height="48" alt="Szpadel" title="Szpadel"/></a>
|
||||
</p>
|
||||
@@ -257,30 +257,8 @@ final class AppState {
|
||||
|
||||
let configRoot = ClawdbotConfigFile.loadDict()
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let configMode: ConnectionMode? = switch configModeRaw {
|
||||
case "local":
|
||||
.local
|
||||
case "remote":
|
||||
.remote
|
||||
default:
|
||||
nil
|
||||
}
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configHasRemoteUrl = !(configRemoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
||||
configMode
|
||||
} else if configHasRemoteUrl {
|
||||
.remote
|
||||
} else if let storedMode {
|
||||
ConnectionMode(rawValue: storedMode) ?? .local
|
||||
} else {
|
||||
onboardingSeen ? .local : .unconfigured
|
||||
}
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
|
||||
@@ -385,14 +385,8 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let modeRaw = defaults.string(forKey: connectionModeKey)
|
||||
let mode: AppState.ConnectionMode
|
||||
if let modeRaw {
|
||||
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||
} else {
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
mode = seen ? .local : .unconfigured
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
enum EffectiveConnectionModeSource: Sendable, Equatable {
|
||||
case configMode
|
||||
case configRemoteURL
|
||||
case userDefaults
|
||||
case onboarding
|
||||
}
|
||||
|
||||
struct EffectiveConnectionMode: Sendable, Equatable {
|
||||
let mode: AppState.ConnectionMode
|
||||
let source: EffectiveConnectionModeSource
|
||||
}
|
||||
|
||||
enum ConnectionModeResolver {
|
||||
static func resolve(
|
||||
root: [String: Any],
|
||||
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
|
||||
{
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let configModeRaw = (gateway?["mode"] as? String) ?? ""
|
||||
let configMode = configModeRaw
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
switch configMode {
|
||||
case "local":
|
||||
return EffectiveConnectionMode(mode: .local, source: .configMode)
|
||||
case "remote":
|
||||
return EffectiveConnectionMode(mode: .remote, source: .configMode)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
|
||||
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !remoteURL.isEmpty {
|
||||
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
|
||||
}
|
||||
|
||||
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
|
||||
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
|
||||
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
|
||||
}
|
||||
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import ConcurrencyExtras
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -16,6 +17,13 @@ actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
private enum EnvOverrideWarningKind: Sendable {
|
||||
case token
|
||||
case password
|
||||
}
|
||||
|
||||
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
|
||||
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
@@ -30,16 +38,18 @@ actor GatewayEndpointStore {
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||
return GatewayEndpointStore.resolveGatewayToken(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
isRemote: isRemote,
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
},
|
||||
password: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
isRemote: isRemote,
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
@@ -68,6 +78,14 @@ actor GatewayEndpointStore {
|
||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
|
||||
!configPassword.isEmpty
|
||||
{
|
||||
self.warnEnvOverrideOnce(
|
||||
kind: .password,
|
||||
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
|
||||
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
@@ -99,6 +117,26 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let password = remote["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveGatewayToken(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
@@ -108,6 +146,14 @@ actor GatewayEndpointStore {
|
||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||
!configToken.isEmpty
|
||||
{
|
||||
self.warnEnvOverrideOnce(
|
||||
kind: .token,
|
||||
envVar: "CLAWDBOT_GATEWAY_TOKEN",
|
||||
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
@@ -139,6 +185,49 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let token = remote["token"] as? String
|
||||
{
|
||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func warnEnvOverrideOnce(
|
||||
kind: EnvOverrideWarningKind,
|
||||
envVar: String,
|
||||
configKey: String)
|
||||
{
|
||||
let shouldWarn = Self.envOverrideWarnings.withValue { state in
|
||||
switch kind {
|
||||
case .token:
|
||||
guard !state.token else { return false }
|
||||
state.token = true
|
||||
return true
|
||||
case .password:
|
||||
guard !state.password else { return false }
|
||||
state.password = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
guard shouldWarn else { return }
|
||||
Self.staticLogger.warning(
|
||||
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
|
||||
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
|
||||
}
|
||||
|
||||
private let deps: Deps
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@ import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct GatewayEndpointStoreTests {
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
|
||||
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
@@ -66,4 +73,70 @@ import Testing
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(password == "launchd-pass")
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " local ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverTrimsConfigMode() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " remote ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": "staging",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": " ws://umbrel:18789 ",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ Isolated jobs run a dedicated agent turn in session `cron:<jobId>`.
|
||||
|
||||
Key behaviors:
|
||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||
- Each run starts a **fresh session id** (no prior conversation carry-over).
|
||||
- A summary is posted to the main session (prefix `Cron`, configurable).
|
||||
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
|
||||
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
|
||||
|
||||
@@ -13,6 +13,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
||||
2) Set the token for Clawdbot:
|
||||
- Env: `DISCORD_BOT_TOKEN=...`
|
||||
- Or config: `channels.discord.token: "..."`.
|
||||
- If both are set, config wins; env is fallback.
|
||||
3) Invite the bot to your server with message permissions.
|
||||
4) Start the gateway.
|
||||
5) DM access is pairing by default; approve the pairing code on first contact.
|
||||
@@ -39,8 +40,8 @@ Minimal config:
|
||||
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
|
||||
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
|
||||
3. Configure Clawdbot with `DISCORD_BOT_TOKEN` (or `channels.discord.token` in `~/.clawdbot/clawdbot.json`).
|
||||
4. Run the gateway; it auto-starts the Discord channel when a token is available (env or config) and `channels.discord.enabled` is not `false`.
|
||||
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (a config block is optional).
|
||||
4. Run the gateway; it auto-starts the Discord channel when a token is available (config first, env fallback) and `channels.discord.enabled` is not `false`.
|
||||
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (and omit config).
|
||||
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected.
|
||||
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
|
||||
7. Direct chats: secure by default via `channels.discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); approve via `clawdbot pairing approve discord <code>`.
|
||||
|
||||
@@ -20,6 +20,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -32,6 +32,7 @@ Details: [Plugins](/plugin)
|
||||
2) Configure credentials:
|
||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
|
||||
- Or config: `channels.matrix.*`
|
||||
- Config takes precedence over env; env is fallback.
|
||||
3) Restart the gateway (or finish onboarding).
|
||||
4) DM access defaults to pairing; approve the pairing code on first contact.
|
||||
|
||||
|
||||
+30
-15
@@ -13,6 +13,7 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
|
||||
2) Set the token:
|
||||
- Env: `TELEGRAM_BOT_TOKEN=...`
|
||||
- Or config: `channels.telegram.botToken: "..."`.
|
||||
- If both are set, config wins; env is fallback.
|
||||
3) Start the gateway.
|
||||
4) DM access is pairing by default; approve the pairing code on first contact.
|
||||
|
||||
@@ -60,11 +61,11 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account).
|
||||
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account; used only when config is missing).
|
||||
|
||||
Multi-account support: use `channels.telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
3) Start the gateway. Telegram starts when a token is resolved (env or config).
|
||||
3) Start the gateway. Telegram starts when a token is resolved (config first, env fallback).
|
||||
4) DM access defaults to pairing. Approve the code when the bot is first contacted.
|
||||
5) For groups: add the bot, decide privacy/admin behavior (below), then set `channels.telegram.groups` to control mention gating + allowlists.
|
||||
|
||||
@@ -219,13 +220,15 @@ Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps
|
||||
|
||||
## Inline Buttons
|
||||
|
||||
Telegram supports inline keyboards with callback buttons. Enable this feature via capabilities:
|
||||
Telegram supports inline keyboards with callback buttons.
|
||||
|
||||
```json5
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"capabilities": ["inlineButtons"]
|
||||
"capabilities": {
|
||||
"inlineButtons": "allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,7 +241,9 @@ For per-account configuration:
|
||||
"telegram": {
|
||||
"accounts": {
|
||||
"main": {
|
||||
"capabilities": ["inlineButtons"]
|
||||
"capabilities": {
|
||||
"inlineButtons": "allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +251,16 @@ For per-account configuration:
|
||||
}
|
||||
```
|
||||
|
||||
Scopes:
|
||||
- `off` — inline buttons disabled
|
||||
- `dm` — only DMs (group targets blocked)
|
||||
- `group` — only groups (DM targets blocked)
|
||||
- `all` — DMs + groups
|
||||
- `allowlist` — DMs + groups, but only senders allowed by `allowFrom`/`groupAllowFrom` (same rules as control commands)
|
||||
|
||||
Default: `allowlist`.
|
||||
Legacy: `capabilities: ["inlineButtons"]` = `inlineButtons: "all"`.
|
||||
|
||||
### Sending buttons
|
||||
|
||||
Use the message tool with the `buttons` parameter:
|
||||
@@ -273,12 +288,12 @@ When a user clicks a button, the callback data is sent back to the agent as a me
|
||||
|
||||
### Configuration options
|
||||
|
||||
Telegram capabilities can be configured at two levels:
|
||||
Telegram capabilities can be configured at two levels (object form shown above; legacy string arrays still supported):
|
||||
|
||||
- `channels.telegram.capabilities`: Global default capability list applied to all Telegram accounts unless overridden.
|
||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override or extend the global defaults for that specific account.
|
||||
- `channels.telegram.capabilities`: Global default capability config applied to all Telegram accounts unless overridden.
|
||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override the global defaults for that specific account.
|
||||
|
||||
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups or has extra capabilities).
|
||||
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups).
|
||||
## Access control (DMs + groups)
|
||||
|
||||
### DM access
|
||||
@@ -397,8 +412,8 @@ The agent sees reactions as **system notifications** in the conversation history
|
||||
|
||||
**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)
|
||||
- `"off"` — ignore all reactions
|
||||
- `"own"` — notify when users react to bot messages (best-effort; in-memory) (default)
|
||||
- `"all"` — notify for all reactions
|
||||
|
||||
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
|
||||
@@ -477,8 +492,8 @@ Provider options:
|
||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- `channels.telegram.capabilities`: Enable channel features (e.g., "inlineButtons").
|
||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
@@ -491,8 +506,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).
|
||||
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
|
||||
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
|
||||
|
||||
Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
|
||||
|
||||
@@ -202,9 +202,9 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted
|
||||
- `always`: always triggers.
|
||||
- `/activation mention|always` is owner-only and must be sent as a standalone message.
|
||||
- Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset).
|
||||
- **History injection**:
|
||||
- Recent messages (default 50) inserted under:
|
||||
`[Chat messages since your last reply - for context]`
|
||||
- **History injection** (pending-only):
|
||||
- Recent *unprocessed* messages (default 50) inserted under:
|
||||
`[Chat messages since your last reply - for context]` (messages already in the session are not re-injected)
|
||||
- Current message under:
|
||||
`[Current message - respond to this]`
|
||||
- Sender suffix appended: `[from: Name (+E164)]`
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration"
|
||||
read_when:
|
||||
- Setting up Zalo Personal for Clawdbot
|
||||
- Debugging Zalo Personal login or message flow
|
||||
---
|
||||
# Zalo Personal (unofficial)
|
||||
|
||||
Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`.
|
||||
|
||||
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
|
||||
|
||||
## Plugin required
|
||||
Zalo Personal ships as a plugin and is not bundled with the core install.
|
||||
- Install via CLI: `clawdbot plugins install @clawdbot/zalouser`
|
||||
- Or from a source checkout: `clawdbot plugins install ./extensions/zalouser`
|
||||
- Details: [Plugins](/plugin)
|
||||
|
||||
## Prerequisite: zca-cli
|
||||
The Gateway machine must have the `zca` binary available in `PATH`.
|
||||
|
||||
- Verify: `zca --version`
|
||||
- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs).
|
||||
|
||||
## Quick setup (beginner)
|
||||
1) Install the plugin (see above).
|
||||
2) Login (QR, on the Gateway machine):
|
||||
- `clawdbot channels login --channel zalouser`
|
||||
- Scan the QR code in the terminal with the Zalo mobile app.
|
||||
3) Enable the channel:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4) Restart the Gateway (or finish onboarding).
|
||||
5) DM access defaults to pairing; approve the pairing code on first contact.
|
||||
|
||||
## What it is
|
||||
- Uses `zca listen` to receive inbound messages.
|
||||
- Uses `zca msg ...` to send replies (text/media/link).
|
||||
- Designed for “personal account” use cases where Zalo Bot API is not available.
|
||||
|
||||
## Naming
|
||||
Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration.
|
||||
|
||||
## Finding IDs (directory)
|
||||
Use the directory CLI to discover peers/groups and their IDs:
|
||||
|
||||
```bash
|
||||
clawdbot directory self --channel zalouser
|
||||
clawdbot directory peers list --channel zalouser --query "name"
|
||||
clawdbot directory groups list --channel zalouser --query "work"
|
||||
```
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to ~2000 characters (Zalo client limits).
|
||||
- Streaming is blocked by default.
|
||||
|
||||
## Access control (DMs)
|
||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||
|
||||
Approve via:
|
||||
- `clawdbot pairing list zalouser`
|
||||
- `clawdbot pairing approve zalouser <code>`
|
||||
|
||||
## Multi-account
|
||||
Accounts map to zca profiles. Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
defaultAccount: "default",
|
||||
accounts: {
|
||||
work: { enabled: true, profile: "work" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`zca` not found:**
|
||||
- Install zca-cli and ensure it’s on `PATH` for the Gateway process.
|
||||
|
||||
**Login doesn’t stick:**
|
||||
- `clawdbot channels status --probe`
|
||||
- Re-login: `clawdbot channels logout --channel zalouser && clawdbot channels login --channel zalouser`
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot directory` (self, peers, groups)"
|
||||
read_when:
|
||||
- You want to look up contacts/groups/self ids for a channel
|
||||
- You are developing a channel directory adapter
|
||||
---
|
||||
|
||||
# `clawdbot directory`
|
||||
|
||||
Directory lookups for channels that support it (contacts/peers, groups, and “me”).
|
||||
|
||||
## Common flags
|
||||
- `--channel <name>`: channel id/alias (required when multiple channels are configured; auto when only one is configured)
|
||||
- `--account <id>`: account id (default: channel default)
|
||||
- `--json`: output JSON
|
||||
|
||||
## Notes
|
||||
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --to ...`).
|
||||
- For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory.
|
||||
- Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting.
|
||||
|
||||
## Using results with `message send`
|
||||
|
||||
```bash
|
||||
clawdbot directory peers list --channel slack --query "U0"
|
||||
clawdbot message send --channel slack --to user:U012ABCDEF --message "hello"
|
||||
```
|
||||
|
||||
## ID formats (by channel)
|
||||
|
||||
- WhatsApp: `+15551234567` (DM), `1234567890-1234567890@g.us` (group)
|
||||
- Telegram: `@username` or numeric chat id; groups are numeric ids
|
||||
- Slack: `user:U…` and `channel:C…`
|
||||
- Discord: `user:<id>` and `channel:<id>`
|
||||
- Matrix (plugin): `user:@user:server`, `room:!roomId:server`, or `#alias:server`
|
||||
- Microsoft Teams (plugin): `user:<id>` and `conversation:<id>`
|
||||
- Zalo (plugin): user id (Bot API)
|
||||
- Zalo Personal / `zalouser` (plugin): thread id (DM/group) from `zca` (`me`, `friend list`, `group list`)
|
||||
|
||||
## Self (“me”)
|
||||
|
||||
```bash
|
||||
clawdbot directory self --channel zalouser
|
||||
```
|
||||
|
||||
## Peers (contacts/users)
|
||||
|
||||
```bash
|
||||
clawdbot directory peers list --channel zalouser
|
||||
clawdbot directory peers list --channel zalouser --query "name"
|
||||
clawdbot directory peers list --channel zalouser --limit 50
|
||||
```
|
||||
|
||||
## Groups
|
||||
|
||||
```bash
|
||||
clawdbot directory groups list --channel zalouser
|
||||
clawdbot directory groups list --channel zalouser --query "work"
|
||||
clawdbot directory groups members --channel zalouser --group-id <id>
|
||||
```
|
||||
@@ -21,3 +21,14 @@ clawdbot doctor --repair
|
||||
clawdbot doctor --deep
|
||||
```
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||
|
||||
```bash
|
||||
launchctl getenv CLAWDBOT_GATEWAY_TOKEN
|
||||
launchctl getenv CLAWDBOT_GATEWAY_PASSWORD
|
||||
|
||||
launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN
|
||||
launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD
|
||||
```
|
||||
+2
-1
@@ -283,7 +283,7 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@@ -291,6 +291,7 @@ Options:
|
||||
- `--anthropic-api-key <key>`
|
||||
- `--openai-api-key <key>`
|
||||
- `--openrouter-api-key <key>`
|
||||
- `--ai-gateway-api-key <key>`
|
||||
- `--moonshot-api-key <key>`
|
||||
- `--gemini-api-key <key>`
|
||||
- `--zai-api-key <key>`
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Target formats (`--to`):
|
||||
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
||||
- Required: `--to`, plus `--message` or `--media`
|
||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`)
|
||||
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
||||
- Telegram only: `--thread-id` (forum topic id)
|
||||
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
||||
- WhatsApp only: `--gif-playback`
|
||||
|
||||
@@ -13,7 +13,7 @@ Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
|
||||
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
|
||||
- Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
||||
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
|
||||
- Context injection: **pending-only** group messages (default 50) that *did not* trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected.
|
||||
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
|
||||
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
|
||||
- Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat.
|
||||
|
||||
@@ -177,6 +177,8 @@ Quick mental model (evaluation order for group messages):
|
||||
## Mention gating (default)
|
||||
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
|
||||
|
||||
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
@@ -219,7 +221,7 @@ Notes:
|
||||
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
|
||||
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
|
||||
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
|
||||
- Group history context is wrapped uniformly across channels; use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
||||
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
||||
|
||||
## Group allowlists
|
||||
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
|
||||
|
||||
@@ -85,6 +85,10 @@ When a channel supplies history, it uses a shared wrapper:
|
||||
- `[Chat messages since your last reply - for context]`
|
||||
- `[Current message - respond to this]`
|
||||
|
||||
History buffers are **pending-only**: they include group messages that did *not*
|
||||
trigger a run (for example, mention-gated messages) and **exclude** messages
|
||||
already in the session transcript.
|
||||
|
||||
Directive stripping only applies to the **current message** section so history
|
||||
remains intact. Channels that wrap history should set `CommandBody` (or
|
||||
`RawBody`) to the original message text and keep `Body` as the combined prompt.
|
||||
|
||||
@@ -93,6 +93,13 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
||||
- CLI: `clawdbot onboard --auth-choice zai-api-key`
|
||||
- Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*`
|
||||
|
||||
### Vercel AI Gateway
|
||||
|
||||
- Provider: `vercel-ai-gateway`
|
||||
- Auth: `AI_GATEWAY_API_KEY`
|
||||
- Example model: `vercel-ai-gateway/anthropic/claude-opus-4.5`
|
||||
- CLI: `clawdbot onboard --auth-choice ai-gateway-api-key`
|
||||
|
||||
### Other built-in providers
|
||||
|
||||
- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`)
|
||||
|
||||
+11
-11
@@ -1,28 +1,28 @@
|
||||
---
|
||||
summary: "Command queue design that serializes auto-reply command execution"
|
||||
summary: "Command queue design that serializes inbound auto-reply runs"
|
||||
read_when:
|
||||
- Changing auto-reply execution or concurrency
|
||||
---
|
||||
# Command Queue (2026-01-03)
|
||||
# Command Queue (2026-01-16)
|
||||
|
||||
We now serialize command-based auto-replies (WhatsApp Web listener) through a tiny in-process queue to prevent multiple commands from running at once, while allowing safe parallelism across sessions.
|
||||
We serialize inbound auto-reply runs (all channels) through a tiny in-process queue to prevent multiple agent runs from colliding, while still allowing safe parallelism across sessions.
|
||||
|
||||
## Why
|
||||
- Some auto-reply commands are expensive (LLM calls) and can collide when multiple inbound messages arrive close together.
|
||||
- Serializing avoids competing for terminal/stdin, keeps logs readable, and reduces the chance of rate limits from upstream tools.
|
||||
- Auto-reply runs can be expensive (LLM calls) and can collide when multiple inbound messages arrive close together.
|
||||
- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
|
||||
|
||||
## How it works
|
||||
- A lane-aware FIFO queue drains each lane synchronously.
|
||||
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1).
|
||||
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
|
||||
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
|
||||
- When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting.
|
||||
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn.
|
||||
- When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
|
||||
- Typing indicators still fire immediately on enqueue (when supported by the channel) so user experience is unchanged while we wait our turn.
|
||||
|
||||
## Queue modes (per channel)
|
||||
Inbound messages can steer the current run, wait for a followup turn, or do both:
|
||||
- `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup.
|
||||
- `followup`: enqueue for the next agent turn after the current run ends.
|
||||
- `collect`: coalesce all queued messages into a **single** followup turn (default).
|
||||
- `collect`: coalesce all queued messages into a **single** followup turn (default). If messages target different channels/threads, they drain individually to preserve routing.
|
||||
- `steer-backlog` (aka `steer+backlog`): steer now **and** preserve the message for a followup turn.
|
||||
- `interrupt` (legacy): abort the active run for that session, then run the newest message.
|
||||
- `queue` (legacy alias): same as `steer`.
|
||||
@@ -66,9 +66,9 @@ Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
|
||||
- `/queue default` or `/queue reset` clears the session override.
|
||||
|
||||
## Scope and guarantees
|
||||
- Applies only to config-driven command replies; plain text replies are unaffected.
|
||||
- Applies to auto-reply agent runs across all inbound channels that use the gateway reply pipeline (WhatsApp web, Telegram, Slack, Discord, Signal, iMessage, webchat, etc.).
|
||||
- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agents.defaults.maxConcurrent` to allow multiple sessions in parallel.
|
||||
- Additional lanes may exist (e.g. `cron`) so background jobs can run in parallel without blocking inbound replies.
|
||||
- Additional lanes may exist (e.g. `cron`, `subagent`) so background jobs can run in parallel without blocking inbound replies.
|
||||
- Per-session lanes guarantee that only one agent run touches a given session at a time.
|
||||
- No external dependencies or background worker threads; pure TypeScript + promises.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Use `session.dmScope` to control how **direct messages** are grouped:
|
||||
- `main` (default): all DMs share the main session for continuity.
|
||||
- `per-peer`: isolate by sender id across channels.
|
||||
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
|
||||
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
||||
|
||||
## Gateway is the source of truth
|
||||
All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
|
||||
@@ -42,6 +43,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
|
||||
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
|
||||
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
|
||||
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
|
||||
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
|
||||
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
|
||||
- Legacy `group:<id>` keys are still recognized for migration.
|
||||
@@ -55,6 +57,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
||||
|
||||
## Send policy (optional)
|
||||
Block delivery for specific session types without listing individual ids.
|
||||
@@ -86,6 +89,9 @@ Send these as standalone messages so they register.
|
||||
session: {
|
||||
scope: "per-sender", // keep group keys separate
|
||||
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
|
||||
identityLinks: {
|
||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||
},
|
||||
idleMinutes: 120,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||
@@ -100,7 +106,7 @@ Send these as standalone messages so they register.
|
||||
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
|
||||
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
|
||||
- Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
|
||||
- Send `/stop` as a standalone message to abort the current run.
|
||||
- Send `/stop` as a standalone message to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count).
|
||||
- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction).
|
||||
- JSONL transcripts can be opened directly to review full turns.
|
||||
|
||||
|
||||
+27
-1
@@ -733,6 +733,22 @@
|
||||
"source": "/wizard",
|
||||
"destination": "/start/wizard"
|
||||
},
|
||||
{
|
||||
"source": "/install/node",
|
||||
"destination": "/install#nodejs--npm-path-sanity"
|
||||
},
|
||||
{
|
||||
"source": "/install/node/",
|
||||
"destination": "/install#nodejs--npm-path-sanity"
|
||||
},
|
||||
{
|
||||
"source": "/start/faq",
|
||||
"destination": "/help"
|
||||
},
|
||||
{
|
||||
"source": "/start/faq/",
|
||||
"destination": "/help"
|
||||
},
|
||||
{
|
||||
"source": "/oauth",
|
||||
"destination": "/concepts/oauth"
|
||||
@@ -752,7 +768,6 @@
|
||||
"start/wizard",
|
||||
"start/setup",
|
||||
"start/pairing",
|
||||
"start/faq",
|
||||
"start/clawd",
|
||||
"start/showcase",
|
||||
"start/hubs",
|
||||
@@ -760,6 +775,14 @@
|
||||
"start/lore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Help",
|
||||
"pages": [
|
||||
"help/index",
|
||||
"help/troubleshooting",
|
||||
"help/faq"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Install & Updates",
|
||||
"pages": [
|
||||
@@ -792,6 +815,7 @@
|
||||
"cli/health",
|
||||
"cli/sessions",
|
||||
"cli/channels",
|
||||
"cli/directory",
|
||||
"cli/skills",
|
||||
"cli/plugins",
|
||||
"cli/memory",
|
||||
@@ -907,6 +931,7 @@
|
||||
"channels/msteams",
|
||||
"channels/matrix",
|
||||
"channels/zalo",
|
||||
"channels/zalouser",
|
||||
"broadcast-groups",
|
||||
"channels/troubleshooting",
|
||||
"channels/location"
|
||||
@@ -945,6 +970,7 @@
|
||||
"tools",
|
||||
"plugin",
|
||||
"plugins/voice-call",
|
||||
"plugins/zalouser",
|
||||
"tools/exec",
|
||||
"tools/web",
|
||||
"tools/apply-patch",
|
||||
|
||||
@@ -44,7 +44,7 @@ clawdbot doctor
|
||||
If you’d rather not manage env vars yourself, the onboarding wizard can store
|
||||
API keys for daemon use: `clawdbot onboard`.
|
||||
|
||||
See [/start/faq](/start/faq) for details on env inheritance (`env.shellEnv`,
|
||||
See [Help](/help) for details on env inheritance (`env.shellEnv`,
|
||||
`~/.clawdbot/.env`, systemd/launchd).
|
||||
|
||||
## Anthropic: Claude Code CLI setup-token (supported)
|
||||
|
||||
@@ -943,6 +943,7 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
||||
breakPreference: "paragraph" // paragraph | newline | sentence
|
||||
},
|
||||
actions: { reactions: true, sendMessage: true }, // tool action gates (false disables)
|
||||
reactionNotifications: "own", // off | own | all
|
||||
mediaMaxMb: 5,
|
||||
retry: { // outbound retry policy
|
||||
attempts: 3,
|
||||
@@ -1708,11 +1709,12 @@ Legacy: `tools.bash` is still accepted as an alias.
|
||||
- `tools.web.search.maxResults` (1–10, default 5)
|
||||
- `tools.web.search.timeoutSeconds` (default 30)
|
||||
- `tools.web.search.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.enabled` (default false; sandboxed sessions auto-enable unless set to false)
|
||||
- `tools.web.fetch.enabled` (default true)
|
||||
- `tools.web.fetch.maxChars` (default 50000)
|
||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.userAgent` (optional override)
|
||||
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
||||
|
||||
`agents.defaults.subagents` configures sub-agent defaults:
|
||||
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
||||
@@ -2269,6 +2271,9 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
dmScope: "main",
|
||||
identityLinks: {
|
||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||
},
|
||||
idleMinutes: 60,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||
@@ -2297,6 +2302,8 @@ Fields:
|
||||
- `main`: all DMs share the main session for continuity.
|
||||
- `per-peer`: isolate DMs by sender id across channels.
|
||||
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
||||
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
||||
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
|
||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||
|
||||
@@ -133,7 +133,7 @@ By default, Clawdbot routes **all DMs into the main session** so your assistant
|
||||
}
|
||||
```
|
||||
|
||||
This prevents cross-user context leakage while keeping group chats isolated. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
|
||||
This prevents cross-user context leakage while keeping group chats isolated. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
|
||||
|
||||
## Allowlists (DM + groups) — terminology
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ clawdbot channels login
|
||||
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
|
||||
|
||||
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
|
||||
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
|
||||
The repo declares `packageManager: "pnpm@…"`.
|
||||
|
||||
Typical recovery:
|
||||
```bash
|
||||
@@ -376,8 +376,7 @@ pnpm clawdbot doctor
|
||||
clawdbot daemon restart
|
||||
```
|
||||
|
||||
Why: pnpm is the configured package manager for this repo, and the dependency
|
||||
patching workflow relies on it.
|
||||
Why: pnpm is the configured package manager for this repo.
|
||||
|
||||
### How do I switch between git installs and npm installs?
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
summary: "FAQ (concepts): what Clawdbot is and how it fits together"
|
||||
read_when:
|
||||
- You’re new and want the mental model
|
||||
- You’re not debugging a specific error
|
||||
---
|
||||
|
||||
# FAQ (concepts)
|
||||
|
||||
If you’re here because something’s broken, start with: [Troubleshooting](/help/troubleshooting).
|
||||
|
||||
## What is Clawdbot?
|
||||
|
||||
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always‑on control plane; the assistant is the product.
|
||||
|
||||
## What runtime do I need?
|
||||
|
||||
Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** for the Gateway.
|
||||
|
||||
## What’s the recommended setup flow?
|
||||
|
||||
Use the onboarding wizard:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
Then use:
|
||||
|
||||
```bash
|
||||
clawdbot dashboard
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
summary: "Help hub: common fixes, install sanity, and where to look when something breaks"
|
||||
read_when:
|
||||
- You’re new and want the “what do I click/run” guide
|
||||
- Something broke and you want the fastest path to a fix
|
||||
---
|
||||
|
||||
# Help
|
||||
|
||||
If you want a quick “get unstuck” flow, start here:
|
||||
|
||||
- **Troubleshooting:** [Start here](/help/troubleshooting)
|
||||
- **Install sanity (Node/npm/PATH):** [Install](/install#nodejs--npm-path-sanity)
|
||||
- **Gateway issues:** [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- **Logs:** [Logging](/logging) and [Gateway logging](/gateway/logging)
|
||||
- **Repairs:** [Doctor](/gateway/doctor)
|
||||
|
||||
If you’re looking for conceptual questions (not “something broke”):
|
||||
|
||||
- [FAQ (concepts)](/help/faq)
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
summary: "Troubleshooting hub: symptoms → checks → fixes"
|
||||
read_when:
|
||||
- You see an error and want the fix path
|
||||
- The installer says “success” but the CLI doesn’t work
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## First 60 seconds
|
||||
|
||||
Run these in order:
|
||||
|
||||
```bash
|
||||
clawdbot status
|
||||
clawdbot status --all
|
||||
clawdbot daemon status
|
||||
clawdbot logs --follow
|
||||
clawdbot doctor
|
||||
```
|
||||
|
||||
If the gateway is reachable, deep probes:
|
||||
|
||||
```bash
|
||||
clawdbot status --deep
|
||||
```
|
||||
|
||||
## Common “it broke” cases
|
||||
|
||||
### `clawdbot: command not found`
|
||||
|
||||
Almost always a Node/npm PATH issue. Start here:
|
||||
|
||||
- [Install (Node/npm PATH sanity)](/install#nodejs--npm-path-sanity)
|
||||
|
||||
### Gateway “unauthorized”, can’t connect, or keeps reconnecting
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- [Gateway authentication](/gateway/authentication)
|
||||
|
||||
### Daemon says running, but RPC probe fails
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- [Background process / daemon](/gateway/background-process)
|
||||
|
||||
### Model/auth failures (rate limit, billing, “all models failed”)
|
||||
|
||||
- [Models](/cli/models)
|
||||
- [OAuth / auth concepts](/concepts/oauth)
|
||||
|
||||
### When filing an issue
|
||||
|
||||
Paste a safe report:
|
||||
|
||||
```bash
|
||||
clawdbot status --all
|
||||
```
|
||||
|
||||
If you can, include the relevant log tail from `clawdbot logs --follow`.
|
||||
+1
-1
@@ -165,7 +165,7 @@ Example:
|
||||
|
||||
- Start here:
|
||||
- [Docs hubs (all pages linked)](/start/hubs)
|
||||
- [FAQ](/start/faq) ← *common questions answered*
|
||||
- [Help](/help) ← *common fixes + troubleshooting*
|
||||
- [Configuration](/gateway/configuration)
|
||||
- [Configuration examples](/gateway/configuration-examples)
|
||||
- [Slash commands](/tools/slash-commands)
|
||||
|
||||
+2
-16
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Bun workflow (experimental): installs, patches, and gotchas vs pnpm"
|
||||
summary: "Bun workflow (experimental): installs and gotchas vs pnpm"
|
||||
read_when:
|
||||
- You want the fastest local dev loop (bun + watch)
|
||||
- You hit Bun install/patch/lifecycle script issues
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
# Bun (experimental)
|
||||
|
||||
Goal: run this repo with **Bun** (optional, not recommended for WhatsApp/Telegram)
|
||||
without losing pnpm patch behavior.
|
||||
without diverging from pnpm workflows.
|
||||
|
||||
⚠️ **Not recommended for Gateway runtime** (WhatsApp/Telegram bugs). Use Node for production.
|
||||
|
||||
@@ -39,20 +39,6 @@ bun run build
|
||||
bun run vitest run
|
||||
```
|
||||
|
||||
## pnpm patchedDependencies under Bun
|
||||
|
||||
pnpm supports `package.json#pnpm.patchedDependencies` and records it in `pnpm-lock.yaml`.
|
||||
Bun (and npm/yarn) do not support pnpm patches, so we apply them in `postinstall` when pnpm is **not** the installer:
|
||||
|
||||
- [`scripts/postinstall.js`](https://github.com/clawdbot/clawdbot/blob/main/scripts/postinstall.js) detects the package manager via `npm_config_user_agent` and applies every entry from `package.json#pnpm.patchedDependencies` into `node_modules/...` using a built-in JS patcher (no `git`/system `patch` dependency).
|
||||
- Under pnpm, this fallback is skipped because pnpm already applies `patchedDependencies` itself.
|
||||
|
||||
To add a new patch that works in both pnpm + Bun:
|
||||
|
||||
1. Add an entry to `package.json#pnpm.patchedDependencies`
|
||||
2. Add the patch file under `patches/`
|
||||
3. Run `pnpm install` (updates `pnpm-lock.yaml` patch hash)
|
||||
|
||||
## Bun lifecycle scripts (blocked by default)
|
||||
|
||||
Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`).
|
||||
|
||||
@@ -152,7 +152,6 @@ WORKDIR /app
|
||||
# Cache dependencies unless package metadata changes
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts ./scripts
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -9,6 +9,29 @@ read_when:
|
||||
|
||||
Runtime baseline: **Node >=22**.
|
||||
|
||||
If the installer says it succeeded but you later see `clawdbot: command not found`, it’s usually a Node/npm PATH issue (global npm bin dir not on PATH). See the section below.
|
||||
|
||||
## Node.js + npm (PATH sanity)
|
||||
|
||||
Quick diagnosis:
|
||||
|
||||
```bash
|
||||
node -v
|
||||
npm -v
|
||||
npm bin -g
|
||||
echo "$PATH"
|
||||
```
|
||||
|
||||
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
||||
|
||||
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
|
||||
|
||||
```bash
|
||||
export PATH="/path/from/npm/bin/-g:$PATH"
|
||||
```
|
||||
|
||||
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
|
||||
|
||||
## Recommended (installer script)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,6 +19,8 @@ To see the current flags/behavior, run:
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
||||
|
||||
## install.sh (recommended)
|
||||
|
||||
What it does (high level):
|
||||
@@ -71,9 +73,3 @@ Help:
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
## Patches (npm / pnpm / bun)
|
||||
|
||||
Clawdbot’s `postinstall` script includes a builtin JS patcher that can apply `pnpm.patchedDependencies` patches even when the package manager doesn’t support them (notably Bun). pnpm itself already applies `pnpm.patchedDependencies`, so the fallback skips pnpm installs to avoid double-applying.
|
||||
|
||||
See: [Bun notes](/install/bun).
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
summary: "Node.js + npm install sanity: versions, PATH, and global installs"
|
||||
read_when:
|
||||
- You installed Clawdbot but `clawdbot` is “command not found”
|
||||
- You’re setting up Node.js/npm on a new machine
|
||||
- `npm install -g ...` fails with permissions or PATH issues
|
||||
---
|
||||
|
||||
# Node.js + npm (PATH sanity)
|
||||
|
||||
Clawdbot’s runtime baseline is **Node 22+**.
|
||||
|
||||
If you can run `npm install -g clawdbot@latest` but later see `clawdbot: command not found`, it’s almost always a **PATH** issue: the directory where npm puts global binaries isn’t on your shell’s PATH.
|
||||
|
||||
## Quick diagnosis
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node -v
|
||||
npm -v
|
||||
npm bin -g
|
||||
echo "$PATH"
|
||||
```
|
||||
|
||||
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
||||
|
||||
## Fix: put npm’s global bin dir on PATH
|
||||
|
||||
1) Find your global bin directory:
|
||||
|
||||
```bash
|
||||
npm bin -g
|
||||
```
|
||||
|
||||
2) Add it to your shell startup file:
|
||||
|
||||
- zsh: `~/.zshrc`
|
||||
- bash: `~/.bashrc`
|
||||
|
||||
Example (replace the path with your `npm bin -g` output):
|
||||
|
||||
```bash
|
||||
export PATH="/path/from/npm/bin/-g:$PATH"
|
||||
```
|
||||
|
||||
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
|
||||
|
||||
## Fix: avoid `sudo npm install -g` / permission errors (Linux)
|
||||
|
||||
If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory:
|
||||
|
||||
```bash
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
```
|
||||
|
||||
Persist the `export PATH=...` line in your shell startup file.
|
||||
|
||||
## Recommended Node install options
|
||||
|
||||
You’ll have the fewest surprises if Node/npm are installed in a way that:
|
||||
|
||||
- keeps Node updated (22+)
|
||||
- makes `npm bin -g` stable and on PATH in new shells
|
||||
|
||||
Common choices:
|
||||
|
||||
- macOS: Homebrew (`brew install node`) or a version manager
|
||||
- Linux: your preferred version manager, or a distro-supported install that provides Node 22+
|
||||
- Windows: official Node installer, `winget`, or a Windows Node version manager
|
||||
|
||||
If you use a version manager (nvm/fnm/asdf/etc), ensure it’s initialized in the shell you use day-to-day (zsh vs bash) so the PATH it sets is present when you run installers.
|
||||
@@ -99,7 +99,7 @@ It can set up:
|
||||
- model provider config/login
|
||||
- Linux systemd **user** service (daemon)
|
||||
|
||||
If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [FAQ](/start/faq)).
|
||||
If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [Help](/help)).
|
||||
|
||||
## 5) Remote access options
|
||||
|
||||
|
||||
@@ -241,7 +241,6 @@ RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Lin
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts ./scripts
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
@@ -37,6 +37,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
|
||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
summary: "Zalo Personal plugin: QR login + messaging via zca-cli (plugin install + channel config + CLI + tool)"
|
||||
read_when:
|
||||
- You want Zalo Personal (unofficial) support in Clawdbot
|
||||
- You are configuring or developing the zalouser plugin
|
||||
---
|
||||
|
||||
# Zalo Personal (plugin)
|
||||
|
||||
Zalo Personal support for Clawdbot via a plugin, using `zca-cli` to automate a normal Zalo user account.
|
||||
|
||||
> **Warning:** Unofficial automation may lead to account suspension/ban. Use at your own risk.
|
||||
|
||||
## Naming
|
||||
Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration.
|
||||
|
||||
## Where it runs
|
||||
This plugin runs **inside the Gateway process**.
|
||||
|
||||
If you use a remote Gateway, install/configure it on the **machine running the Gateway**, then restart the Gateway.
|
||||
|
||||
## Install
|
||||
|
||||
### Option A: install from npm
|
||||
|
||||
```bash
|
||||
clawdbot plugins install @clawdbot/zalouser
|
||||
```
|
||||
|
||||
Restart the Gateway afterwards.
|
||||
|
||||
### Option B: install from a local folder (dev)
|
||||
|
||||
```bash
|
||||
clawdbot plugins install ./extensions/zalouser
|
||||
cd ./extensions/zalouser && pnpm install
|
||||
```
|
||||
|
||||
Restart the Gateway afterwards.
|
||||
|
||||
## Prerequisite: zca-cli
|
||||
The Gateway machine must have `zca` on `PATH`:
|
||||
|
||||
```bash
|
||||
zca --version
|
||||
```
|
||||
|
||||
## Config
|
||||
Channel config lives under `channels.zalouser` (not `plugins.entries.*`):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
clawdbot channels login --channel zalouser
|
||||
clawdbot channels logout --channel zalouser
|
||||
clawdbot channels status --probe
|
||||
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot"
|
||||
clawdbot directory peers list --channel zalouser --query "name"
|
||||
```
|
||||
|
||||
## Agent tool
|
||||
Tool name: `zalouser`
|
||||
|
||||
Actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
|
||||
@@ -27,6 +27,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
|
||||
- [OpenAI (API + Codex)](/providers/openai)
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [Z.AI](/providers/zai)
|
||||
|
||||
@@ -25,6 +25,7 @@ model as `provider/model`.
|
||||
- [OpenAI (API + Codex)](/providers/openai)
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [Synthetic](/providers/synthetic)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
summary: "Vercel AI Gateway setup (auth + model selection)"
|
||||
read_when:
|
||||
- You want to use Vercel AI Gateway with Clawdbot
|
||||
- You need the API key env var or CLI auth choice
|
||||
---
|
||||
# Vercel AI Gateway
|
||||
|
||||
|
||||
The [Vercel AI Gateway](https://vercel.com/ai-gateway) provides a unified API to access hundreds of models through a single endpoint.
|
||||
|
||||
- Provider: `vercel-ai-gateway`
|
||||
- Auth: `AI_GATEWAY_API_KEY`
|
||||
- API: Anthropic Messages compatible
|
||||
|
||||
## Quick start
|
||||
|
||||
1) Set the API key (recommended: store it for the Gateway):
|
||||
|
||||
```bash
|
||||
clawdbot onboard --auth-choice ai-gateway-api-key
|
||||
```
|
||||
|
||||
2) Set a default model:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "vercel-ai-gateway/anthropic/claude-opus-4.5" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Non-interactive example
|
||||
|
||||
```bash
|
||||
clawdbot onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice ai-gateway-api-key \
|
||||
--ai-gateway-api-key "$AI_GATEWAY_API_KEY"
|
||||
```
|
||||
|
||||
## Environment note
|
||||
|
||||
If the Gateway runs as a daemon (launchd/systemd), make sure `AI_GATEWAY_API_KEY`
|
||||
is available to that process (for example, in `~/.clawdbot/.env` or via
|
||||
`env.shellEnv`).
|
||||
+1
-1
@@ -15,7 +15,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [Wizard](/start/wizard)
|
||||
- [Setup](/start/setup)
|
||||
- [Dashboard (local Gateway)](http://127.0.0.1:18789/)
|
||||
- [FAQ](/start/faq)
|
||||
- [Help](/help)
|
||||
- [Configuration](/gateway/configuration)
|
||||
- [Configuration examples](/gateway/configuration-examples)
|
||||
- [Clawdbot assistant (Clawd)](/start/clawd)
|
||||
|
||||
@@ -146,6 +146,19 @@ Full setup walkthrough (28m) by VelvetShark.
|
||||
|
||||
14+ agents under one gateway with Opus 4.5 orchestrator delegating to Codex workers. Comprehensive [technical write-up](https://github.com/adam91holt/orchestrated-ai-articles) covering the Dream Team roster, model selection, sandboxing, webhooks, heartbeats, and delegation flows. [Clawdspace](https://github.com/adam91holt/clawdspace) for agent sandboxing. [Blog post](https://adams-ai-journey.ghost.io/2026-the-year-of-the-orchestrator/).
|
||||
</Card>
|
||||
|
||||
<Card title="Linear CLI" icon="terminal" href="https://github.com/Finesssee/linear-cli">
|
||||
**@NessZerra** • `devtools` `linear` `cli` `issues`
|
||||
|
||||
CLI for Linear that integrates with agentic workflows (Claude Code, Clawdbot). Manage issues, projects, and workflows from the terminal. First external PR merged!
|
||||
</Card>
|
||||
|
||||
<Card title="Beeper CLI" icon="message" href="https://github.com/blqke/beepcli">
|
||||
**@jules** • `messaging` `beeper` `cli` `automation`
|
||||
|
||||
Read, send, and archive messages via Beeper Desktop. Uses Beeper local MCP API so agents can manage all your chats (iMessage, WhatsApp, etc.) in one place.
|
||||
</Card>
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## 🤖 Automation & Workflows
|
||||
|
||||
+14
-1
@@ -45,7 +45,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
## What the wizard does
|
||||
|
||||
**Local mode (default)** walks you through:
|
||||
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or `claude setup-token`, plus MiniMax/GLM/Moonshot options)
|
||||
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or `claude setup-token`, plus MiniMax/GLM/Moonshot/AI Gateway options)
|
||||
- Workspace location + bootstrap files
|
||||
- Gateway settings (port/bind/auth/tailscale)
|
||||
- Providers (Telegram, WhatsApp, Discord, Signal)
|
||||
@@ -88,6 +88,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it.
|
||||
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth).
|
||||
- **API key**: stores the key for you.
|
||||
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
|
||||
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- **MiniMax M2.1**: config is auto-written.
|
||||
- More detail: [MiniMax](/providers/minimax)
|
||||
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
||||
@@ -211,6 +213,17 @@ clawdbot onboard --non-interactive \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
Vercel AI Gateway example:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice ai-gateway-api-key \
|
||||
--ai-gateway-api-key "$AI_GATEWAY_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
Moonshot example:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -26,6 +26,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
||||
{
|
||||
commands: {
|
||||
native: "auto",
|
||||
nativeSkills: "auto",
|
||||
text: true,
|
||||
bash: false,
|
||||
bashForegroundMs: 2000,
|
||||
@@ -43,6 +44,9 @@ They run immediately, are stripped before the model sees the message, and the re
|
||||
- Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support.
|
||||
- Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`).
|
||||
- `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically.
|
||||
- `commands.nativeSkills` (default `"auto"`) registers **skill** commands natively when supported.
|
||||
- Auto: on for Discord/Telegram; off for Slack (Slack requires creating a slash command per skill).
|
||||
- Set `channels.discord.commands.nativeSkills`, `channels.telegram.commands.nativeSkills`, or `channels.slack.commands.nativeSkills` to override per provider (bool or `"auto"`).
|
||||
- `commands.bash` (default `false`) enables `! <cmd>` to run host shell commands (`/bash <cmd>` is an alias; requires `tools.elevated` allowlists).
|
||||
- `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately).
|
||||
- `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`).
|
||||
@@ -90,6 +94,7 @@ Notes:
|
||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||
- `/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.
|
||||
- **Unknown slash commands:** if a command-only message starts with `/foo` and no built-in or skill command matches, Clawdbot looks for `~/.codex/prompts/foo.*` (or `$CODEX_HOME/prompts/foo.*`) and uses that file as the request body (front matter is stripped). Arguments are available as `$1`, `$2`, and `$*`.
|
||||
- **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.
|
||||
|
||||
@@ -43,6 +43,15 @@ Auto-archive:
|
||||
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
|
||||
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
|
||||
|
||||
## Authentication
|
||||
|
||||
Sub-agent auth is resolved by **agent id**, not by session type:
|
||||
- The sub-agent session key is `agent:<agentId>:subagent:<uuid>`.
|
||||
- The auth store is loaded from that agent’s `agentDir`.
|
||||
- The main agent’s auth profiles are merged in as a **fallback**; agent profiles override main profiles on conflicts.
|
||||
|
||||
Note: the merge is additive, so main profiles are always available as fallbacks. Fully isolated auth per agent is not supported yet.
|
||||
|
||||
## Announce
|
||||
|
||||
Sub-agents report back via an announce step:
|
||||
@@ -99,6 +108,10 @@ Sub-agents use a dedicated in-process queue lane:
|
||||
- Lane name: `subagent`
|
||||
- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `1`)
|
||||
|
||||
## Stopping
|
||||
|
||||
- Sending `/stop` in the requester chat aborts the requester session and stops any active sub-agent runs spawned from it.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost.
|
||||
|
||||
@@ -14,6 +14,8 @@ read_when:
|
||||
- high → “ultrathink” (max budget)
|
||||
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
|
||||
- `highest`, `max` map to `high`.
|
||||
- Provider notes:
|
||||
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
|
||||
|
||||
## Resolution order
|
||||
1. Inline directive on the message (applies only to that message).
|
||||
|
||||
+27
-2
@@ -73,6 +73,29 @@ Search the web with Brave’s API.
|
||||
|
||||
- `query` (required)
|
||||
- `count` (1–10; default from config)
|
||||
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
|
||||
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
|
||||
- `ui_lang` (optional): ISO language code for UI elements
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
// German-specific search
|
||||
await web_search({
|
||||
query: "TV online schauen",
|
||||
count: 10,
|
||||
country: "DE",
|
||||
search_lang: "de"
|
||||
});
|
||||
|
||||
// French search with French UI
|
||||
await web_search({
|
||||
query: "actualités",
|
||||
country: "FR",
|
||||
search_lang: "fr",
|
||||
ui_lang: "fr"
|
||||
});
|
||||
```
|
||||
|
||||
## web_fetch
|
||||
|
||||
@@ -93,7 +116,8 @@ Fetch a URL and extract readable content.
|
||||
maxChars: 50000,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
userAgent: "clawdbot/2026.1.15"
|
||||
userAgent: "clawdbot/2026.1.15",
|
||||
readability: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +131,8 @@ Fetch a URL and extract readable content.
|
||||
- `maxChars` (truncate long pages)
|
||||
|
||||
Notes:
|
||||
- `web_fetch` uses Readability (main-content extraction) by default and falls back to basic HTML cleanup if it fails.
|
||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||
- If you use tool profiles/allowlists, add `web_search`/`web_fetch` or `group:web`.
|
||||
- If the Brave key is missing, `web_search` returns a short setup hint with a docs link.
|
||||
- If the Brave key is missing, `web_search` returns a short setup hint with a docs link.
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
|
||||
describe("matrix directory", () => {
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
|
||||
rooms: {
|
||||
"!room1:example.org": { users: ["@carol:example.org"] },
|
||||
"#alias:example.org": { users: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.directory).toBeTruthy();
|
||||
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
|
||||
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
matrixPlugin.directory!.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined }),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "user:@alice:example.org" },
|
||||
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
|
||||
{ kind: "user", id: "user:@carol:example.org" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
matrixPlugin.directory!.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined }),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "group", id: "room:!room1:example.org" },
|
||||
{ kind: "group", id: "#alias:example.org" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -162,6 +162,67 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of account.config.dm?.allowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") continue;
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
|
||||
for (const room of Object.values(account.config.rooms ?? {})) {
|
||||
for (const entry of room.users ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") continue;
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const lowered = raw.toLowerCase();
|
||||
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
|
||||
if (cleaned.startsWith("@")) return `user:${cleaned}`;
|
||||
return cleaned;
|
||||
})
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => {
|
||||
const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
|
||||
const incomplete = !raw.startsWith("@") || !raw.includes(":");
|
||||
return {
|
||||
kind: "user",
|
||||
id,
|
||||
...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = Object.keys(account.config.rooms ?? {})
|
||||
.map((raw) => raw.trim())
|
||||
.filter((raw) => Boolean(raw) && raw !== "*")
|
||||
.map((raw) => raw.replace(/^matrix:/i, ""))
|
||||
.map((raw) => {
|
||||
const lowered = raw.toLowerCase();
|
||||
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) return raw;
|
||||
if (raw.startsWith("!")) return `room:${raw}`;
|
||||
return raw;
|
||||
})
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return ids;
|
||||
},
|
||||
},
|
||||
actions: matrixMessageActions,
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixConfig } from "./client.js";
|
||||
|
||||
describe("resolveMatrixConfig", () => {
|
||||
it("prefers config over env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
expect(resolved).toEqual({
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env when config is missing", () => {
|
||||
const cfg = {} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
expect(resolved.homeserver).toBe("https://env.example.org");
|
||||
expect(resolved.userId).toBe("@env:example.org");
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -50,13 +50,13 @@ export function resolveMatrixConfig(
|
||||
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 homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
|
||||
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
|
||||
const accessToken =
|
||||
clean(env.MATRIX_ACCESS_TOKEN) || clean(matrix.accessToken) || undefined;
|
||||
const password = clean(env.MATRIX_PASSWORD) || clean(matrix.password) || undefined;
|
||||
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
|
||||
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
|
||||
const deviceName =
|
||||
clean(env.MATRIX_DEVICE_NAME) || clean(matrix.deviceName) || undefined;
|
||||
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
|
||||
const initialSyncLimit =
|
||||
typeof matrix.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||
|
||||
import { msteamsPlugin } from "./channel.js";
|
||||
|
||||
describe("msteams directory", () => {
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
msteams: {
|
||||
allowFrom: ["alice", "user:Bob"],
|
||||
dms: { carol: {}, bob: {} },
|
||||
teams: {
|
||||
team1: {
|
||||
channels: {
|
||||
"conversation:chan1": {},
|
||||
chan2: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ClawdbotConfig;
|
||||
|
||||
expect(msteamsPlugin.directory).toBeTruthy();
|
||||
expect(msteamsPlugin.directory?.listPeers).toBeTruthy();
|
||||
expect(msteamsPlugin.directory?.listGroups).toBeTruthy();
|
||||
|
||||
await expect(msteamsPlugin.directory!.listPeers({ cfg, query: undefined, limit: undefined })).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "user:alice" },
|
||||
{ kind: "user", id: "user:Bob" },
|
||||
{ kind: "user", id: "user:carol" },
|
||||
{ kind: "user", id: "user:bob" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(msteamsPlugin.directory!.listGroups({ cfg, query: undefined, limit: undefined })).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "group", id: "conversation:chan1" },
|
||||
{ kind: "group", id: "conversation:chan2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,21 @@ const meta = {
|
||||
order: 60,
|
||||
} as const;
|
||||
|
||||
function normalizeMSTeamsMessagingTarget(raw: string): string | undefined {
|
||||
let trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (/^(msteams|teams):/i.test(trimmed)) {
|
||||
trimmed = trimmed.replace(/^(msteams|teams):/i, "");
|
||||
}
|
||||
if (/^conversation:/i.test(trimmed)) {
|
||||
return `conversation:${trimmed.slice("conversation:".length).trim()}`;
|
||||
}
|
||||
if (/^user:/i.test(trimmed)) {
|
||||
return `user:${trimmed.slice("user:".length).trim()}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
id: "msteams",
|
||||
meta: {
|
||||
@@ -113,6 +128,55 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
},
|
||||
}),
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMSTeamsMessagingTarget,
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, query, limit }) => {
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
|
||||
const trimmed = String(entry).trim();
|
||||
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
||||
}
|
||||
for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
|
||||
const trimmed = userId.trim();
|
||||
if (trimmed) ids.add(trimmed);
|
||||
}
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
|
||||
.map((raw) => {
|
||||
const lowered = raw.toLowerCase();
|
||||
if (lowered.startsWith("user:")) return raw;
|
||||
if (lowered.startsWith("conversation:")) return raw;
|
||||
return `user:${raw}`;
|
||||
})
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
},
|
||||
listGroups: async ({ cfg, query, limit }) => {
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
|
||||
for (const channelId of Object.keys(team.channels ?? {})) {
|
||||
const trimmed = channelId.trim();
|
||||
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => raw.replace(/^conversation:/i, "").trim())
|
||||
.map((id) => `conversation:${id}`)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
listActions: ({ cfg }) => {
|
||||
const enabled =
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
} from "../../../../src/auto-reply/inbound-debounce.js";
|
||||
import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js";
|
||||
import {
|
||||
buildHistoryContextFromMap,
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntries,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
recordPendingHistoryEntry,
|
||||
type HistoryEntry,
|
||||
} from "../../../../src/auto-reply/reply/history.js";
|
||||
import { resolveMentionGating } from "../../../../src/channels/mention-gating.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
|
||||
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
|
||||
import {
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
} from "../policy.js";
|
||||
import { extractMSTeamsPollVote } from "../polls.js";
|
||||
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
|
||||
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
|
||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||
|
||||
@@ -74,6 +77,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
text: string;
|
||||
attachments: MSTeamsAttachmentLike[];
|
||||
wasMentioned: boolean;
|
||||
implicitMention: boolean;
|
||||
};
|
||||
|
||||
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
|
||||
@@ -299,21 +303,40 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
teamConfig,
|
||||
channelConfig,
|
||||
});
|
||||
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
|
||||
|
||||
if (!isDirectMessage) {
|
||||
const mentioned = params.wasMentioned;
|
||||
if (requireMention && !mentioned) {
|
||||
const mentionGate = resolveMentionGating({
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention: true,
|
||||
wasMentioned: params.wasMentioned,
|
||||
implicitMention: params.implicitMention,
|
||||
shouldBypassMention: false,
|
||||
});
|
||||
const mentioned = mentionGate.effectiveWasMentioned;
|
||||
if (requireMention && mentionGate.shouldSkip) {
|
||||
log.debug("skipping message (mention required)", {
|
||||
teamId,
|
||||
channelId,
|
||||
requireMention,
|
||||
mentioned,
|
||||
});
|
||||
if (historyLimit > 0) {
|
||||
recordPendingHistoryEntry({
|
||||
historyMap: conversationHistories,
|
||||
historyKey: conversationId,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: senderName,
|
||||
body: rawBody,
|
||||
timestamp: timestamp?.getTime(),
|
||||
messageId: activity.id ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
|
||||
const mediaList = await resolveMSTeamsInboundMedia({
|
||||
attachments,
|
||||
htmlSummary: htmlSummary ?? undefined,
|
||||
@@ -342,16 +365,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
const isRoomish = !isDirectMessage;
|
||||
const historyKey = isRoomish ? conversationId : undefined;
|
||||
if (isRoomish && historyKey && historyLimit > 0) {
|
||||
combinedBody = buildHistoryContextFromMap({
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: conversationHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: senderName,
|
||||
body: rawBody,
|
||||
timestamp: timestamp?.getTime(),
|
||||
messageId: activity.id ?? undefined,
|
||||
},
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatAgentEnvelope({
|
||||
@@ -379,7 +396,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
Surface: "msteams" as const,
|
||||
MessageSid: activity.id,
|
||||
Timestamp: timestamp?.getTime() ?? Date.now(),
|
||||
WasMentioned: isDirectMessage || params.wasMentioned,
|
||||
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
|
||||
CommandAuthorized: true,
|
||||
OriginatingChannel: "msteams" as const,
|
||||
OriginatingTo: teamsTo,
|
||||
@@ -401,6 +418,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
context,
|
||||
replyStyle,
|
||||
textLimit,
|
||||
onSentMessageIds: (ids) => {
|
||||
for (const id of ids) {
|
||||
recordMSTeamsSentMessage(conversationId, id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||
@@ -417,7 +439,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
const didSendReply = counts.final + counts.tool + counts.block > 0;
|
||||
if (!queuedFinal) {
|
||||
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
|
||||
if (isRoomish && historyKey && historyLimit > 0) {
|
||||
clearHistoryEntries({
|
||||
historyMap: conversationHistories,
|
||||
historyKey,
|
||||
@@ -431,7 +453,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||
);
|
||||
}
|
||||
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
|
||||
if (isRoomish && historyKey && historyLimit > 0) {
|
||||
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -480,12 +502,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const wasMentioned = entries.some((entry) => entry.wasMentioned);
|
||||
const implicitMention = entries.some((entry) => entry.implicitMention);
|
||||
await handleTeamsMessageNow({
|
||||
context: last.context,
|
||||
rawText: combinedRawText,
|
||||
text: combinedText,
|
||||
attachments: [],
|
||||
wasMentioned,
|
||||
implicitMention,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -501,7 +525,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
|
||||
: [];
|
||||
const wasMentioned = wasMSTeamsBotMentioned(activity);
|
||||
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
|
||||
const replyToId = activity.replyToId ?? undefined;
|
||||
const implicitMention = Boolean(
|
||||
conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId),
|
||||
);
|
||||
|
||||
await inboundDebouncer.enqueue({ context, rawText, text, attachments, wasMentioned });
|
||||
await inboundDebouncer.enqueue({
|
||||
context,
|
||||
rawText,
|
||||
text,
|
||||
attachments,
|
||||
wasMentioned,
|
||||
implicitMention,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
context: MSTeamsTurnContext;
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
textLimit: number;
|
||||
onSentMessageIds?: (ids: string[]) => void;
|
||||
}) {
|
||||
const sendTypingIndicator = async () => {
|
||||
try {
|
||||
@@ -46,7 +47,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
chunkText: true,
|
||||
mediaMode: "split",
|
||||
});
|
||||
await sendMSTeamsMessages({
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: params.replyStyle,
|
||||
adapter: params.adapter,
|
||||
appId: params.appId,
|
||||
@@ -62,6 +63,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
});
|
||||
},
|
||||
});
|
||||
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
||||
},
|
||||
onError: (err, info) => {
|
||||
const errMsg = formatUnknownError(err);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
clearMSTeamsSentMessageCache,
|
||||
recordMSTeamsSentMessage,
|
||||
wasMSTeamsMessageSent,
|
||||
} from "./sent-message-cache.js";
|
||||
|
||||
describe("msteams sent message cache", () => {
|
||||
it("records and resolves sent message ids", () => {
|
||||
clearMSTeamsSentMessageCache();
|
||||
recordMSTeamsSentMessage("conv-1", "msg-1");
|
||||
expect(wasMSTeamsMessageSent("conv-1", "msg-1")).toBe(true);
|
||||
expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
type CacheEntry = {
|
||||
messageIds: Set<string>;
|
||||
timestamps: Map<string, number>;
|
||||
};
|
||||
|
||||
const sentMessages = new Map<string, CacheEntry>();
|
||||
|
||||
function cleanupExpired(entry: CacheEntry): void {
|
||||
const now = Date.now();
|
||||
for (const [msgId, timestamp] of entry.timestamps) {
|
||||
if (now - timestamp > TTL_MS) {
|
||||
entry.messageIds.delete(msgId);
|
||||
entry.timestamps.delete(msgId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void {
|
||||
if (!conversationId || !messageId) return;
|
||||
let entry = sentMessages.get(conversationId);
|
||||
if (!entry) {
|
||||
entry = { messageIds: new Set(), timestamps: new Map() };
|
||||
sentMessages.set(conversationId, entry);
|
||||
}
|
||||
entry.messageIds.add(messageId);
|
||||
entry.timestamps.set(messageId, Date.now());
|
||||
if (entry.messageIds.size > 200) cleanupExpired(entry);
|
||||
}
|
||||
|
||||
export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean {
|
||||
const entry = sentMessages.get(conversationId);
|
||||
if (!entry) return false;
|
||||
cleanupExpired(entry);
|
||||
return entry.messageIds.has(messageId);
|
||||
}
|
||||
|
||||
export function clearMSTeamsSentMessageCache(): void {
|
||||
sentMessages.clear();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
describe("zalo directory", () => {
|
||||
it("lists peers from allowFrom", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalo: {
|
||||
allowFrom: ["zalo:123", "zl:234", "345"],
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(zaloPlugin.directory).toBeTruthy();
|
||||
expect(zaloPlugin.directory?.listPeers).toBeTruthy();
|
||||
expect(zaloPlugin.directory?.listGroups).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
zaloPlugin.directory!.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined }),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "123" },
|
||||
{ kind: "user", id: "234" },
|
||||
{ kind: "user", id: "345" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(zaloPlugin.directory!.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined })).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,26 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
messaging: {
|
||||
normalizeTarget: normalizeZaloMessagingTarget,
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveZaloAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const peers = Array.from(
|
||||
new Set(
|
||||
(account.config.allowFrom ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry) => Boolean(entry) && entry !== "*")
|
||||
.map((entry) => entry.replace(/^(zalo|zl):/i, "")),
|
||||
),
|
||||
)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
return peers;
|
||||
},
|
||||
listGroups: async () => [],
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# @clawdbot/zalouser
|
||||
|
||||
Clawdbot extension for Zalo Personal Account messaging via [zca-cli](https://zca-cli.dev).
|
||||
|
||||
> **Warning:** Using Zalo automation may result in account suspension or ban. Use at your own risk. This is an unofficial integration.
|
||||
|
||||
## Features
|
||||
|
||||
- **Channel Plugin Integration**: Appears in onboarding wizard with QR login
|
||||
- **Gateway Integration**: Real-time message listening via the gateway
|
||||
- **Multi-Account Support**: Manage multiple Zalo personal accounts
|
||||
- **CLI Commands**: Full command-line interface for messaging
|
||||
- **Agent Tool**: AI agent integration for automated messaging
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install `zca` CLI and ensure it's in your PATH:
|
||||
|
||||
|
||||
**macOS / Linux:**
|
||||
```bash
|
||||
curl -fsSL https://get.zca-cli.dev/install.sh | bash
|
||||
|
||||
# Or with custom install directory
|
||||
ZCA_INSTALL_DIR=~/.local/bin curl -fsSL https://get.zca-cli.dev/install.sh | bash
|
||||
|
||||
# Install specific version
|
||||
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s v1.0.0
|
||||
|
||||
# Uninstall
|
||||
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s uninstall
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
irm https://get.zca-cli.dev/install.ps1 | iex
|
||||
|
||||
# Or with custom install directory
|
||||
$env:ZCA_INSTALL_DIR = "C:\Tools\zca"; irm https://get.zca-cli.dev/install.ps1 | iex
|
||||
|
||||
# Install specific version
|
||||
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Version v1.0.0"
|
||||
|
||||
# Uninstall
|
||||
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Uninstall"
|
||||
```
|
||||
|
||||
### Manual Download
|
||||
|
||||
Download binary directly:
|
||||
|
||||
**macOS / Linux:**
|
||||
```bash
|
||||
curl -fsSL https://get.zca-cli.dev/latest/zca-darwin-arm64 -o zca && chmod +x zca
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri https://get.zca-cli.dev/latest/zca-windows-x64.exe -OutFile zca.exe
|
||||
```
|
||||
|
||||
Available binaries:
|
||||
- `zca-darwin-arm64` - macOS Apple Silicon
|
||||
- `zca-darwin-x64` - macOS Intel
|
||||
- `zca-linux-arm64` - Linux ARM64
|
||||
- `zca-linux-x64` - Linux x86_64
|
||||
- `zca-windows-x64.exe` - Windows
|
||||
|
||||
See [zca-cli](https://zca-cli.dev) for manual download (binaries for macOS/Linux/Windows) or building from source.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Onboarding Wizard (Recommended)
|
||||
|
||||
```bash
|
||||
clawdbot onboard
|
||||
# Select "Zalo Personal" from channel list
|
||||
# Follow QR code login flow
|
||||
```
|
||||
|
||||
### Option 2: Login (QR, on the Gateway machine)
|
||||
|
||||
```bash
|
||||
clawdbot channels login --channel zalouser
|
||||
# Scan QR code with Zalo app
|
||||
```
|
||||
|
||||
### Send a Message
|
||||
|
||||
```bash
|
||||
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot!"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
After onboarding, your config will include:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
dmPolicy: pairing # pairing | allowlist | open | disabled
|
||||
```
|
||||
|
||||
For multi-account:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
defaultAccount: default
|
||||
accounts:
|
||||
default:
|
||||
enabled: true
|
||||
profile: default
|
||||
work:
|
||||
enabled: true
|
||||
profile: work
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
clawdbot channels login --channel zalouser # Login via QR
|
||||
clawdbot channels login --channel zalouser --account work
|
||||
clawdbot channels status --probe
|
||||
clawdbot channels logout --channel zalouser
|
||||
```
|
||||
|
||||
### Directory (IDs, contacts, groups)
|
||||
|
||||
```bash
|
||||
clawdbot directory self --channel zalouser
|
||||
clawdbot directory peers list --channel zalouser --query "name"
|
||||
clawdbot directory groups list --channel zalouser --query "work"
|
||||
clawdbot directory groups members --channel zalouser --group-id <id>
|
||||
```
|
||||
|
||||
### Account Management
|
||||
|
||||
```bash
|
||||
zca account list # List all profiles
|
||||
zca account current # Show active profile
|
||||
zca account switch <profile>
|
||||
zca account remove <profile>
|
||||
zca account label <profile> "Work Account"
|
||||
```
|
||||
|
||||
### Messaging
|
||||
|
||||
```bash
|
||||
# Text
|
||||
clawdbot message send --channel zalouser --to <threadId> --message "message"
|
||||
|
||||
# Media (URL)
|
||||
clawdbot message send --channel zalouser --to <threadId> --message "caption" --media-url "https://example.com/img.jpg"
|
||||
```
|
||||
|
||||
### Listener
|
||||
|
||||
The listener runs inside the Gateway when the channel is enabled. For debugging,
|
||||
use `clawdbot channels logs --channel zalouser` or run `zca listen` directly.
|
||||
|
||||
### Data Access
|
||||
|
||||
```bash
|
||||
# Friends
|
||||
zca friend list
|
||||
zca friend list -j # JSON output
|
||||
zca friend find "name"
|
||||
zca friend online
|
||||
|
||||
# Groups
|
||||
zca group list
|
||||
zca group info <groupId>
|
||||
zca group members <groupId>
|
||||
|
||||
# Profile
|
||||
zca me info
|
||||
zca me id
|
||||
```
|
||||
|
||||
## Multi-Account Support
|
||||
|
||||
Use `--profile` or `-p` to work with multiple accounts:
|
||||
|
||||
```bash
|
||||
clawdbot channels login --channel zalouser --account work
|
||||
clawdbot message send --channel zalouser --account work --to <id> --message "Hello"
|
||||
ZCA_PROFILE=work zca listen
|
||||
```
|
||||
|
||||
Profile resolution order: `--profile` flag > `ZCA_PROFILE` env > default
|
||||
|
||||
## Agent Tool
|
||||
|
||||
The extension registers a `zalouser` tool for AI agents:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "send",
|
||||
"threadId": "123456",
|
||||
"message": "Hello from AI!",
|
||||
"isGroup": false,
|
||||
"profile": "default"
|
||||
}
|
||||
```
|
||||
|
||||
Available actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Login Issues:** Run `zca auth logout` then `zca auth login`
|
||||
- **API Errors:** Try `zca auth cache-refresh` or re-login
|
||||
- **File Uploads:** Check size (max 100MB) and path accessibility
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [zca-cli](https://zca-cli.dev) which uses [zca-js](https://github.com/RFS-ADRENO/zca-js).
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
import { zalouserPlugin } from "./src/channel.js";
|
||||
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
|
||||
|
||||
const plugin = {
|
||||
id: "zalouser",
|
||||
name: "Zalo Personal",
|
||||
description: "Zalo personal account messaging via zca-cli",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
// Register channel plugin (for onboarding & gateway)
|
||||
api.registerChannel(zalouserPlugin);
|
||||
|
||||
// Register agent tool
|
||||
api.registerTool({
|
||||
name: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
description:
|
||||
"Send messages and access data via Zalo personal account. " +
|
||||
"Actions: send (text message), image (send image URL), link (send link), " +
|
||||
"friends (list/search friends), groups (list groups), me (profile info), status (auth check).",
|
||||
parameters: ZalouserToolSchema,
|
||||
execute: executeZalouserTool,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/zalouser",
|
||||
"version": "2026.1.15",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { runZca, parseJsonOutput } from "./zca.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type CoreConfig,
|
||||
type ResolvedZalouserAccount,
|
||||
type ZalouserAccountConfig,
|
||||
type ZalouserConfig,
|
||||
} from "./types.js";
|
||||
|
||||
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
||||
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listZalouserAccountIds(cfg: CoreConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||
return ids.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultZalouserAccountId(cfg: CoreConfig): string {
|
||||
const zalouserConfig = cfg.channels?.zalouser as ZalouserConfig | undefined;
|
||||
if (zalouserConfig?.defaultAccount?.trim()) return zalouserConfig.defaultAccount.trim();
|
||||
const ids = listZalouserAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function normalizeAccountId(accountId?: string | null): string {
|
||||
const trimmed = accountId?.trim();
|
||||
if (!trimmed) return DEFAULT_ACCOUNT_ID;
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
): ZalouserAccountConfig | undefined {
|
||||
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as ZalouserAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeZalouserAccountConfig(cfg: CoreConfig, accountId: string): ZalouserAccountConfig {
|
||||
const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig;
|
||||
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
function resolveZcaProfile(config: ZalouserAccountConfig, accountId: string): string {
|
||||
if (config.profile?.trim()) return config.profile.trim();
|
||||
if (process.env.ZCA_PROFILE?.trim()) return process.env.ZCA_PROFILE.trim();
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID) return accountId;
|
||||
return "default";
|
||||
}
|
||||
|
||||
export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
|
||||
const result = await runZca(["auth", "status"], { profile, timeout: 5000 });
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
export async function resolveZalouserAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): Promise<ResolvedZalouserAccount> {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const profile = resolveZcaProfile(merged, accountId);
|
||||
const authenticated = await checkZcaAuthenticated(profile);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: merged.name?.trim() || undefined,
|
||||
enabled,
|
||||
profile,
|
||||
authenticated,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveZalouserAccountSync(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedZalouserAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const profile = resolveZcaProfile(merged, accountId);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: merged.name?.trim() || undefined,
|
||||
enabled,
|
||||
profile,
|
||||
authenticated: false, // unknown without async check
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEnabledZalouserAccounts(cfg: CoreConfig): Promise<ResolvedZalouserAccount[]> {
|
||||
const ids = listZalouserAccountIds(cfg);
|
||||
const accounts = await Promise.all(
|
||||
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId }))
|
||||
);
|
||||
return accounts.filter((account) => account.enabled);
|
||||
}
|
||||
|
||||
export async function getZcaUserInfo(profile: string): Promise<{ userId?: string; displayName?: string } | null> {
|
||||
const result = await runZca(["me", "info", "-j"], { profile, timeout: 10000 });
|
||||
if (!result.ok) return null;
|
||||
return parseJsonOutput<{ userId?: string; displayName?: string }>(result.stdout);
|
||||
}
|
||||
|
||||
export type { ResolvedZalouserAccount } from "./types.js";
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
|
||||
describe("zalouser outbound chunker", () => {
|
||||
it("chunks without empty strings and respects limit", () => {
|
||||
const chunker = zalouserPlugin.outbound?.chunker;
|
||||
expect(chunker).toBeTypeOf("function");
|
||||
if (!chunker) return;
|
||||
|
||||
const limit = 10;
|
||||
const chunks = chunker("hello world\nthis is a test", limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((c) => c.length > 0)).toBe(true);
|
||||
expect(chunks.every((c) => c.length <= limit)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
} from "../../../src/channels/plugins/types.core.js";
|
||||
|
||||
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
getZcaUserInfo,
|
||||
checkZcaAuthenticated,
|
||||
type ResolvedZalouserAccount,
|
||||
} from "./accounts.js";
|
||||
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type CoreConfig,
|
||||
type ZalouserConfig,
|
||||
type ZcaFriend,
|
||||
type ZcaGroup,
|
||||
type ZcaUserInfo,
|
||||
} from "./types.js";
|
||||
|
||||
const meta = {
|
||||
id: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
selectionLabel: "Zalo (Personal Account)",
|
||||
docsPath: "/channels/zalouser",
|
||||
docsLabel: "zalouser",
|
||||
blurb: "Zalo personal account via QR code login.",
|
||||
aliases: ["zlu"],
|
||||
order: 85,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
function resolveZalouserQrProfile(accountId?: string | null): string {
|
||||
const normalized = String(accountId ?? "").trim();
|
||||
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
|
||||
return process.env.ZCA_PROFILE?.trim() || "default";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapUser(params: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
raw?: unknown;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "user",
|
||||
id: params.id,
|
||||
name: params.name ?? undefined,
|
||||
avatarUrl: params.avatarUrl ?? undefined,
|
||||
raw: params.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGroup(params: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
raw?: unknown;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "group",
|
||||
id: params.id,
|
||||
name: params.name ?? undefined,
|
||||
raw: params.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function deleteAccountFromConfigSection(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
}): CoreConfig {
|
||||
const { cfg, accountId } = params;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const { zalouser: _removed, ...restChannels } = cfg.channels ?? {};
|
||||
return { ...cfg, channels: restChannels };
|
||||
}
|
||||
const accounts = { ...(cfg.channels?.zalouser?.accounts ?? {}) };
|
||||
delete accounts[accountId];
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setAccountEnabledInConfigSection(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
}): CoreConfig {
|
||||
const { cfg, accountId, enabled } = params;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
id: "zalouser",
|
||||
meta,
|
||||
onboarding: zalouserOnboardingAdapter,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: true,
|
||||
threads: false,
|
||||
polls: false,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.zalouser"] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => listZalouserAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as CoreConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId,
|
||||
enabled,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId,
|
||||
}),
|
||||
isConfigured: async (account) => {
|
||||
// Check if zca auth status is OK for this profile
|
||||
const result = await runZca(["auth", "status"], {
|
||||
profile: account.profile,
|
||||
timeout: 5000,
|
||||
});
|
||||
return result.ok;
|
||||
},
|
||||
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: undefined,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(zalouser|zlu):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
(cfg as CoreConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const basePath = useAccountPath
|
||||
? `channels.zalouser.accounts.${resolvedAccountId}.`
|
||||
: "channels.zalouser.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: formatPairingApproveHint("zalouser"),
|
||||
normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
|
||||
};
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (raw) => {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/^(zalouser|zlu):/i, "");
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async ({ cfg, accountId, runtime }) => {
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await runZca(["me", "info", "-j"], { profile: account.profile, timeout: 10000 });
|
||||
if (!result.ok) {
|
||||
runtime.error(result.stderr || "Failed to fetch profile");
|
||||
return null;
|
||||
}
|
||||
const parsed = parseJsonOutput<ZcaUserInfo>(result.stdout);
|
||||
if (!parsed?.userId) return null;
|
||||
return mapUser({
|
||||
id: String(parsed.userId),
|
||||
name: parsed.displayName ?? null,
|
||||
avatarUrl: parsed.avatar ?? null,
|
||||
raw: parsed,
|
||||
});
|
||||
},
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const args = query?.trim()
|
||||
? ["friend", "find", query.trim()]
|
||||
: ["friend", "list", "-j"];
|
||||
const result = await runZca(args, { profile: account.profile, timeout: 15000 });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to list peers");
|
||||
}
|
||||
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
|
||||
const rows = Array.isArray(parsed)
|
||||
? parsed.map((f) =>
|
||||
mapUser({
|
||||
id: String(f.userId),
|
||||
name: f.displayName ?? null,
|
||||
avatarUrl: f.avatar ?? null,
|
||||
raw: f,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to list groups");
|
||||
}
|
||||
const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout);
|
||||
let rows = Array.isArray(parsed)
|
||||
? parsed.map((g) =>
|
||||
mapGroup({
|
||||
id: String(g.groupId),
|
||||
name: g.name ?? null,
|
||||
raw: g,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
const q = query?.trim().toLowerCase();
|
||||
if (q) {
|
||||
rows = rows.filter((g) => (g.name ?? "").toLowerCase().includes(q) || g.id.includes(q));
|
||||
}
|
||||
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
},
|
||||
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await runZca(["group", "members", groupId, "-j"], {
|
||||
profile: account.profile,
|
||||
timeout: 20000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to list group members");
|
||||
}
|
||||
const parsed = parseJsonOutput<Array<Partial<ZcaFriend> & { userId?: string | number }>>(result.stdout);
|
||||
const rows = Array.isArray(parsed)
|
||||
? parsed
|
||||
.map((m) => {
|
||||
const id = m.userId ?? (m as { id?: string | number }).id;
|
||||
if (!id) return null;
|
||||
return mapUser({
|
||||
id: String(id),
|
||||
name: (m as { displayName?: string }).displayName ?? null,
|
||||
avatarUrl: (m as { avatar?: string }).avatar ?? null,
|
||||
raw: m,
|
||||
});
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const sliced = typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
return sliced as ChannelDirectoryEntry[];
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "zalouserUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig });
|
||||
const authenticated = await checkZcaAuthenticated(account.profile);
|
||||
if (!authenticated) throw new Error("Zalouser not authenticated");
|
||||
await sendMessageZalouser(id, "Your pairing request has been approved.", {
|
||||
profile: account.profile,
|
||||
});
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
login: async ({ cfg, accountId, runtime }) => {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
"Missing dependency: `zca` not found in PATH. See docs.clawd.bot/channels/zalouser",
|
||||
);
|
||||
}
|
||||
runtime.log(
|
||||
`Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`,
|
||||
);
|
||||
const result = await runZcaInteractive(["auth", "login"], { profile: account.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Zalouser login failed");
|
||||
}
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => {
|
||||
if (!text) return [];
|
||||
if (limit <= 0 || text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
const window = remaining.slice(0, limit);
|
||||
const lastNewline = window.lastIndexOf("\n");
|
||||
const lastSpace = window.lastIndexOf(" ");
|
||||
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
|
||||
if (breakIdx <= 0) breakIdx = limit;
|
||||
const rawChunk = remaining.slice(0, breakIdx);
|
||||
const chunk = rawChunk.trimEnd();
|
||||
if (chunk.length > 0) chunks.push(chunk);
|
||||
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
||||
remaining = remaining.slice(nextStart).trimStart();
|
||||
}
|
||||
if (remaining.length) chunks.push(remaining);
|
||||
return chunks;
|
||||
},
|
||||
textChunkLimit: 2000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error("Delivering to Zalouser requires --to <threadId>"),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, cfg }) => {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await sendMessageZalouser(to, text, { profile: account.profile });
|
||||
return {
|
||||
channel: "zalouser",
|
||||
ok: result.ok,
|
||||
messageId: result.messageId ?? "",
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await sendMessageZalouser(to, text, {
|
||||
profile: account.profile,
|
||||
mediaUrl,
|
||||
});
|
||||
return {
|
||||
channel: "zalouser",
|
||||
ok: result.ok,
|
||||
messageId: result.messageId ?? "",
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
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 }) => {
|
||||
const result = await runZca(["me", "info", "-j"], {
|
||||
profile: account.profile,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.stderr };
|
||||
}
|
||||
try {
|
||||
return { ok: true, user: JSON.parse(result.stdout) };
|
||||
} catch {
|
||||
return { ok: false, error: "Failed to parse user info" };
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: async ({ account, runtime }) => {
|
||||
const configured = await checkZcaAuthenticated(account.profile);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: configured ? (runtime?.lastError ?? null) : "not configured",
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
let userLabel = "";
|
||||
try {
|
||||
const userInfo = await getZcaUserInfo(account.profile);
|
||||
if (userInfo?.displayName) userLabel = ` (${userInfo.displayName})`;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
user: userInfo,
|
||||
});
|
||||
} catch {
|
||||
// ignore probe errors
|
||||
}
|
||||
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
|
||||
const { monitorZalouserProvider } = await import("./monitor.js");
|
||||
return monitorZalouserProvider({
|
||||
account,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
});
|
||||
},
|
||||
loginWithQrStart: async (params) => {
|
||||
const profile = resolveZalouserQrProfile(params.accountId);
|
||||
// Start login and get QR code
|
||||
const result = await runZca(["auth", "login", "--qr-base64"], {
|
||||
profile,
|
||||
timeout: params.timeoutMs ?? 30000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { message: result.stderr || "Failed to start QR login" };
|
||||
}
|
||||
// The stdout should contain the base64 QR data URL
|
||||
const qrMatch = result.stdout.match(/data:image\/png;base64,[A-Za-z0-9+/=]+/);
|
||||
if (qrMatch) {
|
||||
return { qrDataUrl: qrMatch[0], message: "Scan QR code with Zalo app" };
|
||||
}
|
||||
return { message: result.stdout || "QR login started" };
|
||||
},
|
||||
loginWithQrWait: async (params) => {
|
||||
const profile = resolveZalouserQrProfile(params.accountId);
|
||||
// Check if already authenticated
|
||||
const statusResult = await runZca(["auth", "status"], {
|
||||
profile,
|
||||
timeout: params.timeoutMs ?? 60000,
|
||||
});
|
||||
return {
|
||||
connected: statusResult.ok,
|
||||
message: statusResult.ok ? "Login successful" : statusResult.stderr || "Login pending",
|
||||
};
|
||||
},
|
||||
logoutAccount: async (ctx) => {
|
||||
const result = await runZca(["auth", "logout"], {
|
||||
profile: ctx.account.profile,
|
||||
timeout: 10000,
|
||||
});
|
||||
return {
|
||||
cleared: result.ok,
|
||||
loggedOut: result.ok,
|
||||
message: result.ok ? "Logged out" : result.stderr,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type { ResolvedZalouserAccount };
|
||||
@@ -0,0 +1,171 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
export type CoreChannelDeps = {
|
||||
chunkMarkdownText: (text: string, limit: number) => string[];
|
||||
formatAgentEnvelope: (params: {
|
||||
channel: string;
|
||||
from: string;
|
||||
timestamp?: number;
|
||||
body: string;
|
||||
}) => string;
|
||||
dispatchReplyWithBufferedBlockDispatcher: (params: {
|
||||
ctx: unknown;
|
||||
cfg: unknown;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: unknown) => Promise<void>;
|
||||
onError?: (err: unknown, info: { kind: string }) => void;
|
||||
};
|
||||
}) => Promise<void>;
|
||||
resolveAgentRoute: (params: {
|
||||
cfg: unknown;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
peer: { kind: "dm" | "group" | "channel"; id: string };
|
||||
}) => { sessionKey: string; accountId: string };
|
||||
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
|
||||
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
|
||||
upsertChannelPairingRequest: (params: {
|
||||
channel: string;
|
||||
id: string;
|
||||
meta?: { name?: string };
|
||||
}) => Promise<{ code: string; created: boolean }>;
|
||||
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
|
||||
saveMediaBuffer: (
|
||||
buffer: Buffer,
|
||||
contentType: string | undefined,
|
||||
type: "inbound" | "outbound",
|
||||
maxBytes: number,
|
||||
) => Promise<{ path: string; contentType: string }>;
|
||||
shouldLogVerbose: () => boolean;
|
||||
};
|
||||
|
||||
let coreRootCache: string | null = null;
|
||||
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
|
||||
|
||||
function findPackageRoot(startDir: string, name: string): string | null {
|
||||
let dir = startDir;
|
||||
for (;;) {
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
try {
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const raw = fs.readFileSync(pkgPath, "utf8");
|
||||
const pkg = JSON.parse(raw) as { name?: string };
|
||||
if (pkg.name === name) return dir;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClawdbotRoot(): string {
|
||||
if (coreRootCache) return coreRootCache;
|
||||
const override = process.env.CLAWDBOT_ROOT?.trim();
|
||||
if (override) {
|
||||
coreRootCache = override;
|
||||
return override;
|
||||
}
|
||||
|
||||
const candidates = new Set<string>();
|
||||
if (process.argv[1]) {
|
||||
candidates.add(path.dirname(process.argv[1]));
|
||||
}
|
||||
candidates.add(process.cwd());
|
||||
try {
|
||||
const urlPath = fileURLToPath(import.meta.url);
|
||||
candidates.add(path.dirname(urlPath));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
for (const start of candidates) {
|
||||
const found = findPackageRoot(start, "clawdbot");
|
||||
if (found) {
|
||||
coreRootCache = found;
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
|
||||
);
|
||||
}
|
||||
|
||||
async function importCoreModule<T>(relativePath: string): Promise<T> {
|
||||
const root = resolveClawdbotRoot();
|
||||
const distPath = path.join(root, "dist", relativePath);
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
|
||||
);
|
||||
}
|
||||
return (await import(pathToFileURL(distPath).href)) as T;
|
||||
}
|
||||
|
||||
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
|
||||
if (coreDepsPromise) return coreDepsPromise;
|
||||
|
||||
coreDepsPromise = (async () => {
|
||||
const [
|
||||
chunk,
|
||||
envelope,
|
||||
dispatcher,
|
||||
routing,
|
||||
pairingMessages,
|
||||
pairingStore,
|
||||
mediaFetch,
|
||||
mediaStore,
|
||||
globals,
|
||||
] = await Promise.all([
|
||||
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
|
||||
"auto-reply/chunk.js",
|
||||
),
|
||||
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
|
||||
"auto-reply/envelope.js",
|
||||
),
|
||||
importCoreModule<{
|
||||
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
|
||||
}>("auto-reply/reply/provider-dispatcher.js"),
|
||||
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
|
||||
"routing/resolve-route.js",
|
||||
),
|
||||
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
|
||||
"pairing/pairing-messages.js",
|
||||
),
|
||||
importCoreModule<{
|
||||
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
|
||||
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
|
||||
}>("pairing/pairing-store.js"),
|
||||
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
|
||||
"media/fetch.js",
|
||||
),
|
||||
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
|
||||
"media/store.js",
|
||||
),
|
||||
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
|
||||
"globals.js",
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
chunkMarkdownText: chunk.chunkMarkdownText,
|
||||
formatAgentEnvelope: envelope.formatAgentEnvelope,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
|
||||
resolveAgentRoute: routing.resolveAgentRoute,
|
||||
buildPairingReply: pairingMessages.buildPairingReply,
|
||||
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
|
||||
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
|
||||
saveMediaBuffer: mediaStore.saveMediaBuffer,
|
||||
shouldLogVerbose: globals.shouldLogVerbose,
|
||||
};
|
||||
})();
|
||||
|
||||
return coreDepsPromise;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
|
||||
import { runZcaStreaming } from "./zca.js";
|
||||
|
||||
export type ZalouserMonitorOptions = {
|
||||
account: ResolvedZalouserAccount;
|
||||
config: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export type ZalouserMonitorResult = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
const ZALOUSER_TEXT_LIMIT = 2000;
|
||||
|
||||
function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
|
||||
if (deps.shouldLogVerbose()) {
|
||||
runtime.log(`[zalouser] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedSenderId = senderId.toLowerCase();
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
|
||||
return normalized === normalizedSenderId;
|
||||
});
|
||||
}
|
||||
|
||||
function startZcaListener(
|
||||
runtime: RuntimeEnv,
|
||||
profile: string,
|
||||
onMessage: (msg: ZcaMessage) => void,
|
||||
onError: (err: Error) => void,
|
||||
abortSignal: AbortSignal,
|
||||
): ChildProcess {
|
||||
let buffer = "";
|
||||
|
||||
const { proc, promise } = runZcaStreaming(["listen", "-r", "-k"], {
|
||||
profile,
|
||||
onData: (chunk) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as ZcaMessage;
|
||||
onMessage(parsed);
|
||||
} catch {
|
||||
// ignore non-JSON lines
|
||||
}
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString().trim();
|
||||
if (text) runtime.error(`[zalouser] zca stderr: ${text}`);
|
||||
});
|
||||
|
||||
void promise.then((result) => {
|
||||
if (!result.ok && !abortSignal.aborted) {
|
||||
onError(new Error(result.stderr || `zca listen exited with code ${result.exitCode}`));
|
||||
}
|
||||
});
|
||||
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
proc.kill("SIGTERM");
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
return proc;
|
||||
}
|
||||
|
||||
async function processMessage(
|
||||
message: ZcaMessage,
|
||||
account: ResolvedZalouserAccount,
|
||||
config: CoreConfig,
|
||||
deps: CoreChannelDeps,
|
||||
runtime: RuntimeEnv,
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||
): Promise<void> {
|
||||
const { threadId, content, timestamp, metadata } = message;
|
||||
if (!content?.trim()) return;
|
||||
|
||||
const isGroup = metadata?.isGroup ?? false;
|
||||
const senderId = metadata?.fromId ?? threadId;
|
||||
const senderName = metadata?.senderName ?? "";
|
||||
const chatId = threadId;
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(deps, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await deps.readChannelAllowFromStore("zalouser").catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await deps.upsertChannelPairingRequest({
|
||||
channel: "zalouser",
|
||||
id: senderId,
|
||||
meta: { name: senderName || undefined },
|
||||
});
|
||||
|
||||
if (created) {
|
||||
logVerbose(deps, runtime, `zalouser pairing request sender=${senderId}`);
|
||||
try {
|
||||
await sendMessageZalouser(
|
||||
chatId,
|
||||
deps.buildPairingReply({
|
||||
channel: "zalouser",
|
||||
idLine: `Your Zalo user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
{ profile: account.profile },
|
||||
);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
deps,
|
||||
runtime,
|
||||
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
deps,
|
||||
runtime,
|
||||
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
|
||||
|
||||
const route = deps.resolveAgentRoute({
|
||||
cfg: config,
|
||||
channel: "zalouser",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
// Use "group" kind to avoid dmScope=main collapsing all DMs into the main session.
|
||||
kind: peer.kind,
|
||||
id: peer.id,
|
||||
},
|
||||
});
|
||||
|
||||
const rawBody = content.trim();
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId} from ${senderName || senderId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`,
|
||||
To: `zalouser:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
Provider: "zalouser",
|
||||
Surface: "zalouser",
|
||||
MessageSid: message.msgId ?? `${timestamp}`,
|
||||
OriginatingChannel: "zalouser",
|
||||
OriginatingTo: `zalouser:${chatId}`,
|
||||
};
|
||||
|
||||
await deps.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverZalouserReply({
|
||||
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
|
||||
profile: account.profile,
|
||||
chatId,
|
||||
isGroup,
|
||||
runtime,
|
||||
deps,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error(
|
||||
`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverZalouserReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
||||
profile: string;
|
||||
chatId: string;
|
||||
isGroup: boolean;
|
||||
runtime: RuntimeEnv;
|
||||
deps: CoreChannelDeps;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { payload, profile, chatId, isGroup, runtime, deps, statusSink } = params;
|
||||
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? payload.text : undefined;
|
||||
first = false;
|
||||
try {
|
||||
logVerbose(deps, runtime, `Sending media to ${chatId}`);
|
||||
await sendMessageZalouser(chatId, caption ?? "", {
|
||||
profile,
|
||||
mediaUrl,
|
||||
isGroup,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error(`Zalouser media send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.text) {
|
||||
const chunks = deps.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
|
||||
logVerbose(deps, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error(`Zalouser message send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorZalouserProvider(
|
||||
options: ZalouserMonitorOptions,
|
||||
): Promise<ZalouserMonitorResult> {
|
||||
const { account, config, abortSignal, statusSink, runtime } = options;
|
||||
|
||||
const deps = await loadCoreChannelDeps();
|
||||
let stopped = false;
|
||||
let proc: ChildProcess | null = null;
|
||||
let restartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resolveRunning: (() => void) | null = null;
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer);
|
||||
restartTimer = null;
|
||||
}
|
||||
if (proc) {
|
||||
proc.kill("SIGTERM");
|
||||
proc = null;
|
||||
}
|
||||
resolveRunning?.();
|
||||
};
|
||||
|
||||
const startListener = () => {
|
||||
if (stopped || abortSignal.aborted) {
|
||||
resolveRunning?.();
|
||||
return;
|
||||
}
|
||||
|
||||
logVerbose(
|
||||
deps,
|
||||
runtime,
|
||||
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
|
||||
);
|
||||
|
||||
proc = startZcaListener(
|
||||
runtime,
|
||||
account.profile,
|
||||
(msg) => {
|
||||
logVerbose(deps, runtime, `[${account.accountId}] inbound message`);
|
||||
statusSink?.({ lastInboundAt: Date.now() });
|
||||
processMessage(msg, account, config, deps, runtime, statusSink).catch((err) => {
|
||||
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
|
||||
if (!stopped && !abortSignal.aborted) {
|
||||
logVerbose(deps, runtime, `[${account.accountId}] restarting listener in 5s...`);
|
||||
restartTimer = setTimeout(startListener, 5000);
|
||||
} else {
|
||||
resolveRunning?.();
|
||||
}
|
||||
},
|
||||
abortSignal,
|
||||
);
|
||||
};
|
||||
|
||||
// Create a promise that stays pending until abort or stop
|
||||
const runningPromise = new Promise<void>((resolve) => {
|
||||
resolveRunning = resolve;
|
||||
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
startListener();
|
||||
|
||||
// Wait for the running promise to resolve (on abort/stop)
|
||||
await runningPromise;
|
||||
|
||||
return { stop };
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
normalizeAccountId,
|
||||
checkZcaAuthenticated,
|
||||
} from "./accounts.js";
|
||||
import { runZcaInteractive, checkZcaInstalled } from "./zca.js";
|
||||
import { DEFAULT_ACCOUNT_ID, type CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "zalouser" as const;
|
||||
|
||||
function setZalouserDmPolicy(
|
||||
cfg: CoreConfig,
|
||||
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
||||
): CoreConfig {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? [...(cfg.channels?.zalouser?.allowFrom ?? []), "*"].filter(
|
||||
(v, i, a) => a.indexOf(v) === i,
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"Zalo Personal Account login via QR code.",
|
||||
"",
|
||||
"Prerequisites:",
|
||||
"1) Install zca-cli",
|
||||
"2) You'll scan a QR code with your Zalo app",
|
||||
"",
|
||||
"Docs: https://docs.clawd.bot/channels/zalouser",
|
||||
].join("\n"),
|
||||
"Zalo Personal Setup",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptZalouserAllowFrom(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<CoreConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Zalouser allowFrom (user id)",
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
async function promptAccountId(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
currentId: string;
|
||||
listAccountIds: (cfg: CoreConfig) => string[];
|
||||
defaultAccountId: string;
|
||||
}): Promise<string> {
|
||||
const { cfg, prompter, label, currentId, listAccountIds, defaultAccountId } = params;
|
||||
const existingIds = listAccountIds(cfg);
|
||||
const options = [
|
||||
...existingIds.map((id) => ({
|
||||
value: id,
|
||||
label: id === defaultAccountId ? `${id} (default)` : id,
|
||||
})),
|
||||
{ value: "__new__", label: "Create new account" },
|
||||
];
|
||||
|
||||
const selected = await prompter.select({
|
||||
message: `${label} account`,
|
||||
options,
|
||||
initialValue: currentId,
|
||||
});
|
||||
|
||||
if (selected === "__new__") {
|
||||
const newId = await prompter.text({
|
||||
message: "New account ID",
|
||||
placeholder: "work",
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim().toLowerCase();
|
||||
if (!raw) return "Required";
|
||||
if (!/^[a-z0-9_-]+$/.test(raw)) return "Use lowercase alphanumeric, dash, or underscore";
|
||||
if (existingIds.includes(raw)) return "Account already exists";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
return String(newId).trim().toLowerCase();
|
||||
}
|
||||
|
||||
return selected as string;
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Zalo Personal",
|
||||
channel,
|
||||
policyKey: "channels.zalouser.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.allowFrom",
|
||||
getCurrent: (cfg) => ((cfg as CoreConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
|
||||
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as CoreConfig, policy),
|
||||
};
|
||||
|
||||
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const ids = listZalouserAccountIds(cfg as CoreConfig);
|
||||
let configured = false;
|
||||
for (const accountId of ids) {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const isAuth = await checkZcaAuthenticated(account.profile);
|
||||
if (isAuth) {
|
||||
configured = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`],
|
||||
selectionHint: configured ? "recommended · logged in" : "recommended · QR login",
|
||||
quickstartScore: configured ? 1 : 15,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
|
||||
// Check zca is installed
|
||||
const zcaInstalled = await checkZcaInstalled();
|
||||
if (!zcaInstalled) {
|
||||
await prompter.note(
|
||||
[
|
||||
"The `zca` binary was not found in PATH.",
|
||||
"",
|
||||
"Install zca-cli, then re-run onboarding:",
|
||||
"Docs: https://docs.clawd.bot/channels/zalouser",
|
||||
].join("\n"),
|
||||
"Missing Dependency",
|
||||
);
|
||||
return { cfg, accountId: DEFAULT_ACCOUNT_ID };
|
||||
}
|
||||
|
||||
const zalouserOverride = accountOverrides.zalouser?.trim();
|
||||
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as CoreConfig);
|
||||
let accountId = zalouserOverride
|
||||
? normalizeAccountId(zalouserOverride)
|
||||
: defaultAccountId;
|
||||
|
||||
if (shouldPromptAccountIds && !zalouserOverride) {
|
||||
accountId = await promptAccountId({
|
||||
cfg: cfg as CoreConfig,
|
||||
prompter,
|
||||
label: "Zalo Personal",
|
||||
currentId: accountId,
|
||||
listAccountIds: listZalouserAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg as CoreConfig;
|
||||
const account = resolveZalouserAccountSync({ cfg: next, accountId });
|
||||
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
|
||||
|
||||
if (!alreadyAuthenticated) {
|
||||
await noteZalouserHelp(prompter);
|
||||
|
||||
const wantsLogin = await prompter.confirm({
|
||||
message: "Login via QR code now?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (wantsLogin) {
|
||||
await prompter.note(
|
||||
"A QR code will appear in your terminal.\nScan it with your Zalo app to login.",
|
||||
"QR Login",
|
||||
);
|
||||
|
||||
// Run interactive login
|
||||
const result = await runZcaInteractive(["auth", "login"], {
|
||||
profile: account.profile,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
await prompter.note(
|
||||
`Login failed: ${result.stderr || "Unknown error"}`,
|
||||
"Error",
|
||||
);
|
||||
} else {
|
||||
const isNowAuth = await checkZcaAuthenticated(account.profile);
|
||||
if (isNowAuth) {
|
||||
await prompter.note("Login successful!", "Success");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const keepSession = await prompter.confirm({
|
||||
message: "Zalo Personal already logged in. Keep session?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keepSession) {
|
||||
await runZcaInteractive(["auth", "logout"], { profile: account.profile });
|
||||
await runZcaInteractive(["auth", "login"], { profile: account.profile });
|
||||
}
|
||||
}
|
||||
|
||||
// Enable the channel
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalouser: {
|
||||
...next.channels?.zalouser,
|
||||
enabled: true,
|
||||
profile: account.profile !== "default" ? account.profile : undefined,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalouser: {
|
||||
...next.channels?.zalouser,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(next.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
profile: account.profile,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptZalouserAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { runZca } from "./zca.js";
|
||||
|
||||
export type ZalouserSendOptions = {
|
||||
profile?: string;
|
||||
mediaUrl?: string;
|
||||
caption?: string;
|
||||
isGroup?: boolean;
|
||||
};
|
||||
|
||||
export type ZalouserSendResult = {
|
||||
ok: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function sendMessageZalouser(
|
||||
threadId: string,
|
||||
text: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const profile = options.profile || process.env.ZCA_PROFILE || "default";
|
||||
|
||||
if (!threadId?.trim()) {
|
||||
return { ok: false, error: "No threadId provided" };
|
||||
}
|
||||
|
||||
// Handle media sending
|
||||
if (options.mediaUrl) {
|
||||
return sendMediaZalouser(threadId, options.mediaUrl, {
|
||||
...options,
|
||||
caption: text || options.caption,
|
||||
});
|
||||
}
|
||||
|
||||
// Send text message
|
||||
const args = ["msg", "send", threadId.trim(), text.slice(0, 2000)];
|
||||
if (options.isGroup) args.push("-g");
|
||||
|
||||
try {
|
||||
const result = await runZca(args, { profile });
|
||||
|
||||
if (result.ok) {
|
||||
return { ok: true, messageId: extractMessageId(result.stdout) };
|
||||
}
|
||||
|
||||
return { ok: false, error: result.stderr || "Failed to send message" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMediaZalouser(
|
||||
threadId: string,
|
||||
mediaUrl: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const profile = options.profile || process.env.ZCA_PROFILE || "default";
|
||||
|
||||
if (!threadId?.trim()) {
|
||||
return { ok: false, error: "No threadId provided" };
|
||||
}
|
||||
|
||||
if (!mediaUrl?.trim()) {
|
||||
return { ok: false, error: "No media URL provided" };
|
||||
}
|
||||
|
||||
// Determine media type from URL
|
||||
const lowerUrl = mediaUrl.toLowerCase();
|
||||
let command: string;
|
||||
if (lowerUrl.match(/\.(mp4|mov|avi|webm)$/)) {
|
||||
command = "video";
|
||||
} else if (lowerUrl.match(/\.(mp3|wav|ogg|m4a)$/)) {
|
||||
command = "voice";
|
||||
} else {
|
||||
command = "image";
|
||||
}
|
||||
|
||||
const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()];
|
||||
if (options.caption) {
|
||||
args.push("-m", options.caption.slice(0, 2000));
|
||||
}
|
||||
if (options.isGroup) args.push("-g");
|
||||
|
||||
try {
|
||||
const result = await runZca(args, { profile });
|
||||
|
||||
if (result.ok) {
|
||||
return { ok: true, messageId: extractMessageId(result.stdout) };
|
||||
}
|
||||
|
||||
return { ok: false, error: result.stderr || `Failed to send ${command}` };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendImageZalouser(
|
||||
threadId: string,
|
||||
imageUrl: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const profile = options.profile || process.env.ZCA_PROFILE || "default";
|
||||
const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()];
|
||||
if (options.caption) {
|
||||
args.push("-m", options.caption.slice(0, 2000));
|
||||
}
|
||||
if (options.isGroup) args.push("-g");
|
||||
|
||||
try {
|
||||
const result = await runZca(args, { profile });
|
||||
if (result.ok) {
|
||||
return { ok: true, messageId: extractMessageId(result.stdout) };
|
||||
}
|
||||
return { ok: false, error: result.stderr || "Failed to send image" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendLinkZalouser(
|
||||
threadId: string,
|
||||
url: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const profile = options.profile || process.env.ZCA_PROFILE || "default";
|
||||
const args = ["msg", "link", threadId.trim(), url.trim()];
|
||||
if (options.isGroup) args.push("-g");
|
||||
|
||||
try {
|
||||
const result = await runZca(args, { profile });
|
||||
if (result.ok) {
|
||||
return { ok: true, messageId: extractMessageId(result.stdout) };
|
||||
}
|
||||
return { ok: false, error: result.stderr || "Failed to send link" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessageId(stdout: string): string | undefined {
|
||||
// Try to extract message ID from output
|
||||
const match = stdout.match(/message[_\s]?id[:\s]+(\S+)/i);
|
||||
if (match) return match[1];
|
||||
// Return first word if it looks like an ID
|
||||
const firstWord = stdout.trim().split(/\s+/)[0];
|
||||
if (firstWord && /^[a-zA-Z0-9_-]+$/.test(firstWord)) {
|
||||
return firstWord;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { runZca, parseJsonOutput } from "./zca.js";
|
||||
|
||||
const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
|
||||
|
||||
function stringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { description?: string } = {},
|
||||
) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
// Tool schema - avoiding Type.Union per tool schema guardrails
|
||||
export const ZalouserToolSchema = Type.Object({
|
||||
action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }),
|
||||
threadId: Type.Optional(
|
||||
Type.String({ description: "Thread ID for messaging" }),
|
||||
),
|
||||
message: Type.Optional(Type.String({ description: "Message text" })),
|
||||
isGroup: Type.Optional(Type.Boolean({ description: "Is group chat" })),
|
||||
profile: Type.Optional(Type.String({ description: "Profile name" })),
|
||||
query: Type.Optional(Type.String({ description: "Search query" })),
|
||||
url: Type.Optional(Type.String({ description: "URL for media/link" })),
|
||||
}, { additionalProperties: false });
|
||||
|
||||
type ToolParams = {
|
||||
action: (typeof ACTIONS)[number];
|
||||
threadId?: string;
|
||||
message?: string;
|
||||
isGroup?: boolean;
|
||||
profile?: string;
|
||||
query?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type ToolResult = {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
details: unknown;
|
||||
};
|
||||
|
||||
function json(payload: unknown): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
||||
details: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeZalouserTool(
|
||||
_toolCallId: string,
|
||||
params: ToolParams,
|
||||
): Promise<ToolResult> {
|
||||
try {
|
||||
switch (params.action) {
|
||||
case "send": {
|
||||
if (!params.threadId || !params.message) {
|
||||
throw new Error("threadId and message required for send action");
|
||||
}
|
||||
const args = ["msg", "send", params.threadId, params.message];
|
||||
if (params.isGroup) args.push("-g");
|
||||
const result = await runZca(args, { profile: params.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to send message");
|
||||
}
|
||||
return json({ success: true, output: result.stdout });
|
||||
}
|
||||
|
||||
case "image": {
|
||||
if (!params.threadId) {
|
||||
throw new Error("threadId required for image action");
|
||||
}
|
||||
if (!params.url) {
|
||||
throw new Error("url required for image action");
|
||||
}
|
||||
const args = ["msg", "image", params.threadId, "-u", params.url];
|
||||
if (params.message) args.push("-m", params.message);
|
||||
if (params.isGroup) args.push("-g");
|
||||
const result = await runZca(args, { profile: params.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to send image");
|
||||
}
|
||||
return json({ success: true, output: result.stdout });
|
||||
}
|
||||
|
||||
case "link": {
|
||||
if (!params.threadId || !params.url) {
|
||||
throw new Error("threadId and url required for link action");
|
||||
}
|
||||
const args = ["msg", "link", params.threadId, params.url];
|
||||
if (params.isGroup) args.push("-g");
|
||||
const result = await runZca(args, { profile: params.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to send link");
|
||||
}
|
||||
return json({ success: true, output: result.stdout });
|
||||
}
|
||||
|
||||
case "friends": {
|
||||
const args = params.query
|
||||
? ["friend", "find", params.query]
|
||||
: ["friend", "list", "-j"];
|
||||
const result = await runZca(args, { profile: params.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to get friends");
|
||||
}
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
return json(parsed ?? { raw: result.stdout });
|
||||
}
|
||||
|
||||
case "groups": {
|
||||
const result = await runZca(["group", "list", "-j"], {
|
||||
profile: params.profile,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to get groups");
|
||||
}
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
return json(parsed ?? { raw: result.stdout });
|
||||
}
|
||||
|
||||
case "me": {
|
||||
const result = await runZca(["me", "info", "-j"], {
|
||||
profile: params.profile,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to get profile");
|
||||
}
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
return json(parsed ?? { raw: result.stdout });
|
||||
}
|
||||
|
||||
case "status": {
|
||||
const result = await runZca(["auth", "status"], {
|
||||
profile: params.profile,
|
||||
});
|
||||
return json({
|
||||
authenticated: result.ok,
|
||||
output: result.stdout || result.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown action: ${params.action}. Valid actions: send, image, link, friends, groups, me, status`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
return json({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// zca-cli wrapper types
|
||||
export type ZcaRunOptions = {
|
||||
profile?: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export type ZcaResult = {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
export type ZcaProfile = {
|
||||
name: string;
|
||||
label?: string;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
export type ZcaFriend = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZcaGroup = {
|
||||
groupId: string;
|
||||
name: string;
|
||||
memberCount?: number;
|
||||
};
|
||||
|
||||
export type ZcaMessage = {
|
||||
threadId: string;
|
||||
msgId?: string;
|
||||
cliMsgId?: string;
|
||||
type: number;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
metadata?: {
|
||||
isGroup: boolean;
|
||||
threadName?: string;
|
||||
senderName?: string;
|
||||
fromId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ZcaUserInfo = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type CommonOptions = {
|
||||
profile?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type SendOptions = CommonOptions & {
|
||||
group?: boolean;
|
||||
};
|
||||
|
||||
export type ListenOptions = CommonOptions & {
|
||||
raw?: boolean;
|
||||
keepAlive?: boolean;
|
||||
webhook?: string;
|
||||
echo?: boolean;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
// Channel plugin config types
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
export type ZalouserAccountConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
profile?: string;
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
messagePrefix?: string;
|
||||
};
|
||||
|
||||
export type ZalouserConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
profile?: string;
|
||||
defaultAccount?: string;
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
messagePrefix?: string;
|
||||
accounts?: Record<string, ZalouserAccountConfig>;
|
||||
};
|
||||
|
||||
export type CoreConfig = {
|
||||
channels?: {
|
||||
zalouser?: ZalouserConfig;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ResolvedZalouserAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
profile: string;
|
||||
authenticated: boolean;
|
||||
config: ZalouserAccountConfig;
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { spawn, type SpawnOptions } from "node:child_process";
|
||||
|
||||
import type { ZcaResult, ZcaRunOptions } from "./types.js";
|
||||
|
||||
const ZCA_BINARY = "zca";
|
||||
const DEFAULT_TIMEOUT = 30000;
|
||||
|
||||
function buildArgs(args: string[], options?: ZcaRunOptions): string[] {
|
||||
const result: string[] = [];
|
||||
// Profile flag comes first (before subcommand)
|
||||
const profile = options?.profile || process.env.ZCA_PROFILE;
|
||||
if (profile) {
|
||||
result.push("--profile", profile);
|
||||
}
|
||||
result.push(...args);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runZca(
|
||||
args: string[],
|
||||
options?: ZcaRunOptions,
|
||||
): Promise<ZcaResult> {
|
||||
const fullArgs = buildArgs(args, options);
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: options?.cwd,
|
||||
env: { ...process.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
};
|
||||
|
||||
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill("SIGTERM");
|
||||
}, timeout);
|
||||
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
resolve({
|
||||
ok: false,
|
||||
stdout,
|
||||
stderr: stderr || "Command timed out",
|
||||
exitCode: code ?? 124,
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function runZcaInteractive(
|
||||
args: string[],
|
||||
options?: ZcaRunOptions,
|
||||
): Promise<ZcaResult> {
|
||||
const fullArgs = buildArgs(args, options);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: options?.cwd,
|
||||
env: { ...process.env },
|
||||
stdio: "inherit",
|
||||
};
|
||||
|
||||
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseJsonOutput<T>(stdout: string): T | null {
|
||||
try {
|
||||
return JSON.parse(stdout) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkZcaInstalled(): Promise<boolean> {
|
||||
const result = await runZca(["--version"], { timeout: 5000 });
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
export type ZcaStreamingOptions = ZcaRunOptions & {
|
||||
onData?: (data: string) => void;
|
||||
onError?: (err: Error) => void;
|
||||
};
|
||||
|
||||
export function runZcaStreaming(
|
||||
args: string[],
|
||||
options?: ZcaStreamingOptions,
|
||||
): { proc: ReturnType<typeof spawn>; promise: Promise<ZcaResult> } {
|
||||
const fullArgs = buildArgs(args, options);
|
||||
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: options?.cwd,
|
||||
env: { ...process.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
};
|
||||
|
||||
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
options?.onData?.(text);
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const promise = new Promise<ZcaResult>((resolve) => {
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
options?.onError?.(err);
|
||||
resolve({
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { proc, promise };
|
||||
}
|
||||
+5
-6
@@ -47,7 +47,6 @@
|
||||
"README-header.png",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"patches/**",
|
||||
"scripts/postinstall.js",
|
||||
"dist/terminal/**",
|
||||
"dist/routing/**",
|
||||
@@ -143,6 +142,7 @@
|
||||
"@mariozechner/pi-ai": "0.46.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.46.0",
|
||||
"@mariozechner/pi-tui": "^0.46.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"@slack/bolt": "^4.6.0",
|
||||
"@slack/web-api": "^7.13.0",
|
||||
@@ -163,6 +163,7 @@
|
||||
"hono": "4.11.4",
|
||||
"jiti": "^2.6.1",
|
||||
"json5": "^2.2.3",
|
||||
"linkedom": "^0.18.12",
|
||||
"long": "5.3.2",
|
||||
"markdown-it": "^14.1.0",
|
||||
"osc-progress": "^0.2.0",
|
||||
@@ -170,7 +171,7 @@
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tar": "^7.5.2",
|
||||
"tar": "^7.5.3",
|
||||
"tslog": "^4.10.2",
|
||||
"undici": "^7.18.2",
|
||||
"ws": "^8.19.0",
|
||||
@@ -212,10 +213,8 @@
|
||||
"minimumReleaseAge": 2880,
|
||||
"overrides": {
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"hono": "4.11.4"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@mariozechner/pi-agent-core@0.46.0": "patches/@mariozechner__pi-agent-core.patch"
|
||||
"hono": "4.11.4",
|
||||
"tar": "7.5.3"
|
||||
}
|
||||
},
|
||||
"vitest": {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
diff --git a/dist/agent.d.ts b/dist/agent.d.ts
|
||||
index fcfb19924ef6ce233aa55795e3687ce23938c5a6..a63daea868c5b3b7f7bb9272576c65c6ad95da8a 100644
|
||||
--- a/dist/agent.d.ts
|
||||
+++ b/dist/agent.d.ts
|
||||
@@ -38,6 +38,10 @@ export interface AgentOptions {
|
||||
* Useful for expiring tokens (e.g., GitHub Copilot OAuth).
|
||||
*/
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
+ /**
|
||||
+ * Extra params to pass to the provider API (e.g., Z.AI GLM thinking mode params).
|
||||
+ */
|
||||
+ extraParams?: Record<string, unknown>;
|
||||
/**
|
||||
* Custom token budgets for thinking levels (token-based providers only).
|
||||
*/
|
||||
@@ -56,6 +60,8 @@ export declare class Agent {
|
||||
streamFn: StreamFn;
|
||||
private _sessionId?;
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
+ /** Extra params to pass to the provider API. */
|
||||
+ extraParams?: Record<string, unknown>;
|
||||
private runningPrompt?;
|
||||
private resolveRunningPrompt?;
|
||||
private _thinkingBudgets?;
|
||||
diff --git a/dist/agent.js b/dist/agent.js
|
||||
index 34ceb4ddcbc53d83edd82d774a76d9bf469b42f3..ecd8b7641c71523296890e11ac0cf0855a0dadd5 100644
|
||||
--- a/dist/agent.js
|
||||
+++ b/dist/agent.js
|
||||
@@ -33,6 +33,7 @@ export class Agent {
|
||||
streamFn;
|
||||
_sessionId;
|
||||
getApiKey;
|
||||
+ extraParams;
|
||||
runningPrompt;
|
||||
resolveRunningPrompt;
|
||||
_thinkingBudgets;
|
||||
@@ -45,6 +46,8 @@ export class Agent {
|
||||
this.streamFn = opts.streamFn || streamSimple;
|
||||
this._sessionId = opts.sessionId;
|
||||
this.getApiKey = opts.getApiKey;
|
||||
+ // PATCH: Support extraParams for provider-specific features (e.g., GLM-4.7 thinking mode)
|
||||
+ this.extraParams = opts.extraParams;
|
||||
this._thinkingBudgets = opts.thinkingBudgets;
|
||||
}
|
||||
/**
|
||||
@@ -225,6 +228,8 @@ export class Agent {
|
||||
convertToLlm: this.convertToLlm,
|
||||
transformContext: this.transformContext,
|
||||
getApiKey: this.getApiKey,
|
||||
+ // PATCH: Pass extraParams through to stream function
|
||||
+ extraParams: this.extraParams,
|
||||
getSteeringMessages: async () => {
|
||||
if (this.steeringMode === "one-at-a-time") {
|
||||
if (this.steeringQueue.length > 0) {
|
||||
@@ -1,48 +0,0 @@
|
||||
diff --git a/dist/core/sdk.js b/dist/core/sdk.js
|
||||
index 0000000..1111111 100644
|
||||
--- a/dist/core/sdk.js
|
||||
+++ b/dist/core/sdk.js
|
||||
@@ -441,6 +441,8 @@ export async function createAgentSession(options = {}) {
|
||||
}
|
||||
return key;
|
||||
},
|
||||
+ // PATCH: Pass extraParams through for provider-specific features (e.g., GLM-4.7 thinking mode)
|
||||
+ extraParams: options.extraParams,
|
||||
});
|
||||
time("createAgent");
|
||||
// Restore messages if session has existing data
|
||||
diff --git a/dist/core/sdk.d.ts b/dist/core/sdk.d.ts
|
||||
index 0000000..1111111 100644
|
||||
--- a/dist/core/sdk.d.ts
|
||||
+++ b/dist/core/sdk.d.ts
|
||||
@@ -79,6 +79,10 @@ export interface CreateAgentSessionOptions {
|
||||
sessionManager?: SessionManager;
|
||||
/** Settings manager. Default: SettingsManager.create(cwd, agentDir) */
|
||||
settingsManager?: SettingsManager;
|
||||
+ /**
|
||||
+ * Extra params to pass to the provider API (e.g., Z.AI GLM thinking mode params).
|
||||
+ */
|
||||
+ extraParams?: Record<string, unknown>;
|
||||
}
|
||||
/** Result from createAgentSession */
|
||||
export interface CreateAgentSessionResult {
|
||||
diff --git a/dist/core/session-manager.js b/dist/core/session-manager.js
|
||||
index b2aba5280d002253b0938b75aedbb9e6e6c4dcf8..67464efff535dbd7a8e6ed825aab2b305ca2aee2 100644
|
||||
--- a/dist/core/session-manager.js
|
||||
+++ b/dist/core/session-manager.js
|
||||
@@ -161,6 +161,15 @@ export function buildSessionContext(entries, leafId, byId) {
|
||||
const messages = [];
|
||||
const appendMessage = (entry) => {
|
||||
if (entry.type === "message") {
|
||||
+ // PATCH: Filter out empty error assistant messages to prevent session corruption
|
||||
+ // When 429/500 errors occur during tool execution, empty error messages get persisted
|
||||
+ // to the session file. These break the tool_use -> tool_result chain for Claude/Gemini.
|
||||
+ const msg = entry.message;
|
||||
+ if (msg.role === "assistant" &&
|
||||
+ msg.stopReason === "error" &&
|
||||
+ (!msg.content || msg.content.length === 0)) {
|
||||
+ return; // Skip empty error messages
|
||||
+ }
|
||||
messages.push(entry.message);
|
||||
}
|
||||
else if (entry.type === "custom_message") {
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/lib/utilsBundle.js b/lib/utilsBundle.js
|
||||
index 7dd8831f29c19f2e20468508b77b0a3f9d204ae6..c50a1ac2b3439a5b2fbf8afa61c369360710071f 100644
|
||||
--- a/lib/utilsBundle.js
|
||||
+++ b/lib/utilsBundle.js
|
||||
@@ -59,7 +59,7 @@ const program = require("./utilsBundleImpl").program;
|
||||
const ProgramOption = require("./utilsBundleImpl").ProgramOption;
|
||||
const progress = require("./utilsBundleImpl").progress;
|
||||
const SocksProxyAgent = require("./utilsBundleImpl").SocksProxyAgent;
|
||||
-const ws = require("./utilsBundleImpl").ws;
|
||||
+const ws = "Bun" in globalThis ? require("ws") : require("./utilsBundleImpl").ws;
|
||||
const wsServer = require("./utilsBundleImpl").wsServer;
|
||||
const wsReceiver = require("./utilsBundleImpl").wsReceiver;
|
||||
const wsSender = require("./utilsBundleImpl").wsSender;
|
||||
@@ -1,12 +0,0 @@
|
||||
diff --git a/lib/main.js b/lib/main.js
|
||||
index 488cc1aea9802b3d6ae13aee27556403bec55d1c..3de1f934868d81e8204f00e6a4bf2696a05f7340 100644
|
||||
--- a/lib/main.js
|
||||
+++ b/lib/main.js
|
||||
@@ -1,5 +1,5 @@
|
||||
-var QRCode = require('./../vendor/QRCode'),
|
||||
- QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),
|
||||
+var QRCode = require('./../vendor/QRCode/index.js'),
|
||||
+ QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),
|
||||
black = "\033[40m \033[0m",
|
||||
white = "\033[47m \033[0m",
|
||||
toCell = function (isBlack) {
|
||||
Generated
+136
-78
@@ -7,11 +7,7 @@ settings:
|
||||
overrides:
|
||||
'@sinclair/typebox': 0.34.47
|
||||
hono: 4.11.4
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-agent-core@0.46.0':
|
||||
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
|
||||
path: patches/@mariozechner__pi-agent-core.patch
|
||||
tar: 7.5.3
|
||||
|
||||
importers:
|
||||
|
||||
@@ -34,7 +30,7 @@ importers:
|
||||
version: 1.3.4
|
||||
'@mariozechner/pi-agent-core':
|
||||
specifier: 0.46.0
|
||||
version: 0.46.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
version: 0.46.0(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: 0.46.0
|
||||
version: 0.46.0(ws@8.19.0)(zod@4.3.5)
|
||||
@@ -44,6 +40,9 @@ importers:
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.46.0
|
||||
version: 0.46.0
|
||||
'@mozilla/readability':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.47
|
||||
version: 0.34.47
|
||||
@@ -104,6 +103,9 @@ importers:
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
linkedom:
|
||||
specifier: ^0.18.12
|
||||
version: 0.18.12
|
||||
long:
|
||||
specifier: 5.3.2
|
||||
version: 5.3.2
|
||||
@@ -126,8 +128,8 @@ importers:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
tar:
|
||||
specifier: ^7.5.2
|
||||
version: 7.5.2
|
||||
specifier: 7.5.3
|
||||
version: 7.5.3
|
||||
tslog:
|
||||
specifier: ^4.10.2
|
||||
version: 4.10.2
|
||||
@@ -272,6 +274,12 @@ importers:
|
||||
specifier: 7.18.2
|
||||
version: 7.18.2
|
||||
|
||||
extensions/zalouser:
|
||||
dependencies:
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.47
|
||||
version: 0.34.47
|
||||
|
||||
ui:
|
||||
dependencies:
|
||||
dompurify:
|
||||
@@ -1034,6 +1042,10 @@ packages:
|
||||
'@mistralai/mistralai@1.10.0':
|
||||
resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==}
|
||||
|
||||
'@mozilla/readability@0.6.0':
|
||||
resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.1':
|
||||
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
||||
|
||||
@@ -2219,6 +2231,9 @@ packages:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
bottleneck@2.19.5:
|
||||
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
|
||||
|
||||
@@ -2292,10 +2307,6 @@ packages:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
chownr@2.0.0:
|
||||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
chownr@3.0.0:
|
||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2407,6 +2418,16 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-select@5.2.2:
|
||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||
|
||||
css-what@6.2.2:
|
||||
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
cssom@0.5.0:
|
||||
resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==}
|
||||
|
||||
curve25519-js@0.0.4:
|
||||
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
|
||||
|
||||
@@ -2458,9 +2479,22 @@ packages:
|
||||
docx-preview@0.3.7:
|
||||
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.3.1:
|
||||
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
dotenv@17.2.3:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2495,6 +2529,10 @@ packages:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-var@7.5.0:
|
||||
resolution: {integrity: sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2648,10 +2686,6 @@ packages:
|
||||
resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-minipass@2.1.0:
|
||||
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -2773,9 +2807,15 @@ packages:
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
html-escaper@3.0.3:
|
||||
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
|
||||
|
||||
html-parse-string@0.0.9:
|
||||
resolution: {integrity: sha512-wyGnsOolHbNrcb8N6bdJF4EHyzd3zVGCb9/mBxeNjAYBDOZqD7YkqLBz7kXtdgHwNnV8lN/BpSDpsI1zm8Sd8g==}
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3043,6 +3083,15 @@ packages:
|
||||
limiter@1.1.5:
|
||||
resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
|
||||
|
||||
linkedom@0.18.12:
|
||||
resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
canvas: '>= 2'
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
@@ -3223,22 +3272,10 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minipass@3.3.6:
|
||||
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
minipass@5.0.0:
|
||||
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
minipass@7.1.2:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minizlib@2.1.2:
|
||||
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
minizlib@3.1.0:
|
||||
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -3246,11 +3283,6 @@ packages:
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mkdirp@1.0.4:
|
||||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
mpg123-decoder@1.0.3:
|
||||
resolution: {integrity: sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ==}
|
||||
|
||||
@@ -3330,6 +3362,9 @@ packages:
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
deprecated: This package is no longer supported.
|
||||
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3912,12 +3947,8 @@ packages:
|
||||
tailwindcss@4.1.17:
|
||||
resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
|
||||
|
||||
tar@6.2.1:
|
||||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
tar@7.5.2:
|
||||
resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==}
|
||||
tar@7.5.3:
|
||||
resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
@@ -4009,6 +4040,9 @@ packages:
|
||||
uhtml@5.0.9:
|
||||
resolution: {integrity: sha512-qPyu3vGilaLe6zrjOCD/xezWEHLwdevxmbY3hzyhT25KBDF4F7YYW3YZcL3kylD/6dMoVISHjn8ggV3+9FY+5g==}
|
||||
|
||||
uhyphen@0.2.0:
|
||||
resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==}
|
||||
|
||||
uint8array-extras@1.5.0:
|
||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5169,7 +5203,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- tailwindcss
|
||||
|
||||
'@mariozechner/pi-agent-core@0.46.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
|
||||
'@mariozechner/pi-agent-core@0.46.0(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.46.0(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.46.0
|
||||
@@ -5208,7 +5242,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@mariozechner/clipboard': 0.3.0
|
||||
'@mariozechner/jiti': 2.6.2
|
||||
'@mariozechner/pi-agent-core': 0.46.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-agent-core': 0.46.0(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.46.0(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.46.0
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
@@ -5280,6 +5314,8 @@ snapshots:
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
|
||||
'@mozilla/readability@0.6.0': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.1':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.8.1
|
||||
@@ -6571,6 +6607,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
bottleneck@2.19.5: {}
|
||||
|
||||
bowser@2.13.1: {}
|
||||
@@ -6655,9 +6693,6 @@ snapshots:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
chownr@2.0.0:
|
||||
optional: true
|
||||
|
||||
chownr@3.0.0: {}
|
||||
|
||||
chromium-bidi@12.0.1(devtools-protocol@0.0.1561482):
|
||||
@@ -6715,7 +6750,7 @@ snapshots:
|
||||
npmlog: 6.0.2
|
||||
rc: 1.2.8
|
||||
semver: 7.7.3
|
||||
tar: 6.2.1
|
||||
tar: 7.5.3
|
||||
url-join: 4.0.1
|
||||
which: 2.0.2
|
||||
yargs: 17.7.2
|
||||
@@ -6775,6 +6810,18 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-select@5.2.2:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
css-what: 6.2.2
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
nth-check: 2.1.1
|
||||
|
||||
css-what@6.2.2: {}
|
||||
|
||||
cssom@0.5.0: {}
|
||||
|
||||
curve25519-js@0.0.4: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
@@ -6807,10 +6854,28 @@ snapshots:
|
||||
dependencies:
|
||||
jszip: 3.10.1
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.3.1:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -6838,6 +6903,8 @@ snapshots:
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-var@7.5.0:
|
||||
optional: true
|
||||
|
||||
@@ -7038,11 +7105,6 @@ snapshots:
|
||||
universalify: 2.0.1
|
||||
optional: true
|
||||
|
||||
fs-minipass@2.1.0:
|
||||
dependencies:
|
||||
minipass: 3.3.6
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
@@ -7192,8 +7254,17 @@ snapshots:
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
html-escaper@3.0.3: {}
|
||||
|
||||
html-parse-string@0.0.9: {}
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 6.0.1
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -7471,6 +7542,14 @@ snapshots:
|
||||
|
||||
limiter@1.1.5: {}
|
||||
|
||||
linkedom@0.18.12:
|
||||
dependencies:
|
||||
css-select: 5.2.2
|
||||
cssom: 0.5.0
|
||||
html-escaper: 3.0.3
|
||||
htmlparser2: 10.0.0
|
||||
uhyphen: 0.2.0
|
||||
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
@@ -7651,31 +7730,14 @@ snapshots:
|
||||
minimist@1.2.8:
|
||||
optional: true
|
||||
|
||||
minipass@3.3.6:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
optional: true
|
||||
|
||||
minipass@5.0.0:
|
||||
optional: true
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
minizlib@2.1.2:
|
||||
dependencies:
|
||||
minipass: 3.3.6
|
||||
yallist: 4.0.0
|
||||
optional: true
|
||||
|
||||
minizlib@3.1.0:
|
||||
dependencies:
|
||||
minipass: 7.1.2
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mkdirp@1.0.4:
|
||||
optional: true
|
||||
|
||||
mpg123-decoder@1.0.3:
|
||||
dependencies:
|
||||
'@wasm-audio-decoders/common': 9.0.7
|
||||
@@ -7793,6 +7855,10 @@ snapshots:
|
||||
set-blocking: 2.0.0
|
||||
optional: true
|
||||
|
||||
nth-check@2.1.1:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -8506,17 +8572,7 @@ snapshots:
|
||||
|
||||
tailwindcss@4.1.17: {}
|
||||
|
||||
tar@6.2.1:
|
||||
dependencies:
|
||||
chownr: 2.0.0
|
||||
fs-minipass: 2.1.0
|
||||
minipass: 5.0.0
|
||||
minizlib: 2.1.2
|
||||
mkdirp: 1.0.4
|
||||
yallist: 4.0.0
|
||||
optional: true
|
||||
|
||||
tar@7.5.2:
|
||||
tar@7.5.3:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
chownr: 3.0.0
|
||||
@@ -8599,6 +8655,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@webreflection/alien-signals': 0.3.2
|
||||
|
||||
uhyphen@0.2.0: {}
|
||||
|
||||
uint8array-extras@1.5.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
@@ -9,6 +9,3 @@ onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- protobufjs
|
||||
- sharp
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai@0.45.7': patches/@mariozechner__pi-ai@0.45.7.patch
|
||||
@@ -9,7 +9,6 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /repo
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY patches ./patches
|
||||
COPY scripts/postinstall.js ./scripts/postinstall.js
|
||||
RUN corepack enable \
|
||||
&& pnpm install --frozen-lockfile
|
||||
|
||||
@@ -7,7 +7,6 @@ WORKDIR /app
|
||||
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./
|
||||
COPY patches ./patches
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
COPY docs ./docs
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function detectPackageManager(ua = process.env.npm_config_user_agent ?? "") {
|
||||
@@ -37,6 +38,52 @@ function ensureExecutable(targetPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasGit(repoRoot) {
|
||||
const result = spawnSync("git", ["--version"], { cwd: repoRoot, stdio: "ignore" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function isSubmoduleDirty(submodulePath, repoRoot) {
|
||||
const result = spawnSync("git", ["-C", submodulePath, "status", "--porcelain"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (result.status !== 0) return false;
|
||||
return result.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
function shouldSyncSubmodule(name, repoRoot) {
|
||||
const result = spawnSync("git", ["submodule", "status", "--recursive", "--", name], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (result.status !== 0) return false;
|
||||
const line = result.stdout.trim().split("\n")[0];
|
||||
if (!line) return false;
|
||||
const marker = line[0];
|
||||
return marker === "-" || marker === "+";
|
||||
}
|
||||
|
||||
function syncPeekabooSubmodule(repoRoot) {
|
||||
if (!hasGit(repoRoot)) return;
|
||||
if (!fs.existsSync(path.join(repoRoot, ".gitmodules"))) return;
|
||||
const peekabooPath = path.join(repoRoot, "Peekaboo");
|
||||
if (fs.existsSync(peekabooPath) && isSubmoduleDirty(peekabooPath, repoRoot)) {
|
||||
console.warn("[postinstall] Peekaboo submodule dirty; skipping update");
|
||||
return;
|
||||
}
|
||||
if (!shouldSyncSubmodule("Peekaboo", repoRoot)) return;
|
||||
const result = spawnSync(
|
||||
"git",
|
||||
["submodule", "update", "--init", "--recursive", "--", "Peekaboo"],
|
||||
{ cwd: repoRoot, encoding: "utf-8" },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.toString().trim();
|
||||
console.warn(`[postinstall] Peekaboo submodule update failed: ${stderr || "unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractPackageName(key) {
|
||||
if (key.startsWith("@")) {
|
||||
const idx = key.indexOf("@", 1);
|
||||
@@ -246,6 +293,7 @@ function main() {
|
||||
process.chdir(repoRoot);
|
||||
|
||||
ensureExecutable(path.join(repoRoot, "dist", "entry.js"));
|
||||
syncPeekabooSubmodule(repoRoot);
|
||||
|
||||
if (!shouldApplyPnpmPatchedDependenciesFallback()) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import base64
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
@@ -96,8 +95,8 @@ def request_images(
|
||||
if model != "dall-e-2":
|
||||
args["quality"] = quality
|
||||
|
||||
if model.startswith("dall-e"):
|
||||
args["response_format"] = "b64_json"
|
||||
# Note: response_format no longer supported by OpenAI Images API
|
||||
# dall-e models now return URLs by default
|
||||
|
||||
if model.startswith("gpt-image"):
|
||||
if background:
|
||||
@@ -212,12 +211,19 @@ def main() -> int:
|
||||
args.output_format,
|
||||
args.style,
|
||||
)
|
||||
b64 = res.get("data", [{}])[0].get("b64_json")
|
||||
if not b64:
|
||||
# OpenAI Images API now returns URLs by default
|
||||
image_url = res.get("data", [{}])[0].get("url")
|
||||
if not image_url:
|
||||
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
# Download image from URL
|
||||
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
|
||||
(out_dir / filename).write_bytes(image_bytes)
|
||||
filepath = out_dir / filename
|
||||
try:
|
||||
urllib.request.urlretrieve(image_url, filepath)
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Failed to download image from {image_url}: {e}") from e
|
||||
|
||||
items.append({"prompt": prompt, "file": filename})
|
||||
|
||||
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
|
||||
|
||||
@@ -38,6 +38,20 @@ function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
|
||||
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
||||
}
|
||||
|
||||
export function listAgentIds(cfg: ClawdbotConfig): string[] {
|
||||
const agents = listAgents(cfg);
|
||||
if (agents.length === 0) return [DEFAULT_AGENT_ID];
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const entry of agents) {
|
||||
const id = normalizeAgentId(entry?.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids.length > 0 ? ids : [DEFAULT_AGENT_ID];
|
||||
}
|
||||
|
||||
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
|
||||
const agents = listAgents(cfg);
|
||||
if (agents.length === 0) return DEFAULT_AGENT_ID;
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||
|
||||
describe("ensureAuthProfileStore", () => {
|
||||
it("migrates legacy auth.json and deletes it (PR #368)", () => {
|
||||
@@ -45,4 +46,80 @@ describe("ensureAuthProfileStore", () => {
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("merges main auth profiles into agent store and keeps agent overrides", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-merge-"));
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
try {
|
||||
const mainDir = path.join(root, "main-agent");
|
||||
const agentDir = path.join(root, "agent-x");
|
||||
fs.mkdirSync(mainDir, { recursive: true });
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
process.env.CLAWDBOT_AGENT_DIR = mainDir;
|
||||
process.env.PI_CODING_AGENT_DIR = mainDir;
|
||||
|
||||
const mainStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "main-key",
|
||||
},
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "main-anthropic-key",
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(mainDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(mainStore, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const agentStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "agent-key",
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(agentStore, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(store.profiles["anthropic:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "main-anthropic-key",
|
||||
});
|
||||
expect(store.profiles["openai:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "agent-key",
|
||||
});
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,37 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRecord<T>(
|
||||
base?: Record<string, T>,
|
||||
override?: Record<string, T>,
|
||||
): Record<string, T> | undefined {
|
||||
if (!base && !override) return undefined;
|
||||
if (!base) return { ...override };
|
||||
if (!override) return { ...base };
|
||||
return { ...base, ...override };
|
||||
}
|
||||
|
||||
function mergeAuthProfileStores(
|
||||
base: AuthProfileStore,
|
||||
override: AuthProfileStore,
|
||||
): AuthProfileStore {
|
||||
if (
|
||||
Object.keys(override.profiles).length === 0 &&
|
||||
!override.order &&
|
||||
!override.lastGood &&
|
||||
!override.usageStats
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
version: Math.max(base.version, override.version ?? base.version),
|
||||
profiles: { ...base.profiles, ...override.profiles },
|
||||
order: mergeRecord(base.order, override.order),
|
||||
lastGood: mergeRecord(base.lastGood, override.lastGood),
|
||||
usageStats: mergeRecord(base.usageStats, override.usageStats),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
const oauthRaw = loadJsonFile(oauthPath);
|
||||
@@ -191,7 +222,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
return store;
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(
|
||||
function loadAuthProfileStoreForAgent(
|
||||
agentDir?: string,
|
||||
options?: { allowKeychainPrompt?: boolean },
|
||||
): AuthProfileStore {
|
||||
@@ -207,6 +238,19 @@ export function ensureAuthProfileStore(
|
||||
return asStore;
|
||||
}
|
||||
|
||||
// Fallback: inherit auth-profiles from main agent if subagent has none
|
||||
if (agentDir) {
|
||||
const mainAuthPath = resolveAuthStorePath(); // without agentDir = main
|
||||
const mainRaw = loadJsonFile(mainAuthPath);
|
||||
const mainStore = coerceAuthStore(mainRaw);
|
||||
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
|
||||
// Clone main store to subagent directory for auth inheritance
|
||||
saveJsonFile(authPath, mainStore);
|
||||
log.info("inherited auth-profiles from main agent", { agentDir });
|
||||
return mainStore;
|
||||
}
|
||||
}
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
const store: AuthProfileStore = {
|
||||
@@ -274,6 +318,21 @@ export function ensureAuthProfileStore(
|
||||
return store;
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(
|
||||
agentDir?: string,
|
||||
options?: { allowKeychainPrompt?: boolean },
|
||||
): AuthProfileStore {
|
||||
const store = loadAuthProfileStoreForAgent(agentDir, options);
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const mainAuthPath = resolveAuthStorePath();
|
||||
if (!agentDir || authPath === mainAuthPath) {
|
||||
return store;
|
||||
}
|
||||
|
||||
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
|
||||
return mergeAuthProfileStores(mainStore, store);
|
||||
}
|
||||
|
||||
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const payload = {
|
||||
|
||||
@@ -63,7 +63,7 @@ test("background exec still times out after tool signal abort", async () => {
|
||||
abortController.abort();
|
||||
|
||||
let finished = getFinishedSession(sessionId);
|
||||
const deadline = Date.now() + 2000;
|
||||
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
|
||||
while (!finished && Date.now() < deadline) {
|
||||
await sleep(20);
|
||||
finished = getFinishedSession(sessionId);
|
||||
@@ -72,6 +72,7 @@ test("background exec still times out after tool signal abort", async () => {
|
||||
const running = getSession(sessionId);
|
||||
|
||||
try {
|
||||
expect(finished).toBeTruthy();
|
||||
expect(finished?.status).toBe("failed");
|
||||
} finally {
|
||||
const pid = running?.pid;
|
||||
@@ -121,7 +122,7 @@ test("yielded background exec still times out", async () => {
|
||||
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||
|
||||
let finished = getFinishedSession(sessionId);
|
||||
const deadline = Date.now() + 2000;
|
||||
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
|
||||
while (!finished && Date.now() < deadline) {
|
||||
await sleep(20);
|
||||
finished = getFinishedSession(sessionId);
|
||||
@@ -130,6 +131,7 @@ test("yielded background exec still times out", async () => {
|
||||
const running = getSession(sessionId);
|
||||
|
||||
try {
|
||||
expect(finished).toBeTruthy();
|
||||
expect(finished?.status).toBe("failed");
|
||||
} finally {
|
||||
const pid = running?.pid;
|
||||
|
||||
@@ -256,4 +256,28 @@ describe("getApiKeyForModel", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves Vercel AI Gateway API key from env", async () => {
|
||||
const previousGatewayKey = process.env.AI_GATEWAY_API_KEY;
|
||||
|
||||
try {
|
||||
process.env.AI_GATEWAY_API_KEY = "gateway-test-key";
|
||||
|
||||
vi.resetModules();
|
||||
const { resolveApiKeyForProvider } = await import("./model-auth.js");
|
||||
|
||||
const resolved = await resolveApiKeyForProvider({
|
||||
provider: "vercel-ai-gateway",
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
expect(resolved.apiKey).toBe("gateway-test-key");
|
||||
expect(resolved.source).toContain("AI_GATEWAY_API_KEY");
|
||||
} finally {
|
||||
if (previousGatewayKey === undefined) {
|
||||
delete process.env.AI_GATEWAY_API_KEY;
|
||||
} else {
|
||||
process.env.AI_GATEWAY_API_KEY = previousGatewayKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
||||
moonshot: "MOONSHOT_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
synthetic: "SYNTHETIC_API_KEY",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { downgradeGeminiHistory } from "./pi-embedded-helpers.js";
|
||||
|
||||
describe("downgradeGeminiHistory", () => {
|
||||
it("drops unsigned tool calls and matching tool results", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "/tmp" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
{ role: "user", content: "next" },
|
||||
];
|
||||
|
||||
expect(downgradeGeminiHistory(input)).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
{ role: "user", content: "next" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps signed tool calls and results", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_2",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp" },
|
||||
thought_signature: "sig_123",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_2",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeGeminiHistory(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("drops assistant messages that only contain unsigned tool calls", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_3", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_3",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
{ role: "user", content: "after" },
|
||||
];
|
||||
|
||||
expect(downgradeGeminiHistory(input)).toEqual([{ role: "user", content: "after" }]);
|
||||
});
|
||||
});
|
||||
@@ -16,9 +16,9 @@ export function isAntigravityClaude(api?: string | null, modelId?: string): bool
|
||||
export { sanitizeGoogleTurnOrdering };
|
||||
|
||||
/**
|
||||
* Downgrades tool calls that are missing `thought_signature` (required by Gemini)
|
||||
* into text representations, to prevent 400 INVALID_ARGUMENT errors.
|
||||
* Also converts corresponding tool results into user messages.
|
||||
* Drops tool calls that are missing `thought_signature` (required by Gemini)
|
||||
* to prevent 400 INVALID_ARGUMENT errors. Matching tool results are dropped
|
||||
* so they don't become orphaned in the transcript.
|
||||
*/
|
||||
type GeminiToolCallBlock = {
|
||||
type?: unknown;
|
||||
@@ -86,7 +86,7 @@ export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMe
|
||||
}
|
||||
|
||||
export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[] {
|
||||
const downgradedIds = new Set<string>();
|
||||
const droppedToolCallIds = new Set<string>();
|
||||
const out: AgentMessage[] = [];
|
||||
|
||||
const resolveToolResultId = (
|
||||
@@ -113,9 +113,9 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
|
||||
continue;
|
||||
}
|
||||
|
||||
let hasDowngraded = false;
|
||||
const newContent = assistantMsg.content.map((block) => {
|
||||
if (!block || typeof block !== "object") return block;
|
||||
let dropped = false;
|
||||
const nextContent = assistantMsg.content.filter((block) => {
|
||||
if (!block || typeof block !== "object") return true;
|
||||
const blockRecord = block as GeminiToolCallBlock;
|
||||
const type = blockRecord.type;
|
||||
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
||||
@@ -128,64 +128,26 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
|
||||
: typeof blockRecord.toolCallId === "string"
|
||||
? blockRecord.toolCallId
|
||||
: undefined;
|
||||
const name =
|
||||
typeof blockRecord.name === "string"
|
||||
? blockRecord.name
|
||||
: typeof blockRecord.toolName === "string"
|
||||
? blockRecord.toolName
|
||||
: undefined;
|
||||
const args =
|
||||
blockRecord.arguments !== undefined ? blockRecord.arguments : blockRecord.input;
|
||||
|
||||
if (id) downgradedIds.add(id);
|
||||
hasDowngraded = true;
|
||||
|
||||
const argsText = typeof args === "string" ? args : JSON.stringify(args, null, 2);
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: `[Tool Call: ${name ?? "unknown"}${
|
||||
id ? ` (ID: ${id})` : ""
|
||||
}]\nArguments: ${argsText}`,
|
||||
};
|
||||
if (id) droppedToolCallIds.add(id);
|
||||
dropped = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return block;
|
||||
return true;
|
||||
});
|
||||
|
||||
out.push(hasDowngraded ? ({ ...assistantMsg, content: newContent } as AgentMessage) : msg);
|
||||
if (dropped && nextContent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(dropped ? ({ ...assistantMsg, content: nextContent } as AgentMessage) : msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "toolResult") {
|
||||
const toolMsg = msg as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const toolResultId = resolveToolResultId(toolMsg);
|
||||
if (toolResultId && downgradedIds.has(toolResultId)) {
|
||||
let textContent = "";
|
||||
if (Array.isArray(toolMsg.content)) {
|
||||
textContent = toolMsg.content
|
||||
.map((entry) => {
|
||||
if (entry && typeof entry === "object") {
|
||||
const text = (entry as { text?: unknown }).text;
|
||||
if (typeof text === "string") return text;
|
||||
}
|
||||
return JSON.stringify(entry);
|
||||
})
|
||||
.join("\n");
|
||||
} else {
|
||||
textContent = JSON.stringify(toolMsg.content);
|
||||
}
|
||||
|
||||
out.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[Tool Result for ID ${toolResultId}]\n${textContent}`,
|
||||
},
|
||||
],
|
||||
} as AgentMessage);
|
||||
|
||||
if (toolResultId && droppedToolCallIds.has(toolResultId)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ describeLive("pi embedded extra params (live)", () => {
|
||||
|
||||
const agent = { streamFn: streamSimple };
|
||||
|
||||
applyExtraParamsToAgent(agent, cfg, "openai", model.id, "off");
|
||||
applyExtraParamsToAgent(agent, cfg, "openai", model.id);
|
||||
|
||||
const stream = agent.streamFn(
|
||||
model,
|
||||
|
||||
@@ -1,460 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveExtraParams } from "./pi-embedded-runner.js";
|
||||
|
||||
/**
|
||||
* Tests for resolveExtraParams - the function that auto-enables GLM-4.x thinking mode.
|
||||
*
|
||||
* Z.AI Cloud API format: thinking: { type: "enabled", clear_thinking: boolean }
|
||||
* - GLM-4.7: Preserved thinking (clear_thinking: false) - reasoning kept across turns
|
||||
* - GLM-4.5/4.6: Interleaved thinking (clear_thinking: true) - reasoning cleared each turn
|
||||
*
|
||||
* @see https://docs.z.ai/guides/capabilities/thinking-mode
|
||||
*/
|
||||
|
||||
describe("resolveExtraParams", () => {
|
||||
describe("GLM-4.7 preserved thinking (clear_thinking: false)", () => {
|
||||
it("auto-enables preserved thinking for zai/glm-4.7 with no config", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false, // Preserved thinking for GLM-4.7
|
||||
},
|
||||
});
|
||||
it("returns undefined with no model config", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
});
|
||||
|
||||
it("auto-enables preserved thinking for zai/GLM-4.7 (case insensitive)", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "GLM-4.7",
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
it("returns params for exact provider/model key", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": {
|
||||
params: {
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
provider: "openai",
|
||||
modelId: "gpt-4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GLM-4.5/4.6 interleaved thinking (clear_thinking: true)", () => {
|
||||
it("auto-enables interleaved thinking for zai/glm-4.5", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.5",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: true, // Interleaved thinking for GLM-4.5
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-enables interleaved thinking for zai/glm-4.6", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.6",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: true, // Interleaved thinking for GLM-4.6
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-enables interleaved thinking for zai/glm-4-flash", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4-flash",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: true, // Non-4.7 gets interleaved
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-enables interleaved thinking for zai/glm-4.5-air", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.5-air",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("config overrides", () => {
|
||||
it("respects explicit thinking config from user (disable thinking)", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"zai/glm-4.7": {
|
||||
params: {
|
||||
thinking: {
|
||||
type: "disabled",
|
||||
},
|
||||
},
|
||||
it("ignores unrelated model entries", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": {
|
||||
params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "disabled",
|
||||
},
|
||||
});
|
||||
},
|
||||
provider: "openai",
|
||||
modelId: "gpt-4.1-mini",
|
||||
});
|
||||
|
||||
it("preserves other params while adding thinking config", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"zai/glm-4.7": {
|
||||
params: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 4096,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
temperature: 0.7,
|
||||
max_tokens: 4096,
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override explicit thinking config even if partial", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"zai/glm-4.7": {
|
||||
params: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
// User explicitly omitted clear_thinking
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
});
|
||||
|
||||
// Should use user's config exactly, not merge defaults
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-GLM models", () => {
|
||||
it("returns undefined for anthropic/claude with no config", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "anthropic",
|
||||
modelId: "claude-3-opus",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for openai/gpt-4 with no config", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "openai",
|
||||
modelId: "gpt-4",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through params for non-GLM models without modification", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": {
|
||||
params: {
|
||||
logprobs: true,
|
||||
top_logprobs: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
modelId: "gpt-4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
logprobs: true,
|
||||
top_logprobs: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-enable thinking for non-zai provider even with glm-4 model id", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "openai",
|
||||
modelId: "glm-4.7", // Even if model ID contains glm-4
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty config gracefully", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {},
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("handles config with empty models gracefully", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: { agents: { defaults: { models: {} } } },
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("model alias lookup uses exact provider/model key", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"zai/glm-4.7": {
|
||||
alias: "smart",
|
||||
params: {
|
||||
custom_param: "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
custom_param: "value",
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats thinking: null as explicit config (no auto-enable)", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"zai/glm-4.7": {
|
||||
params: {
|
||||
thinking: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
});
|
||||
|
||||
// null is !== undefined, so we respect the explicit null config
|
||||
expect(result).toEqual({
|
||||
thinking: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles GLM-4.7 variants (glm-4.7-flash, glm-4.7-plus)", () => {
|
||||
// GLM-4.7-flash should get preserved thinking (contains "glm-4.7")
|
||||
const flashResult = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7-flash",
|
||||
});
|
||||
|
||||
expect(flashResult).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false, // Preserved thinking for GLM-4.7 variants
|
||||
},
|
||||
});
|
||||
|
||||
// GLM-4.7-plus should also get preserved thinking
|
||||
const plusResult = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7-plus",
|
||||
});
|
||||
|
||||
expect(plusResult).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinkLevel parameter", () => {
|
||||
it("thinkLevel: 'off' disables auto-enable for GLM-4.x", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
thinkLevel: "off",
|
||||
});
|
||||
|
||||
// Should NOT auto-enable thinking when user explicitly disabled it
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("thinkLevel: 'off' still passes through explicit config", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"zai/glm-4.7": {
|
||||
params: {
|
||||
custom_param: "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
thinkLevel: "off",
|
||||
});
|
||||
|
||||
// Should pass through config params but NOT auto-add thinking
|
||||
expect(result).toEqual({
|
||||
custom_param: "value",
|
||||
});
|
||||
});
|
||||
|
||||
it("thinkLevel: 'low' allows auto-enable", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
thinkLevel: "low",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("thinkLevel: 'high' allows auto-enable", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.5",
|
||||
thinkLevel: "high",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("thinkLevel: undefined (not specified) allows auto-enable", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
// thinkLevel not specified
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -190,10 +190,6 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
expect(assistant.content).toEqual([
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "text", text: "ok" },
|
||||
{
|
||||
type: "text",
|
||||
text: '[Tool Call: read (ID: call_1)]\nArguments: {\n "path": "/tmp/foo"\n}',
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_2",
|
||||
|
||||
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user