Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cdd3f29da | ||
|
|
6e10f1c1f2 | ||
|
|
09bed2ccde | ||
|
|
3af391eec7 | ||
|
|
204309dd3c | ||
|
|
3fcd6fadf3 | ||
|
|
78136c4368 | ||
|
|
25edbdacc2 | ||
|
|
451532f6f1 | ||
|
|
bc7d603867 | ||
|
|
46015a3dd8 | ||
|
|
1481a3d90f | ||
|
|
96a1d03f08 | ||
|
|
0291105913 | ||
|
|
dbf8829283 | ||
|
|
d5332ae29a | ||
|
|
4ba6f6e8ee | ||
|
|
fdaeada3ec | ||
|
|
3fb699a84b | ||
|
|
767f55b127 | ||
|
|
20897e943f | ||
|
|
2d1078fc52 | ||
|
|
19016f16e0 | ||
|
|
b8e3725106 | ||
|
|
413dfc6d6d | ||
|
|
faba508fe0 | ||
|
|
a76cbc43bb | ||
|
|
e16ce1a0a1 | ||
|
|
fa2b92bb00 | ||
|
|
c592f395df | ||
|
|
f14d622c0f | ||
|
|
99aba3a5c4 | ||
|
|
58e02087b5 | ||
|
|
fd49f39a72 | ||
|
|
bbef30daa5 | ||
|
|
c8b865d582 | ||
|
|
4b7c6d4f8f | ||
|
|
c22d2b2ffd | ||
|
|
0179717d61 | ||
|
|
abf4c02a0d | ||
|
|
03a9907055 | ||
|
|
66c99e1608 | ||
|
|
15a95f988a | ||
|
|
7ecf733342 | ||
|
|
3ec221c70e | ||
|
|
cc2d617ea6 | ||
|
|
503aad1417 | ||
|
|
1ad26d6fea | ||
|
|
02a4de0029 | ||
|
|
bcfc9bead5 | ||
|
|
1be0e9b9fb | ||
|
|
6e5eddf292 | ||
|
|
64a2ef4a18 | ||
|
|
25399d39cb | ||
|
|
731080375a | ||
|
|
89bbbe75a6 | ||
|
|
f69298d7eb | ||
|
|
6280305899 | ||
|
|
543d1ea3c1 | ||
|
|
1569db1754 | ||
|
|
c54c665f97 | ||
|
|
a84000c6d9 | ||
|
|
a979a62f8e | ||
|
|
13e2dd97a7 | ||
|
|
6bba84b043 | ||
|
|
7a9ff18260 | ||
|
|
a36735b913 | ||
|
|
9e39a56033 |
No files matched your search
@@ -57,3 +57,4 @@ apps/ios/*.mobileprovision
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [
|
||||
"unicorn",
|
||||
"typescript",
|
||||
"oxc"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"ignorePatterns": ["src/canvas-host/a2ui/a2ui.bundle.js"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/oxlintrc",
|
||||
"extends": ["recommended"]
|
||||
}
|
||||
@@ -54,6 +54,7 @@
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
||||
|
||||
+59
-24
@@ -2,6 +2,57 @@
|
||||
|
||||
## 2026.1.16 (unreleased)
|
||||
|
||||
### Highlights
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos.
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
|
||||
- Hooks: add internal hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
|
||||
### Changes
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Tools: add Firecrawl fallback for `web_fetch` when configured.
|
||||
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
|
||||
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
|
||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||
- UI: allow deleting sessions from the Control UI.
|
||||
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
|
||||
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
||||
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
||||
- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras.
|
||||
|
||||
### Fixes
|
||||
- Sub-agents: route announce delivery through the correct channel account IDs. (#1061, #1058) — thanks @adam91holt.
|
||||
- Telegram: split long media captions into follow-up text messages in bot delivery. (#1063) — thanks @mukhtharcm.
|
||||
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
|
||||
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
|
||||
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
|
||||
- Sessions: repair orphaned user turns before embedded prompts.
|
||||
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
||||
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
|
||||
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
|
||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
||||
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
|
||||
- Auth: inherit/merge sub-agent auth profiles from the main agent.
|
||||
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- OpenAI image-gen: remove deprecated `response_format` and use URL downloads.
|
||||
- CLI: auto-update global installs when installed via a package manager.
|
||||
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
|
||||
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
|
||||
- Security: bump `tar` to 7.5.3.
|
||||
- Models: align ZAI thinking toggles.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Highlights
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
|
||||
@@ -11,43 +62,35 @@
|
||||
### 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).
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
|
||||
### Changes
|
||||
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
|
||||
- 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 (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.
|
||||
- UI: add session deletion action in Control UI sessions list. (#1017) — thanks @Szpadel.
|
||||
- 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`.
|
||||
@@ -56,33 +99,34 @@
|
||||
- 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: report linked status consistently in channel status. (#1050) — thanks @YuriNachos.
|
||||
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
|
||||
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
|
||||
- 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.
|
||||
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.
|
||||
- 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.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||
- Fix: honor message tool channel for duplicate suppression (prefer `NO_REPLY` after `message` tool sends). (#1053) — thanks @sashcatanzarite.
|
||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
@@ -92,15 +136,6 @@
|
||||
- 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
|
||||
|
||||
|
||||
@@ -477,21 +477,21 @@ Thanks to all clawtributors:
|
||||
<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/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>
|
||||
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></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/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/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/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/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></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/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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/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/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/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=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/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/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/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></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/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=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/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/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
@@ -0,0 +1,368 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConfigSchemaForm: View {
|
||||
@Bindable var store: ChannelsStore
|
||||
let schema: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
|
||||
var body: some View {
|
||||
self.renderNode(schema, path: path)
|
||||
}
|
||||
|
||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
||||
let storedValue = store.configValue(at: path)
|
||||
let value = storedValue ?? schema.explicitDefault
|
||||
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
||||
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
||||
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
|
||||
|
||||
if !variants.isEmpty {
|
||||
let nonNull = variants.filter { !$0.isNullSchema }
|
||||
if nonNull.count == 1, let only = nonNull.first {
|
||||
return self.renderNode(only, path: path)
|
||||
}
|
||||
let literals = nonNull.compactMap { $0.literalValue }
|
||||
if !literals.isEmpty, literals.count == nonNull.count {
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
|
||||
Text("Select…").tag(-1)
|
||||
ForEach(literals.indices, id: \ .self) { index in
|
||||
Text(String(describing: literals[index])).tag(index)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
switch schema.schemaType {
|
||||
case "object":
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let label {
|
||||
Text(label)
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
let properties = schema.properties
|
||||
let sortedKeys = properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
|
||||
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
ForEach(sortedKeys, id: \ .self) { key in
|
||||
if let child = properties[key] {
|
||||
self.renderNode(child, path: path + [.key(key)])
|
||||
}
|
||||
}
|
||||
if schema.allowsAdditionalProperties {
|
||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||
}
|
||||
}
|
||||
)
|
||||
case "array":
|
||||
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
||||
case "boolean":
|
||||
return AnyView(
|
||||
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
||||
if let label { Text(label) } else { Text("Enabled") }
|
||||
}
|
||||
.help(help ?? "")
|
||||
)
|
||||
case "number", "integer":
|
||||
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
|
||||
case "string":
|
||||
return AnyView(self.renderStringField(schema, path: path, label: label, help: help))
|
||||
default:
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
Text("Unsupported field type.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderStringField(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
label: String?,
|
||||
help: String?) -> some View
|
||||
{
|
||||
let hint = hintForPath(path, hints: store.configUiHints)
|
||||
let placeholder = hint?.placeholder ?? ""
|
||||
let sensitive = hint?.sensitive ?? isSensitivePath(path)
|
||||
let defaultValue = schema.explicitDefault as? String
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let options = schema.enumValues {
|
||||
Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) {
|
||||
Text("Select…").tag(-1)
|
||||
ForEach(options.indices, id: \ .self) { index in
|
||||
Text(String(describing: options[index])).tag(index)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
} else if sensitive {
|
||||
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderNumberField(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
label: String?,
|
||||
help: String?) -> some View
|
||||
{
|
||||
let defaultValue = (schema.explicitDefault as? Double)
|
||||
?? (schema.explicitDefault as? Int).map(Double.init)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
TextField(
|
||||
"",
|
||||
text: self.numberBinding(
|
||||
path,
|
||||
isInteger: schema.schemaType == "integer",
|
||||
defaultValue: defaultValue
|
||||
)
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderArray(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
value: Any?,
|
||||
label: String?,
|
||||
help: String?) -> some View
|
||||
{
|
||||
let items = value as? [Any] ?? []
|
||||
let itemSchema = schema.items
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(items.indices, id: \ .self) { index in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if let itemSchema {
|
||||
self.renderNode(itemSchema, path: path + [.index(index)])
|
||||
} else {
|
||||
Text(String(describing: items[index]))
|
||||
}
|
||||
Button("Remove") {
|
||||
var next = items
|
||||
next.remove(at: index)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Button("Add") {
|
||||
var next = items
|
||||
if let itemSchema {
|
||||
next.append(itemSchema.defaultValue)
|
||||
} else {
|
||||
next.append("")
|
||||
}
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderAdditionalProperties(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
value: Any?) -> some View
|
||||
{
|
||||
if let additionalSchema = schema.additionalProperties {
|
||||
let dict = value as? [String: Any] ?? [:]
|
||||
let reserved = Set(schema.properties.keys)
|
||||
let extras = dict.keys.filter { !reserved.contains($0) }.sorted()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Extra entries")
|
||||
.font(.callout.weight(.semibold))
|
||||
if extras.isEmpty {
|
||||
Text("No extra entries yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(extras, id: \ .self) { key in
|
||||
let itemPath: ConfigPath = path + [.key(key)]
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
TextField("Key", text: self.mapKeyBinding(path: path, key: key))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 160)
|
||||
self.renderNode(additionalSchema, path: itemPath)
|
||||
Button("Remove") {
|
||||
var next = dict
|
||||
next.removeValue(forKey: key)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Add") {
|
||||
var next = dict
|
||||
var index = 1
|
||||
var key = "new-\(index)"
|
||||
while next[key] != nil {
|
||||
index += 1
|
||||
key = "new-\(index)"
|
||||
}
|
||||
next[key] = additionalSchema.defaultValue
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
if let value = store.configValue(at: path) as? String { return value }
|
||||
return defaultValue ?? ""
|
||||
},
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
if let value = store.configValue(at: path) as? Bool { return value }
|
||||
return defaultValue ?? false
|
||||
},
|
||||
set: { newValue in
|
||||
store.updateConfigValue(path: path, value: newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func numberBinding(
|
||||
_ path: ConfigPath,
|
||||
isInteger: Bool,
|
||||
defaultValue: Double?
|
||||
) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
if let value = store.configValue(at: path) { return String(describing: value) }
|
||||
guard let defaultValue else { return "" }
|
||||
return isInteger ? String(Int(defaultValue)) : String(defaultValue)
|
||||
},
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
} else if let value = Double(trimmed) {
|
||||
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func enumBinding(
|
||||
_ path: ConfigPath,
|
||||
options: [Any],
|
||||
defaultValue: Any?
|
||||
) -> Binding<Int> {
|
||||
Binding(
|
||||
get: {
|
||||
let value = store.configValue(at: path) ?? defaultValue
|
||||
guard let value else { return -1 }
|
||||
return options.firstIndex { option in
|
||||
String(describing: option) == String(describing: value)
|
||||
} ?? -1
|
||||
},
|
||||
set: { index in
|
||||
guard index >= 0, index < options.count else {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
return
|
||||
}
|
||||
store.updateConfigValue(path: path, value: options[index])
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
|
||||
Binding(
|
||||
get: { key },
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard trimmed != key else { return }
|
||||
let current = store.configValue(at: path) as? [String: Any] ?? [:]
|
||||
guard current[trimmed] == nil else { return }
|
||||
var next = current
|
||||
next[trimmed] = current[key]
|
||||
next.removeValue(forKey: key)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChannelConfigForm: View {
|
||||
@Bindable var store: ChannelsStore
|
||||
let channelId: String
|
||||
|
||||
var body: some View {
|
||||
if store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
|
||||
} else {
|
||||
Text("Schema unavailable for this channel.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ChannelsSettings {
|
||||
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
GroupBox(title) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func channelHeaderActions(_ channel: ChannelItem) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if channel.id == "whatsapp" {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutWhatsApp() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
}
|
||||
|
||||
if channel.id == "telegram" {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutTelegram() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.telegramBusy)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await self.store.refresh(probe: true) }
|
||||
} label: {
|
||||
if self.store.isRefreshing {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Refresh")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isRefreshing)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
var whatsAppSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Linking") {
|
||||
if let message = self.store.whatsappLoginMessage {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
.frame(width: 180, height: 180)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.startWhatsAppLogin(force: false) }
|
||||
} label: {
|
||||
if self.store.whatsappBusy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Show QR")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
|
||||
Button("Relink") {
|
||||
Task { await self.store.startWhatsAppLogin(force: true) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
self.configEditorSection(channelId: "whatsapp")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func genericChannelSection(_ channel: ChannelItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.configEditorSection(channelId: channel.id)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func configEditorSection(channelId: String) -> some View {
|
||||
self.formSection("Configuration") {
|
||||
ChannelConfigForm(store: self.store, channelId: channelId)
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveConfigDraft() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig || !self.store.configDirty)
|
||||
|
||||
Button("Reload") {
|
||||
Task { await self.store.reloadConfigDraft() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var configStatusMessage: some View {
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
-98
@@ -1,6 +1,7 @@
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
extension ChannelsSettings {
|
||||
private func channelStatus<T: Decodable>(
|
||||
_ id: String,
|
||||
as type: T.Type) -> T?
|
||||
@@ -242,16 +243,18 @@ extension ConnectionsSettings {
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var isTelegramTokenLocked: Bool {
|
||||
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var isDiscordTokenLocked: Bool {
|
||||
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var orderedChannels: [ConnectionChannel] {
|
||||
ConnectionChannel.allCases.sorted { lhs, rhs in
|
||||
var orderedChannels: [ChannelItem] {
|
||||
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
|
||||
let order = self.store.snapshot?.channelOrder ?? fallback
|
||||
let channels = order.enumerated().map { index, id in
|
||||
ChannelItem(
|
||||
id: id,
|
||||
title: self.resolveChannelTitle(id),
|
||||
detailTitle: self.resolveChannelDetailTitle(id),
|
||||
systemImage: self.resolveChannelSystemImage(id),
|
||||
sortOrder: index)
|
||||
}
|
||||
return channels.sorted { lhs, rhs in
|
||||
let lhsEnabled = self.channelEnabled(lhs)
|
||||
let rhsEnabled = self.channelEnabled(rhs)
|
||||
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
||||
@@ -259,11 +262,11 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
var enabledChannels: [ConnectionChannel] {
|
||||
var enabledChannels: [ChannelItem] {
|
||||
self.orderedChannels.filter { self.channelEnabled($0) }
|
||||
}
|
||||
|
||||
var availableChannels: [ConnectionChannel] {
|
||||
var availableChannels: [ChannelItem] {
|
||||
self.orderedChannels.filter { !self.channelEnabled($0) }
|
||||
}
|
||||
|
||||
@@ -277,143 +280,183 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
func channelEnabled(_ channel: ConnectionChannel) -> Bool {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.linked || status.running
|
||||
case .telegram:
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .discord:
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .signal:
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .imessage:
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
}
|
||||
func channelEnabled(_ channel: ChannelItem) -> Bool {
|
||||
let status = self.channelStatusDictionary(channel.id)
|
||||
let configured = status?["configured"]?.boolValue ?? false
|
||||
let running = status?["running"]?.boolValue ?? false
|
||||
let connected = status?["connected"]?.boolValue ?? false
|
||||
let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains(
|
||||
where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false
|
||||
return configured || running || connected || accountActive
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func channelSection(_ channel: ConnectionChannel) -> some View {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
func channelSection(_ channel: ChannelItem) -> some View {
|
||||
if channel.id == "whatsapp" {
|
||||
self.whatsAppSection
|
||||
case .telegram:
|
||||
self.telegramSection
|
||||
case .discord:
|
||||
self.discordSection
|
||||
case .signal:
|
||||
self.signalSection
|
||||
case .imessage:
|
||||
self.imessageSection
|
||||
} else {
|
||||
self.genericChannelSection(channel)
|
||||
}
|
||||
}
|
||||
|
||||
func channelTint(_ channel: ConnectionChannel) -> Color {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppTint
|
||||
case .telegram:
|
||||
self.telegramTint
|
||||
case .discord:
|
||||
self.discordTint
|
||||
case .signal:
|
||||
self.signalTint
|
||||
case .imessage:
|
||||
self.imessageTint
|
||||
func channelTint(_ channel: ChannelItem) -> Color {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
return self.whatsAppTint
|
||||
case "telegram":
|
||||
return self.telegramTint
|
||||
case "discord":
|
||||
return self.discordTint
|
||||
case "signal":
|
||||
return self.signalTint
|
||||
case "imessage":
|
||||
return self.imessageTint
|
||||
default:
|
||||
if self.channelHasError(channel) { return .orange }
|
||||
if self.channelEnabled(channel) { return .green }
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
func channelSummary(_ channel: ConnectionChannel) -> String {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppSummary
|
||||
case .telegram:
|
||||
self.telegramSummary
|
||||
case .discord:
|
||||
self.discordSummary
|
||||
case .signal:
|
||||
self.signalSummary
|
||||
case .imessage:
|
||||
self.imessageSummary
|
||||
func channelSummary(_ channel: ChannelItem) -> String {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
return self.whatsAppSummary
|
||||
case "telegram":
|
||||
return self.telegramSummary
|
||||
case "discord":
|
||||
return self.discordSummary
|
||||
case "signal":
|
||||
return self.signalSummary
|
||||
case "imessage":
|
||||
return self.imessageSummary
|
||||
default:
|
||||
if self.channelHasError(channel) { return "Error" }
|
||||
if self.channelEnabled(channel) { return "Active" }
|
||||
return "Not configured"
|
||||
}
|
||||
}
|
||||
|
||||
func channelDetails(_ channel: ConnectionChannel) -> String? {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppDetails
|
||||
case .telegram:
|
||||
self.telegramDetails
|
||||
case .discord:
|
||||
self.discordDetails
|
||||
case .signal:
|
||||
self.signalDetails
|
||||
case .imessage:
|
||||
self.imessageDetails
|
||||
func channelDetails(_ channel: ChannelItem) -> String? {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
return self.whatsAppDetails
|
||||
case "telegram":
|
||||
return self.telegramDetails
|
||||
case "discord":
|
||||
return self.discordDetails
|
||||
case "signal":
|
||||
return self.signalDetails
|
||||
case "imessage":
|
||||
return self.imessageDetails
|
||||
default:
|
||||
let status = self.channelStatusDictionary(channel.id)
|
||||
if let err = status?["lastError"]?.stringValue, !err.isEmpty {
|
||||
return "Error: \(err)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func channelLastCheckText(_ channel: ConnectionChannel) -> String {
|
||||
func channelLastCheckText(_ channel: ChannelItem) -> String {
|
||||
guard let date = self.channelLastCheck(channel) else { return "never" }
|
||||
return relativeAge(from: date)
|
||||
}
|
||||
|
||||
func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
func channelLastCheck(_ channel: ChannelItem) -> Date? {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return nil }
|
||||
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
|
||||
case .telegram:
|
||||
case "telegram":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .discord:
|
||||
case "discord":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .signal:
|
||||
case "signal":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||
case .imessage:
|
||||
case "imessage":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
|
||||
.lastProbeAt)
|
||||
default:
|
||||
let status = self.channelStatusDictionary(channel.id)
|
||||
if let probeAt = status?["lastProbeAt"]?.doubleValue {
|
||||
return self.date(fromMs: probeAt)
|
||||
}
|
||||
if let accounts = self.store.snapshot?.channelAccounts[channel.id] {
|
||||
let last = accounts.compactMap { $0.lastInboundAt ?? $0.lastOutboundAt }.max()
|
||||
return self.date(fromMs: last)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func channelHasError(_ channel: ConnectionChannel) -> Bool {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
func channelHasError(_ channel: ChannelItem) -> Bool {
|
||||
switch channel.id {
|
||||
case "whatsapp":
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
|
||||
case .telegram:
|
||||
case "telegram":
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .discord:
|
||||
case "discord":
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .signal:
|
||||
case "signal":
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .imessage:
|
||||
case "imessage":
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
default:
|
||||
let status = self.channelStatusDictionary(channel.id)
|
||||
return status?["lastError"]?.stringValue?.isEmpty == false
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveChannelTitle(_ id: String) -> String {
|
||||
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
|
||||
return label
|
||||
}
|
||||
return id.prefix(1).uppercased() + id.dropFirst()
|
||||
}
|
||||
|
||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "WhatsApp Web"
|
||||
case "telegram": return "Telegram Bot"
|
||||
case "discord": return "Discord Bot"
|
||||
case "slack": return "Slack Bot"
|
||||
case "signal": return "Signal REST"
|
||||
case "imessage": return "iMessage"
|
||||
default: return self.resolveChannelTitle(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "message"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "signal": return "antenna.radiowaves.left.and.right"
|
||||
case "imessage": return "message.fill"
|
||||
default: return "message"
|
||||
}
|
||||
}
|
||||
|
||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||
self.store.snapshot?.channels[id]?.dictionaryValue
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import AppKit
|
||||
|
||||
extension ConnectionsSettings {
|
||||
extension ChannelsSettings {
|
||||
func date(fromMs ms: Double?) -> Date? {
|
||||
guard let ms else { return nil }
|
||||
return Date(timeIntervalSince1970: ms / 1000)
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
extension ChannelsSettings {
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
self.sidebar
|
||||
@@ -57,7 +57,7 @@ extension ConnectionsSettings {
|
||||
|
||||
private var emptyDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Connections")
|
||||
Text("Channels")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Select a channel to view status and settings.")
|
||||
.font(.callout)
|
||||
@@ -67,7 +67,7 @@ extension ConnectionsSettings {
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
private func channelDetail(_ channel: ConnectionChannel) -> some View {
|
||||
private func channelDetail(_ channel: ChannelItem) -> some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailHeader(for: channel)
|
||||
@@ -81,7 +81,7 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarRow(_ channel: ConnectionChannel) -> some View {
|
||||
private func sidebarRow(_ channel: ChannelItem) -> some View {
|
||||
let isSelected = self.selectedChannel == channel
|
||||
return Button {
|
||||
self.selectedChannel = channel
|
||||
@@ -119,7 +119,7 @@ extension ConnectionsSettings {
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private func detailHeader(for channel: ConnectionChannel) -> some View {
|
||||
private func detailHeader(for channel: ChannelItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Label(channel.detailTitle, systemImage: channel.systemImage)
|
||||
@@ -0,0 +1,19 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelsSettings: View {
|
||||
struct ChannelItem: Identifiable, Hashable {
|
||||
let id: String
|
||||
let title: String
|
||||
let detailTitle: String
|
||||
let systemImage: String
|
||||
let sortOrder: Int
|
||||
}
|
||||
|
||||
@Bindable var store: ChannelsStore
|
||||
@State var selectedChannel: ChannelItem?
|
||||
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension ChannelsStore {
|
||||
func loadConfigSchema() async {
|
||||
guard !self.configSchemaLoading else { return }
|
||||
self.configSchemaLoading = true
|
||||
defer { self.configSchemaLoading = false }
|
||||
|
||||
do {
|
||||
let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configSchema,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
let schemaValue = res.schema.foundationValue
|
||||
self.configSchema = ConfigSchemaNode(raw: schemaValue)
|
||||
let hintValues = res.uihints.mapValues { $0.foundationValue }
|
||||
self.configUiHints = decodeUiHints(hintValues)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = snap.valid == false
|
||||
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
||||
: nil
|
||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot
|
||||
self.configDirty = false
|
||||
self.configLoaded = true
|
||||
|
||||
self.applyUIConfig(snap)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
||||
let ui = snap.config?["ui"]?.dictionaryValue
|
||||
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
}
|
||||
|
||||
func channelConfigSchema(for channelId: String) -> ConfigSchemaNode? {
|
||||
guard let root = self.configSchema else { return nil }
|
||||
return root.node(at: [.key("channels"), .key(channelId)])
|
||||
}
|
||||
|
||||
func configValue(at path: ConfigPath) -> Any? {
|
||||
if let value = valueAtPath(self.configDraft, path: path) {
|
||||
return value
|
||||
}
|
||||
guard path.count >= 2 else { return nil }
|
||||
if case .key("channels") = path[0], case .key(_) = path[1] {
|
||||
let fallbackPath = Array(path.dropFirst())
|
||||
return valueAtPath(self.configDraft, path: fallbackPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateConfigValue(path: ConfigPath, value: Any?) {
|
||||
var root: Any = self.configDraft
|
||||
setValue(&root, path: path, value: value)
|
||||
self.configDraft = root as? [String: Any] ?? self.configDraft
|
||||
self.configDirty = true
|
||||
}
|
||||
|
||||
func saveConfigDraft() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(self.configDraft)
|
||||
await self.loadConfig()
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func reloadConfigDraft() async {
|
||||
await self.loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
||||
var current: Any? = root
|
||||
for segment in path {
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
guard let dict = current as? [String: Any] else { return nil }
|
||||
current = dict[key]
|
||||
case .index(let index):
|
||||
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
||||
current = array[index]
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
||||
guard let segment = path.first else { return }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
var dict = root as? [String: Any] ?? [:]
|
||||
if path.count == 1 {
|
||||
if let value {
|
||||
dict[key] = value
|
||||
} else {
|
||||
dict.removeValue(forKey: key)
|
||||
}
|
||||
root = dict
|
||||
return
|
||||
}
|
||||
var child = dict[key] ?? [:]
|
||||
setValue(&child, path: Array(path.dropFirst()), value: value)
|
||||
dict[key] = child
|
||||
root = dict
|
||||
case .index(let index):
|
||||
var array = root as? [Any] ?? []
|
||||
if index >= array.count {
|
||||
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
|
||||
}
|
||||
if path.count == 1 {
|
||||
if let value {
|
||||
array[index] = value
|
||||
} else if array.indices.contains(index) {
|
||||
array.remove(at: index)
|
||||
}
|
||||
root = array
|
||||
return
|
||||
}
|
||||
var child = array[index]
|
||||
setValue(&child, path: Array(path.dropFirst()), value: value)
|
||||
array[index] = child
|
||||
root = array
|
||||
}
|
||||
}
|
||||
|
||||
private func cloneConfigValue(_ value: Any) -> Any {
|
||||
guard JSONSerialization.isValidJSONObject(value) else { return value }
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: value, options: [])
|
||||
return try JSONSerialization.jsonObject(with: data, options: [])
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,13 +1,14 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension ConnectionsStore {
|
||||
extension ChannelsStore {
|
||||
func start() {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.pollTask == nil else { return }
|
||||
self.pollTask = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh(probe: true)
|
||||
await self.loadConfigSchema()
|
||||
await self.loadConfig()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||
+8
-101
@@ -187,49 +187,10 @@ struct ConfigSnapshot: Codable {
|
||||
let issues: [Issue]?
|
||||
}
|
||||
|
||||
struct DiscordGuildChannelForm: Identifiable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var allow: Bool
|
||||
var requireMention: Bool
|
||||
|
||||
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
|
||||
self.key = key
|
||||
self.allow = allow
|
||||
self.requireMention = requireMention
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscordGuildForm: Identifiable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var slug: String
|
||||
var requireMention: Bool
|
||||
var reactionNotifications: String
|
||||
var users: String
|
||||
var channels: [DiscordGuildChannelForm]
|
||||
|
||||
init(
|
||||
key: String = "",
|
||||
slug: String = "",
|
||||
requireMention: Bool = false,
|
||||
reactionNotifications: String = "own",
|
||||
users: String = "",
|
||||
channels: [DiscordGuildChannelForm] = [])
|
||||
{
|
||||
self.key = key
|
||||
self.slug = slug
|
||||
self.requireMention = requireMention
|
||||
self.reactionNotifications = reactionNotifications
|
||||
self.users = users
|
||||
self.channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ConnectionsStore {
|
||||
static let shared = ConnectionsStore()
|
||||
final class ChannelsStore {
|
||||
static let shared = ChannelsStore()
|
||||
|
||||
var snapshot: ChannelsStatusSnapshot?
|
||||
var lastError: String?
|
||||
@@ -240,75 +201,21 @@ final class ConnectionsStore {
|
||||
var whatsappLoginQrDataUrl: String?
|
||||
var whatsappLoginConnected: Bool?
|
||||
var whatsappBusy = false
|
||||
|
||||
var telegramToken: String = ""
|
||||
var telegramRequireMention = true
|
||||
var telegramAllowFrom: String = ""
|
||||
var telegramProxy: String = ""
|
||||
var telegramWebhookUrl: String = ""
|
||||
var telegramWebhookSecret: String = ""
|
||||
var telegramWebhookPath: String = ""
|
||||
var telegramBusy = false
|
||||
var discordEnabled = true
|
||||
var discordToken: String = ""
|
||||
var discordDmEnabled = true
|
||||
var discordAllowFrom: String = ""
|
||||
var discordGroupEnabled = false
|
||||
var discordGroupChannels: String = ""
|
||||
var discordMediaMaxMb: String = ""
|
||||
var discordHistoryLimit: String = ""
|
||||
var discordTextChunkLimit: String = ""
|
||||
var discordReplyToMode: String = "off"
|
||||
var discordGuilds: [DiscordGuildForm] = []
|
||||
var discordActionReactions = true
|
||||
var discordActionStickers = true
|
||||
var discordActionPolls = true
|
||||
var discordActionPermissions = true
|
||||
var discordActionMessages = true
|
||||
var discordActionThreads = true
|
||||
var discordActionPins = true
|
||||
var discordActionSearch = true
|
||||
var discordActionMemberInfo = true
|
||||
var discordActionRoleInfo = true
|
||||
var discordActionChannelInfo = true
|
||||
var discordActionVoiceStatus = true
|
||||
var discordActionEvents = true
|
||||
var discordActionRoles = false
|
||||
var discordActionModeration = false
|
||||
var discordSlashEnabled = false
|
||||
var discordSlashName: String = ""
|
||||
var discordSlashSessionPrefix: String = ""
|
||||
var discordSlashEphemeral = true
|
||||
var signalEnabled = true
|
||||
var signalAccount: String = ""
|
||||
var signalHttpUrl: String = ""
|
||||
var signalHttpHost: String = ""
|
||||
var signalHttpPort: String = ""
|
||||
var signalCliPath: String = ""
|
||||
var signalAutoStart = true
|
||||
var signalReceiveMode: String = ""
|
||||
var signalIgnoreAttachments = false
|
||||
var signalIgnoreStories = false
|
||||
var signalSendReadReceipts = false
|
||||
var signalAllowFrom: String = ""
|
||||
var signalMediaMaxMb: String = ""
|
||||
var imessageEnabled = true
|
||||
var imessageCliPath: String = ""
|
||||
var imessageDbPath: String = ""
|
||||
var imessageService: String = "auto"
|
||||
var imessageRegion: String = ""
|
||||
var imessageAllowFrom: String = ""
|
||||
var imessageIncludeAttachments = false
|
||||
var imessageMediaMaxMb: String = ""
|
||||
|
||||
var configStatus: String?
|
||||
var isSavingConfig = false
|
||||
var configSchemaLoading = false
|
||||
var configSchema: ConfigSchemaNode?
|
||||
var configUiHints: [String: ConfigUiHint] = [:]
|
||||
var configDraft: [String: Any] = [:]
|
||||
var configDirty = false
|
||||
|
||||
let interval: TimeInterval = 45
|
||||
let isPreview: Bool
|
||||
var pollTask: Task<Void, Never>?
|
||||
var configRoot: [String: Any] = [:]
|
||||
var configLoaded = false
|
||||
var configHash: String?
|
||||
|
||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||
self.isPreview = isPreview
|
||||
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
|
||||
enum ConfigPathSegment: Hashable {
|
||||
case key(String)
|
||||
case index(Int)
|
||||
}
|
||||
|
||||
typealias ConfigPath = [ConfigPathSegment]
|
||||
|
||||
struct ConfigUiHint {
|
||||
let label: String?
|
||||
let help: String?
|
||||
let order: Double?
|
||||
let advanced: Bool?
|
||||
let sensitive: Bool?
|
||||
let placeholder: String?
|
||||
|
||||
init(raw: [String: Any]) {
|
||||
self.label = raw["label"] as? String
|
||||
self.help = raw["help"] as? String
|
||||
if let order = raw["order"] as? Double {
|
||||
self.order = order
|
||||
} else if let orderInt = raw["order"] as? Int {
|
||||
self.order = Double(orderInt)
|
||||
} else {
|
||||
self.order = nil
|
||||
}
|
||||
self.advanced = raw["advanced"] as? Bool
|
||||
self.sensitive = raw["sensitive"] as? Bool
|
||||
self.placeholder = raw["placeholder"] as? String
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSchemaNode {
|
||||
let raw: [String: Any]
|
||||
|
||||
init?(raw: Any) {
|
||||
guard let dict = raw as? [String: Any] else { return nil }
|
||||
self.raw = dict
|
||||
}
|
||||
|
||||
var title: String? { self.raw["title"] as? String }
|
||||
var description: String? { self.raw["description"] as? String }
|
||||
var enumValues: [Any]? { self.raw["enum"] as? [Any] }
|
||||
var constValue: Any? { self.raw["const"] }
|
||||
var explicitDefault: Any? { self.raw["default"] }
|
||||
var requiredKeys: Set<String> {
|
||||
Set((self.raw["required"] as? [String]) ?? [])
|
||||
}
|
||||
|
||||
var typeList: [String] {
|
||||
if let type = self.raw["type"] as? String { return [type] }
|
||||
if let types = self.raw["type"] as? [String] { return types }
|
||||
return []
|
||||
}
|
||||
|
||||
var schemaType: String? {
|
||||
let filtered = self.typeList.filter { $0 != "null" }
|
||||
if let first = filtered.first { return first }
|
||||
return self.typeList.first
|
||||
}
|
||||
|
||||
var isNullSchema: Bool {
|
||||
let types = self.typeList
|
||||
return types.count == 1 && types.first == "null"
|
||||
}
|
||||
|
||||
var properties: [String: ConfigSchemaNode] {
|
||||
guard let props = self.raw["properties"] as? [String: Any] else { return [:] }
|
||||
return props.compactMapValues { ConfigSchemaNode(raw: $0) }
|
||||
}
|
||||
|
||||
var anyOf: [ConfigSchemaNode] {
|
||||
guard let raw = self.raw["anyOf"] as? [Any] else { return [] }
|
||||
return raw.compactMap { ConfigSchemaNode(raw: $0) }
|
||||
}
|
||||
|
||||
var oneOf: [ConfigSchemaNode] {
|
||||
guard let raw = self.raw["oneOf"] as? [Any] else { return [] }
|
||||
return raw.compactMap { ConfigSchemaNode(raw: $0) }
|
||||
}
|
||||
|
||||
var literalValue: Any? {
|
||||
if let constValue { return constValue }
|
||||
if let enumValues, enumValues.count == 1 { return enumValues[0] }
|
||||
return nil
|
||||
}
|
||||
|
||||
var items: ConfigSchemaNode? {
|
||||
if let items = self.raw["items"] as? [Any], let first = items.first {
|
||||
return ConfigSchemaNode(raw: first)
|
||||
}
|
||||
if let items = self.raw["items"] {
|
||||
return ConfigSchemaNode(raw: items)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var additionalProperties: ConfigSchemaNode? {
|
||||
if let additional = self.raw["additionalProperties"] as? [String: Any] {
|
||||
return ConfigSchemaNode(raw: additional)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var allowsAdditionalProperties: Bool {
|
||||
if let allow = self.raw["additionalProperties"] as? Bool { return allow }
|
||||
return self.additionalProperties != nil
|
||||
}
|
||||
|
||||
var defaultValue: Any {
|
||||
if let value = self.raw["default"] { return value }
|
||||
switch self.schemaType {
|
||||
case "object":
|
||||
return [String: Any]()
|
||||
case "array":
|
||||
return [Any]()
|
||||
case "boolean":
|
||||
return false
|
||||
case "integer":
|
||||
return 0
|
||||
case "number":
|
||||
return 0.0
|
||||
case "string":
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func node(at path: ConfigPath) -> ConfigSchemaNode? {
|
||||
var current: ConfigSchemaNode? = self
|
||||
for segment in path {
|
||||
guard let node = current else { return nil }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
if node.schemaType == "object" {
|
||||
if let next = node.properties[key] {
|
||||
current = next
|
||||
continue
|
||||
}
|
||||
if let additional = node.additionalProperties {
|
||||
current = additional
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case .index:
|
||||
guard node.schemaType == "array" else { return nil }
|
||||
current = node.items
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
func decodeUiHints(_ raw: [String: Any]) -> [String: ConfigUiHint] {
|
||||
raw.reduce(into: [:]) { result, entry in
|
||||
if let hint = entry.value as? [String: Any] {
|
||||
result[entry.key] = ConfigUiHint(raw: hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiHint? {
|
||||
let key = pathKey(path)
|
||||
if let direct = hints[key] { return direct }
|
||||
let segments = key.split(separator: ".").map(String.init)
|
||||
for (hintKey, hint) in hints {
|
||||
guard hintKey.contains("*") else { continue }
|
||||
let hintSegments = hintKey.split(separator: ".").map(String.init)
|
||||
guard hintSegments.count == segments.count else { continue }
|
||||
var match = true
|
||||
for (index, seg) in segments.enumerated() {
|
||||
let hintSegment = hintSegments[index]
|
||||
if hintSegment != "*" && hintSegment != seg {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match { return hint }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSensitivePath(_ path: ConfigPath) -> Bool {
|
||||
let key = pathKey(path).lowercased()
|
||||
return key.contains("token")
|
||||
|| key.contains("password")
|
||||
|| key.contains("secret")
|
||||
|| key.contains("apikey")
|
||||
|| key.hasSuffix("key")
|
||||
}
|
||||
|
||||
func pathKey(_ path: ConfigPath) -> String {
|
||||
path.compactMap { segment -> String? in
|
||||
switch segment {
|
||||
case .key(let key): return key
|
||||
case .index: return nil
|
||||
}
|
||||
}
|
||||
.joined(separator: ".")
|
||||
}
|
||||
@@ -4,86 +4,54 @@ import SwiftUI
|
||||
struct ConfigSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
private let state = AppStateStore.shared
|
||||
private let labelColumnWidth: CGFloat = 120
|
||||
private static let browserAttachOnlyHelp =
|
||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||
private static let browserProfileNote =
|
||||
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
||||
+ "so it won’t interfere with your daily browser."
|
||||
@State private var configModel: String = ""
|
||||
@State private var configSaving = false
|
||||
@Bindable var store: ChannelsStore
|
||||
@State private var hasLoaded = false
|
||||
@State private var models: [ModelChoice] = []
|
||||
@State private var modelsLoading = false
|
||||
@State private var modelSearchQuery: String = ""
|
||||
@State private var isModelPickerOpen = false
|
||||
@State private var modelError: String?
|
||||
@State private var modelsSourceLabel: String?
|
||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||
@State private var allowAutosave = false
|
||||
@State private var heartbeatMinutes: Int?
|
||||
@State private var heartbeatBody: String = "HEARTBEAT"
|
||||
|
||||
// clawd browser settings (stored in ~/.clawdbot/clawdbot.json under "browser")
|
||||
@State private var browserEnabled: Bool = true
|
||||
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
|
||||
@State private var browserColorHex: String = "#FF4500"
|
||||
@State private var browserAttachOnly: Bool = false
|
||||
|
||||
// Talk mode settings (stored in ~/.clawdbot/clawdbot.json under "talk")
|
||||
@State private var talkVoiceId: String = ""
|
||||
@State private var talkInterruptOnSpeech: Bool = true
|
||||
@State private var talkApiKey: String = ""
|
||||
@State private var gatewayApiKeyFound = false
|
||||
@FocusState private var modelSearchFocused: Bool
|
||||
|
||||
private struct ConfigDraft {
|
||||
let configModel: String
|
||||
let heartbeatMinutes: Int?
|
||||
let heartbeatBody: String
|
||||
let browserEnabled: Bool
|
||||
let browserControlUrl: String
|
||||
let browserColorHex: String
|
||||
let browserAttachOnly: Bool
|
||||
let talkVoiceId: String
|
||||
let talkApiKey: String
|
||||
let talkInterruptOnSpeech: Bool
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView { self.content }
|
||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
.onChange(of: self.modelCatalogReloadBump) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
self.hasLoaded = true
|
||||
await self.loadConfig()
|
||||
await self.loadModels()
|
||||
await self.refreshGatewayTalkApiKey()
|
||||
self.allowAutosave = true
|
||||
}
|
||||
ScrollView {
|
||||
self.content
|
||||
}
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
self.hasLoaded = true
|
||||
await self.store.loadConfigSchema()
|
||||
await self.store.loadConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.agentSection
|
||||
.disabled(self.isNixMode)
|
||||
self.heartbeatSection
|
||||
.disabled(self.isNixMode)
|
||||
self.talkSection
|
||||
.disabled(self.isNixMode)
|
||||
self.browserSection
|
||||
.disabled(self.isNixMode)
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.actionRow
|
||||
Group {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = self.store.configSchema {
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
||||
.disabled(self.isNixMode)
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if self.store.configDirty && !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -94,843 +62,33 @@ extension ConfigSettings {
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
Text("Clawdbot CLI config")
|
||||
Text("Config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(self.isNixMode
|
||||
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
||||
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
|
||||
: "Edit ~/.clawdbot/clawdbot.json using the schema-driven form.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var agentSection: some View {
|
||||
GroupBox("Agent") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.modelPickerField
|
||||
self.modelMetaLabels
|
||||
}
|
||||
}
|
||||
private var actionRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button("Reload") {
|
||||
Task { await self.store.reloadConfigDraft() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.disabled(!self.store.configLoaded)
|
||||
|
||||
private var modelPickerField: some View {
|
||||
Button {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.isModelPickerOpen = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.modelPickerLabel)
|
||||
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.foregroundStyle(.secondary)
|
||||
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
|
||||
Task { await self.store.saveConfigDraft() }
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(
|
||||
Color(nsColor: .textBackgroundColor)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(
|
||||
Color.secondary.opacity(0.25),
|
||||
lineWidth: 1))
|
||||
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
|
||||
self.modelPickerPopover
|
||||
}
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.isModelPickerOpen) { _, isOpen in
|
||||
if isOpen {
|
||||
self.modelSearchQuery = ""
|
||||
self.modelSearchFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var modelPickerPopover: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
TextField("Search models", text: self.$modelSearchQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused(self.$modelSearchFocused)
|
||||
.controlSize(.small)
|
||||
.onSubmit {
|
||||
if let exact = self.exactMatchForQuery() {
|
||||
self.selectModel(exact)
|
||||
return
|
||||
}
|
||||
if let manual = self.manualEntryCandidate {
|
||||
self.selectManualModel(manual)
|
||||
return
|
||||
}
|
||||
if self.modelSearchMatches.count == 1 {
|
||||
self.selectModel(self.modelSearchMatches[0])
|
||||
}
|
||||
}
|
||||
List {
|
||||
if self.modelSearchMatches.isEmpty {
|
||||
Text("No models match \"\(self.modelSearchQuery)\"")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.modelSearchMatches) { choice in
|
||||
Button {
|
||||
self.selectModel(choice)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(choice.name)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(choice.provider.uppercased())
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.secondary.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
}
|
||||
|
||||
if let manual = self.manualEntryCandidate {
|
||||
Button("Use \"\(manual)\"") {
|
||||
self.selectManualModel(manual)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.frame(width: 340, height: 260)
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modelMetaLabels: some View {
|
||||
if self.shouldShowProviderHintForSelection {
|
||||
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
|
||||
}
|
||||
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let authMode = self.selectedAnthropicAuthMode {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(authMode.isConfigured ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("Anthropic auth: \(authMode.shortLabel)")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
|
||||
.help(self.anthropicAuthHelpText)
|
||||
|
||||
AnthropicAuthControls(connectionMode: self.state.connectionMode)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let modelsSourceLabel {
|
||||
Text("Model catalog: \(modelsSourceLabel)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var anthropicAuthHelpText: String {
|
||||
"Determined from Clawdbot OAuth token file (~/.clawdbot/credentials/oauth.json) " +
|
||||
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
||||
}
|
||||
|
||||
private var heartbeatSection: some View {
|
||||
GroupBox("Heartbeat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Schedule")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
.frame(width: 150, alignment: .leading)
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
}
|
||||
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var browserSection: some View {
|
||||
GroupBox("Browser (clawd)") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$browserEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Control URL")
|
||||
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Browser path")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let label = self.browserPathLabel {
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Accent")
|
||||
HStack(spacing: 8) {
|
||||
TextField("#FF4500", text: self.$browserColorHex)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
||||
Circle()
|
||||
.fill(self.browserColor)
|
||||
.frame(width: 12, height: 12)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
Text("lobster-orange")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$browserAttachOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||
.help(Self.browserAttachOnlyHelp)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(Self.browserProfileNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var talkSection: some View {
|
||||
GroupBox("Talk Mode") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Voice ID")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("ElevenLabs voice ID", text: self.$talkVoiceId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.talkVoiceId) { _, _ in self.autosaveConfig() }
|
||||
if !self.talkVoiceSuggestions.isEmpty {
|
||||
Menu {
|
||||
ForEach(self.talkVoiceSuggestions, id: \.self) { value in
|
||||
Button(value) {
|
||||
self.talkVoiceId = value
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Suggestions", systemImage: "chevron.up.chevron.down")
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
Text("Defaults to ELEVENLABS_VOICE_ID / SAG_VOICE_ID if unset.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("API key")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
SecureField("ELEVENLABS_API_KEY", text: self.$talkApiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.hasEnvApiKey)
|
||||
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
|
||||
if !self.hasEnvApiKey, !self.talkApiKey.isEmpty {
|
||||
Button("Clear") {
|
||||
self.talkApiKey = ""
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
self.statusLine(label: self.apiKeyStatusLabel, color: self.apiKeyStatusColor)
|
||||
if self.hasEnvApiKey {
|
||||
Text("Using ELEVENLABS_API_KEY from the environment.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.gatewayApiKeyFound,
|
||||
self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
Text("Using API key from the gateway profile.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Interrupt")
|
||||
Toggle("Stop speaking when you start talking", isOn: self.$talkInterruptOnSpeech)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.talkInterruptOnSpeech) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
private func statusLine(label: String, color: Color) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private func loadConfig() async {
|
||||
let parsed = await ConfigStore.load()
|
||||
let agents = parsed["agents"] as? [String: Any]
|
||||
let defaults = agents?["defaults"] as? [String: Any]
|
||||
let heartbeat = defaults?["heartbeat"] as? [String: Any]
|
||||
let heartbeatEvery = heartbeat?["every"] as? String
|
||||
let heartbeatBody = heartbeat?["prompt"] as? String
|
||||
let browser = parsed["browser"] as? [String: Any]
|
||||
let talk = parsed["talk"] as? [String: Any]
|
||||
|
||||
let loadedModel: String = {
|
||||
if let raw = defaults?["model"] as? String { return raw }
|
||||
if let modelDict = defaults?["model"] as? [String: Any],
|
||||
let primary = modelDict["primary"] as? String { return primary }
|
||||
return ""
|
||||
}()
|
||||
if !loadedModel.isEmpty {
|
||||
self.configModel = loadedModel
|
||||
} else {
|
||||
self.configModel = SessionLoader.fallbackModel
|
||||
}
|
||||
|
||||
if let heartbeatEvery {
|
||||
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.prefix { $0.isNumber }
|
||||
if let minutes = Int(digits) {
|
||||
self.heartbeatMinutes = minutes
|
||||
}
|
||||
}
|
||||
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
|
||||
|
||||
if let browser {
|
||||
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
|
||||
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
|
||||
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
|
||||
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
|
||||
}
|
||||
|
||||
if let talk {
|
||||
if let voice = talk["voiceId"] as? String { self.talkVoiceId = voice }
|
||||
if let apiKey = talk["apiKey"] as? String { self.talkApiKey = apiKey }
|
||||
if let interrupt = talk["interruptOnSpeech"] as? Bool {
|
||||
self.talkInterruptOnSpeech = interrupt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshGatewayTalkApiKey() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
let talk = snap.config?["talk"]?.dictionaryValue
|
||||
let apiKey = talk?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.gatewayApiKeyFound = !(apiKey ?? "").isEmpty
|
||||
} catch {
|
||||
self.gatewayApiKeyFound = false
|
||||
}
|
||||
}
|
||||
|
||||
private func autosaveConfig() {
|
||||
guard self.allowAutosave, !self.isNixMode else { return }
|
||||
Task { await self.saveConfig() }
|
||||
}
|
||||
|
||||
private func saveConfig() async {
|
||||
guard !self.configSaving else { return }
|
||||
self.configSaving = true
|
||||
defer { self.configSaving = false }
|
||||
|
||||
let configModel = self.configModel
|
||||
let heartbeatMinutes = self.heartbeatMinutes
|
||||
let heartbeatBody = self.heartbeatBody
|
||||
let browserEnabled = self.browserEnabled
|
||||
let browserControlUrl = self.browserControlUrl
|
||||
let browserColorHex = self.browserColorHex
|
||||
let browserAttachOnly = self.browserAttachOnly
|
||||
let talkVoiceId = self.talkVoiceId
|
||||
let talkApiKey = self.talkApiKey
|
||||
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
|
||||
|
||||
let draft = ConfigDraft(
|
||||
configModel: configModel,
|
||||
heartbeatMinutes: heartbeatMinutes,
|
||||
heartbeatBody: heartbeatBody,
|
||||
browserEnabled: browserEnabled,
|
||||
browserControlUrl: browserControlUrl,
|
||||
browserColorHex: browserColorHex,
|
||||
browserAttachOnly: browserAttachOnly,
|
||||
talkVoiceId: talkVoiceId,
|
||||
talkApiKey: talkApiKey,
|
||||
talkInterruptOnSpeech: talkInterruptOnSpeech)
|
||||
|
||||
let errorMessage = await ConfigSettings.buildAndSaveConfig(draft)
|
||||
|
||||
if let errorMessage {
|
||||
self.modelError = errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
|
||||
var root = await ConfigStore.load()
|
||||
var agents = root["agents"] as? [String: Any] ?? [:]
|
||||
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
var talk = root["talk"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedModel = chosenModel
|
||||
if !trimmedModel.isEmpty {
|
||||
var model = defaults["model"] as? [String: Any] ?? [:]
|
||||
model["primary"] = trimmedModel
|
||||
defaults["model"] = model
|
||||
|
||||
var models = defaults["models"] as? [String: Any] ?? [:]
|
||||
if models[trimmedModel] == nil {
|
||||
models[trimmedModel] = [:]
|
||||
}
|
||||
defaults["models"] = models
|
||||
}
|
||||
|
||||
if let heartbeatMinutes = draft.heartbeatMinutes {
|
||||
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
||||
heartbeat["every"] = "\(heartbeatMinutes)m"
|
||||
defaults["heartbeat"] = heartbeat
|
||||
}
|
||||
|
||||
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedBody.isEmpty {
|
||||
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
||||
heartbeat["prompt"] = trimmedBody
|
||||
defaults["heartbeat"] = heartbeat
|
||||
}
|
||||
|
||||
if defaults.isEmpty {
|
||||
agents.removeValue(forKey: "defaults")
|
||||
} else {
|
||||
agents["defaults"] = defaults
|
||||
}
|
||||
if agents.isEmpty {
|
||||
root.removeValue(forKey: "agents")
|
||||
} else {
|
||||
root["agents"] = agents
|
||||
}
|
||||
|
||||
browser["enabled"] = draft.browserEnabled
|
||||
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
|
||||
let trimmedColor = draft.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
|
||||
browser["attachOnly"] = draft.browserAttachOnly
|
||||
root["browser"] = browser
|
||||
|
||||
let trimmedVoice = draft.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedVoice.isEmpty {
|
||||
talk.removeValue(forKey: "voiceId")
|
||||
} else {
|
||||
talk["voiceId"] = trimmedVoice
|
||||
}
|
||||
let trimmedApiKey = draft.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedApiKey.isEmpty {
|
||||
talk.removeValue(forKey: "apiKey")
|
||||
} else {
|
||||
talk["apiKey"] = trimmedApiKey
|
||||
}
|
||||
talk["interruptOnSpeech"] = draft.talkInterruptOnSpeech
|
||||
root["talk"] = talk
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return nil
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var browserColor: Color {
|
||||
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
|
||||
private var talkVoiceSuggestions: [String] {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let candidates = [
|
||||
self.talkVoiceId,
|
||||
env["ELEVENLABS_VOICE_ID"] ?? "",
|
||||
env["SAG_VOICE_ID"] ?? "",
|
||||
]
|
||||
var seen = Set<String>()
|
||||
return candidates
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private var hasEnvApiKey: Bool {
|
||||
let raw = ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] ?? ""
|
||||
return !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var apiKeyStatusLabel: String {
|
||||
if self.hasEnvApiKey { return "ElevenLabs API key: found (environment)" }
|
||||
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "ElevenLabs API key: stored in config"
|
||||
}
|
||||
if self.gatewayApiKeyFound { return "ElevenLabs API key: found (gateway)" }
|
||||
return "ElevenLabs API key: missing"
|
||||
}
|
||||
|
||||
private var apiKeyStatusColor: Color {
|
||||
if self.hasEnvApiKey { return .green }
|
||||
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .green }
|
||||
if self.gatewayApiKeyFound { return .green }
|
||||
return .red
|
||||
}
|
||||
|
||||
private var browserPathLabel: String? {
|
||||
guard self.browserEnabled else { return nil }
|
||||
|
||||
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
|
||||
if !host.isEmpty, !Self.isLoopbackHost(host) {
|
||||
return "remote (\(host))"
|
||||
}
|
||||
|
||||
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
|
||||
return candidate.executablePath ?? candidate.appPath
|
||||
}
|
||||
|
||||
private struct BrowserCandidate {
|
||||
let name: String
|
||||
let appPath: String
|
||||
let executablePath: String?
|
||||
}
|
||||
|
||||
private static func detectedBrowserCandidate() -> BrowserCandidate? {
|
||||
let candidates: [(name: String, appName: String)] = [
|
||||
("Google Chrome Canary", "Google Chrome Canary.app"),
|
||||
("Chromium", "Chromium.app"),
|
||||
("Google Chrome", "Google Chrome.app"),
|
||||
]
|
||||
|
||||
let roots = [
|
||||
"/Applications",
|
||||
"\(NSHomeDirectory())/Applications",
|
||||
]
|
||||
|
||||
let fm = FileManager.default
|
||||
for (name, appName) in candidates {
|
||||
for root in roots {
|
||||
let appPath = "\(root)/\(appName)"
|
||||
if fm.fileExists(atPath: appPath) {
|
||||
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
|
||||
let exec = bundle?.executableURL?.path
|
||||
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
if host == "localhost" { return true }
|
||||
if host == "127.0.0.1" { return true }
|
||||
if host == "::1" { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private func loadModels() async {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.modelsLoading = true
|
||||
self.modelError = nil
|
||||
self.modelsSourceLabel = nil
|
||||
do {
|
||||
let res: ModelsListResult =
|
||||
try await GatewayConnection.shared
|
||||
.requestDecoded(
|
||||
method: .modelsList,
|
||||
timeoutMs: 15000)
|
||||
self.models = res.models
|
||||
self.modelsSourceLabel = "gateway"
|
||||
} catch {
|
||||
do {
|
||||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||
self.models = loaded
|
||||
self.modelsSourceLabel = "local fallback"
|
||||
} catch {
|
||||
self.modelError = error.localizedDescription
|
||||
self.models = []
|
||||
}
|
||||
}
|
||||
self.modelsLoading = false
|
||||
}
|
||||
|
||||
private struct ModelsListResult: Decodable {
|
||||
let models: [ModelChoice]
|
||||
}
|
||||
|
||||
private var modelSearchMatches: [ModelChoice] {
|
||||
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !raw.isEmpty else { return self.models }
|
||||
let tokens = raw
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { token in
|
||||
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
}
|
||||
.filter { !$0.isEmpty }
|
||||
guard !tokens.isEmpty else { return self.models }
|
||||
return self.models.filter { choice in
|
||||
let haystack = [
|
||||
choice.id,
|
||||
choice.name,
|
||||
choice.provider,
|
||||
self.modelRef(for: choice),
|
||||
]
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
return tokens.allSatisfy { haystack.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedModelChoice: ModelChoice? {
|
||||
guard !self.configModel.isEmpty else { return nil }
|
||||
return self.models.first(where: { self.matchesConfigModel($0) })
|
||||
}
|
||||
|
||||
private var modelPickerLabel: String {
|
||||
if let choice = self.selectedModelChoice {
|
||||
return "\(choice.name) — \(choice.provider.uppercased())"
|
||||
}
|
||||
if !self.configModel.isEmpty { return self.configModel }
|
||||
return "Select model"
|
||||
}
|
||||
|
||||
private var modelPickerLabelIsPlaceholder: Bool {
|
||||
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var manualEntryCandidate: String? {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
guard !self.isKnownModelRef(cleaned) else { return nil }
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private func isKnownModelRef(_ value: String) -> Bool {
|
||||
let needle = value.lowercased()
|
||||
return self.models.contains { choice in
|
||||
choice.id.lowercased() == needle
|
||||
|| self.modelRef(for: choice).lowercased() == needle
|
||||
}
|
||||
}
|
||||
|
||||
private func modelRef(for choice: ModelChoice) -> String {
|
||||
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !provider.isEmpty else { return id }
|
||||
let normalizedProvider = provider.lowercased()
|
||||
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
|
||||
return id
|
||||
}
|
||||
return "\(normalizedProvider)/\(id)"
|
||||
}
|
||||
|
||||
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
|
||||
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !configured.isEmpty else { return false }
|
||||
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
|
||||
let ref = self.modelRef(for: choice)
|
||||
return configured.caseInsensitiveCompare(ref) == .orderedSame
|
||||
}
|
||||
|
||||
private func exactMatchForQuery() -> ModelChoice? {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return self.models.first(where: { choice in
|
||||
let id = choice.id.lowercased()
|
||||
if id == cleaned { return true }
|
||||
return self.modelRef(for: choice).lowercased() == cleaned
|
||||
})
|
||||
}
|
||||
|
||||
private var shouldShowProviderHint: Bool {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
return !cleaned.contains("/")
|
||||
}
|
||||
|
||||
private var shouldShowProviderHintForSelection: Bool {
|
||||
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
return !trimmed.contains("/")
|
||||
}
|
||||
|
||||
private func selectModel(_ choice: ModelChoice) {
|
||||
self.configModel = self.modelRef(for: choice)
|
||||
self.autosaveConfig()
|
||||
self.isModelPickerOpen = false
|
||||
}
|
||||
|
||||
private func selectManualModel(_ value: String) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let slash = trimmed.firstIndex(of: "/") {
|
||||
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
|
||||
} else {
|
||||
self.configModel = trimmed
|
||||
}
|
||||
self.autosaveConfig()
|
||||
self.isModelPickerOpen = false
|
||||
}
|
||||
|
||||
private var selectedContextLabel: String? {
|
||||
guard
|
||||
let choice = self.selectedModelChoice,
|
||||
let context = choice.contextWindow
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
|
||||
return "Context window: \(human) tokens"
|
||||
}
|
||||
|
||||
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
|
||||
guard let choice = self.selectedModelChoice else { return nil }
|
||||
guard choice.provider.lowercased() == "anthropic" else { return nil }
|
||||
return AnthropicAuthResolver.resolve()
|
||||
}
|
||||
|
||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
configuration.label
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
configuration.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ConfigSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ConfigSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,707 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
GroupBox(title) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if channel == .whatsapp {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutWhatsApp() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
}
|
||||
|
||||
if channel == .telegram {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutTelegram() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.telegramBusy)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await self.store.refresh(probe: true) }
|
||||
} label: {
|
||||
if self.store.isRefreshing {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Refresh")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isRefreshing)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
var whatsAppSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Linking") {
|
||||
if let message = self.store.whatsappLoginMessage {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
.frame(width: 180, height: 180)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.startWhatsAppLogin(force: false) }
|
||||
} label: {
|
||||
if self.store.whatsappBusy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Show QR")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
|
||||
Button("Relink") {
|
||||
Task { await self.store.startWhatsAppLogin(force: true) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.whatsappBusy)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var telegramSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Authentication") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Bot token")
|
||||
if self.showTelegramToken {
|
||||
TextField("123:abc", text: self.$store.telegramToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isTelegramTokenLocked)
|
||||
} else {
|
||||
SecureField("123:abc", text: self.$store.telegramToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isTelegramTokenLocked)
|
||||
}
|
||||
Toggle("Show", isOn: self.$showTelegramToken)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(self.isTelegramTokenLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Access") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Require mention")
|
||||
Toggle("", isOn: self.$store.telegramRequireMention)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow from")
|
||||
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Webhook") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Webhook URL")
|
||||
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Webhook secret")
|
||||
TextField("secret", text: self.$store.telegramWebhookSecret)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Webhook path")
|
||||
TextField("/telegram-webhook", text: self.$store.telegramWebhookPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Network") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Proxy")
|
||||
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.isTelegramTokenLocked {
|
||||
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveTelegramConfig() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
var discordSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Authentication") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$store.discordEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Bot token")
|
||||
if self.showDiscordToken {
|
||||
TextField("bot token", text: self.$store.discordToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
} else {
|
||||
SecureField("bot token", text: self.$store.discordToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
}
|
||||
Toggle("Show", isOn: self.$showDiscordToken)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Messages") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Allow DMs from")
|
||||
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("DMs enabled")
|
||||
Toggle("", isOn: self.$store.discordDmEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Group DMs")
|
||||
Toggle("", isOn: self.$store.discordGroupEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Group channels")
|
||||
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Reply to mode")
|
||||
Picker("", selection: self.$store.discordReplyToMode) {
|
||||
Text("off").tag("off")
|
||||
Text("first").tag("first")
|
||||
Text("all").tag("all")
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Limits") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Media max MB")
|
||||
TextField("8", text: self.$store.discordMediaMaxMb)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("History limit")
|
||||
TextField("20", text: self.$store.discordHistoryLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Text chunk limit")
|
||||
TextField("2000", text: self.$store.discordTextChunkLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Slash command") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$store.discordSlashEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Slash name")
|
||||
TextField("clawd", text: self.$store.discordSlashName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Session prefix")
|
||||
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Ephemeral")
|
||||
Toggle("", isOn: self.$store.discordSlashEphemeral)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupBox("Guilds") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(self.$store.discordGuilds) { $guild in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
TextField("guild id or slug", text: $guild.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Remove") {
|
||||
self.store.discordGuilds.removeAll { $0.id == guild.id }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Slug")
|
||||
TextField("optional slug", text: $guild.slug)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Require mention")
|
||||
Toggle("", isOn: $guild.requireMention)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Reaction notifications")
|
||||
Picker("", selection: $guild.reactionNotifications) {
|
||||
Text("Off").tag("off")
|
||||
Text("Own").tag("own")
|
||||
Text("All").tag("all")
|
||||
Text("Allowlist").tag("allowlist")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Users allowlist")
|
||||
TextField("123456789, username#1234", text: $guild.users)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Channels")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach($guild.channels) { $channel in
|
||||
HStack(spacing: 10) {
|
||||
TextField("channel id or slug", text: $channel.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Allow", isOn: $channel.allow)
|
||||
.toggleStyle(.checkbox)
|
||||
Toggle("Require mention", isOn: $channel.requireMention)
|
||||
.toggleStyle(.checkbox)
|
||||
Button("Remove") {
|
||||
guild.channels.removeAll { $0.id == channel.id }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
Button("Add channel") {
|
||||
guild.channels.append(DiscordGuildChannelForm())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
Button("Add guild") {
|
||||
self.store.discordGuilds.append(DiscordGuildForm())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
GroupBox("Tool actions") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Reactions")
|
||||
Toggle("", isOn: self.$store.discordActionReactions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Stickers")
|
||||
Toggle("", isOn: self.$store.discordActionStickers)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Polls")
|
||||
Toggle("", isOn: self.$store.discordActionPolls)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Permissions")
|
||||
Toggle("", isOn: self.$store.discordActionPermissions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Messages")
|
||||
Toggle("", isOn: self.$store.discordActionMessages)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Threads")
|
||||
Toggle("", isOn: self.$store.discordActionThreads)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Pins")
|
||||
Toggle("", isOn: self.$store.discordActionPins)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Search")
|
||||
Toggle("", isOn: self.$store.discordActionSearch)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Member info")
|
||||
Toggle("", isOn: self.$store.discordActionMemberInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Role info")
|
||||
Toggle("", isOn: self.$store.discordActionRoleInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Channel info")
|
||||
Toggle("", isOn: self.$store.discordActionChannelInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Voice status")
|
||||
Toggle("", isOn: self.$store.discordActionVoiceStatus)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Events")
|
||||
Toggle("", isOn: self.$store.discordActionEvents)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Role changes")
|
||||
Toggle("", isOn: self.$store.discordActionRoles)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Moderation")
|
||||
Toggle("", isOn: self.$store.discordActionModeration)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if self.isDiscordTokenLocked {
|
||||
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveDiscordConfig() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
var signalSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Connection") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$store.signalEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Account")
|
||||
TextField("+15551234567", text: self.$store.signalAccount)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("HTTP URL")
|
||||
TextField("http://127.0.0.1:8080", text: self.$store.signalHttpUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("HTTP host")
|
||||
TextField("127.0.0.1", text: self.$store.signalHttpHost)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("HTTP port")
|
||||
TextField("8080", text: self.$store.signalHttpPort)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("CLI path")
|
||||
TextField("signal-cli", text: self.$store.signalCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Behavior") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Auto start")
|
||||
Toggle("", isOn: self.$store.signalAutoStart)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Receive mode")
|
||||
Picker("", selection: self.$store.signalReceiveMode) {
|
||||
Text("Default").tag("")
|
||||
Text("on-start").tag("on-start")
|
||||
Text("manual").tag("manual")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Ignore attachments")
|
||||
Toggle("", isOn: self.$store.signalIgnoreAttachments)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Ignore stories")
|
||||
Toggle("", isOn: self.$store.signalIgnoreStories)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Read receipts")
|
||||
Toggle("", isOn: self.$store.signalSendReadReceipts)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Access & limits") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Allow from")
|
||||
TextField("12345, +1555", text: self.$store.signalAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Media max MB")
|
||||
TextField("8", text: self.$store.signalMediaMaxMb)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveSignalConfig() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
var imessageSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.formSection("Connection") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$store.imessageEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("CLI path")
|
||||
TextField("imsg", text: self.$store.imessageCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("DB path")
|
||||
TextField("~/Library/Messages/chat.db", text: self.$store.imessageDbPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Service")
|
||||
Picker("", selection: self.$store.imessageService) {
|
||||
Text("auto").tag("auto")
|
||||
Text("imessage").tag("imessage")
|
||||
Text("sms").tag("sms")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.formSection("Behavior") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
self.gridLabel("Region")
|
||||
TextField("US", text: self.$store.imessageRegion)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow from")
|
||||
TextField("chat_id:101, +1555", text: self.$store.imessageAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attachments")
|
||||
Toggle("", isOn: self.$store.imessageIncludeAttachments)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Media max MB")
|
||||
TextField("16", text: self.$store.imessageMediaMaxMb)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveIMessageConfig() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var configStatusMessage: some View {
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: 140, alignment: .leading)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionsSettings: View {
|
||||
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
case signal
|
||||
case imessage
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .whatsapp: 0
|
||||
case .telegram: 1
|
||||
case .discord: 2
|
||||
case .signal: 3
|
||||
case .imessage: 4
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .whatsapp: "WhatsApp"
|
||||
case .telegram: "Telegram"
|
||||
case .discord: "Discord"
|
||||
case .signal: "Signal"
|
||||
case .imessage: "iMessage"
|
||||
}
|
||||
}
|
||||
|
||||
var detailTitle: String {
|
||||
switch self {
|
||||
case .whatsapp: "WhatsApp Web"
|
||||
case .telegram: "Telegram Bot"
|
||||
case .discord: "Discord Bot"
|
||||
case .signal: "Signal REST"
|
||||
case .imessage: "iMessage (imsg)"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .whatsapp: "message"
|
||||
case .telegram: "paperplane"
|
||||
case .discord: "bubble.left.and.bubble.right"
|
||||
case .signal: "antenna.radiowaves.left.and.right"
|
||||
case .imessage: "message.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Bindable var store: ConnectionsStore
|
||||
@State var selectedChannel: ConnectionChannel?
|
||||
@State var showTelegramToken = false
|
||||
@State var showDiscordToken = false
|
||||
|
||||
init(store: ConnectionsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension ConnectionsStore {
|
||||
var isTelegramTokenLocked: Bool {
|
||||
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||
.tokenSource == "env"
|
||||
}
|
||||
|
||||
var isDiscordTokenLocked: Bool {
|
||||
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.tokenSource == "env"
|
||||
}
|
||||
|
||||
func loadConfig() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = snap.valid == false
|
||||
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
||||
: nil
|
||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
self.configHash = snap.hash
|
||||
self.configLoaded = true
|
||||
|
||||
self.applyUIConfig(snap)
|
||||
self.applyTelegramConfig(snap)
|
||||
self.applyDiscordConfig(snap)
|
||||
self.applySignalConfig(snap)
|
||||
self.applyIMessageConfig(snap)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
||||
let ui = snap.config?[
|
||||
"ui",
|
||||
]?.dictionaryValue
|
||||
let rawSeam = ui?[
|
||||
"seamColor",
|
||||
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
}
|
||||
|
||||
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
|
||||
if let channels = snap.config?["channels"]?.dictionaryValue,
|
||||
let entry = channels[key]?.dictionaryValue
|
||||
{
|
||||
return entry
|
||||
}
|
||||
return snap.config?[key]?.dictionaryValue
|
||||
}
|
||||
|
||||
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
|
||||
let telegram = self.resolveChannelConfig(snap, key: "telegram")
|
||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||
let groups = telegram?["groups"]?.dictionaryValue
|
||||
let defaultGroup = groups?["*"]?.dictionaryValue
|
||||
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
|
||||
?? telegram?["requireMention"]?.boolValue
|
||||
?? true
|
||||
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
|
||||
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
|
||||
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
|
||||
}
|
||||
|
||||
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
|
||||
let discord = self.resolveChannelConfig(snap, key: "discord")
|
||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
|
||||
let discordDm = discord?["dm"]?.dictionaryValue
|
||||
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
|
||||
self.discordAllowFrom = self.stringList(from: discordDm?["allowFrom"]?.arrayValue)
|
||||
self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false
|
||||
self.discordGroupChannels = self.stringList(from: discordDm?["groupChannels"]?.arrayValue)
|
||||
self.discordMediaMaxMb = self.numberString(from: discord?["mediaMaxMb"])
|
||||
self.discordHistoryLimit = self.numberString(from: discord?["historyLimit"])
|
||||
self.discordTextChunkLimit = self.numberString(from: discord?["textChunkLimit"])
|
||||
self.discordReplyToMode = self.replyMode(from: discord?["replyToMode"]?.stringValue)
|
||||
self.discordGuilds = self.decodeDiscordGuilds(discord?["guilds"]?.dictionaryValue)
|
||||
|
||||
let discordActions = discord?["actions"]?.dictionaryValue
|
||||
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
|
||||
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
|
||||
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
|
||||
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
|
||||
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
|
||||
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
|
||||
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
|
||||
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
|
||||
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
|
||||
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
|
||||
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
|
||||
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
|
||||
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
|
||||
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
|
||||
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
|
||||
|
||||
let slash = discord?["slashCommand"]?.dictionaryValue
|
||||
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
|
||||
self.discordSlashName = slash?["name"]?.stringValue ?? ""
|
||||
self.discordSlashSessionPrefix = slash?["sessionPrefix"]?.stringValue ?? ""
|
||||
self.discordSlashEphemeral = slash?["ephemeral"]?.boolValue ?? true
|
||||
}
|
||||
|
||||
private func decodeDiscordGuilds(_ guilds: [String: AnyCodable]?) -> [DiscordGuildForm] {
|
||||
guard let guilds else { return [] }
|
||||
return guilds
|
||||
.map { key, value in
|
||||
let entry = value.dictionaryValue ?? [:]
|
||||
let slug = entry["slug"]?.stringValue ?? ""
|
||||
let requireMention = entry["requireMention"]?.boolValue ?? false
|
||||
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
|
||||
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
|
||||
? reactionModeRaw
|
||||
: "own"
|
||||
let users = self.stringList(from: entry["users"]?.arrayValue)
|
||||
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?.dictionaryValue {
|
||||
channelMap.map { channelKey, channelValue in
|
||||
let channelEntry = channelValue.dictionaryValue ?? [:]
|
||||
let allow = channelEntry["allow"]?.boolValue ?? true
|
||||
let channelRequireMention = channelEntry["requireMention"]?.boolValue ?? false
|
||||
return DiscordGuildChannelForm(
|
||||
key: channelKey,
|
||||
allow: allow,
|
||||
requireMention: channelRequireMention)
|
||||
}
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
return DiscordGuildForm(
|
||||
key: key,
|
||||
slug: slug,
|
||||
requireMention: requireMention,
|
||||
reactionNotifications: reactionNotifications,
|
||||
users: users,
|
||||
channels: channels)
|
||||
}
|
||||
.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
private func applySignalConfig(_ snap: ConfigSnapshot) {
|
||||
let signal = self.resolveChannelConfig(snap, key: "signal")
|
||||
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
||||
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
||||
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
||||
self.signalHttpHost = signal?["httpHost"]?.stringValue ?? ""
|
||||
self.signalHttpPort = self.numberString(from: signal?["httpPort"])
|
||||
self.signalCliPath = signal?["cliPath"]?.stringValue ?? ""
|
||||
self.signalAutoStart = signal?["autoStart"]?.boolValue ?? true
|
||||
self.signalReceiveMode = signal?["receiveMode"]?.stringValue ?? ""
|
||||
self.signalIgnoreAttachments = signal?["ignoreAttachments"]?.boolValue ?? false
|
||||
self.signalIgnoreStories = signal?["ignoreStories"]?.boolValue ?? false
|
||||
self.signalSendReadReceipts = signal?["sendReadReceipts"]?.boolValue ?? false
|
||||
self.signalAllowFrom = self.stringList(from: signal?["allowFrom"]?.arrayValue)
|
||||
self.signalMediaMaxMb = self.numberString(from: signal?["mediaMaxMb"])
|
||||
}
|
||||
|
||||
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
|
||||
let imessage = self.resolveChannelConfig(snap, key: "imessage")
|
||||
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
|
||||
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
|
||||
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
|
||||
self.imessageService = imessage?["service"]?.stringValue ?? "auto"
|
||||
self.imessageRegion = imessage?["region"]?.stringValue ?? ""
|
||||
self.imessageAllowFrom = self.stringList(from: imessage?["allowFrom"]?.arrayValue)
|
||||
self.imessageIncludeAttachments = imessage?["includeAttachments"]?.boolValue ?? false
|
||||
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
|
||||
}
|
||||
|
||||
private func channelConfigRoot(for key: String) -> [String: Any] {
|
||||
if let channels = self.configRoot["channels"] as? [String: Any],
|
||||
let entry = channels[key] as? [String: Any]
|
||||
{
|
||||
return entry
|
||||
}
|
||||
return self.configRoot[key] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
func saveTelegramConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
if !self.configLoaded {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var telegram: [String: Any] = [:]
|
||||
if !self.isTelegramTokenLocked {
|
||||
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
|
||||
}
|
||||
telegram["requireMention"] = NSNull()
|
||||
telegram["groups"] = [
|
||||
"*": [
|
||||
"requireMention": self.telegramRequireMention,
|
||||
],
|
||||
]
|
||||
let allow = self.splitCsv(self.telegramAllowFrom)
|
||||
self.setPatchList(&telegram, key: "allowFrom", values: allow)
|
||||
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||
|
||||
await self.persistChannelPatch("telegram", payload: telegram)
|
||||
}
|
||||
|
||||
func saveDiscordConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
if !self.configLoaded {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
let base = self.channelConfigRoot(for: "discord")
|
||||
let discord = self.buildDiscordPatch(base: base)
|
||||
await self.persistChannelPatch("discord", payload: discord)
|
||||
}
|
||||
|
||||
func saveSignalConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
if !self.configLoaded {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var signal: [String: Any] = [:]
|
||||
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "account", value: self.signalAccount)
|
||||
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
|
||||
let allow = self.splitCsv(self.signalAllowFrom)
|
||||
self.setPatchList(&signal, key: "allowFrom", values: allow)
|
||||
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
await self.persistChannelPatch("signal", payload: signal)
|
||||
}
|
||||
|
||||
func saveIMessageConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
if !self.configLoaded {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var imessage: [String: Any] = [:]
|
||||
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
|
||||
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||
|
||||
let service = self.trimmed(self.imessageService)
|
||||
if service.isEmpty || service == "auto" {
|
||||
imessage["service"] = NSNull()
|
||||
} else {
|
||||
imessage["service"] = service
|
||||
}
|
||||
|
||||
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
|
||||
|
||||
let allow = self.splitCsv(self.imessageAllowFrom)
|
||||
self.setPatchList(&imessage, key: "allowFrom", values: allow)
|
||||
|
||||
self.setPatchBool(
|
||||
&imessage,
|
||||
key: "includeAttachments",
|
||||
value: self.imessageIncludeAttachments,
|
||||
defaultValue: false)
|
||||
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
|
||||
await self.persistChannelPatch("imessage", payload: imessage)
|
||||
}
|
||||
|
||||
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
|
||||
var discord: [String: Any] = [:]
|
||||
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
|
||||
if !self.isDiscordTokenLocked {
|
||||
self.setPatchString(&discord, key: "token", value: self.discordToken)
|
||||
}
|
||||
|
||||
if let dm = self.buildDiscordDmPatch() {
|
||||
discord["dm"] = dm
|
||||
} else {
|
||||
discord["dm"] = NSNull()
|
||||
}
|
||||
|
||||
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||
|
||||
let replyToMode = self.trimmed(self.discordReplyToMode)
|
||||
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = NSNull()
|
||||
} else {
|
||||
discord["replyToMode"] = replyToMode
|
||||
}
|
||||
|
||||
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
|
||||
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
|
||||
discord["guilds"] = guilds
|
||||
} else {
|
||||
discord["guilds"] = NSNull()
|
||||
}
|
||||
|
||||
if let actions = self.buildDiscordActionsPatch() {
|
||||
discord["actions"] = actions
|
||||
} else {
|
||||
discord["actions"] = NSNull()
|
||||
}
|
||||
|
||||
if let slash = self.buildDiscordSlashPatch() {
|
||||
discord["slashCommand"] = slash
|
||||
} else {
|
||||
discord["slashCommand"] = NSNull()
|
||||
}
|
||||
|
||||
return discord
|
||||
}
|
||||
|
||||
private func buildDiscordDmPatch() -> [String: Any]? {
|
||||
var dm: [String: Any] = [:]
|
||||
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
|
||||
let allow = self.splitCsv(self.discordAllowFrom)
|
||||
self.setPatchList(&dm, key: "allowFrom", values: allow)
|
||||
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
|
||||
let groupChannels = self.splitCsv(self.discordGroupChannels)
|
||||
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
|
||||
return dm.isEmpty ? nil : dm
|
||||
}
|
||||
|
||||
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
|
||||
if self.discordGuilds.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for entry in self.discordGuilds {
|
||||
let key = self.trimmed(entry.key)
|
||||
guard !key.isEmpty else { continue }
|
||||
formKeys.insert(key)
|
||||
let baseGuild = base[key] as? [String: Any] ?? [:]
|
||||
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = self.trimmed(entry.slug)
|
||||
if slug.isEmpty {
|
||||
payload["slug"] = NSNull()
|
||||
} else {
|
||||
payload["slug"] = slug
|
||||
}
|
||||
if entry.requireMention {
|
||||
payload["requireMention"] = true
|
||||
} else {
|
||||
payload["requireMention"] = NSNull()
|
||||
}
|
||||
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||
payload["reactionNotifications"] = entry.reactionNotifications
|
||||
} else {
|
||||
payload["reactionNotifications"] = NSNull()
|
||||
}
|
||||
let users = self.splitCsv(entry.users)
|
||||
self.setPatchList(&payload, key: "users", values: users)
|
||||
|
||||
let baseChannels = base["channels"] as? [String: Any] ?? [:]
|
||||
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
|
||||
payload["channels"] = channels
|
||||
} else {
|
||||
payload["channels"] = NSNull()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
|
||||
if forms.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for channel in forms {
|
||||
let channelKey = self.trimmed(channel.key)
|
||||
guard !channelKey.isEmpty else { continue }
|
||||
formKeys.insert(channelKey)
|
||||
var channelPayload: [String: Any] = [:]
|
||||
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
|
||||
self.setPatchBool(
|
||||
&channelPayload,
|
||||
key: "requireMention",
|
||||
value: channel.requireMention,
|
||||
defaultValue: false)
|
||||
patch[channelKey] = channelPayload
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordActionsPatch() -> [String: Any]? {
|
||||
var actions: [String: Any] = [:]
|
||||
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
|
||||
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
|
||||
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
|
||||
self.setAction(&actions, key: "permissions", value: self.discordActionPermissions, defaultValue: true)
|
||||
self.setAction(&actions, key: "messages", value: self.discordActionMessages, defaultValue: true)
|
||||
self.setAction(&actions, key: "threads", value: self.discordActionThreads, defaultValue: true)
|
||||
self.setAction(&actions, key: "pins", value: self.discordActionPins, defaultValue: true)
|
||||
self.setAction(&actions, key: "search", value: self.discordActionSearch, defaultValue: true)
|
||||
self.setAction(&actions, key: "memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
|
||||
self.setAction(&actions, key: "roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
|
||||
self.setAction(&actions, key: "channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
|
||||
self.setAction(&actions, key: "voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
|
||||
self.setAction(&actions, key: "events", value: self.discordActionEvents, defaultValue: true)
|
||||
self.setAction(&actions, key: "roles", value: self.discordActionRoles, defaultValue: false)
|
||||
self.setAction(&actions, key: "moderation", value: self.discordActionModeration, defaultValue: false)
|
||||
return actions.isEmpty ? nil : actions
|
||||
}
|
||||
|
||||
private func buildDiscordSlashPatch() -> [String: Any]? {
|
||||
var slash: [String: Any] = [:]
|
||||
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
|
||||
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
|
||||
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
|
||||
return slash.isEmpty ? nil : slash
|
||||
}
|
||||
|
||||
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
|
||||
do {
|
||||
guard let baseHash = self.configHash else {
|
||||
self.configStatus = "Config hash missing; reload and retry."
|
||||
return
|
||||
}
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: ["channels": [channelId: payload]],
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
self.configStatus = "Failed to encode config."
|
||||
return
|
||||
}
|
||||
let params: [String: AnyCodable] = [
|
||||
"raw": AnyCodable(raw),
|
||||
"baseHash": AnyCodable(baseHash),
|
||||
]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configPatch,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
|
||||
await self.loadConfig()
|
||||
await self.refresh(probe: true)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func stringList(from values: [AnyCodable]?) -> String {
|
||||
guard let values else { return "" }
|
||||
let strings = values.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
return strings.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func numberString(from value: AnyCodable?) -> String {
|
||||
if let number = value?.doubleValue ?? value?.intValue.map(Double.init) {
|
||||
return String(Int(number))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func replyMode(from value: String?) -> String {
|
||||
if let value, ["off", "first", "all"].contains(value) {
|
||||
return value
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
|
||||
private func splitCsv(_ value: String) -> [String] {
|
||||
value
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func trimmed(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
if let number = Double(trimmed) {
|
||||
target[key] = number
|
||||
} else {
|
||||
target[key] = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchInt(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: String,
|
||||
allowZero: Bool)
|
||||
{
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
guard let number = Int(trimmed) else {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
let isValid = allowZero ? number >= 0 : number > 0
|
||||
guard isValid else {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
target[key] = number
|
||||
}
|
||||
|
||||
private func setPatchBool(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: Bool,
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
|
||||
if values.isEmpty {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
private func setAction(
|
||||
_ actions: inout [String: Any],
|
||||
key: String,
|
||||
value: Bool,
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
actions[key] = NSNull()
|
||||
} else {
|
||||
actions[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -900,7 +900,7 @@ extension DebugSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
configuration.label
|
||||
|
||||
@@ -56,6 +56,7 @@ actor GatewayConnection {
|
||||
case configGet = "config.get"
|
||||
case configSet = "config.set"
|
||||
case configPatch = "config.patch"
|
||||
case configSchema = "config.schema"
|
||||
case wizardStart = "wizard.start"
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
|
||||
@@ -694,10 +694,10 @@ extension OnboardingView {
|
||||
systemImage: "bubble.left.and.bubble.right")
|
||||
self.featureActionRow(
|
||||
title: "Connect WhatsApp or Telegram",
|
||||
subtitle: "Open Settings → Connections to link channels and monitor status.",
|
||||
subtitle: "Open Settings → Channels to link channels and monitor status.",
|
||||
systemImage: "link")
|
||||
{
|
||||
self.openSettings(tab: .connections)
|
||||
self.openSettings(tab: .channels)
|
||||
}
|
||||
self.featureRow(
|
||||
title: "Try Voice Wake",
|
||||
|
||||
@@ -27,9 +27,9 @@ struct SettingsRootView: View {
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
.tag(SettingsTab.general)
|
||||
|
||||
ConnectionsSettings()
|
||||
.tabItem { Label("Connections", systemImage: "link") }
|
||||
.tag(SettingsTab.connections)
|
||||
ChannelsSettings()
|
||||
.tabItem { Label("Channels", systemImage: "link") }
|
||||
.tag(SettingsTab.channels)
|
||||
|
||||
VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake)
|
||||
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
||||
@@ -176,13 +176,13 @@ struct SettingsRootView: View {
|
||||
}
|
||||
|
||||
enum SettingsTab: CaseIterable {
|
||||
case general, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
case general, channels, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
static let windowWidth: CGFloat = 824 // wider
|
||||
static let windowHeight: CGFloat = 790 // +10% (more room)
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: "General"
|
||||
case .connections: "Connections"
|
||||
case .channels: "Channels"
|
||||
case .skills: "Skills"
|
||||
case .sessions: "Sessions"
|
||||
case .cron: "Cron"
|
||||
@@ -198,7 +198,7 @@ enum SettingsTab: CaseIterable {
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .general: "gearshape"
|
||||
case .connections: "link"
|
||||
case .channels: "link"
|
||||
case .skills: "sparkles"
|
||||
case .sessions: "clock.arrow.circlepath"
|
||||
case .cron: "calendar"
|
||||
|
||||
@@ -347,6 +347,7 @@ public struct SendParams: Codable, Sendable {
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
public let sessionkey: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
@@ -356,6 +357,7 @@ public struct SendParams: Codable, Sendable {
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
sessionkey: String? = nil,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.to = to
|
||||
@@ -364,6 +366,7 @@ public struct SendParams: Codable, Sendable {
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
self.sessionkey = sessionkey
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -373,6 +376,7 @@ public struct SendParams: Codable, Sendable {
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
case sessionkey = "sessionKey"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
+7
-14
@@ -5,9 +5,9 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ConnectionsSettingsSmokeTests {
|
||||
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
struct ChannelsSettingsSmokeTests {
|
||||
@Test func channelsSettingsBuildsBodyWithSnapshot() {
|
||||
let store = ChannelsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
@@ -84,20 +84,13 @@ struct ConnectionsSettingsSmokeTests {
|
||||
store.whatsappLoginMessage = "Scan QR"
|
||||
store.whatsappLoginQrDataUrl =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII="
|
||||
store.telegramToken = "123:abc"
|
||||
store.telegramRequireMention = false
|
||||
store.telegramAllowFrom = "123456789"
|
||||
store.telegramProxy = "socks5://localhost:9050"
|
||||
store.telegramWebhookUrl = "https://example.com/telegram"
|
||||
store.telegramWebhookSecret = "secret"
|
||||
store.telegramWebhookPath = "/telegram"
|
||||
|
||||
let view = ConnectionsSettings(store: store)
|
||||
let view = ChannelsSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
@Test func channelsSettingsBuildsBodyWithoutSnapshot() {
|
||||
let store = ChannelsStore(isPreview: true)
|
||||
store.snapshot = ChannelsStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
@@ -157,7 +150,7 @@ struct ConnectionsSettingsSmokeTests {
|
||||
"imessage": "default",
|
||||
])
|
||||
|
||||
let view = ConnectionsSettings(store: store)
|
||||
let view = ChannelsSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,9 @@ Common `agentTurn` fields:
|
||||
- `bestEffortDeliver`: avoid failing the job if delivery fails.
|
||||
|
||||
Isolation options (only for `session=isolated`):
|
||||
- `postToMainPrefix` (CLI: `--post-prefix`): prefix for the summary system event in main.
|
||||
- `postToMainPrefix` (CLI: `--post-prefix`): prefix for the system event in main.
|
||||
- `postToMainMode`: `summary` (default) or `full`.
|
||||
- `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000).
|
||||
|
||||
### Model and thinking overrides
|
||||
Isolated jobs (`agentTurn`) can override the model and thinking level:
|
||||
|
||||
@@ -13,7 +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.
|
||||
- If both are set, config takes precedence (env fallback is default-account only).
|
||||
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,9 +39,9 @@ Minimal config:
|
||||
## How it works
|
||||
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`).
|
||||
3. Configure Clawdbot with `channels.discord.token` (or `DISCORD_BOT_TOKEN` as a fallback).
|
||||
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).
|
||||
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (a config block is optional).
|
||||
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>`.
|
||||
|
||||
@@ -108,10 +108,68 @@ If you want iMessage on another Mac, set `channels.imessage.cliPath` to a wrappe
|
||||
Example wrapper:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
exec ssh -T mac-mini imsg "$@"
|
||||
exec ssh -T gateway-host imsg "$@"
|
||||
```
|
||||
|
||||
Multi-account support: use `channels.imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don’t commit `~/.clawdbot/clawdbot.json` (it often contains tokens).
|
||||
**Remote attachments:** When `cliPath` points to a remote host via SSH, attachment paths in the Messages database reference files on the remote machine. Clawdbot can automatically fetch these over SCP by setting `channels.imessage.remoteHost`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
cliPath: "~/imsg-ssh", // SSH wrapper to remote Mac
|
||||
remoteHost: "user@gateway-host", // for SCP file transfer
|
||||
includeAttachments: true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `remoteHost` is not set, Clawdbot attempts to auto-detect it by parsing the SSH command in your wrapper script. Explicit configuration is recommended for reliability.
|
||||
|
||||
#### Remote Mac via Tailscale (example)
|
||||
If the Gateway runs on a Linux host/VM but iMessage must run on a Mac, Tailscale is the simplest bridge: the Gateway talks to the Mac over the tailnet, runs `imsg` via SSH, and SCPs attachments back.
|
||||
|
||||
Architecture:
|
||||
```
|
||||
┌──────────────────────────────┐ SSH (imsg rpc) ┌──────────────────────────┐
|
||||
│ Gateway host (Linux/VM) │──────────────────────────────────▶│ Mac with Messages + imsg │
|
||||
│ - clawdbot gateway │ SCP (attachments) │ - Messages signed in │
|
||||
│ - channels.imessage.cliPath │◀──────────────────────────────────│ - Remote Login enabled │
|
||||
└──────────────────────────────┘ └──────────────────────────┘
|
||||
▲
|
||||
│ Tailscale tailnet (hostname or 100.x.y.z)
|
||||
▼
|
||||
user@gateway-host
|
||||
```
|
||||
|
||||
Concrete config example (Tailscale hostname):
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
enabled: true,
|
||||
cliPath: "~/.clawdbot/scripts/imsg-ssh",
|
||||
remoteHost: "bot@mac-mini.tailnet-1234.ts.net",
|
||||
includeAttachments: true,
|
||||
dbPath: "/Users/bot/Library/Messages/chat.db"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example wrapper (`~/.clawdbot/scripts/imsg-ssh`):
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Ensure the Mac is signed in to Messages, and Remote Login is enabled.
|
||||
- Use SSH keys so `ssh bot@mac-mini.tailnet-1234.ts.net` works without prompts.
|
||||
- `remoteHost` should match the SSH target so SCP can fetch attachments.
|
||||
|
||||
Multi-account support: use `channels.imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don't commit `~/.clawdbot/clawdbot.json` (it often contains tokens).
|
||||
|
||||
## Access control (DMs + groups)
|
||||
DMs:
|
||||
@@ -182,6 +240,7 @@ Provider options:
|
||||
- `channels.imessage.enabled`: enable/disable channel startup.
|
||||
- `channels.imessage.cliPath`: path to `imsg`.
|
||||
- `channels.imessage.dbPath`: Messages DB path.
|
||||
- `channels.imessage.remoteHost`: SSH host for SCP attachment transfer when `cliPath` points to a remote Mac (e.g., `user@gateway-host`). Auto-detected from SSH wrapper if not set.
|
||||
- `channels.imessage.service`: `imessage | sms | auto`.
|
||||
- `channels.imessage.region`: SMS region.
|
||||
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
|
||||
@@ -32,7 +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.
|
||||
- If both are set, config takes precedence.
|
||||
3) Restart the gateway (or finish onboarding).
|
||||
4) DM access defaults to pairing; approve the pairing code on first contact.
|
||||
|
||||
|
||||
@@ -13,7 +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.
|
||||
- If both are set, config takes precedence (env fallback is default-account only).
|
||||
3) Start the gateway.
|
||||
4) DM access is pairing by default; approve the pairing code on first contact.
|
||||
|
||||
@@ -61,7 +61,8 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account; used only when config is missing).
|
||||
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account).
|
||||
If both env and config are set, config takes precedence.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -11,5 +11,9 @@ Fetch health from the running Gateway.
|
||||
```bash
|
||||
clawdbot health
|
||||
clawdbot health --json
|
||||
clawdbot health --verbose
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--verbose` runs live probes and prints per-account timings when multiple accounts are configured.
|
||||
- Output includes per-agent session stores when multiple agents are configured.
|
||||
+206
-1
@@ -1,6 +1,7 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot hooks` (Gmail Pub/Sub + webhook helpers)"
|
||||
summary: "CLI reference for `clawdbot hooks` (internal hooks + Gmail Pub/Sub + webhook helpers)"
|
||||
read_when:
|
||||
- You want to manage internal agent hooks
|
||||
- You want to wire Gmail Pub/Sub events into Clawdbot hooks
|
||||
- You want to run the gog watch service and renew loop
|
||||
---
|
||||
@@ -10,9 +11,212 @@ read_when:
|
||||
Webhook helpers and hook-based integrations.
|
||||
|
||||
Related:
|
||||
- Internal Hooks: [Internal Agent Hooks](/internal-hooks)
|
||||
- Webhooks: [Webhook](/automation/webhook)
|
||||
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
|
||||
|
||||
## Internal Hooks
|
||||
|
||||
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
|
||||
### List All Hooks
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
```
|
||||
|
||||
List all discovered internal hooks from workspace, managed, and bundled directories.
|
||||
|
||||
**Options:**
|
||||
- `--eligible`: Show only eligible hooks (requirements met)
|
||||
- `--json`: Output as JSON
|
||||
- `-v, --verbose`: Show detailed information including missing requirements
|
||||
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Internal Hooks (2/2 ready)
|
||||
|
||||
Ready:
|
||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||
```
|
||||
|
||||
**Example (verbose):**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list --verbose
|
||||
```
|
||||
|
||||
Shows missing requirements for ineligible hooks.
|
||||
|
||||
**Example (JSON):**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list --json
|
||||
```
|
||||
|
||||
Returns structured JSON for programmatic use.
|
||||
|
||||
### Get Hook Information
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info <name>
|
||||
```
|
||||
|
||||
Show detailed information about a specific hook.
|
||||
|
||||
**Arguments:**
|
||||
- `<name>`: Hook name (e.g., `session-memory`)
|
||||
|
||||
**Options:**
|
||||
- `--json`: Output as JSON
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info session-memory
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
💾 session-memory ✓ Ready
|
||||
|
||||
Save session context to memory when /new command is issued
|
||||
|
||||
Details:
|
||||
Source: clawdbot-bundled
|
||||
Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md
|
||||
Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts
|
||||
Homepage: https://docs.clawd.bot/internal-hooks#session-memory
|
||||
Events: command:new
|
||||
|
||||
Requirements:
|
||||
Config: ✓ workspace.dir
|
||||
```
|
||||
|
||||
### Check Hooks Eligibility
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal check
|
||||
```
|
||||
|
||||
Show summary of hook eligibility status (how many are ready vs. not ready).
|
||||
|
||||
**Options:**
|
||||
- `--json`: Output as JSON
|
||||
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Internal Hooks Status
|
||||
|
||||
Total hooks: 2
|
||||
Ready: 2
|
||||
Not ready: 0
|
||||
```
|
||||
|
||||
### Enable a Hook
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable <name>
|
||||
```
|
||||
|
||||
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
||||
|
||||
**Arguments:**
|
||||
- `<name>`: Hook name (e.g., `session-memory`)
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
✓ Enabled hook: 💾 session-memory
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Checks if hook exists and is eligible
|
||||
- Updates `hooks.internal.entries.<name>.enabled = true` in your config
|
||||
- Saves config to disk
|
||||
|
||||
**After enabling:**
|
||||
- Restart the gateway so hooks reload (menu bar app restart on macOS, or restart your gateway process in dev).
|
||||
|
||||
### Disable a Hook
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal disable <name>
|
||||
```
|
||||
|
||||
Disable a specific hook by updating your config.
|
||||
|
||||
**Arguments:**
|
||||
- `<name>`: Hook name (e.g., `command-logger`)
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal disable command-logger
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
⏸ Disabled hook: 📝 command-logger
|
||||
```
|
||||
|
||||
**After disabling:**
|
||||
- Restart the gateway so hooks reload
|
||||
|
||||
## Bundled Hooks
|
||||
|
||||
### session-memory
|
||||
|
||||
Saves session context to memory when you issue `/new`.
|
||||
|
||||
**Enable:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
```
|
||||
|
||||
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
|
||||
|
||||
**See:** [session-memory documentation](/internal-hooks#session-memory)
|
||||
|
||||
### command-logger
|
||||
|
||||
Logs all command events to a centralized audit file.
|
||||
|
||||
**Enable:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable command-logger
|
||||
```
|
||||
|
||||
**Output:** `~/.clawdbot/logs/commands.log`
|
||||
|
||||
**View logs:**
|
||||
|
||||
```bash
|
||||
# Recent commands
|
||||
tail -n 20 ~/.clawdbot/logs/commands.log
|
||||
|
||||
# Pretty-print
|
||||
cat ~/.clawdbot/logs/commands.log | jq .
|
||||
|
||||
# Filter by action
|
||||
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
||||
```
|
||||
|
||||
**See:** [command-logger documentation](/internal-hooks#command-logger)
|
||||
|
||||
## Gmail
|
||||
|
||||
```bash
|
||||
@@ -20,3 +224,4 @@ clawdbot hooks gmail setup --account you@example.com
|
||||
clawdbot hooks gmail run
|
||||
```
|
||||
|
||||
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
|
||||
+13
-1
@@ -24,16 +24,21 @@ Channel selection:
|
||||
Target formats (`--to`):
|
||||
- WhatsApp: E.164 or group JID
|
||||
- Telegram: chat id or `@username`
|
||||
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are rejected)
|
||||
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
|
||||
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
||||
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
||||
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
|
||||
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
||||
|
||||
Name lookup:
|
||||
- For supported providers (Discord/Slack/etc), channel names like `Help` or `#help` are resolved via the directory cache.
|
||||
- On cache miss, Clawdbot will attempt a live directory lookup when the provider supports it.
|
||||
|
||||
## Common flags
|
||||
|
||||
- `--channel <name>`
|
||||
- `--account <id>`
|
||||
- `--targets <name>` (repeat; broadcast only)
|
||||
- `--json`
|
||||
- `--dry-run`
|
||||
- `--verbose`
|
||||
@@ -166,6 +171,13 @@ Target formats (`--to`):
|
||||
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`, `--reason`)
|
||||
- `timeout` also supports `--reason`
|
||||
|
||||
### Broadcast
|
||||
|
||||
- `broadcast`
|
||||
- Channels: any configured channel; use `--channel all` to target all providers
|
||||
- Required: `--targets` (repeat)
|
||||
- Optional: `--message`, `--media`, `--dry-run`
|
||||
|
||||
## Examples
|
||||
|
||||
Send a Discord reply:
|
||||
|
||||
@@ -16,3 +16,6 @@ clawdbot status --deep
|
||||
clawdbot status --usage
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- Output includes per-agent session stores when multiple agents are configured.
|
||||
@@ -53,6 +53,24 @@ Env var equivalents:
|
||||
- `CLAWDBOT_LOAD_SHELL_ENV=1`
|
||||
- `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`
|
||||
|
||||
## Env var substitution in config
|
||||
|
||||
You can reference env vars directly in config string values using `${VAR_NAME}` syntax:
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
"vercel-gateway": {
|
||||
apiKey: "${VERCEL_GATEWAY_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Configuration: Env var substitution](/gateway/configuration#env-var-substitution-in-config) for full details.
|
||||
|
||||
## Related
|
||||
|
||||
- [Gateway configuration](/gateway/configuration)
|
||||
|
||||
@@ -22,6 +22,9 @@ If the file is missing, Clawdbot uses safe-ish defaults (embedded Pi agent + per
|
||||
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
|
||||
The Control UI renders a form from this schema, with a **Raw JSON** editor as an escape hatch.
|
||||
|
||||
Channel plugins and extensions can register schema + UI hints for their config, so channel settings
|
||||
stay schema-driven across apps without hard-coded forms.
|
||||
|
||||
Hints (labels, grouping, sensitive fields) ship alongside the schema so clients can render
|
||||
better forms without hard-coding config knowledge.
|
||||
|
||||
@@ -283,6 +286,48 @@ Env var equivalent:
|
||||
- `CLAWDBOT_LOAD_SHELL_ENV=1`
|
||||
- `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`
|
||||
|
||||
### Env var substitution in config
|
||||
|
||||
You can reference environment variables directly in any config string value using
|
||||
`${VAR_NAME}` syntax. Variables are substituted at config load time, before validation.
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
"vercel-gateway": {
|
||||
apiKey: "${VERCEL_GATEWAY_API_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
gateway: {
|
||||
auth: {
|
||||
token: "${CLAWDBOT_GATEWAY_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Only uppercase env var names are matched: `[A-Z_][A-Z0-9_]*`
|
||||
- Missing or empty env vars throw an error at config load
|
||||
- Escape with `$${VAR}` to output a literal `${VAR}`
|
||||
- Works with `$include` (included files also get substitution)
|
||||
|
||||
**Inline substitution:**
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "${CUSTOM_API_BASE}/v1" // → "https://api.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Auth storage (OAuth + API keys)
|
||||
|
||||
Clawdbot stores **per-agent** auth profiles (OAuth + API keys) in:
|
||||
@@ -903,7 +948,7 @@ Set `web.enabled: false` to keep it off by default.
|
||||
|
||||
### `channels.telegram` (bot transport)
|
||||
|
||||
Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken`.
|
||||
Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `channels.telegram.botToken` (or `channels.telegram.tokenFile`), with `TELEGRAM_BOT_TOKEN` as a fallback for the default account.
|
||||
Set `channels.telegram.enabled: false` to disable automatic startup.
|
||||
Multi-account support lives under `channels.telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
||||
Set `channels.telegram.configWrites: false` to block Telegram-initiated config writes (including supergroup ID migrations and `/config set|unset`).
|
||||
@@ -1036,7 +1081,7 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc
|
||||
}
|
||||
```
|
||||
|
||||
Clawdbot starts Discord only when a `channels.discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `channels.discord.token` (unless `channels.discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected.
|
||||
Clawdbot starts Discord only when a `channels.discord` config section exists. The token is resolved from `channels.discord.token`, with `DISCORD_BOT_TOKEN` as a fallback for the default account (unless `channels.discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected.
|
||||
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
|
||||
Bot-authored messages are ignored by default. Enable with `channels.discord.allowBots` (own messages are still filtered to prevent self-reply loops).
|
||||
Reaction notification modes:
|
||||
@@ -1164,6 +1209,7 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
enabled: true,
|
||||
cliPath: "imsg",
|
||||
dbPath: "~/Library/Messages/chat.db",
|
||||
remoteHost: "user@gateway-host", // SCP for remote attachments when using SSH wrapper
|
||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
|
||||
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||
@@ -1183,11 +1229,12 @@ Notes:
|
||||
- The first send will prompt for Messages automation permission.
|
||||
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
|
||||
- `channels.imessage.cliPath` can point to a wrapper script (e.g. `ssh` to another Mac that runs `imsg rpc`); use SSH keys to avoid password prompts.
|
||||
- For remote SSH wrappers, set `channels.imessage.remoteHost` to fetch attachments via SCP when `includeAttachments` is enabled.
|
||||
|
||||
Example wrapper:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
exec ssh -T mac-mini "imsg rpc"
|
||||
exec ssh -T gateway-host imsg "$@"
|
||||
```
|
||||
|
||||
### `agents.defaults.workspace`
|
||||
@@ -1715,6 +1762,12 @@ Legacy: `tools.bash` is still accepted as an alias.
|
||||
- `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)
|
||||
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
||||
- `tools.web.fetch.firecrawl.apiKey` (optional; defaults to `FIRECRAWL_API_KEY`)
|
||||
- `tools.web.fetch.firecrawl.baseUrl` (default https://api.firecrawl.dev)
|
||||
- `tools.web.fetch.firecrawl.onlyMainContent` (default true)
|
||||
- `tools.web.fetch.firecrawl.maxAgeMs` (optional)
|
||||
- `tools.web.fetch.firecrawl.timeoutSeconds` (optional)
|
||||
|
||||
`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.
|
||||
|
||||
@@ -112,6 +112,7 @@ Current migrations:
|
||||
- `routing.agents`/`routing.defaultAgentId` → `agents.list` + `agents.list[].default`
|
||||
- `routing.agentToAgent` → `tools.agentToAgent`
|
||||
- `routing.transcribeAudio` → `tools.audio.transcription`
|
||||
- `bindings[].match.accountID` → `bindings[].match.accountId`
|
||||
- `identity` → `agents.list[].identity`
|
||||
- `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents)
|
||||
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
|
||||
|
||||
@@ -15,6 +15,39 @@ This repo supports “remote over SSH” by keeping a single Gateway (the master
|
||||
- The Gateway WebSocket binds to **loopback** on your configured port (defaults to 18789).
|
||||
- For remote use, you forward that loopback port over SSH (or use a tailnet/VPN and tunnel less).
|
||||
|
||||
## Common VPN/tailnet setups (where the agent lives)
|
||||
|
||||
Think of the **Gateway host** as “where the agent lives.” It owns sessions, auth profiles, channels, and state.
|
||||
Your laptop/desktop (and nodes) connect to that host.
|
||||
|
||||
### 1) Always-on Gateway in your tailnet (VPS or home server)
|
||||
|
||||
Run the Gateway on a persistent host and reach it via **Tailscale** or SSH.
|
||||
|
||||
- **Best UX:** keep `gateway.bind: "loopback"` and use **Tailscale Serve** for the Control UI.
|
||||
- **Fallback:** keep loopback + SSH tunnel from any machine that needs access.
|
||||
- **Examples:** [exe.dev](/platforms/exe-dev) (easy VM) or [Hetzner](/platforms/hetzner) (production VPS).
|
||||
|
||||
This is ideal when your laptop sleeps often but you want the agent always-on.
|
||||
|
||||
### 2) Home desktop runs the Gateway, laptop is remote control
|
||||
|
||||
The laptop does **not** run the agent. It connects remotely:
|
||||
|
||||
- Use the macOS app’s **Remote over SSH** mode (Settings → General → “Clawdbot runs”).
|
||||
- The app opens and manages the tunnel, so WebChat + health checks “just work.”
|
||||
|
||||
Runbook: [macOS remote access](/platforms/mac/remote).
|
||||
|
||||
### 3) Laptop runs the Gateway, remote access from other machines
|
||||
|
||||
Keep the Gateway local but expose it safely:
|
||||
|
||||
- SSH tunnel to the laptop from other machines, or
|
||||
- Tailscale Serve the Control UI and keep the Gateway loopback-only.
|
||||
|
||||
Guide: [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||
|
||||
## Command flow (what runs where)
|
||||
|
||||
One gateway daemon owns state + channels. Nodes are peripherals.
|
||||
@@ -73,3 +106,16 @@ WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects direct
|
||||
The macOS menu bar app can drive the same setup end-to-end (remote status checks, WebChat, and Voice Wake forwarding).
|
||||
|
||||
Runbook: [macOS remote access](/platforms/mac/remote).
|
||||
|
||||
## Security rules (remote/VPN)
|
||||
|
||||
Short version: **keep the Gateway loopback-only** unless you’re sure you need a bind.
|
||||
|
||||
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
|
||||
- **Non-loopback binds** (`lan`/`tailnet`/`auto`) must use auth tokens/passwords.
|
||||
- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
|
||||
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
|
||||
Set it to `false` if you want tokens/passwords instead.
|
||||
- Treat `browser.controlUrl` like an admin API: tailnet-only + token auth.
|
||||
|
||||
Deep dive: [Security](/gateway/security).
|
||||
@@ -0,0 +1,791 @@
|
||||
---
|
||||
summary: "Internal agent hooks: event-driven automation for commands and lifecycle events"
|
||||
read_when:
|
||||
- You want event-driven automation for /new, /reset, /stop, and agent lifecycle events
|
||||
- You want to build, install, or debug internal hooks
|
||||
---
|
||||
# Internal Agent Hooks
|
||||
|
||||
Internal hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
|
||||
|
||||
## Getting Oriented
|
||||
|
||||
Hooks are small scripts that run when something happens. There are two kinds:
|
||||
|
||||
- **Internal hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Web-based hooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook).
|
||||
|
||||
Common uses:
|
||||
- Save a memory snapshot when you reset a session
|
||||
- Keep an audit trail of commands for troubleshooting or compliance
|
||||
- Trigger follow-up automation when a session starts or ends
|
||||
- Write files into the agent workspace or call external APIs when events fire
|
||||
|
||||
If you can write a small TypeScript function, you can write an internal hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
The internal hooks system allows you to:
|
||||
- Save session context to memory when `/new` is issued
|
||||
- Log all commands for auditing
|
||||
- Trigger custom automations on agent lifecycle events
|
||||
- Extend Clawdbot's behavior without modifying core code
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Bundled Hooks
|
||||
|
||||
Clawdbot ships with two bundled hooks that are automatically discovered:
|
||||
|
||||
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
||||
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
||||
|
||||
List available hooks:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
```
|
||||
|
||||
Enable a hook:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
```
|
||||
|
||||
Check hook status:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal check
|
||||
```
|
||||
|
||||
Get detailed information:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info session-memory
|
||||
```
|
||||
|
||||
### Onboarding
|
||||
|
||||
During onboarding (`clawdbot onboard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection.
|
||||
|
||||
## Hook Discovery
|
||||
|
||||
Hooks are automatically discovered from three directories (in order of precedence):
|
||||
|
||||
1. **Workspace hooks**: `<workspace>/hooks/` (per-agent, highest precedence)
|
||||
2. **Managed hooks**: `~/.clawdbot/hooks/` (user-installed, shared across workspaces)
|
||||
3. **Bundled hooks**: `<clawdbot>/dist/hooks/bundled/` (shipped with Clawdbot)
|
||||
|
||||
Each hook is a directory containing:
|
||||
|
||||
```
|
||||
my-hook/
|
||||
├── HOOK.md # Metadata + documentation
|
||||
└── handler.ts # Handler implementation
|
||||
```
|
||||
|
||||
## Hook Structure
|
||||
|
||||
### HOOK.md Format
|
||||
|
||||
The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documentation:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-hook
|
||||
description: "Short description of what this hook does"
|
||||
homepage: https://docs.clawd.bot/internal-hooks#my-hook
|
||||
metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}}
|
||||
---
|
||||
|
||||
# My Hook
|
||||
|
||||
Detailed documentation goes here...
|
||||
|
||||
## What It Does
|
||||
|
||||
- Listens for `/new` commands
|
||||
- Performs some action
|
||||
- Logs the result
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js must be installed
|
||||
|
||||
## Configuration
|
||||
|
||||
No configuration needed.
|
||||
```
|
||||
|
||||
### Metadata Fields
|
||||
|
||||
The `metadata.clawdbot` object supports:
|
||||
|
||||
- **`emoji`**: Display emoji for CLI (e.g., `"💾"`)
|
||||
- **`events`**: Array of events to listen for (e.g., `["command:new", "command:reset"]`)
|
||||
- **`export`**: Named export to use (defaults to `"default"`)
|
||||
- **`homepage`**: Documentation URL
|
||||
- **`requires`**: Optional requirements
|
||||
- **`bins`**: Required binaries on PATH (e.g., `["git", "node"]`)
|
||||
- **`anyBins`**: At least one of these binaries must be present
|
||||
- **`env`**: Required environment variables
|
||||
- **`config`**: Required config paths (e.g., `["workspace.dir"]`)
|
||||
- **`os`**: Required platforms (e.g., `["darwin", "linux"]`)
|
||||
- **`always`**: Bypass eligibility checks (boolean)
|
||||
- **`install`**: Installation methods (for bundled hooks: `[{"id":"bundled","kind":"bundled"}]`)
|
||||
|
||||
### Handler Implementation
|
||||
|
||||
The `handler.ts` file exports an `InternalHookHandler` function:
|
||||
|
||||
```typescript
|
||||
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
|
||||
|
||||
const myHandler: InternalHookHandler = async (event) => {
|
||||
// Only trigger on 'new' command
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[my-hook] New command triggered`);
|
||||
console.log(` Session: ${event.sessionKey}`);
|
||||
console.log(` Timestamp: ${event.timestamp.toISOString()}`);
|
||||
|
||||
// Your custom logic here
|
||||
|
||||
// Optionally send message to user
|
||||
event.messages.push('✨ My hook executed!');
|
||||
};
|
||||
|
||||
export default myHandler;
|
||||
```
|
||||
|
||||
#### Event Context
|
||||
|
||||
Each event includes:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'command' | 'session' | 'agent',
|
||||
action: string, // e.g., 'new', 'reset', 'stop'
|
||||
sessionKey: string, // Session identifier
|
||||
timestamp: Date, // When the event occurred
|
||||
messages: string[], // Push messages here to send to user
|
||||
context: {
|
||||
sessionEntry?: SessionEntry,
|
||||
sessionId?: string,
|
||||
sessionFile?: string,
|
||||
commandSource?: string, // e.g., 'whatsapp', 'telegram'
|
||||
senderId?: string,
|
||||
cfg?: ClawdbotConfig
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event Types
|
||||
|
||||
### Command Events
|
||||
|
||||
Triggered when agent commands are issued:
|
||||
|
||||
- **`command`**: All command events (general listener)
|
||||
- **`command:new`**: When `/new` command is issued
|
||||
- **`command:reset`**: When `/reset` command is issued
|
||||
- **`command:stop`**: When `/stop` command is issued
|
||||
|
||||
### Future Events
|
||||
|
||||
Planned event types:
|
||||
|
||||
- **`session:start`**: When a new session begins
|
||||
- **`session:end`**: When a session ends
|
||||
- **`agent:error`**: When an agent encounters an error
|
||||
- **`message:sent`**: When a message is sent
|
||||
- **`message:received`**: When a message is received
|
||||
|
||||
## Creating Custom Hooks
|
||||
|
||||
### 1. Choose Location
|
||||
|
||||
- **Workspace hooks** (`<workspace>/hooks/`): Per-agent, highest precedence
|
||||
- **Managed hooks** (`~/.clawdbot/hooks/`): Shared across workspaces
|
||||
|
||||
### 2. Create Directory Structure
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.clawdbot/hooks/my-hook
|
||||
cd ~/.clawdbot/hooks/my-hook
|
||||
```
|
||||
|
||||
### 3. Create HOOK.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-hook
|
||||
description: "Does something useful"
|
||||
metadata: {"clawdbot":{"emoji":"🎯","events":["command:new"]}}
|
||||
---
|
||||
|
||||
# My Custom Hook
|
||||
|
||||
This hook does something useful when you issue `/new`.
|
||||
```
|
||||
|
||||
### 4. Create handler.ts
|
||||
|
||||
```typescript
|
||||
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
|
||||
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[my-hook] Running!');
|
||||
// Your logic here
|
||||
};
|
||||
|
||||
export default handler;
|
||||
```
|
||||
|
||||
### 5. Enable and Test
|
||||
|
||||
```bash
|
||||
# Verify hook is discovered
|
||||
clawdbot hooks internal list
|
||||
|
||||
# Enable it
|
||||
clawdbot hooks internal enable my-hook
|
||||
|
||||
# Restart your gateway process (menu bar app restart on macOS, or restart your dev process)
|
||||
|
||||
# Trigger the event
|
||||
# Send /new via your messaging channel
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### New Config Format (Recommended)
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"entries": {
|
||||
"session-memory": { "enabled": true },
|
||||
"command-logger": { "enabled": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Per-Hook Configuration
|
||||
|
||||
Hooks can have custom configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"entries": {
|
||||
"my-hook": {
|
||||
"enabled": true,
|
||||
"env": {
|
||||
"MY_CUSTOM_VAR": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Extra Directories
|
||||
|
||||
Load hooks from additional directories:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"load": {
|
||||
"extraDirs": ["/path/to/more/hooks"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Legacy Config Format (Still Supported)
|
||||
|
||||
The old config format still works for backwards compatibility:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"handlers": [
|
||||
{
|
||||
"event": "command:new",
|
||||
"module": "./hooks/handlers/my-handler.ts",
|
||||
"export": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Migration**: Use the new discovery-based system for new hooks. Legacy handlers are loaded after directory-based hooks.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### List Hooks
|
||||
|
||||
```bash
|
||||
# List all hooks
|
||||
clawdbot hooks internal list
|
||||
|
||||
# Show only eligible hooks
|
||||
clawdbot hooks internal list --eligible
|
||||
|
||||
# Verbose output (show missing requirements)
|
||||
clawdbot hooks internal list --verbose
|
||||
|
||||
# JSON output
|
||||
clawdbot hooks internal list --json
|
||||
```
|
||||
|
||||
### Hook Information
|
||||
|
||||
```bash
|
||||
# Show detailed info about a hook
|
||||
clawdbot hooks internal info session-memory
|
||||
|
||||
# JSON output
|
||||
clawdbot hooks internal info session-memory --json
|
||||
```
|
||||
|
||||
### Check Eligibility
|
||||
|
||||
```bash
|
||||
# Show eligibility summary
|
||||
clawdbot hooks internal check
|
||||
|
||||
# JSON output
|
||||
clawdbot hooks internal check --json
|
||||
```
|
||||
|
||||
### Enable/Disable
|
||||
|
||||
```bash
|
||||
# Enable a hook
|
||||
clawdbot hooks internal enable session-memory
|
||||
|
||||
# Disable a hook
|
||||
clawdbot hooks internal disable command-logger
|
||||
```
|
||||
|
||||
## Bundled Hooks
|
||||
|
||||
### session-memory
|
||||
|
||||
Saves session context to memory when you issue `/new`.
|
||||
|
||||
**Events**: `command:new`
|
||||
|
||||
**Requirements**: `workspace.dir` must be configured
|
||||
|
||||
**Output**: `<workspace>/memory/YYYY-MM-DD-slug.md` (defaults to `~/clawd`)
|
||||
|
||||
**What it does**:
|
||||
1. Uses the pre-reset session entry to locate the correct transcript
|
||||
2. Extracts the last 15 lines of conversation
|
||||
3. Uses LLM to generate a descriptive filename slug
|
||||
4. Saves session metadata to a dated memory file
|
||||
|
||||
**Example output**:
|
||||
|
||||
```markdown
|
||||
# Session: 2026-01-16 14:30:00 UTC
|
||||
|
||||
- **Session Key**: agent:main:main
|
||||
- **Session ID**: abc123def456
|
||||
- **Source**: telegram
|
||||
```
|
||||
|
||||
**Filename examples**:
|
||||
- `2026-01-16-vendor-pitch.md`
|
||||
- `2026-01-16-api-design.md`
|
||||
- `2026-01-16-1430.md` (fallback timestamp if slug generation fails)
|
||||
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
```
|
||||
|
||||
### command-logger
|
||||
|
||||
Logs all command events to a centralized audit file.
|
||||
|
||||
**Events**: `command`
|
||||
|
||||
**Requirements**: None
|
||||
|
||||
**Output**: `~/.clawdbot/logs/commands.log`
|
||||
|
||||
**What it does**:
|
||||
1. Captures event details (command action, timestamp, session key, sender ID, source)
|
||||
2. Appends to log file in JSONL format
|
||||
3. Runs silently in the background
|
||||
|
||||
**Example log entries**:
|
||||
|
||||
```jsonl
|
||||
{"timestamp":"2026-01-16T14:30:00.000Z","action":"new","sessionKey":"agent:main:main","senderId":"+1234567890","source":"telegram"}
|
||||
{"timestamp":"2026-01-16T15:45:22.000Z","action":"stop","sessionKey":"agent:main:main","senderId":"user@example.com","source":"whatsapp"}
|
||||
```
|
||||
|
||||
**View logs**:
|
||||
|
||||
```bash
|
||||
# View recent commands
|
||||
tail -n 20 ~/.clawdbot/logs/commands.log
|
||||
|
||||
# Pretty-print with jq
|
||||
cat ~/.clawdbot/logs/commands.log | jq .
|
||||
|
||||
# Filter by action
|
||||
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
||||
```
|
||||
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable command-logger
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Keep Handlers Fast
|
||||
|
||||
Hooks run during command processing. Keep them lightweight:
|
||||
|
||||
```typescript
|
||||
// ✓ Good - async work, returns immediately
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
void processInBackground(event); // Fire and forget
|
||||
};
|
||||
|
||||
// ✗ Bad - blocks command processing
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
await slowDatabaseQuery(event);
|
||||
await evenSlowerAPICall(event);
|
||||
};
|
||||
```
|
||||
|
||||
### Handle Errors Gracefully
|
||||
|
||||
Always wrap risky operations:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
try {
|
||||
await riskyOperation(event);
|
||||
} catch (err) {
|
||||
console.error('[my-handler] Failed:', err instanceof Error ? err.message : String(err));
|
||||
// Don't throw - let other handlers run
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Filter Events Early
|
||||
|
||||
Return early if the event isn't relevant:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
// Only handle 'new' commands
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Your logic here
|
||||
};
|
||||
```
|
||||
|
||||
### Use Specific Event Keys
|
||||
|
||||
Specify exact events in metadata when possible:
|
||||
|
||||
```yaml
|
||||
metadata: {"clawdbot":{"events":["command:new"]}} # Specific
|
||||
```
|
||||
|
||||
Rather than:
|
||||
|
||||
```yaml
|
||||
metadata: {"clawdbot":{"events":["command"]}} # General - more overhead
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Hook Logging
|
||||
|
||||
The gateway logs hook loading at startup:
|
||||
|
||||
```
|
||||
Registered hook: session-memory -> command:new
|
||||
Registered hook: command-logger -> command
|
||||
```
|
||||
|
||||
### Check Discovery
|
||||
|
||||
List all discovered hooks:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list --verbose
|
||||
```
|
||||
|
||||
### Check Registration
|
||||
|
||||
In your handler, log when it's called:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
console.log('[my-handler] Triggered:', event.type, event.action);
|
||||
// Your logic
|
||||
};
|
||||
```
|
||||
|
||||
### Verify Eligibility
|
||||
|
||||
Check why a hook isn't eligible:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info my-hook
|
||||
```
|
||||
|
||||
Look for missing requirements in the output.
|
||||
|
||||
## Testing
|
||||
|
||||
### Gateway Logs
|
||||
|
||||
Monitor gateway logs to see hook execution:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
./scripts/clawlog.sh -f
|
||||
|
||||
# Other platforms
|
||||
tail -f ~/.clawdbot/gateway.log
|
||||
```
|
||||
|
||||
### Test Hooks Directly
|
||||
|
||||
Test your handlers in isolation:
|
||||
|
||||
```typescript
|
||||
import { test } from 'vitest';
|
||||
import { createInternalHookEvent } from './src/hooks/internal-hooks.js';
|
||||
import myHandler from './hooks/my-hook/handler.js';
|
||||
|
||||
test('my handler works', async () => {
|
||||
const event = createInternalHookEvent('command', 'new', 'test-session', {
|
||||
foo: 'bar'
|
||||
});
|
||||
|
||||
await myHandler(event);
|
||||
|
||||
// Assert side effects
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
- **`src/hooks/types.ts`**: Type definitions
|
||||
- **`src/hooks/workspace.ts`**: Directory scanning and loading
|
||||
- **`src/hooks/frontmatter.ts`**: HOOK.md metadata parsing
|
||||
- **`src/hooks/config.ts`**: Eligibility checking
|
||||
- **`src/hooks/hooks-status.ts`**: Status reporting
|
||||
- **`src/hooks/loader.ts`**: Dynamic module loader
|
||||
- **`src/cli/hooks-internal-cli.ts`**: CLI commands
|
||||
- **`src/gateway/server-startup.ts`**: Loads hooks at gateway start
|
||||
- **`src/auto-reply/reply/commands-core.ts`**: Triggers command events
|
||||
|
||||
### Discovery Flow
|
||||
|
||||
```
|
||||
Gateway startup
|
||||
↓
|
||||
Scan directories (workspace → managed → bundled)
|
||||
↓
|
||||
Parse HOOK.md files
|
||||
↓
|
||||
Check eligibility (bins, env, config, os)
|
||||
↓
|
||||
Load handlers from eligible hooks
|
||||
↓
|
||||
Register handlers for events
|
||||
```
|
||||
|
||||
### Event Flow
|
||||
|
||||
```
|
||||
User sends /new
|
||||
↓
|
||||
Command validation
|
||||
↓
|
||||
Create hook event
|
||||
↓
|
||||
Trigger hook (all registered handlers)
|
||||
↓
|
||||
Command processing continues
|
||||
↓
|
||||
Session reset
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook Not Discovered
|
||||
|
||||
1. Check directory structure:
|
||||
```bash
|
||||
ls -la ~/.clawdbot/hooks/my-hook/
|
||||
# Should show: HOOK.md, handler.ts
|
||||
```
|
||||
|
||||
2. Verify HOOK.md format:
|
||||
```bash
|
||||
cat ~/.clawdbot/hooks/my-hook/HOOK.md
|
||||
# Should have YAML frontmatter with name and metadata
|
||||
```
|
||||
|
||||
3. List all discovered hooks:
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
```
|
||||
|
||||
### Hook Not Eligible
|
||||
|
||||
Check requirements:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info my-hook
|
||||
```
|
||||
|
||||
Look for missing:
|
||||
- Binaries (check PATH)
|
||||
- Environment variables
|
||||
- Config values
|
||||
- OS compatibility
|
||||
|
||||
### Hook Not Executing
|
||||
|
||||
1. Verify hook is enabled:
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
# Should show ✓ next to enabled hooks
|
||||
```
|
||||
|
||||
2. Restart your gateway process so hooks reload.
|
||||
|
||||
3. Check gateway logs for errors:
|
||||
```bash
|
||||
./scripts/clawlog.sh | grep hook
|
||||
```
|
||||
|
||||
### Handler Errors
|
||||
|
||||
Check for TypeScript/import errors:
|
||||
|
||||
```bash
|
||||
# Test import directly
|
||||
node -e "import('./path/to/handler.ts').then(console.log)"
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Legacy Config to Discovery
|
||||
|
||||
**Before**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"handlers": [
|
||||
{
|
||||
"event": "command:new",
|
||||
"module": "./hooks/handlers/my-handler.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After**:
|
||||
|
||||
1. Create hook directory:
|
||||
```bash
|
||||
mkdir -p ~/.clawdbot/hooks/my-hook
|
||||
mv ./hooks/handlers/my-handler.ts ~/.clawdbot/hooks/my-hook/handler.ts
|
||||
```
|
||||
|
||||
2. Create HOOK.md:
|
||||
```markdown
|
||||
---
|
||||
name: my-hook
|
||||
description: "My custom hook"
|
||||
metadata: {"clawdbot":{"emoji":"🎯","events":["command:new"]}}
|
||||
---
|
||||
|
||||
# My Hook
|
||||
|
||||
Does something useful.
|
||||
```
|
||||
|
||||
3. Update config:
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"entries": {
|
||||
"my-hook": { "enabled": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Verify and restart your gateway process:
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
# Should show: 🎯 my-hook ✓
|
||||
```
|
||||
|
||||
**Benefits of migration**:
|
||||
- Automatic discovery
|
||||
- CLI management
|
||||
- Eligibility checking
|
||||
- Better documentation
|
||||
- Consistent structure
|
||||
|
||||
## See Also
|
||||
|
||||
- [CLI Reference: hooks internal](/cli/hooks)
|
||||
- [Bundled Hooks README](https://github.com/clawdbot/clawdbot/tree/main/src/hooks/bundled)
|
||||
- [Webhook Hooks](/automation/webhook)
|
||||
- [Configuration](/gateway/configuration#hooks)
|
||||
@@ -18,7 +18,7 @@ How to see whether the linked channel is healthy from the menu bar app.
|
||||
## Settings
|
||||
- General tab gains a Health card showing: linked auth age, session-store path/count, last check time, last error/status code, and buttons for Run Health Check / Reveal Logs.
|
||||
- Uses a cached snapshot so the UI loads instantly and falls back gracefully when offline.
|
||||
- **Connections tab** surfaces channel status + controls for WhatsApp/Telegram (login QR, logout, probe, last disconnect/error).
|
||||
- **Channels tab** surfaces channel status + controls for WhatsApp/Telegram (login QR, logout, probe, last disconnect/error).
|
||||
|
||||
## How the probe works
|
||||
- App runs `clawdbot health --json` via `ShellExecutor` every ~60s and on demand. The probe loads creds and reports status without sending messages.
|
||||
|
||||
@@ -6,7 +6,7 @@ read_when:
|
||||
# Remote Clawdbot (macOS ⇄ remote host)
|
||||
|
||||
|
||||
This flow lets the macOS app act as a full remote control for a Clawdbot gateway running on another host (desktop/server). All features—health checks, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from *Settings → General*.
|
||||
This flow lets the macOS app act as a full remote control for a Clawdbot gateway running on another host (desktop/server). It’s the app’s **Remote over SSH** (remote run) feature. All features—health checks, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from *Settings → General*.
|
||||
|
||||
## Modes
|
||||
- **Local (this Mac)**: Everything runs on the laptop. No SSH involved.
|
||||
@@ -36,6 +36,11 @@ This flow lets the macOS app act as a full remote control for a Clawdbot gateway
|
||||
- The remote host needs the same TCC approvals as local (Automation, Accessibility, Screen Recording, Microphone, Speech Recognition, Notifications). Run onboarding on that machine to grant them once.
|
||||
- Nodes advertise their permission state via `node.list` / `node.describe` so agents know what’s available.
|
||||
|
||||
## Security notes
|
||||
- Prefer loopback binds on the remote host and connect via SSH or Tailscale.
|
||||
- If you bind the Gateway to a non-loopback interface, require token/password auth.
|
||||
- See [Security](/gateway/security) and [Tailscale](/gateway/tailscale).
|
||||
|
||||
## WhatsApp login flow (remote)
|
||||
- Run `clawdbot channels login --verbose` **on the remote host**. Scan the QR with WhatsApp on your phone.
|
||||
- Re-run login on that host if auth expires. Health check will surface link problems.
|
||||
|
||||
@@ -710,6 +710,31 @@ Telegram → Gateway → Agent → `node.*` → Node → Gateway → Telegram
|
||||
|
||||
Nodes don’t see inbound provider traffic; they only receive bridge RPC calls.
|
||||
|
||||
### How can my agent access my computer if the Gateway is hosted remotely?
|
||||
|
||||
Short answer: **pair your computer as a node**. The Gateway runs elsewhere, but it can
|
||||
call `node.*` tools (screen, camera, system) on your local machine over the Bridge.
|
||||
|
||||
Typical setup:
|
||||
1) Run the Gateway on the always‑on host (VPS/home server).
|
||||
2) Put the Gateway host + your computer on the same tailnet.
|
||||
3) Enable the bridge on the Gateway host:
|
||||
```json5
|
||||
{ bridge: { enabled: true, bind: "auto" } }
|
||||
```
|
||||
4) Open the macOS app locally and connect in **Remote over SSH** mode so it can tunnel
|
||||
the bridge port and register as a node.
|
||||
5) Approve the node on the Gateway:
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
Security reminder: pairing a macOS node allows `system.run` on that machine. Only
|
||||
pair devices you trust, and review [Security](/gateway/security).
|
||||
|
||||
Docs: [Nodes](/nodes), [Bridge protocol](/gateway/bridge-protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
||||
|
||||
### Do nodes run a gateway daemon?
|
||||
|
||||
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
|
||||
|
||||
@@ -187,3 +187,4 @@ Health probes: `clawdbot health` (or `clawdbot status --deep`) asks the running
|
||||
- macOS menu bar app + voice wake: [macOS app](/platforms/macos)
|
||||
- iOS/Android nodes (Canvas/camera/voice): [Nodes](/nodes)
|
||||
- Remote access (SSH tunnel / Tailscale Serve): [Remote access](/gateway/remote) and [Tailscale](/gateway/tailscale)
|
||||
- Always-on / VPN setups: [Remote access](/gateway/remote), [exe.dev](/platforms/exe-dev), [Hetzner](/platforms/hetzner), [macOS remote](/platforms/mac/remote)
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
summary: "Firecrawl fallback for web_fetch (anti-bot + cached extraction)"
|
||||
read_when:
|
||||
- You want Firecrawl-backed web extraction
|
||||
- You need a Firecrawl API key
|
||||
- You want anti-bot extraction for web_fetch
|
||||
---
|
||||
|
||||
# Firecrawl
|
||||
|
||||
Clawdbot can use **Firecrawl** as a fallback extractor for `web_fetch`. It is a hosted
|
||||
content extraction service that supports bot circumvention and caching, which helps
|
||||
with JS-heavy sites or pages that block plain HTTP fetches.
|
||||
|
||||
## Get an API key
|
||||
|
||||
1) Create a Firecrawl account and generate an API key.
|
||||
2) Store it in config or set `FIRECRAWL_API_KEY` in the gateway environment.
|
||||
|
||||
## Configure Firecrawl
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
firecrawl: {
|
||||
apiKey: "FIRECRAWL_API_KEY_HERE",
|
||||
baseUrl: "https://api.firecrawl.dev",
|
||||
onlyMainContent: true,
|
||||
maxAgeMs: 172800000,
|
||||
timeoutSeconds: 60
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `firecrawl.enabled` defaults to true when an API key is present.
|
||||
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
|
||||
|
||||
## Stealth / bot circumvention
|
||||
|
||||
Firecrawl exposes a **proxy mode** parameter for bot circumvention (`basic`, `stealth`, or `auto`).
|
||||
Clawdbot always uses `proxy: "auto"` plus `storeInCache: true` for Firecrawl requests.
|
||||
If proxy is omitted, Firecrawl defaults to `auto`. `auto` retries with stealth proxies if a basic attempt fails, which may use more credits
|
||||
than basic-only scraping.
|
||||
|
||||
## How `web_fetch` uses Firecrawl
|
||||
|
||||
`web_fetch` extraction order:
|
||||
1) Readability (local)
|
||||
2) Firecrawl (if configured)
|
||||
3) Basic HTML cleanup (last fallback)
|
||||
|
||||
See [Web tools](/tools/web) for the full web tool setup.
|
||||
@@ -215,6 +215,7 @@ Notes:
|
||||
- Responses are cached (default 15 min).
|
||||
- For JS-heavy sites, prefer the browser tool.
|
||||
- See [Web tools](/tools/web) for setup.
|
||||
- See [Firecrawl](/tools/firecrawl) for the optional anti-bot fallback.
|
||||
|
||||
### `browser`
|
||||
Control the dedicated clawd browser.
|
||||
|
||||
@@ -94,7 +94,6 @@ 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.
|
||||
|
||||
+15
-3
@@ -104,6 +104,7 @@ Fetch a URL and extract readable content.
|
||||
### Requirements
|
||||
|
||||
- `tools.web.fetch.enabled` must not be `false` (default: enabled)
|
||||
- Optional Firecrawl fallback: set `tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -116,8 +117,16 @@ Fetch a URL and extract readable content.
|
||||
maxChars: 50000,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
userAgent: "clawdbot/2026.1.15",
|
||||
readability: true
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
readability: true,
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
apiKey: "FIRECRAWL_API_KEY_HERE", // optional if FIRECRAWL_API_KEY is set
|
||||
baseUrl: "https://api.firecrawl.dev",
|
||||
onlyMainContent: true,
|
||||
maxAgeMs: 86400000, // ms (1 day)
|
||||
timeoutSeconds: 60
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,8 +140,11 @@ 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` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
||||
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
||||
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
||||
- 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.
|
||||
+2
-14
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
## What it can do (today)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Channels: WhatsApp/Telegram status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
|
||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||
@@ -39,23 +39,11 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
|
||||
- Config schema + form rendering (`config.schema`, including plugin + channel schemas); Raw JSON editor remains available
|
||||
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
|
||||
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
|
||||
- Update: run a package/git update + restart (`update.run`) with a restart report
|
||||
|
||||
## Model presets (Config tab)
|
||||
|
||||
The Config tab includes **Model presets**: one-click inserts to add common model providers and set a default model:
|
||||
|
||||
- **MiniMax M2.1 (Anthropic)** → configures MiniMax via `https://api.minimax.io/anthropic` and `anthropic-messages` (see [/providers/minimax](/providers/minimax))
|
||||
- **GLM 4.7 (Z.AI)** → adds `ZAI_API_KEY` + sets `zai/glm-4.7` (see [/providers/zai](/providers/zai))
|
||||
- **Kimi (Moonshot)** → configures Moonshot + sets `moonshot/kimi-k2-0905-preview` (see [/providers/moonshot](/providers/moonshot))
|
||||
|
||||
Notes:
|
||||
- Presets **keep existing API keys and per-model params** when present.
|
||||
- Use `/model` (see [/tools/slash-commands](/tools/slash-commands)) to switch models from chat without editing config.
|
||||
|
||||
## Chat behavior
|
||||
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../../../src/channels/plugins/config-helpers.js";
|
||||
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
|
||||
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
|
||||
import { applyAccountNameToChannelSection } from "../../../src/channels/plugins/setup-helpers.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import {
|
||||
@@ -95,6 +97,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
media: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.matrix"] },
|
||||
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const matrixActionSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const matrixDmSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const matrixRoomSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
autoReply: z.boolean().optional(),
|
||||
users: z.array(allowFromEntry).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const MatrixConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
homeserver: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
deviceName: z.string().optional(),
|
||||
initialSyncLimit: z.number().optional(),
|
||||
allowlistOnly: z.boolean().optional(),
|
||||
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||
textChunkLimit: z.number().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||
dm: matrixDmSchema,
|
||||
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
actions: matrixActionSchema,
|
||||
});
|
||||
@@ -328,20 +328,21 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
|
||||
const messageId = event.getId() ?? "";
|
||||
const threadRootId = resolveMatrixThreadRootId({ event, content });
|
||||
const threadTarget = resolveMatrixThreadTarget({
|
||||
threadReplies,
|
||||
messageId,
|
||||
threadRootId,
|
||||
isThreadRoot: event.isThreadRoot,
|
||||
});
|
||||
const threadTarget = resolveMatrixThreadTarget({
|
||||
threadReplies,
|
||||
messageId,
|
||||
threadRootId,
|
||||
isThreadRoot: event.isThreadRoot,
|
||||
});
|
||||
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: senderName,
|
||||
timestamp: event.getTs() ?? undefined,
|
||||
body: textWithId,
|
||||
});
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
timestamp: event.getTs() ?? undefined,
|
||||
body: textWithId,
|
||||
});
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import { MSTeamsConfigSchema } from "../../../src/config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
@@ -64,6 +66,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
media: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.msteams"] },
|
||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||
config: {
|
||||
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||
resolveAccount: (cfg) => ({
|
||||
|
||||
@@ -352,15 +352,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
channelData: activity.channelData,
|
||||
},
|
||||
log,
|
||||
});
|
||||
});
|
||||
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: senderName,
|
||||
timestamp,
|
||||
body: rawBody,
|
||||
});
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: envelopeFrom,
|
||||
timestamp,
|
||||
body: rawBody,
|
||||
});
|
||||
let combinedBody = body;
|
||||
const isRoomish = !isDirectMessage;
|
||||
const historyKey = isRoomish ? conversationId : undefined;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ChannelDock, ChannelPlugin } from "../../src/channels/plugins/types.js";
|
||||
import type { ChannelAccountSnapshot } from "../../src/channels/plugins/types.js";
|
||||
import type { ChannelAccountSnapshot } from "../../../src/channels/plugins/types.js";
|
||||
import type { ChannelDock, ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
|
||||
|
||||
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
|
||||
import { zaloMessageActions } from "./actions.js";
|
||||
import { ZaloConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
@@ -81,6 +83,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.zalo"] },
|
||||
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const zaloAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
proxy: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZaloConfigSchema = zaloAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
});
|
||||
@@ -495,13 +495,13 @@ async function processMessageWithPipeline(params: {
|
||||
},
|
||||
});
|
||||
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId} from ${senderName || senderId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo",
|
||||
from: fromLabel,
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo",
|
||||
from: fromLabel,
|
||||
timestamp: date ? date * 1000 : undefined,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ function resolveSendContext(options: ZaloSendOptions): {
|
||||
|
||||
const token = options.token ?? resolveZaloToken(undefined, options.accountId).token;
|
||||
const proxy = options.proxy;
|
||||
return { token: token || process.env.ZALO_BOT_TOKEN?.trim() || "", fetcher: resolveZaloProxyFetch(proxy) };
|
||||
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
||||
}
|
||||
|
||||
export async function sendMessageZalo(
|
||||
|
||||
@@ -170,13 +170,13 @@ async function processMessage(
|
||||
},
|
||||
});
|
||||
|
||||
const rawBody = content.trim();
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId} from ${senderName || senderId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
const rawBody = content.trim();
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
+3
-3
@@ -65,7 +65,7 @@
|
||||
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
|
||||
"docs:dev": "cd docs && mint dev",
|
||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
|
||||
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts",
|
||||
"plugins:sync": "tsx scripts/sync-plugin-versions.ts",
|
||||
"release:check": "tsx scripts/release-check.ts",
|
||||
"ui:install": "node scripts/ui.js install",
|
||||
@@ -90,10 +90,10 @@
|
||||
"mac:restart": "bash scripts/restart-mac.sh",
|
||||
"mac:package": "bash scripts/package-mac-app.sh",
|
||||
"mac:open": "open dist/Clawdbot.app",
|
||||
"lint": "oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"lint": "oxlint --type-aware src test",
|
||||
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
||||
"lint:all": "pnpm lint && pnpm lint:swift",
|
||||
"lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test",
|
||||
"format": "oxfmt --check src test",
|
||||
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
||||
"format:all": "pnpm format && pnpm format:swift",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Copy HOOK.md files from src/hooks/bundled to dist/hooks/bundled
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const srcBundled = path.join(projectRoot, 'src', 'hooks', 'bundled');
|
||||
const distBundled = path.join(projectRoot, 'dist', 'hooks', 'bundled');
|
||||
|
||||
function copyHookMetadata() {
|
||||
if (!fs.existsSync(srcBundled)) {
|
||||
console.warn('[copy-hook-metadata] Source directory not found:', srcBundled);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(distBundled)) {
|
||||
fs.mkdirSync(distBundled, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(srcBundled, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const hookName = entry.name;
|
||||
const srcHookDir = path.join(srcBundled, hookName);
|
||||
const distHookDir = path.join(distBundled, hookName);
|
||||
const srcHookMd = path.join(srcHookDir, 'HOOK.md');
|
||||
const distHookMd = path.join(distHookDir, 'HOOK.md');
|
||||
|
||||
if (!fs.existsSync(srcHookMd)) {
|
||||
console.warn(`[copy-hook-metadata] No HOOK.md found for ${hookName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(distHookDir)) {
|
||||
fs.mkdirSync(distHookDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.copyFileSync(srcHookMd, distHookMd);
|
||||
console.log(`[copy-hook-metadata] Copied ${hookName}/HOOK.md`);
|
||||
}
|
||||
|
||||
console.log('[copy-hook-metadata] Done');
|
||||
}
|
||||
|
||||
copyHookMetadata();
|
||||
@@ -0,0 +1,131 @@
|
||||
import { extractReadableContent, fetchFirecrawlContent } from "../src/agents/tools/web-tools.js";
|
||||
|
||||
const DEFAULT_URLS = [
|
||||
"https://en.wikipedia.org/wiki/Web_scraping",
|
||||
"https://news.ycombinator.com/",
|
||||
"https://www.apple.com/iphone/",
|
||||
"https://www.nytimes.com/",
|
||||
"https://www.reddit.com/r/javascript/",
|
||||
];
|
||||
|
||||
const urls = process.argv.slice(2);
|
||||
const targets = urls.length > 0 ? urls : DEFAULT_URLS;
|
||||
const apiKey = process.env.FIRECRAWL_API_KEY;
|
||||
const baseUrl = process.env.FIRECRAWL_BASE_URL ?? "https://api.firecrawl.dev";
|
||||
|
||||
const userAgent =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||
const timeoutMs = 30_000;
|
||||
|
||||
function truncate(value: string, max = 180): string {
|
||||
if (!value) return "";
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
async function fetchHtml(url: string): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
contentType: string;
|
||||
finalUrl: string;
|
||||
body: string;
|
||||
}> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "*/*", "User-Agent": userAgent },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
||||
const body = await res.text();
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
contentType,
|
||||
finalUrl: res.url || url,
|
||||
body,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!apiKey) {
|
||||
console.log("FIRECRAWL_API_KEY not set. Firecrawl comparisons will be skipped.");
|
||||
}
|
||||
|
||||
for (const url of targets) {
|
||||
console.log(`\n=== ${url}`);
|
||||
let localStatus = "skipped";
|
||||
let localTitle = "";
|
||||
let localText = "";
|
||||
let localError: string | undefined;
|
||||
|
||||
try {
|
||||
const res = await fetchHtml(url);
|
||||
if (!res.ok) {
|
||||
localStatus = `http ${res.status}`;
|
||||
} else if (!res.contentType.includes("text/html")) {
|
||||
localStatus = `non-html (${res.contentType})`;
|
||||
} else {
|
||||
const readable = await extractReadableContent({
|
||||
html: res.body,
|
||||
url: res.finalUrl,
|
||||
extractMode: "markdown",
|
||||
});
|
||||
if (readable?.text) {
|
||||
localStatus = "readability";
|
||||
localTitle = readable.title ?? "";
|
||||
localText = readable.text;
|
||||
} else {
|
||||
localStatus = "readability-empty";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
localStatus = "error";
|
||||
localError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`local: ${localStatus} len=${localText.length} title=${truncate(localTitle, 80)}`
|
||||
);
|
||||
if (localError) console.log(`local error: ${localError}`);
|
||||
if (localText) console.log(`local sample: ${truncate(localText)}`);
|
||||
|
||||
if (apiKey) {
|
||||
try {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url,
|
||||
extractMode: "markdown",
|
||||
apiKey,
|
||||
baseUrl,
|
||||
onlyMainContent: true,
|
||||
maxAgeMs: 172_800_000,
|
||||
proxy: "auto",
|
||||
storeInCache: true,
|
||||
timeoutSeconds: 60,
|
||||
});
|
||||
console.log(
|
||||
`firecrawl: ok len=${firecrawl.text.length} title=${truncate(
|
||||
firecrawl.title ?? "",
|
||||
80,
|
||||
)} status=${firecrawl.status ?? "n/a"}`
|
||||
);
|
||||
if (firecrawl.warning) console.log(`firecrawl warning: ${firecrawl.warning}`);
|
||||
if (firecrawl.text) console.log(`firecrawl sample: ${truncate(firecrawl.text)}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`firecrawl: error ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createWebFetchTool } from "../src/agents/tools/web-tools.js";
|
||||
|
||||
const DEFAULT_URLS = [
|
||||
"https://example.com/",
|
||||
"https://news.ycombinator.com/",
|
||||
"https://www.reddit.com/r/javascript/",
|
||||
"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent",
|
||||
"https://httpbin.org/html",
|
||||
];
|
||||
|
||||
const urls = process.argv.slice(2);
|
||||
const targets = urls.length > 0 ? urls : DEFAULT_URLS;
|
||||
|
||||
async function runFetch(url: string, readability: boolean) {
|
||||
if (!readability) {
|
||||
throw new Error("Basic extraction removed. Set readability=true or enable Firecrawl.");
|
||||
}
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: { fetch: { readability, cacheTtlMinutes: 0, firecrawl: { enabled: false } } },
|
||||
},
|
||||
},
|
||||
sandboxed: false,
|
||||
});
|
||||
if (!tool) throw new Error("web_fetch tool is disabled");
|
||||
const result = await tool.execute("test", { url, extractMode: "markdown" });
|
||||
return result.details as {
|
||||
text?: string;
|
||||
title?: string;
|
||||
extractor?: string;
|
||||
length?: number;
|
||||
truncated?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function truncate(value: string, max = 160): string {
|
||||
if (!value) return "";
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
for (const url of targets) {
|
||||
console.log(`\n=== ${url}`);
|
||||
const readable = await runFetch(url, true);
|
||||
|
||||
console.log(
|
||||
`readability: ${readable.extractor ?? "unknown"} len=${readable.length ?? 0} title=${truncate(
|
||||
readable.title ?? "",
|
||||
80,
|
||||
)}`,
|
||||
);
|
||||
if (readable.text) console.log(`readability sample: ${truncate(readable.text)}`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import base64
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
@@ -211,18 +212,21 @@ def main() -> int:
|
||||
args.output_format,
|
||||
args.style,
|
||||
)
|
||||
# OpenAI Images API now returns URLs by default
|
||||
image_url = res.get("data", [{}])[0].get("url")
|
||||
if not image_url:
|
||||
data = res.get("data", [{}])[0]
|
||||
image_b64 = data.get("b64_json")
|
||||
image_url = data.get("url")
|
||||
if not image_b64 and not image_url:
|
||||
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
||||
|
||||
# Download image from URL
|
||||
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
|
||||
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
|
||||
if image_b64:
|
||||
filepath.write_bytes(base64.b64decode(image_b64))
|
||||
else:
|
||||
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})
|
||||
|
||||
|
||||
@@ -80,8 +80,10 @@ export function createClawdbotTools(options?: {
|
||||
}),
|
||||
createMessageTool({
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentChannelProvider: options?.agentChannel,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
@@ -107,6 +109,7 @@ export function createClawdbotTools(options?: {
|
||||
createSessionsSpawnTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentChannel: options?.agentChannel,
|
||||
agentAccountId: options?.agentAccountId,
|
||||
sandboxed: options?.sandboxed,
|
||||
}),
|
||||
createSessionStatusTool({
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractMessagingToolSend } from "./pi-embedded-subscribe.tools.js";
|
||||
|
||||
describe("extractMessagingToolSend", () => {
|
||||
it("uses channel as provider for message tool", () => {
|
||||
const result = extractMessagingToolSend("message", {
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
});
|
||||
|
||||
expect(result?.tool).toBe("message");
|
||||
expect(result?.provider).toBe("telegram");
|
||||
expect(result?.to).toBe("telegram:123");
|
||||
});
|
||||
|
||||
it("prefers provider when both provider and channel are set", () => {
|
||||
const result = extractMessagingToolSend("message", {
|
||||
action: "send",
|
||||
provider: "slack",
|
||||
channel: "telegram",
|
||||
to: "channel:C1",
|
||||
});
|
||||
|
||||
expect(result?.tool).toBe("message");
|
||||
expect(result?.provider).toBe("slack");
|
||||
expect(result?.to).toBe("channel:c1");
|
||||
});
|
||||
});
|
||||
@@ -57,8 +57,10 @@ export function extractMessagingToolSend(
|
||||
const toRaw = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!toRaw) return undefined;
|
||||
const providerRaw = typeof args.provider === "string" ? args.provider.trim() : "";
|
||||
const providerId = providerRaw ? normalizeChannelId(providerRaw) : null;
|
||||
const provider = providerId ?? (providerRaw ? providerRaw.toLowerCase() : "message");
|
||||
const channelRaw = typeof args.channel === "string" ? args.channel.trim() : "";
|
||||
const providerHint = providerRaw || channelRaw;
|
||||
const providerId = providerHint ? normalizeChannelId(providerHint) : null;
|
||||
const provider = providerId ?? (providerHint ? providerHint.toLowerCase() : "message");
|
||||
const to = normalizeTargetForProvider(provider, toRaw);
|
||||
return to ? { tool: toolName, provider, accountId, to } : undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
CHANNEL_TARGET_DESCRIPTION,
|
||||
CHANNEL_TARGETS_DESCRIPTION,
|
||||
} from "../../infra/outbound/channel-target.js";
|
||||
|
||||
type StringEnumOptions<T extends readonly string[]> = {
|
||||
description?: string;
|
||||
@@ -25,3 +29,13 @@ export function optionalStringEnum<T extends readonly string[]>(
|
||||
) {
|
||||
return Type.Optional(stringEnum(values, options));
|
||||
}
|
||||
|
||||
export function channelTargetSchema(options?: { description?: string }) {
|
||||
return Type.String({
|
||||
description: options?.description ?? CHANNEL_TARGET_DESCRIPTION,
|
||||
});
|
||||
}
|
||||
|
||||
export function channelTargetsSchema(options?: { description?: string }) {
|
||||
return Type.Array(channelTargetSchema({ description: options?.description ?? CHANNEL_TARGETS_DESCRIPTION }));
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const agentSpy = vi.fn(async () => ({ runId: "run-main", status: "ok" }));
|
||||
const embeddedRunMock = {
|
||||
isEmbeddedPiRunActive: vi.fn(() => false),
|
||||
isEmbeddedPiRunStreaming: vi.fn(() => false),
|
||||
queueEmbeddedPiMessage: vi.fn(() => false),
|
||||
waitForEmbeddedPiRunEnd: vi.fn(async () => true),
|
||||
};
|
||||
let sessionStore: Record<string, Record<string, unknown>> = {};
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(async (req: unknown) => {
|
||||
@@ -22,20 +35,36 @@ vi.mock("./tools/agent-step.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
loadSessionStore: vi.fn(() => ({})),
|
||||
loadSessionStore: vi.fn(() => sessionStore),
|
||||
resolveAgentIdFromSessionKey: () => "main",
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
resolveMainSessionKey: () => "agent:main:main",
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => ({
|
||||
session: { mainKey: "agent:main:main" },
|
||||
}),
|
||||
}));
|
||||
vi.mock("./pi-embedded.js", () => embeddedRunMock);
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => configOverride,
|
||||
};
|
||||
});
|
||||
|
||||
describe("subagent announce formatting", () => {
|
||||
beforeEach(() => {
|
||||
agentSpy.mockClear();
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReset().mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false);
|
||||
embeddedRunMock.queueEmbeddedPiMessage.mockReset().mockReturnValue(false);
|
||||
embeddedRunMock.waitForEmbeddedPiRunEnd.mockReset().mockResolvedValue(true);
|
||||
sessionStore = {};
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("sends instructional message to main agent with status and findings", async () => {
|
||||
@@ -88,4 +117,159 @@ describe("subagent announce formatting", () => {
|
||||
const msg = call?.params?.message as string;
|
||||
expect(msg).toContain("completed successfully");
|
||||
});
|
||||
|
||||
it("steers announcements into an active run when queue mode is steer", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(true);
|
||||
embeddedRunMock.queueEmbeddedPiMessage.mockReturnValue(true);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-123",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
queueMode: "steer",
|
||||
},
|
||||
};
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-789",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1000,
|
||||
cleanup: "keep",
|
||||
waitForCompletion: false,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(embeddedRunMock.queueEmbeddedPiMessage).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
expect.stringContaining("background task"),
|
||||
);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues announce delivery with origin account routing", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-456",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "kev",
|
||||
queueMode: "collect",
|
||||
queueDebounceMs: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-999",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1000,
|
||||
cleanup: "keep",
|
||||
waitForCompletion: false,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
expect(call?.params?.channel).toBe("whatsapp");
|
||||
expect(call?.params?.to).toBe("+1555");
|
||||
expect(call?.params?.accountId).toBe("kev");
|
||||
});
|
||||
|
||||
it("uses requester accountId for direct announce when not queued", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterChannel: "whatsapp",
|
||||
requesterAccountId: "acct-123",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1000,
|
||||
cleanup: "keep",
|
||||
waitForCompletion: false,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
expect(call?.params?.channel).toBe("whatsapp");
|
||||
expect(call?.params?.accountId).toBe("acct-123");
|
||||
});
|
||||
|
||||
it("splits collect-mode announces when accountId differs", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-789",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
queueMode: "collect",
|
||||
queueDebounceMs: 0,
|
||||
},
|
||||
};
|
||||
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-a",
|
||||
requesterSessionKey: "main",
|
||||
requesterAccountId: "acct-a",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1000,
|
||||
cleanup: "keep",
|
||||
waitForCompletion: false,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-b",
|
||||
requesterSessionKey: "main",
|
||||
requesterAccountId: "acct-b",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
timeoutMs: 1000,
|
||||
cleanup: "keep",
|
||||
waitForCompletion: false,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
const accountIds = agentSpy.mock.calls.map(
|
||||
(call) => (call[0] as { params?: Record<string, unknown> }).params?.accountId,
|
||||
);
|
||||
expect(accountIds).toContain("acct-a");
|
||||
expect(accountIds).toContain("acct-b");
|
||||
expect(agentSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,22 @@ import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
import {
|
||||
resolveQueueSettings,
|
||||
type QueueDropPolicy,
|
||||
type QueueMode,
|
||||
} from "../auto-reply/reply/queue.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
type DeliveryContext,
|
||||
normalizeDeliveryContext,
|
||||
} from "../utils/delivery-context.js";
|
||||
import { isEmbeddedPiRunActive, queueEmbeddedPiMessage } from "./pi-embedded.js";
|
||||
import { readLatestAssistantReply } from "./tools/agent-step.js";
|
||||
|
||||
function formatDurationShort(valueMs?: number) {
|
||||
@@ -75,6 +88,336 @@ async function waitForSessionUsage(params: { sessionKey: string }) {
|
||||
return { entry, storePath };
|
||||
}
|
||||
|
||||
type AnnounceQueueItem = {
|
||||
prompt: string;
|
||||
summaryLine?: string;
|
||||
enqueuedAt: number;
|
||||
sessionKey: string;
|
||||
origin?: DeliveryContext;
|
||||
};
|
||||
|
||||
type AnnounceQueueState = {
|
||||
items: AnnounceQueueItem[];
|
||||
draining: boolean;
|
||||
lastEnqueuedAt: number;
|
||||
mode: QueueMode;
|
||||
debounceMs: number;
|
||||
cap: number;
|
||||
dropPolicy: QueueDropPolicy;
|
||||
droppedCount: number;
|
||||
summaryLines: string[];
|
||||
};
|
||||
|
||||
const ANNOUNCE_QUEUES = new Map<string, AnnounceQueueState>();
|
||||
|
||||
function getAnnounceQueue(
|
||||
key: string,
|
||||
settings: { mode: QueueMode; debounceMs?: number; cap?: number; dropPolicy?: QueueDropPolicy },
|
||||
) {
|
||||
const existing = ANNOUNCE_QUEUES.get(key);
|
||||
if (existing) {
|
||||
existing.mode = settings.mode;
|
||||
existing.debounceMs =
|
||||
typeof settings.debounceMs === "number"
|
||||
? Math.max(0, settings.debounceMs)
|
||||
: existing.debounceMs;
|
||||
existing.cap =
|
||||
typeof settings.cap === "number" && settings.cap > 0
|
||||
? Math.floor(settings.cap)
|
||||
: existing.cap;
|
||||
existing.dropPolicy = settings.dropPolicy ?? existing.dropPolicy;
|
||||
return existing;
|
||||
}
|
||||
const created: AnnounceQueueState = {
|
||||
items: [],
|
||||
draining: false,
|
||||
lastEnqueuedAt: 0,
|
||||
mode: settings.mode,
|
||||
debounceMs: typeof settings.debounceMs === "number" ? Math.max(0, settings.debounceMs) : 1000,
|
||||
cap: typeof settings.cap === "number" && settings.cap > 0 ? Math.floor(settings.cap) : 20,
|
||||
dropPolicy: settings.dropPolicy ?? "summarize",
|
||||
droppedCount: 0,
|
||||
summaryLines: [],
|
||||
};
|
||||
ANNOUNCE_QUEUES.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function elideText(text: string, limit = 140): string {
|
||||
if (text.length <= limit) return text;
|
||||
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function buildQueueSummaryLine(item: AnnounceQueueItem): string {
|
||||
const base = item.summaryLine?.trim() || item.prompt.trim();
|
||||
const cleaned = base.replace(/\s+/g, " ").trim();
|
||||
return elideText(cleaned, 160);
|
||||
}
|
||||
|
||||
function enqueueAnnounce(
|
||||
key: string,
|
||||
item: AnnounceQueueItem,
|
||||
settings: { mode: QueueMode; debounceMs?: number; cap?: number; dropPolicy?: QueueDropPolicy },
|
||||
): boolean {
|
||||
const queue = getAnnounceQueue(key, settings);
|
||||
queue.lastEnqueuedAt = Date.now();
|
||||
|
||||
const cap = queue.cap;
|
||||
if (cap > 0 && queue.items.length >= cap) {
|
||||
if (queue.dropPolicy === "new") {
|
||||
return false;
|
||||
}
|
||||
const dropCount = queue.items.length - cap + 1;
|
||||
const dropped = queue.items.splice(0, dropCount);
|
||||
if (queue.dropPolicy === "summarize") {
|
||||
for (const droppedItem of dropped) {
|
||||
queue.droppedCount += 1;
|
||||
queue.summaryLines.push(buildQueueSummaryLine(droppedItem));
|
||||
}
|
||||
while (queue.summaryLines.length > cap) queue.summaryLines.shift();
|
||||
}
|
||||
}
|
||||
|
||||
queue.items.push(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function waitForQueueDebounce(queue: { debounceMs: number; lastEnqueuedAt: number }) {
|
||||
const debounceMs = Math.max(0, queue.debounceMs);
|
||||
if (debounceMs <= 0) return;
|
||||
while (true) {
|
||||
const since = Date.now() - queue.lastEnqueuedAt;
|
||||
if (since >= debounceMs) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, debounceMs - since));
|
||||
}
|
||||
}
|
||||
|
||||
function buildSummaryPrompt(queue: {
|
||||
dropPolicy: QueueDropPolicy;
|
||||
droppedCount: number;
|
||||
summaryLines: string[];
|
||||
}): string | undefined {
|
||||
if (queue.dropPolicy !== "summarize" || queue.droppedCount <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = [
|
||||
`[Queue overflow] Dropped ${queue.droppedCount} announce${queue.droppedCount === 1 ? "" : "s"} due to cap.`,
|
||||
];
|
||||
if (queue.summaryLines.length > 0) {
|
||||
lines.push("Summary:");
|
||||
for (const line of queue.summaryLines) {
|
||||
lines.push(`- ${line}`);
|
||||
}
|
||||
}
|
||||
queue.droppedCount = 0;
|
||||
queue.summaryLines = [];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildCollectPrompt(items: AnnounceQueueItem[], summary?: string): string {
|
||||
const blocks: string[] = ["[Queued announce messages while agent was busy]"];
|
||||
if (summary) blocks.push(summary);
|
||||
items.forEach((item, idx) => {
|
||||
blocks.push(`---\nQueued #${idx + 1}\n${item.prompt}`.trim());
|
||||
});
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
function hasCrossChannelItems(items: AnnounceQueueItem[]): boolean {
|
||||
const keys = new Set<string>();
|
||||
let hasUnkeyed = false;
|
||||
for (const item of items) {
|
||||
const origin = item.origin;
|
||||
const channel = origin?.channel;
|
||||
const to = origin?.to;
|
||||
const accountId = origin?.accountId;
|
||||
if (!channel && !to && !accountId) {
|
||||
hasUnkeyed = true;
|
||||
continue;
|
||||
}
|
||||
if (!channel || !to) {
|
||||
return true;
|
||||
}
|
||||
keys.add([channel, to, accountId || ""].join("|"));
|
||||
}
|
||||
if (keys.size === 0) return false;
|
||||
if (hasUnkeyed) return true;
|
||||
return keys.size > 1;
|
||||
}
|
||||
|
||||
function scheduleAnnounceDrain(key: string) {
|
||||
const queue = ANNOUNCE_QUEUES.get(key);
|
||||
if (!queue || queue.draining) return;
|
||||
queue.draining = true;
|
||||
void (async () => {
|
||||
try {
|
||||
let forceIndividualCollect = false;
|
||||
while (queue.items.length > 0 || queue.droppedCount > 0) {
|
||||
await waitForQueueDebounce(queue);
|
||||
if (queue.mode === "collect") {
|
||||
if (forceIndividualCollect) {
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await sendAnnounce(next);
|
||||
continue;
|
||||
}
|
||||
const isCrossChannel = hasCrossChannelItems(queue.items);
|
||||
if (isCrossChannel) {
|
||||
forceIndividualCollect = true;
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await sendAnnounce(next);
|
||||
continue;
|
||||
}
|
||||
const items = queue.items.splice(0, queue.items.length);
|
||||
const summary = buildSummaryPrompt(queue);
|
||||
const prompt = buildCollectPrompt(items, summary);
|
||||
const last = items.at(-1);
|
||||
if (!last) break;
|
||||
await sendAnnounce({ ...last, prompt });
|
||||
continue;
|
||||
}
|
||||
|
||||
const summaryPrompt = buildSummaryPrompt(queue);
|
||||
if (summaryPrompt) {
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await sendAnnounce({ ...next, prompt: summaryPrompt });
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await sendAnnounce(next);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error?.(`announce queue drain failed for ${key}: ${String(err)}`);
|
||||
} finally {
|
||||
queue.draining = false;
|
||||
if (queue.items.length === 0 && queue.droppedCount === 0) {
|
||||
ANNOUNCE_QUEUES.delete(key);
|
||||
} else {
|
||||
scheduleAnnounceDrain(key);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function sendAnnounce(item: AnnounceQueueItem) {
|
||||
const origin = item.origin;
|
||||
await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
sessionKey: item.sessionKey,
|
||||
message: item.prompt,
|
||||
channel: origin?.channel,
|
||||
accountId: origin?.accountId,
|
||||
to: origin?.to,
|
||||
deliver: true,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRequesterStoreKey(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
requesterSessionKey: string,
|
||||
): string {
|
||||
const raw = requesterSessionKey.trim();
|
||||
if (!raw) return raw;
|
||||
if (raw === "global" || raw === "unknown") return raw;
|
||||
if (raw.startsWith("agent:")) return raw;
|
||||
const mainKey = normalizeMainKey(cfg.session?.mainKey);
|
||||
if (raw === "main" || raw === mainKey) {
|
||||
return resolveMainSessionKey(cfg);
|
||||
}
|
||||
const agentId = resolveAgentIdFromSessionKey(raw);
|
||||
return `agent:${agentId}:${raw}`;
|
||||
}
|
||||
|
||||
function loadRequesterSessionEntry(requesterSessionKey: string) {
|
||||
const cfg = loadConfig();
|
||||
const canonicalKey = resolveRequesterStoreKey(cfg, requesterSessionKey);
|
||||
const agentId = resolveAgentIdFromSessionKey(canonicalKey);
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const legacyKey = canonicalKey.startsWith("agent:")
|
||||
? canonicalKey.split(":").slice(2).join(":")
|
||||
: undefined;
|
||||
const entry =
|
||||
store[canonicalKey] ?? store[requesterSessionKey] ?? (legacyKey ? store[legacyKey] : undefined);
|
||||
return { cfg, entry, canonicalKey };
|
||||
}
|
||||
|
||||
function resolveAnnounceOrigin(params: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
fallbackAccountId?: string;
|
||||
}) {
|
||||
return normalizeDeliveryContext({
|
||||
channel: params.channel,
|
||||
to: params.to,
|
||||
accountId: params.accountId ?? params.fallbackAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeQueueSubagentAnnounce(params: {
|
||||
requesterSessionKey: string;
|
||||
triggerMessage: string;
|
||||
summaryLine?: string;
|
||||
requesterAccountId?: string;
|
||||
}): Promise<"steered" | "queued" | "none"> {
|
||||
const { cfg, entry } = loadRequesterSessionEntry(params.requesterSessionKey);
|
||||
const canonicalKey = resolveRequesterStoreKey(cfg, params.requesterSessionKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
if (!sessionId) return "none";
|
||||
|
||||
const queueSettings = resolveQueueSettings({
|
||||
cfg,
|
||||
channel: entry?.channel ?? entry?.lastChannel,
|
||||
sessionEntry: entry,
|
||||
});
|
||||
const isActive = isEmbeddedPiRunActive(sessionId);
|
||||
|
||||
const shouldSteer = queueSettings.mode === "steer" || queueSettings.mode === "steer-backlog";
|
||||
if (shouldSteer) {
|
||||
const steered = queueEmbeddedPiMessage(sessionId, params.triggerMessage);
|
||||
if (steered) return "steered";
|
||||
}
|
||||
|
||||
const shouldFollowup =
|
||||
queueSettings.mode === "followup" ||
|
||||
queueSettings.mode === "collect" ||
|
||||
queueSettings.mode === "steer-backlog" ||
|
||||
queueSettings.mode === "interrupt";
|
||||
if (isActive && (shouldFollowup || queueSettings.mode === "steer")) {
|
||||
const origin = resolveAnnounceOrigin({
|
||||
channel: entry?.lastChannel,
|
||||
to: entry?.lastTo,
|
||||
accountId: entry?.lastAccountId,
|
||||
fallbackAccountId: params.requesterAccountId,
|
||||
});
|
||||
enqueueAnnounce(
|
||||
canonicalKey,
|
||||
{
|
||||
prompt: params.triggerMessage,
|
||||
summaryLine: params.summaryLine,
|
||||
enqueuedAt: Date.now(),
|
||||
sessionKey: canonicalKey,
|
||||
origin,
|
||||
},
|
||||
queueSettings,
|
||||
);
|
||||
scheduleAnnounceDrain(canonicalKey);
|
||||
return "queued";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
async function buildSubagentStatsLine(params: {
|
||||
sessionKey: string;
|
||||
startedAt?: number;
|
||||
@@ -187,6 +530,7 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
childRunId: string;
|
||||
requesterSessionKey: string;
|
||||
requesterChannel?: string;
|
||||
requesterAccountId?: string;
|
||||
requesterDisplayKey: string;
|
||||
task: string;
|
||||
timeoutMs: number;
|
||||
@@ -278,20 +622,43 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
"You can respond with NO_REPLY if no announcement is needed (e.g., internal task with no user-facing result).",
|
||||
].join("\n");
|
||||
|
||||
const queued = await maybeQueueSubagentAnnounce({
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
triggerMessage,
|
||||
summaryLine: taskLabel,
|
||||
requesterAccountId: params.requesterAccountId,
|
||||
});
|
||||
if (queued === "steered") {
|
||||
didAnnounce = true;
|
||||
return true;
|
||||
}
|
||||
if (queued === "queued") {
|
||||
didAnnounce = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Send to main agent - it will respond in its own voice
|
||||
const directOrigin = resolveAnnounceOrigin({
|
||||
channel: params.requesterChannel,
|
||||
accountId: params.requesterAccountId,
|
||||
});
|
||||
await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
sessionKey: params.requesterSessionKey,
|
||||
message: triggerMessage,
|
||||
deliver: true,
|
||||
channel: directOrigin?.channel,
|
||||
accountId: directOrigin?.accountId,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
didAnnounce = true;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
defaultRuntime.error?.(`Subagent announce failed: ${String(err)}`);
|
||||
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
|
||||
} finally {
|
||||
// Patch label after all writes complete
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("subagent registry persistence", () => {
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterAccountId: "acct-main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do the thing",
|
||||
cleanup: "keep",
|
||||
@@ -61,6 +62,8 @@ describe("subagent registry persistence", () => {
|
||||
const raw = await fs.readFile(registryPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { runs?: Record<string, unknown> };
|
||||
expect(parsed.runs && Object.keys(parsed.runs)).toContain("run-1");
|
||||
const run = parsed.runs?.["run-1"] as { requesterAccountId?: string } | undefined;
|
||||
expect(run?.requesterAccountId).toBe("acct-main");
|
||||
|
||||
// Simulate a process restart: module re-import should load persisted runs
|
||||
// and trigger the announce flow once the run resolves.
|
||||
@@ -77,12 +80,14 @@ describe("subagent registry persistence", () => {
|
||||
childSessionKey: string;
|
||||
childRunId: string;
|
||||
requesterSessionKey: string;
|
||||
requesterAccountId?: string;
|
||||
task: string;
|
||||
cleanup: string;
|
||||
label?: string;
|
||||
};
|
||||
const first = announceSpy.mock.calls[0]?.[0] as unknown as AnnounceParams;
|
||||
expect(first.childSessionKey).toBe("agent:main:subagent:test");
|
||||
expect(first.requesterAccountId).toBe("acct-main");
|
||||
});
|
||||
|
||||
it("skips cleanup when cleanupHandled/announceHandled was persisted", async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export type SubagentRunRecord = {
|
||||
childSessionKey: string;
|
||||
requesterSessionKey: string;
|
||||
requesterChannel?: string;
|
||||
requesterAccountId?: string;
|
||||
requesterDisplayKey: string;
|
||||
task: string;
|
||||
cleanup: "delete" | "keep";
|
||||
@@ -59,6 +60,7 @@ function resumeSubagentRun(runId: string) {
|
||||
childRunId: entry.runId,
|
||||
requesterSessionKey: entry.requesterSessionKey,
|
||||
requesterChannel: entry.requesterChannel,
|
||||
requesterAccountId: entry.requesterAccountId,
|
||||
requesterDisplayKey: entry.requesterDisplayKey,
|
||||
task: entry.task,
|
||||
timeoutMs: 30_000,
|
||||
@@ -199,6 +201,7 @@ function ensureListener() {
|
||||
childRunId: entry.runId,
|
||||
requesterSessionKey: entry.requesterSessionKey,
|
||||
requesterChannel: entry.requesterChannel,
|
||||
requesterAccountId: entry.requesterAccountId,
|
||||
requesterDisplayKey: entry.requesterDisplayKey,
|
||||
task: entry.task,
|
||||
timeoutMs: 30_000,
|
||||
@@ -248,6 +251,7 @@ export function registerSubagentRun(params: {
|
||||
childSessionKey: string;
|
||||
requesterSessionKey: string;
|
||||
requesterChannel?: string;
|
||||
requesterAccountId?: string;
|
||||
requesterDisplayKey: string;
|
||||
task: string;
|
||||
cleanup: "delete" | "keep";
|
||||
@@ -264,6 +268,7 @@ export function registerSubagentRun(params: {
|
||||
childSessionKey: params.childSessionKey,
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
requesterChannel: params.requesterChannel,
|
||||
requesterAccountId: params.requesterAccountId,
|
||||
requesterDisplayKey: params.requesterDisplayKey,
|
||||
task: params.task,
|
||||
cleanup: params.cleanup,
|
||||
@@ -318,6 +323,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
||||
childRunId: entry.runId,
|
||||
requesterSessionKey: entry.requesterSessionKey,
|
||||
requesterChannel: entry.requesterChannel,
|
||||
requesterAccountId: entry.requesterAccountId,
|
||||
requesterDisplayKey: entry.requesterDisplayKey,
|
||||
task: entry.task,
|
||||
timeoutMs: 30_000,
|
||||
|
||||
@@ -216,6 +216,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
|
||||
expect(prompt).toContain("message: Send messages and channel actions");
|
||||
expect(prompt).toContain("### message tool");
|
||||
expect(prompt).toContain("respond with ONLY: NO_REPLY");
|
||||
});
|
||||
|
||||
it("includes runtime provider capabilities when present", () => {
|
||||
|
||||
@@ -95,6 +95,7 @@ function buildMessagingSection(params: {
|
||||
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||
"- For `action=send`, include `to` and `message`.",
|
||||
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
||||
`- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
|
||||
params.inlineButtonsEnabled
|
||||
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||
: params.runtimeChannel
|
||||
|
||||
@@ -210,9 +210,12 @@ export async function handleDiscordMessagingAction(
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const embeds =
|
||||
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
|
||||
const result = await sendMessageDiscord(to, content, {
|
||||
mediaUrl,
|
||||
replyTo,
|
||||
embeds,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||
import { createMessageTool } from "./message-tool.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runMessageAction: vi.fn(),
|
||||
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/message-action-runner.js", () => ({
|
||||
runMessageAction: mocks.runMessageAction,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||
"../../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||
};
|
||||
});
|
||||
|
||||
describe("message tool mirroring", () => {
|
||||
it("mirrors media filename for plugin-handled sends", async () => {
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||
mocks.runMessageAction.mockResolvedValue({
|
||||
kind: "send",
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
handledBy: "plugin",
|
||||
payload: {},
|
||||
dryRun: false,
|
||||
} satisfies MessageActionRunResult);
|
||||
|
||||
const tool = createMessageTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
config: {} as never,
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
to: "telegram:123",
|
||||
message: "",
|
||||
media: "https://example.com/files/report.pdf?sig=1",
|
||||
});
|
||||
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "report.pdf" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mirror on dry-run", async () => {
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||
mocks.runMessageAction.mockResolvedValue({
|
||||
kind: "send",
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
handledBy: "plugin",
|
||||
payload: {},
|
||||
dryRun: true,
|
||||
} satisfies MessageActionRunResult);
|
||||
|
||||
const tool = createMessageTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
config: {} as never,
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
to: "telegram:123",
|
||||
message: "hi",
|
||||
});
|
||||
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,15 @@ import {
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
appendAssistantMessageToSessionTranscript,
|
||||
resolveMirroredTranscriptText,
|
||||
} from "../../config/sessions.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
|
||||
import { runMessageAction } from "../../infra/outbound/message-action-runner.js";
|
||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { stringEnum } from "../schema/typebox.js";
|
||||
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||
|
||||
@@ -20,7 +25,8 @@ const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
||||
|
||||
const MessageToolCommonSchema = {
|
||||
channel: Type.Optional(Type.String()),
|
||||
to: Type.Optional(Type.String()),
|
||||
to: Type.Optional(channelTargetSchema()),
|
||||
targets: Type.Optional(channelTargetsSchema()),
|
||||
message: Type.Optional(Type.String()),
|
||||
media: Type.Optional(Type.String()),
|
||||
buttons: Type.Optional(
|
||||
@@ -53,8 +59,8 @@ const MessageToolCommonSchema = {
|
||||
pollOption: Type.Optional(Type.Array(Type.String())),
|
||||
pollDurationHours: Type.Optional(Type.Number()),
|
||||
pollMulti: Type.Optional(Type.Boolean()),
|
||||
channelId: Type.Optional(Type.String()),
|
||||
channelIds: Type.Optional(Type.Array(Type.String())),
|
||||
channelId: Type.Optional(channelTargetSchema()),
|
||||
channelIds: Type.Optional(channelTargetsSchema()),
|
||||
guildId: Type.Optional(Type.String()),
|
||||
userId: Type.Optional(Type.String()),
|
||||
authorId: Type.Optional(Type.String()),
|
||||
@@ -119,8 +125,10 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
||||
|
||||
type MessageToolOptions = {
|
||||
agentAccountId?: string;
|
||||
agentSessionKey?: string;
|
||||
config?: ClawdbotConfig;
|
||||
currentChannelId?: string;
|
||||
currentChannelProvider?: string;
|
||||
currentThreadTs?: string;
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
@@ -169,11 +177,13 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
|
||||
const toolContext =
|
||||
options?.currentChannelId ||
|
||||
options?.currentChannelProvider ||
|
||||
options?.currentThreadTs ||
|
||||
options?.replyToMode ||
|
||||
options?.hasRepliedRef
|
||||
? {
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentChannelProvider: options?.currentChannelProvider,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
@@ -187,9 +197,37 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
defaultAccountId: accountId ?? undefined,
|
||||
gateway,
|
||||
toolContext,
|
||||
sessionKey: options?.agentSessionKey,
|
||||
agentId: options?.agentSessionKey
|
||||
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (result.toolResult) return result.toolResult;
|
||||
if (
|
||||
action === "send" &&
|
||||
options?.agentSessionKey &&
|
||||
!result.dryRun &&
|
||||
result.handledBy === "plugin"
|
||||
) {
|
||||
const mediaUrl = typeof params.media === "string" ? params.media : undefined;
|
||||
const mirrorText = resolveMirroredTranscriptText({
|
||||
text: typeof params.message === "string" ? params.message : undefined,
|
||||
mediaUrls: mediaUrl ? [mediaUrl] : undefined,
|
||||
});
|
||||
if (mirrorText) {
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: options.agentSessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
await appendAssistantMessageToSessionTranscript({
|
||||
agentId,
|
||||
sessionKey: options.agentSessionKey,
|
||||
text: mirrorText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ("toolResult" in result && result.toolResult) return result.toolResult;
|
||||
return jsonResult(result.payload);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ function normalizeModelSelection(value: unknown): string | undefined {
|
||||
export function createSessionsSpawnTool(opts?: {
|
||||
agentSessionKey?: string;
|
||||
agentChannel?: GatewayMessageChannel;
|
||||
agentAccountId?: string;
|
||||
sandboxed?: boolean;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
@@ -206,6 +207,7 @@ export function createSessionsSpawnTool(opts?: {
|
||||
childSessionKey,
|
||||
requesterSessionKey: requesterInternalKey,
|
||||
requesterChannel: opts?.agentChannel,
|
||||
requesterAccountId: opts?.agentAccountId,
|
||||
requesterDisplayKey,
|
||||
task,
|
||||
cleanup,
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createWebFetchTool } from "./web-tools.js";
|
||||
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
url?: string;
|
||||
headers?: { get: (key: string) => string | null };
|
||||
text?: () => Promise<string>;
|
||||
json?: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
|
||||
return {
|
||||
get: (key) => map[key.toLowerCase()] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function htmlResponse(html: string, url = "https://example.com/"): MockResponse {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
url,
|
||||
headers: makeHeaders({ "content-type": "text/html; charset=utf-8" }),
|
||||
text: async () => html,
|
||||
};
|
||||
}
|
||||
|
||||
function firecrawlResponse(markdown: string, url = "https://example.com/"): MockResponse {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
markdown,
|
||||
metadata: { title: "Firecrawl Title", sourceURL: url, statusCode: 200 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function firecrawlError(): MockResponse {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ success: false, error: "blocked" }),
|
||||
};
|
||||
}
|
||||
|
||||
function requestUrl(input: RequestInfo): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
if ("url" in input && typeof input.url === "string") return input.url;
|
||||
return "";
|
||||
}
|
||||
|
||||
describe("web_fetch extraction fallbacks", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-expect-error restore
|
||||
global.fetch = priorFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("falls back to firecrawl when readability returns no content", async () => {
|
||||
const mockFetch = vi.fn((input: RequestInfo) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("api.firecrawl.dev")) {
|
||||
return Promise.resolve(firecrawlResponse("firecrawl content")) as Promise<Response>;
|
||||
}
|
||||
return Promise.resolve(
|
||||
htmlResponse("<!doctype html><html><head></head><body></body></html>", url),
|
||||
) as Promise<Response>;
|
||||
});
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
cacheTtlMinutes: 0,
|
||||
firecrawl: { apiKey: "firecrawl-test" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: false,
|
||||
});
|
||||
|
||||
const result = await tool?.execute?.("call", { url: "https://example.com/empty" });
|
||||
const details = result?.details as { extractor?: string; text?: string };
|
||||
expect(details.extractor).toBe("firecrawl");
|
||||
expect(details.text).toContain("firecrawl content");
|
||||
});
|
||||
|
||||
it("throws when readability is disabled and firecrawl is unavailable", async () => {
|
||||
const mockFetch = vi.fn((input: RequestInfo) =>
|
||||
Promise.resolve(htmlResponse("<html><body>hi</body></html>", requestUrl(input))),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: { readability: false, cacheTtlMinutes: 0, firecrawl: { enabled: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool?.execute?.("call", { url: "https://example.com/readability-off" }),
|
||||
).rejects.toThrow("Readability disabled");
|
||||
});
|
||||
|
||||
it("throws when readability is empty and firecrawl fails", async () => {
|
||||
const mockFetch = vi.fn((input: RequestInfo) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("api.firecrawl.dev")) {
|
||||
return Promise.resolve(firecrawlError()) as Promise<Response>;
|
||||
}
|
||||
return Promise.resolve(
|
||||
htmlResponse("<!doctype html><html><head></head><body></body></html>", url),
|
||||
) as Promise<Response>;
|
||||
});
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool?.execute?.("call", { url: "https://example.com/readability-empty" }),
|
||||
).rejects.toThrow("Readability and Firecrawl returned no content");
|
||||
});
|
||||
|
||||
it("uses firecrawl when direct fetch fails", async () => {
|
||||
const mockFetch = vi.fn((input: RequestInfo) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("api.firecrawl.dev")) {
|
||||
return Promise.resolve(firecrawlResponse("firecrawl fallback", url)) as Promise<Response>;
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 403,
|
||||
headers: makeHeaders({ "content-type": "text/html" }),
|
||||
text: async () => "blocked",
|
||||
} as Response);
|
||||
});
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: false,
|
||||
});
|
||||
|
||||
const result = await tool?.execute?.("call", { url: "https://example.com/blocked" });
|
||||
const details = result?.details as { extractor?: string; text?: string };
|
||||
expect(details.extractor).toBe("firecrawl");
|
||||
expect(details.text).toContain("firecrawl fallback");
|
||||
});
|
||||
});
|
||||
+322
-20
@@ -1,7 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import { stringEnum } from "../schema/typebox.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||
@@ -15,6 +14,10 @@ const DEFAULT_FETCH_MAX_CHARS = 50_000;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const DEFAULT_CACHE_TTL_MINUTES = 15;
|
||||
const DEFAULT_CACHE_MAX_ENTRIES = 100;
|
||||
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
||||
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
||||
const DEFAULT_FETCH_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||
|
||||
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
||||
|
||||
@@ -30,6 +33,16 @@ type WebFetchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
type FirecrawlFetchConfig =
|
||||
| {
|
||||
enabled?: boolean;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
onlyMainContent?: boolean;
|
||||
maxAgeMs?: number;
|
||||
timeoutSeconds?: number;
|
||||
}
|
||||
| undefined;
|
||||
type CacheEntry<T> = {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
@@ -123,6 +136,13 @@ function resolveFetchReadabilityEnabled(fetch?: WebFetchConfig): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig {
|
||||
if (!fetch || typeof fetch !== "object") return undefined;
|
||||
const firecrawl = "firecrawl" in fetch ? fetch.firecrawl : undefined;
|
||||
if (!firecrawl || typeof firecrawl !== "object") return undefined;
|
||||
return firecrawl as FirecrawlFetchConfig;
|
||||
}
|
||||
|
||||
function resolveSearchApiKey(search?: WebSearchConfig): string | undefined {
|
||||
const fromConfig =
|
||||
search && "apiKey" in search && typeof search.apiKey === "string" ? search.apiKey.trim() : "";
|
||||
@@ -130,6 +150,52 @@ function resolveSearchApiKey(search?: WebSearchConfig): string | undefined {
|
||||
return fromConfig || fromEnv || undefined;
|
||||
}
|
||||
|
||||
function resolveFirecrawlApiKey(firecrawl?: FirecrawlFetchConfig): string | undefined {
|
||||
const fromConfig =
|
||||
firecrawl && "apiKey" in firecrawl && typeof firecrawl.apiKey === "string"
|
||||
? firecrawl.apiKey.trim()
|
||||
: "";
|
||||
const fromEnv = (process.env.FIRECRAWL_API_KEY ?? "").trim();
|
||||
return fromConfig || fromEnv || undefined;
|
||||
}
|
||||
|
||||
function resolveFirecrawlEnabled(params: {
|
||||
firecrawl?: FirecrawlFetchConfig;
|
||||
apiKey?: string;
|
||||
}): boolean {
|
||||
if (typeof params.firecrawl?.enabled === "boolean") return params.firecrawl.enabled;
|
||||
return Boolean(params.apiKey);
|
||||
}
|
||||
|
||||
function resolveFirecrawlBaseUrl(firecrawl?: FirecrawlFetchConfig): string {
|
||||
const raw =
|
||||
firecrawl && "baseUrl" in firecrawl && typeof firecrawl.baseUrl === "string"
|
||||
? firecrawl.baseUrl.trim()
|
||||
: "";
|
||||
return raw || DEFAULT_FIRECRAWL_BASE_URL;
|
||||
}
|
||||
|
||||
function resolveFirecrawlOnlyMainContent(firecrawl?: FirecrawlFetchConfig): boolean {
|
||||
if (typeof firecrawl?.onlyMainContent === "boolean") return firecrawl.onlyMainContent;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveFirecrawlMaxAgeMs(firecrawl?: FirecrawlFetchConfig): number | undefined {
|
||||
const raw =
|
||||
firecrawl && "maxAgeMs" in firecrawl && typeof firecrawl.maxAgeMs === "number"
|
||||
? firecrawl.maxAgeMs
|
||||
: undefined;
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
|
||||
const parsed = Math.max(0, Math.floor(raw));
|
||||
return parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function resolveFirecrawlMaxAgeMsOrDefault(firecrawl?: FirecrawlFetchConfig): number {
|
||||
const resolved = resolveFirecrawlMaxAgeMs(firecrawl);
|
||||
if (typeof resolved === "number") return resolved;
|
||||
return DEFAULT_FIRECRAWL_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
function missingSearchKeyPayload() {
|
||||
return {
|
||||
error: "missing_brave_api_key",
|
||||
@@ -278,9 +344,18 @@ function htmlToMarkdown(html: string): { text: string; title?: string } {
|
||||
return { text, title };
|
||||
}
|
||||
|
||||
function htmlToText(html: string): { text: string; title?: string } {
|
||||
const { text, title } = htmlToMarkdown(html);
|
||||
return { text, title };
|
||||
function markdownToText(markdown: string): string {
|
||||
let text = markdown;
|
||||
text = text.replace(/!\[[^\]]*]\([^)]+\)/g, "");
|
||||
text = text.replace(/\[([^\]]+)]\([^)]+\)/g, "$1");
|
||||
text = text.replace(/```[\s\S]*?```/g, (block) =>
|
||||
block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""),
|
||||
);
|
||||
text = text.replace(/`([^`]+)`/g, "$1");
|
||||
text = text.replace(/^#{1,6}\s+/gm, "");
|
||||
text = text.replace(/^\s*[-*+]\s+/gm, "");
|
||||
text = text.replace(/^\s*\d+\.\s+/gm, "");
|
||||
return normalizeWhitespace(text);
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxChars: number): { text: string; truncated: boolean } {
|
||||
@@ -336,6 +411,81 @@ export async function extractReadableContent(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchFirecrawlContent(params: {
|
||||
url: string;
|
||||
extractMode: (typeof EXTRACT_MODES)[number];
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
onlyMainContent: boolean;
|
||||
maxAgeMs: number;
|
||||
proxy: "auto" | "basic" | "stealth";
|
||||
storeInCache: boolean;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
title?: string;
|
||||
finalUrl?: string;
|
||||
status?: number;
|
||||
warning?: string;
|
||||
}> {
|
||||
const endpoint = resolveFirecrawlEndpoint(params.baseUrl);
|
||||
const body: Record<string, unknown> = {
|
||||
url: params.url,
|
||||
formats: ["markdown"],
|
||||
onlyMainContent: params.onlyMainContent,
|
||||
timeout: params.timeoutSeconds * 1000,
|
||||
maxAge: params.maxAgeMs,
|
||||
proxy: params.proxy,
|
||||
storeInCache: params.storeInCache,
|
||||
};
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
||||
});
|
||||
|
||||
const payload = (await res.json()) as {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
markdown?: string;
|
||||
content?: string;
|
||||
metadata?: {
|
||||
title?: string;
|
||||
sourceURL?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
};
|
||||
warning?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!res.ok || payload?.success === false) {
|
||||
const detail = payload?.error || res.statusText;
|
||||
throw new Error(`Firecrawl fetch failed (${res.status}): ${detail}`.trim());
|
||||
}
|
||||
|
||||
const data = payload?.data ?? {};
|
||||
const rawText =
|
||||
typeof data.markdown === "string"
|
||||
? data.markdown
|
||||
: typeof data.content === "string"
|
||||
? data.content
|
||||
: "";
|
||||
const text = params.extractMode === "text" ? markdownToText(rawText) : rawText;
|
||||
return {
|
||||
text,
|
||||
title: data.metadata?.title,
|
||||
finalUrl: data.metadata?.sourceURL,
|
||||
status: data.metadata?.statusCode,
|
||||
warning: payload?.warning,
|
||||
};
|
||||
}
|
||||
|
||||
async function runWebSearch(params: {
|
||||
query: string;
|
||||
count: number;
|
||||
@@ -348,7 +498,7 @@ async function runWebSearch(params: {
|
||||
ui_lang?: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`
|
||||
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
|
||||
);
|
||||
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||
if (cached) return { ...cached.value, cached: true };
|
||||
@@ -414,6 +564,14 @@ async function runWebFetch(params: {
|
||||
cacheTtlMs: number;
|
||||
userAgent: string;
|
||||
readabilityEnabled: boolean;
|
||||
firecrawlEnabled: boolean;
|
||||
firecrawlApiKey?: string;
|
||||
firecrawlBaseUrl: string;
|
||||
firecrawlOnlyMainContent: boolean;
|
||||
firecrawlMaxAgeMs: number;
|
||||
firecrawlProxy: "auto" | "basic" | "stealth";
|
||||
firecrawlStoreInCache: boolean;
|
||||
firecrawlTimeoutSeconds: number;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`fetch:${params.url}:${params.extractMode}:${params.maxChars}`,
|
||||
@@ -432,16 +590,84 @@ async function runWebFetch(params: {
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const res = await fetch(parsedUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"User-Agent": params.userAgent,
|
||||
},
|
||||
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(parsedUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"User-Agent": params.userAgent,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
||||
});
|
||||
} catch (error) {
|
||||
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url: params.url,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
onlyMainContent: params.firecrawlOnlyMainContent,
|
||||
maxAgeMs: params.firecrawlMaxAgeMs,
|
||||
proxy: params.firecrawlProxy,
|
||||
storeInCache: params.firecrawlStoreInCache,
|
||||
timeoutSeconds: params.firecrawlTimeoutSeconds,
|
||||
});
|
||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||
const payload = {
|
||||
url: params.url,
|
||||
finalUrl: firecrawl.finalUrl || params.url,
|
||||
status: firecrawl.status ?? 200,
|
||||
contentType: "text/markdown",
|
||||
title: firecrawl.title,
|
||||
extractMode: params.extractMode,
|
||||
extractor: "firecrawl",
|
||||
truncated: truncated.truncated,
|
||||
length: truncated.text.length,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
tookMs: Date.now() - start,
|
||||
text: truncated.text,
|
||||
warning: firecrawl.warning,
|
||||
};
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url: params.url,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
onlyMainContent: params.firecrawlOnlyMainContent,
|
||||
maxAgeMs: params.firecrawlMaxAgeMs,
|
||||
proxy: params.firecrawlProxy,
|
||||
storeInCache: params.firecrawlStoreInCache,
|
||||
timeoutSeconds: params.firecrawlTimeoutSeconds,
|
||||
});
|
||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||
const payload = {
|
||||
url: params.url,
|
||||
finalUrl: firecrawl.finalUrl || params.url,
|
||||
status: firecrawl.status ?? res.status,
|
||||
contentType: "text/markdown",
|
||||
title: firecrawl.title,
|
||||
extractMode: params.extractMode,
|
||||
extractor: "firecrawl",
|
||||
truncated: truncated.truncated,
|
||||
length: truncated.text.length,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
tookMs: Date.now() - start,
|
||||
text: truncated.text,
|
||||
warning: firecrawl.warning,
|
||||
};
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
const detail = await readResponseText(res);
|
||||
throw new Error(`Web fetch failed (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
@@ -450,6 +676,7 @@ async function runWebFetch(params: {
|
||||
const body = await readResponseText(res);
|
||||
|
||||
let title: string | undefined;
|
||||
let extractor = "raw";
|
||||
let text = body;
|
||||
if (contentType.includes("text/html")) {
|
||||
if (params.readabilityEnabled) {
|
||||
@@ -461,21 +688,31 @@ async function runWebFetch(params: {
|
||||
if (readable?.text) {
|
||||
text = readable.text;
|
||||
title = readable.title;
|
||||
extractor = "readability";
|
||||
} else {
|
||||
const parsed = params.extractMode === "text" ? htmlToText(body) : htmlToMarkdown(body);
|
||||
text = parsed.text;
|
||||
title = parsed.title;
|
||||
const firecrawl = await tryFirecrawlFallback(params);
|
||||
if (firecrawl) {
|
||||
text = firecrawl.text;
|
||||
title = firecrawl.title;
|
||||
extractor = "firecrawl";
|
||||
} else {
|
||||
throw new Error(
|
||||
"Web fetch extraction failed: Readability and Firecrawl returned no content.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const parsed = params.extractMode === "text" ? htmlToText(body) : htmlToMarkdown(body);
|
||||
text = parsed.text;
|
||||
title = parsed.title;
|
||||
throw new Error(
|
||||
"Web fetch extraction failed: Readability disabled and Firecrawl unavailable.",
|
||||
);
|
||||
}
|
||||
} else if (contentType.includes("application/json")) {
|
||||
try {
|
||||
text = JSON.stringify(JSON.parse(body), null, 2);
|
||||
extractor = "json";
|
||||
} catch {
|
||||
text = body;
|
||||
extractor = "raw";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +724,7 @@ async function runWebFetch(params: {
|
||||
contentType,
|
||||
title,
|
||||
extractMode: params.extractMode,
|
||||
extractor,
|
||||
truncated: truncated.truncated,
|
||||
length: truncated.text.length,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
@@ -497,6 +735,37 @@ async function runWebFetch(params: {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function tryFirecrawlFallback(params: {
|
||||
url: string;
|
||||
extractMode: (typeof EXTRACT_MODES)[number];
|
||||
firecrawlEnabled: boolean;
|
||||
firecrawlApiKey?: string;
|
||||
firecrawlBaseUrl: string;
|
||||
firecrawlOnlyMainContent: boolean;
|
||||
firecrawlMaxAgeMs: number;
|
||||
firecrawlProxy: "auto" | "basic" | "stealth";
|
||||
firecrawlStoreInCache: boolean;
|
||||
firecrawlTimeoutSeconds: number;
|
||||
}): Promise<{ text: string; title?: string } | null> {
|
||||
if (!params.firecrawlEnabled || !params.firecrawlApiKey) return null;
|
||||
try {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url: params.url,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
onlyMainContent: params.firecrawlOnlyMainContent,
|
||||
maxAgeMs: params.firecrawlMaxAgeMs,
|
||||
proxy: params.firecrawlProxy,
|
||||
storeInCache: params.firecrawlStoreInCache,
|
||||
timeoutSeconds: params.firecrawlTimeoutSeconds,
|
||||
});
|
||||
return { text: firecrawl.text, title: firecrawl.title };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebSearchTool(options?: {
|
||||
config?: ClawdbotConfig;
|
||||
sandboxed?: boolean;
|
||||
@@ -537,6 +806,21 @@ export function createWebSearchTool(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFirecrawlEndpoint(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) return `${DEFAULT_FIRECRAWL_BASE_URL}/v2/scrape`;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.pathname && url.pathname !== "/") {
|
||||
return url.toString();
|
||||
}
|
||||
url.pathname = "/v2/scrape";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return `${DEFAULT_FIRECRAWL_BASE_URL}/v2/scrape`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebFetchTool(options?: {
|
||||
config?: ClawdbotConfig;
|
||||
sandboxed?: boolean;
|
||||
@@ -544,9 +828,19 @@ export function createWebFetchTool(options?: {
|
||||
const fetch = resolveFetchConfig(options?.config);
|
||||
if (!resolveFetchEnabled({ fetch, sandboxed: options?.sandboxed })) return null;
|
||||
const readabilityEnabled = resolveFetchReadabilityEnabled(fetch);
|
||||
const firecrawl = resolveFirecrawlConfig(fetch);
|
||||
const firecrawlApiKey = resolveFirecrawlApiKey(firecrawl);
|
||||
const firecrawlEnabled = resolveFirecrawlEnabled({ firecrawl, apiKey: firecrawlApiKey });
|
||||
const firecrawlBaseUrl = resolveFirecrawlBaseUrl(firecrawl);
|
||||
const firecrawlOnlyMainContent = resolveFirecrawlOnlyMainContent(firecrawl);
|
||||
const firecrawlMaxAgeMs = resolveFirecrawlMaxAgeMsOrDefault(firecrawl);
|
||||
const firecrawlTimeoutSeconds = resolveTimeoutSeconds(
|
||||
firecrawl?.timeoutSeconds ?? fetch?.timeoutSeconds,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
);
|
||||
const userAgent =
|
||||
(fetch && "userAgent" in fetch && typeof fetch.userAgent === "string" && fetch.userAgent) ||
|
||||
`clawdbot/${VERSION}`;
|
||||
DEFAULT_FETCH_USER_AGENT;
|
||||
return {
|
||||
label: "Web Fetch",
|
||||
name: "web_fetch",
|
||||
@@ -566,6 +860,14 @@ export function createWebFetchTool(options?: {
|
||||
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||
userAgent,
|
||||
readabilityEnabled,
|
||||
firecrawlEnabled,
|
||||
firecrawlApiKey,
|
||||
firecrawlBaseUrl,
|
||||
firecrawlOnlyMainContent,
|
||||
firecrawlMaxAgeMs,
|
||||
firecrawlProxy: "auto",
|
||||
firecrawlStoreInCache: true,
|
||||
firecrawlTimeoutSeconds,
|
||||
});
|
||||
return jsonResult(result);
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ export function buildThreadingToolContext(params: {
|
||||
: provider === "imessage" && sessionCtx.ChatType === "direct"
|
||||
? (sessionCtx.From ?? sessionCtx.To)
|
||||
: sessionCtx.To;
|
||||
return (
|
||||
const context =
|
||||
dock.threading.buildToolContext({
|
||||
cfg: config,
|
||||
accountId: sessionCtx.AccountId,
|
||||
@@ -44,8 +44,11 @@ export function buildThreadingToolContext(params: {
|
||||
MessageThreadId: sessionCtx.MessageThreadId,
|
||||
},
|
||||
hasRepliedRef,
|
||||
}) ?? {}
|
||||
);
|
||||
}) ?? {};
|
||||
return {
|
||||
...context,
|
||||
currentChannelProvider: provider,
|
||||
};
|
||||
}
|
||||
|
||||
export const isBunFetchSocketError = (message?: string) =>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseSlashCommand,
|
||||
renderCodexPrompt,
|
||||
resolveCodexPrompt,
|
||||
stripFrontMatter,
|
||||
} from "./codex-prompts.js";
|
||||
|
||||
describe("codex prompts", () => {
|
||||
it("parses slash command names and args", () => {
|
||||
expect(parseSlashCommand("/landpr 123 abc")).toEqual({ name: "landpr", args: "123 abc" });
|
||||
expect(parseSlashCommand("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves prompt files and substitutes args", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-codex-prompts-"));
|
||||
const promptDir = path.join(tmp, "prompts");
|
||||
await fs.mkdir(promptDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(promptDir, "landpr.md"),
|
||||
`---\nsummary: test\n---\nHello $1 [$*] $0\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = tmp;
|
||||
try {
|
||||
const resolved = await resolveCodexPrompt("landpr");
|
||||
expect(resolved?.path).toContain("landpr.md");
|
||||
const stripped = stripFrontMatter(`---\nsummary: test\n---\nHello`);
|
||||
expect(stripped).toBe("Hello");
|
||||
const rendered = renderCodexPrompt({
|
||||
body: resolved?.body ?? "",
|
||||
args: "123 abc",
|
||||
commandName: "landpr",
|
||||
});
|
||||
expect(rendered).toContain("Hello 123 [123 abc] landpr");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
|
||||
const PROMPT_NAME_RE = /^[a-z0-9_-]+$/i;
|
||||
|
||||
export type CodexPrompt = {
|
||||
path: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export function parseSlashCommand(input: string): { name: string; args?: string } | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
|
||||
if (!match) return null;
|
||||
const name = match[1]?.trim();
|
||||
if (!name) return null;
|
||||
const args = match[2]?.trim();
|
||||
return { name, args: args || undefined };
|
||||
}
|
||||
|
||||
export function stripFrontMatter(input: string): string {
|
||||
if (!input.startsWith("---")) return input;
|
||||
const match = input.match(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/);
|
||||
return match ? input.slice(match[0].length) : input;
|
||||
}
|
||||
|
||||
export async function resolveCodexPrompt(
|
||||
commandName: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<CodexPrompt | null> {
|
||||
const normalized = commandName.trim().toLowerCase();
|
||||
if (!PROMPT_NAME_RE.test(normalized)) return null;
|
||||
const home = env.CODEX_HOME?.trim()
|
||||
? resolveUserPath(env.CODEX_HOME.trim())
|
||||
: resolveUserPath("~/.codex");
|
||||
const promptsDir = path.join(home, "prompts");
|
||||
let entries: Array<Dirent> | null = null;
|
||||
try {
|
||||
entries = await fs.readdir(promptsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const match = entries.find((entry) => {
|
||||
if (!entry.isFile()) return false;
|
||||
const base = path.parse(entry.name).name.toLowerCase();
|
||||
return base === normalized;
|
||||
});
|
||||
if (!match) return null;
|
||||
const promptPath = path.join(promptsDir, match.name);
|
||||
try {
|
||||
const raw = await fs.readFile(promptPath, "utf-8");
|
||||
const body = stripFrontMatter(raw).trim();
|
||||
if (!body) return null;
|
||||
return { path: promptPath, body };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCodexPrompt(params: {
|
||||
body: string;
|
||||
args?: string;
|
||||
commandName?: string;
|
||||
}): string {
|
||||
const args = params.args?.trim() ?? "";
|
||||
const tokens = args ? args.split(/\s+/) : [];
|
||||
return params.body.replace(/\$(\d+|\*|@|0)/g, (match, token) => {
|
||||
if (token === "*" || token === "@") return args;
|
||||
if (token === "0") return params.commandName ?? "";
|
||||
const index = Number(token);
|
||||
if (!Number.isFinite(index) || index < 1) return match;
|
||||
return tokens[index - 1] ?? "";
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { routeReply } from "./route-reply.js";
|
||||
import { handleBashCommand } from "./commands-bash.js";
|
||||
import { handleCompactCommand } from "./commands-compact.js";
|
||||
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
|
||||
@@ -42,9 +44,8 @@ const HANDLERS: CommandHandler[] = [
|
||||
];
|
||||
|
||||
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
|
||||
const resetRequested =
|
||||
params.command.commandBodyNormalized === "/reset" ||
|
||||
params.command.commandBodyNormalized === "/new";
|
||||
const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
|
||||
const resetRequested = Boolean(resetMatch);
|
||||
if (resetRequested && !params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /reset from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -52,6 +53,40 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
// Trigger internal hook for reset/new commands
|
||||
if (resetRequested && params.command.isAuthorizedSender) {
|
||||
const commandAction = resetMatch?.[1] ?? "new";
|
||||
const hookEvent = createInternalHookEvent("command", commandAction, params.sessionKey ?? "", {
|
||||
sessionEntry: params.sessionEntry,
|
||||
previousSessionEntry: params.previousSessionEntry,
|
||||
commandSource: params.command.surface,
|
||||
senderId: params.command.senderId,
|
||||
cfg: params.cfg, // Pass config for LLM slug generation
|
||||
});
|
||||
await triggerInternalHook(hookEvent);
|
||||
|
||||
// Send hook messages immediately if present
|
||||
if (hookEvent.messages.length > 0) {
|
||||
// Use OriginatingChannel/To if available, otherwise fall back to command channel/from
|
||||
const channel = params.ctx.OriginatingChannel || (params.command.channel as any);
|
||||
// For replies, use 'from' (the sender) not 'to' (which might be the bot itself)
|
||||
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
|
||||
|
||||
if (channel && to) {
|
||||
const hookReply = { text: hookEvent.messages.join("\n\n") };
|
||||
await routeReply({
|
||||
payload: hookReply,
|
||||
channel: channel,
|
||||
to: to,
|
||||
sessionKey: params.sessionKey,
|
||||
accountId: params.ctx.AccountId,
|
||||
threadId: params.ctx.MessageThreadId,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg: params.cfg,
|
||||
surface: params.command.surface,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { updateSessionStore } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { parseActivationCommand } from "../group-activation.js";
|
||||
@@ -12,8 +13,8 @@ import {
|
||||
setAbortMemory,
|
||||
stopSubagentsForRequester,
|
||||
} from "./abort.js";
|
||||
import { clearSessionQueues } from "./queue.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { clearSessionQueues } from "./queue.js";
|
||||
|
||||
function resolveSessionEntryForKey(
|
||||
store: Record<string, SessionEntry> | undefined,
|
||||
@@ -213,14 +214,27 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
|
||||
} else if (params.command.abortKey) {
|
||||
setAbortMemory(params.command.abortKey, true);
|
||||
}
|
||||
|
||||
// Trigger internal hook for stop command
|
||||
const hookEvent = createInternalHookEvent(
|
||||
"command",
|
||||
"stop",
|
||||
abortTarget.key ?? params.sessionKey ?? "",
|
||||
{
|
||||
sessionEntry: abortTarget.entry ?? params.sessionEntry,
|
||||
sessionId: abortTarget.sessionId,
|
||||
commandSource: params.command.surface,
|
||||
senderId: params.command.senderId,
|
||||
},
|
||||
);
|
||||
await triggerInternalHook(hookEvent);
|
||||
|
||||
const { stopped } = stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: abortTarget.key ?? params.sessionKey,
|
||||
});
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: formatAbortReplyText(stopped) },
|
||||
};
|
||||
|
||||
return { shouldContinue: false, reply: { text: formatAbortReplyText(stopped) } };
|
||||
};
|
||||
|
||||
export const handleAbortTrigger: CommandHandler = async (params, allowTextCommands) => {
|
||||
@@ -235,12 +249,6 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
|
||||
if (abortTarget.sessionId) {
|
||||
abortEmbeddedPiRun(abortTarget.sessionId);
|
||||
}
|
||||
const cleared = clearSessionQueues([abortTarget.key, abortTarget.sessionId]);
|
||||
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
|
||||
logVerbose(
|
||||
`stop-trigger: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
||||
);
|
||||
}
|
||||
if (abortTarget.entry && params.sessionStore && abortTarget.key) {
|
||||
abortTarget.entry.abortedLastRun = true;
|
||||
abortTarget.entry.updatedAt = Date.now();
|
||||
@@ -253,12 +261,5 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
|
||||
} else if (params.command.abortKey) {
|
||||
setAbortMemory(params.command.abortKey, true);
|
||||
}
|
||||
const { stopped } = stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: abortTarget.key ?? params.sessionKey,
|
||||
});
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: formatAbortReplyText(stopped) },
|
||||
};
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
|
||||
};
|
||||
@@ -33,6 +33,7 @@ export type HandleCommandsParams = {
|
||||
failures: Array<{ gate: string; key: string }>;
|
||||
};
|
||||
sessionEntry?: SessionEntry;
|
||||
previousSessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
@@ -143,6 +144,22 @@ describe("handleCommands identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands internal hooks", () => {
|
||||
it("triggers hooks for /new with arguments", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/new take notes", cfg);
|
||||
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
|
||||
|
||||
await handleCommands(params);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ type: "command", action: "new" }));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands context", () => {
|
||||
it("returns context help for /context", async () => {
|
||||
const cfg = {
|
||||
|
||||
@@ -5,10 +5,8 @@ import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { listChatCommands } from "../commands-registry.js";
|
||||
import { getAbortMemory } from "./abort.js";
|
||||
import { buildStatusReply, handleCommands } from "./commands.js";
|
||||
import { parseSlashCommand, renderCodexPrompt, resolveCodexPrompt } from "./codex-prompts.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { isDirectiveOnly } from "./directive-handling.js";
|
||||
import type { createModelSelectionState } from "./model-selection.js";
|
||||
@@ -31,6 +29,7 @@ export async function handleInlineActions(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
previousSessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
@@ -69,6 +68,7 @@ export async function handleInlineActions(params: {
|
||||
cfg,
|
||||
agentId,
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
@@ -140,46 +140,6 @@ export async function handleInlineActions(params: {
|
||||
cleanedBody = rewrittenBody;
|
||||
}
|
||||
|
||||
const slashCommand =
|
||||
allowTextCommands && command.isAuthorizedSender
|
||||
? parseSlashCommand(command.commandBodyNormalized)
|
||||
: null;
|
||||
if (slashCommand) {
|
||||
const reserved = new Set<string>();
|
||||
for (const entry of listChatCommands()) {
|
||||
if (entry.nativeName) reserved.add(entry.nativeName.toLowerCase());
|
||||
for (const alias of entry.textAliases) {
|
||||
if (!alias.startsWith("/")) continue;
|
||||
reserved.add(alias.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
for (const entry of skillCommands) {
|
||||
reserved.add(entry.name.toLowerCase());
|
||||
}
|
||||
const hasInlineDirective =
|
||||
directives.hasThinkDirective ||
|
||||
directives.hasVerboseDirective ||
|
||||
directives.hasReasoningDirective ||
|
||||
directives.hasElevatedDirective ||
|
||||
directives.hasModelDirective ||
|
||||
directives.hasQueueDirective ||
|
||||
directives.hasStatusDirective;
|
||||
if (!hasInlineDirective && !reserved.has(slashCommand.name.toLowerCase())) {
|
||||
const prompt = await resolveCodexPrompt(slashCommand.name);
|
||||
if (prompt) {
|
||||
const rendered = renderCodexPrompt({
|
||||
body: prompt.body,
|
||||
args: slashCommand.args,
|
||||
commandName: slashCommand.name,
|
||||
});
|
||||
ctx.Body = rendered;
|
||||
sessionCtx.Body = rendered;
|
||||
sessionCtx.BodyStripped = rendered;
|
||||
cleanedBody = rendered;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendInlineReply = async (reply?: ReplyPayload) => {
|
||||
if (!reply) return;
|
||||
if (!opts?.onBlockReply) return;
|
||||
@@ -245,6 +205,7 @@ export async function handleInlineActions(params: {
|
||||
failures: elevatedFailures,
|
||||
},
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
@@ -307,6 +268,7 @@ export async function handleInlineActions(params: {
|
||||
failures: elevatedFailures,
|
||||
},
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function getReplyFromConfig(
|
||||
let {
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
@@ -179,6 +180,7 @@ export async function getReplyFromConfig(
|
||||
cfg,
|
||||
agentId,
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||
|
||||
describe("formatInboundBodyWithSenderMeta", () => {
|
||||
it("does nothing for direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
|
||||
});
|
||||
|
||||
it("appends a sender meta line for non-direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi\n[from: Alice (A1)]");
|
||||
});
|
||||
|
||||
it("prefers SenderE164 in the label when present", () => {
|
||||
const ctx: MsgContext = {
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderId: "bob@s.whatsapp.net",
|
||||
SenderE164: "+222",
|
||||
};
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi\n[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("preserves escaped newline style when body uses literal \\\\n", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
|
||||
"[X] one\\n[X] two\\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate a sender meta line when one is already present", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
export function formatInboundBodyWithSenderMeta(params: {
|
||||
body: string;
|
||||
ctx: MsgContext;
|
||||
}): string {
|
||||
const body = params.body;
|
||||
if (!body.trim()) return body;
|
||||
const chatType = params.ctx.ChatType?.trim().toLowerCase();
|
||||
if (!chatType || chatType === "direct") return body;
|
||||
if (hasSenderMetaLine(body)) return body;
|
||||
|
||||
const senderLabel = formatSenderLabel(params.ctx);
|
||||
if (!senderLabel) return body;
|
||||
|
||||
const lineBreak = resolveBodyLineBreak(body);
|
||||
return `${body}${lineBreak}[from: ${senderLabel}]`;
|
||||
}
|
||||
|
||||
function resolveBodyLineBreak(body: string): string {
|
||||
const hasEscaped = body.includes("\\n");
|
||||
const hasNewline = body.includes("\n");
|
||||
if (hasEscaped && !hasNewline) return "\\n";
|
||||
return "\n";
|
||||
}
|
||||
|
||||
function hasSenderMetaLine(body: string): boolean {
|
||||
return /(^|\n|\\n)\[from:/i.test(body);
|
||||
}
|
||||
|
||||
function formatSenderLabel(ctx: MsgContext): string | null {
|
||||
const senderName = ctx.SenderName?.trim();
|
||||
const senderId = (ctx.SenderE164?.trim() || ctx.SenderId?.trim()) ?? "";
|
||||
if (senderName && senderId && senderName !== senderId) {
|
||||
return `${senderName} (${senderId})`;
|
||||
}
|
||||
return senderName ?? (senderId || null);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
|
||||
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
|
||||
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })),
|
||||
deliverOutboundPayloads: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../discord/send.js", () => ({
|
||||
@@ -37,12 +38,25 @@ vi.mock("../../telegram/send.js", () => ({
|
||||
vi.mock("../../web/outbound.js", () => ({
|
||||
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
|
||||
}));
|
||||
vi.mock("../../infra/outbound/deliver.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
|
||||
"../../infra/outbound/deliver.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
||||
};
|
||||
});
|
||||
const actualDeliver = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
|
||||
"../../infra/outbound/deliver.js",
|
||||
);
|
||||
|
||||
const { routeReply } = await import("./route-reply.js");
|
||||
|
||||
describe("routeReply", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
mocks.deliverOutboundPayloads.mockImplementation(actualDeliver.deliverOutboundPayloads);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -261,6 +275,25 @@ describe("routeReply", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes mirror data when sessionKey is set", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
sessionKey: "agent:main:main",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mirror: expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "hi",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||
|
||||
@@ -113,7 +113,16 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
replyToId: replyToId ?? null,
|
||||
threadId: threadId ?? null,
|
||||
abortSignal,
|
||||
mirror: params.sessionKey
|
||||
? {
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||
text,
|
||||
mediaUrls,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const last = results.at(-1);
|
||||
return { ok: true, messageId: last?.messageId };
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
|
||||
describe("initSessionState sender meta", () => {
|
||||
it("injects sender meta into BodyStripped for group chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp 123@g.us] ping",
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SenderId: "222@s.whatsapp.net",
|
||||
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("does not inject sender meta for direct chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp +1] ping",
|
||||
ChatType: "direct",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SessionKey: "agent:main:whatsapp:dm:+222",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,10 +26,12 @@ import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||
|
||||
export type SessionInitResult = {
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry: SessionEntry;
|
||||
previousSessionEntry?: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
@@ -115,6 +117,7 @@ export async function initSessionState(params: {
|
||||
let bodyStripped: string | undefined;
|
||||
let systemSent = false;
|
||||
let abortedLastRun = false;
|
||||
let resetTriggered = false;
|
||||
|
||||
let persistedThinking: string | undefined;
|
||||
let persistedVerbose: string | undefined;
|
||||
@@ -149,12 +152,14 @@ export async function initSessionState(params: {
|
||||
if (trimmedBody === trigger || strippedForReset === trigger) {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
resetTriggered = true;
|
||||
break;
|
||||
}
|
||||
const triggerPrefix = `${trigger} `;
|
||||
if (trimmedBody.startsWith(triggerPrefix) || strippedForReset.startsWith(triggerPrefix)) {
|
||||
isNewSession = true;
|
||||
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
|
||||
resetTriggered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -168,6 +173,7 @@ export async function initSessionState(params: {
|
||||
}
|
||||
}
|
||||
const entry = sessionStore[sessionKey];
|
||||
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
|
||||
@@ -278,10 +284,13 @@ export async function initSessionState(params: {
|
||||
ctx.MessageThreadId,
|
||||
);
|
||||
}
|
||||
// Fresh start for new sessions - don't inherit old state like compactionCount
|
||||
sessionStore[sessionKey] = isNewSession
|
||||
? sessionEntry
|
||||
: { ...sessionStore[sessionKey], ...sessionEntry };
|
||||
if (isNewSession) {
|
||||
sessionEntry.compactionCount = 0;
|
||||
sessionEntry.memoryFlushCompactionCount = undefined;
|
||||
sessionEntry.memoryFlushAt = undefined;
|
||||
}
|
||||
// Preserve per-session overrides while resetting compaction state on /new.
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
|
||||
if (store[groupResolution.legacyKey] && !store[sessionKey]) {
|
||||
@@ -289,17 +298,18 @@ export async function initSessionState(params: {
|
||||
}
|
||||
delete store[groupResolution.legacyKey];
|
||||
}
|
||||
// Fresh start for new sessions - don't inherit old state like compactionCount
|
||||
store[sessionKey] = isNewSession
|
||||
? sessionEntry
|
||||
: { ...store[sessionKey], ...sessionEntry };
|
||||
// Preserve per-session overrides while resetting compaction state on /new.
|
||||
store[sessionKey] = { ...store[sessionKey], ...sessionEntry };
|
||||
});
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
// Keep BodyStripped aligned with Body (best default for agent prompts).
|
||||
// RawBody is reserved for command/directive parsing and may omit context.
|
||||
BodyStripped: bodyStripped ?? ctx.Body ?? ctx.CommandBody ?? ctx.RawBody,
|
||||
BodyStripped: formatInboundBodyWithSenderMeta({
|
||||
ctx,
|
||||
body: bodyStripped ?? ctx.Body ?? ctx.CommandBody ?? ctx.RawBody ?? "",
|
||||
}),
|
||||
SessionId: sessionId,
|
||||
IsNewSession: isNewSession ? "true" : "false",
|
||||
};
|
||||
@@ -307,6 +317,7 @@ export async function initSessionState(params: {
|
||||
return {
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
sessionId: sessionId ?? crypto.randomUUID(),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { CONFIG_DIR } from "../../utils.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
|
||||
export async function stageSandboxMedia(params: {
|
||||
@@ -29,7 +31,13 @@ export async function stageSandboxMedia(params: {
|
||||
sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
if (!sandbox) return;
|
||||
|
||||
// For remote attachments without sandbox, use ~/.clawdbot/media (not agent workspace for privacy)
|
||||
const remoteMediaCacheDir = ctx.MediaRemoteHost
|
||||
? path.join(CONFIG_DIR, "media", "remote-cache", sessionKey)
|
||||
: null;
|
||||
const effectiveWorkspaceDir = sandbox?.workspaceDir ?? remoteMediaCacheDir;
|
||||
if (!effectiveWorkspaceDir) return;
|
||||
|
||||
const resolveAbsolutePath = (value: string): string | null => {
|
||||
let resolved = value.trim();
|
||||
@@ -46,7 +54,10 @@ export async function stageSandboxMedia(params: {
|
||||
};
|
||||
|
||||
try {
|
||||
const destDir = path.join(sandbox.workspaceDir, "media", "inbound");
|
||||
// For sandbox: <workspace>/media/inbound, for remote cache: use dir directly
|
||||
const destDir = sandbox
|
||||
? path.join(effectiveWorkspaceDir, "media", "inbound")
|
||||
: effectiveWorkspaceDir;
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
|
||||
const usedNames = new Set<string>();
|
||||
@@ -69,9 +80,15 @@ export async function stageSandboxMedia(params: {
|
||||
usedNames.add(fileName);
|
||||
|
||||
const dest = path.join(destDir, fileName);
|
||||
await fs.copyFile(source, dest);
|
||||
const relative = path.posix.join("media", "inbound", fileName);
|
||||
staged.set(source, relative);
|
||||
if (ctx.MediaRemoteHost) {
|
||||
// Always use SCP when remote host is configured - local paths refer to remote machine
|
||||
await scpFile(ctx.MediaRemoteHost, source, dest);
|
||||
} else {
|
||||
await fs.copyFile(source, dest);
|
||||
}
|
||||
// For sandbox use relative path, for remote cache use absolute path
|
||||
const stagedPath = sandbox ? path.posix.join("media", "inbound", fileName) : dest;
|
||||
staged.set(source, stagedPath);
|
||||
}
|
||||
|
||||
const rewriteIfStaged = (value: string | undefined): string | undefined => {
|
||||
@@ -111,3 +128,32 @@ export async function stageSandboxMedia(params: {
|
||||
logVerbose(`Failed to stage inbound media for sandbox: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function scpFile(remoteHost: string, remotePath: string, localPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"/usr/bin/scp",
|
||||
[
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
`${remoteHost}:${remotePath}`,
|
||||
localPath,
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`scp failed (${code}): ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export type MsgContext = {
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
||||
MediaRemoteHost?: string;
|
||||
Transcript?: string;
|
||||
ChatType?: string;
|
||||
GroupSubject?: string;
|
||||
@@ -73,6 +75,11 @@ export type MsgContext = {
|
||||
* The chat/channel/user ID where the reply should be sent.
|
||||
*/
|
||||
OriginatingTo?: string;
|
||||
/**
|
||||
* Messages from internal hooks to be included in the response.
|
||||
* Used for hook confirmation messages like "Session context saved to memory".
|
||||
*/
|
||||
HookMessages?: string[];
|
||||
};
|
||||
|
||||
export type TemplateContext = MsgContext & {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
||||
|
||||
export type PwAiModule = typeof import("./pw-ai.js");
|
||||
|
||||
type PwAiLoadMode = "soft" | "strict";
|
||||
|
||||
let pwAiModuleSoft: Promise<PwAiModule | null> | null = null;
|
||||
let pwAiModuleStrict: Promise<PwAiModule | null> | null = null;
|
||||
|
||||
function isModuleNotFoundError(err: unknown): boolean {
|
||||
const code = extractErrorCode(err);
|
||||
if (code === "ERR_MODULE_NOT_FOUND") return true;
|
||||
const msg = formatErrorMessage(err);
|
||||
return (
|
||||
msg.includes("Cannot find module") ||
|
||||
msg.includes("Cannot find package") ||
|
||||
msg.includes("Failed to resolve import") ||
|
||||
msg.includes("Failed to resolve entry for package") ||
|
||||
msg.includes("Failed to load url")
|
||||
);
|
||||
}
|
||||
|
||||
async function loadPwAiModule(mode: PwAiLoadMode): Promise<PwAiModule | null> {
|
||||
try {
|
||||
return await import("./pw-ai.js");
|
||||
} catch (err) {
|
||||
if (mode === "soft") return null;
|
||||
if (isModuleNotFoundError(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPwAiModule(opts?: { mode?: PwAiLoadMode }): Promise<PwAiModule | null> {
|
||||
const mode: PwAiLoadMode = opts?.mode ?? "soft";
|
||||
if (mode === "soft") {
|
||||
if (!pwAiModuleSoft) pwAiModuleSoft = loadPwAiModule("soft");
|
||||
return await pwAiModuleSoft;
|
||||
}
|
||||
if (!pwAiModuleStrict) pwAiModuleStrict = loadPwAiModule("strict");
|
||||
return await pwAiModuleStrict;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
export {
|
||||
type BrowserConsoleMessage,
|
||||
closePageByTargetIdViaPlaywright,
|
||||
closePlaywrightBrowserConnection,
|
||||
createPageViaPlaywright,
|
||||
ensurePageState,
|
||||
focusPageByTargetIdViaPlaywright,
|
||||
getPageForTargetId,
|
||||
listPagesViaPlaywright,
|
||||
refLocator,
|
||||
type WithSnapshotForAI,
|
||||
} from "./pw-session.js";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const CDP_URL = process.env.CLAWDBOT_LIVE_BROWSER_CDP_URL?.trim() || "";
|
||||
const describeLive = LIVE && CDP_URL ? describe : describe.skip;
|
||||
|
||||
async function waitFor(
|
||||
fn: () => Promise<boolean>,
|
||||
opts: { timeoutMs: number; intervalMs: number },
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await fn()) return;
|
||||
await new Promise((r) => setTimeout(r, opts.intervalMs));
|
||||
}
|
||||
throw new Error("timed out");
|
||||
}
|
||||
|
||||
describeLive("browser (live): remote CDP tab persistence", () => {
|
||||
it("creates, lists, focuses, and closes tabs via Playwright", { timeout: 60_000 }, async () => {
|
||||
const pw = await import("./pw-ai.js");
|
||||
await pw.closePlaywrightBrowserConnection().catch(() => {});
|
||||
|
||||
const created = await pw.createPageViaPlaywright({ cdpUrl: CDP_URL, url: "about:blank" });
|
||||
try {
|
||||
await waitFor(
|
||||
async () => {
|
||||
const pages = await pw.listPagesViaPlaywright({ cdpUrl: CDP_URL });
|
||||
return pages.some((p) => p.targetId === created.targetId);
|
||||
},
|
||||
{ timeoutMs: 10_000, intervalMs: 250 },
|
||||
);
|
||||
|
||||
await pw.focusPageByTargetIdViaPlaywright({ cdpUrl: CDP_URL, targetId: created.targetId });
|
||||
|
||||
await pw.closePageByTargetIdViaPlaywright({ cdpUrl: CDP_URL, targetId: created.targetId });
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const pages = await pw.listPagesViaPlaywright({ cdpUrl: CDP_URL });
|
||||
return !pages.some((p) => p.targetId === created.targetId);
|
||||
},
|
||||
{ timeoutMs: 10_000, intervalMs: 250 },
|
||||
);
|
||||
} finally {
|
||||
await pw.closePlaywrightBrowserConnection().catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user