Compare commits
308 Commits
fix/build-
...
fix/cli-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec9ba5b784 | ||
|
|
cee4149884 | ||
|
|
5599e4cf35 | ||
|
|
84cdd2df73 | ||
|
|
7929f57460 | ||
|
|
7876679c5d | ||
|
|
bc6928525d | ||
|
|
755c847d9a | ||
|
|
80a8639940 | ||
|
|
6cb5704291 | ||
|
|
4a987c836d | ||
|
|
a2fb55326c | ||
|
|
af29c6a980 | ||
|
|
f2a0e8e5bb | ||
|
|
f6456c2883 | ||
|
|
39f0d000d1 | ||
|
|
a8d9d630bc | ||
|
|
e93a1d8138 | ||
|
|
f6681be6f4 | ||
|
|
c79ac3fe81 | ||
|
|
b78b06353a | ||
|
|
c49b6cc241 | ||
|
|
30c945fe92 | ||
|
|
dfd511c310 | ||
|
|
1657525201 | ||
|
|
3e4b0d0505 | ||
|
|
003c6c9ae1 | ||
|
|
41fbb4d8b0 | ||
|
|
eecb340f64 | ||
|
|
d029eaa0bb | ||
|
|
9c7dcc1ed7 | ||
|
|
be37b39782 | ||
|
|
49c35c752c | ||
|
|
25d8043b9d | ||
|
|
f9f4a953fc | ||
|
|
34c3fbc66c | ||
|
|
a9f21b3d3a | ||
|
|
ed5c5629f6 | ||
|
|
868952f958 | ||
|
|
9b9836be71 | ||
|
|
22cd839cb2 | ||
|
|
dc3ac9fa28 | ||
|
|
c874fa9712 | ||
|
|
6b784a9771 | ||
|
|
f8e673cdbc | ||
|
|
ad360b4d18 | ||
|
|
69ba2765de | ||
|
|
31e8ecca10 | ||
|
|
4ca38286d8 | ||
|
|
fbf1c3ca3c | ||
|
|
1a4313c2aa | ||
|
|
a6deb0d9d5 | ||
|
|
b6ea5895b6 | ||
|
|
d66bc65ca6 | ||
|
|
89f85ddeab | ||
|
|
bbb71c9198 | ||
|
|
ae6792522d | ||
|
|
e637bbdfb5 | ||
|
|
869ef0c5ba | ||
|
|
1002c74d9c | ||
|
|
61e60f3b84 | ||
|
|
13b931c006 | ||
|
|
ab49fe0e92 | ||
|
|
d0bc08a934 | ||
|
|
64d21f5ea8 | ||
|
|
0a95d8a840 | ||
|
|
56f3a2de25 | ||
|
|
d8b463d0b3 | ||
|
|
eef3df9fa5 | ||
|
|
837eea4ebd | ||
|
|
55622bac06 | ||
|
|
f172ccfcf6 | ||
|
|
a2a6893566 | ||
|
|
616ee3075c | ||
|
|
c5239f6a8e | ||
|
|
cccd7c7b8e | ||
|
|
8f1132e8ec | ||
|
|
e6477363e9 | ||
|
|
1a4fc8dea6 | ||
|
|
a3daf3d115 | ||
|
|
f3f80509e3 | ||
|
|
7cebe7a506 | ||
|
|
5986175268 | ||
|
|
7630c6dccb | ||
|
|
d63cc1e8a7 | ||
|
|
80bb6b712c | ||
|
|
410b8f223e | ||
|
|
693f152895 | ||
|
|
78a4441ac2 | ||
|
|
c92265a51b | ||
|
|
d5fdda8e28 | ||
|
|
cddf198321 | ||
|
|
6d969fe58e | ||
|
|
68c7d577a4 | ||
|
|
1ea8917e2b | ||
|
|
07c93dfd30 | ||
|
|
cf0ea6c756 | ||
|
|
8c9e32c4a3 | ||
|
|
34d59d7913 | ||
|
|
0c0d9e1d22 | ||
|
|
2ee45d50a4 | ||
|
|
0c0e1e4226 | ||
|
|
86a46874da | ||
|
|
3a6ee5ee00 | ||
|
|
5dc87a2ed4 | ||
|
|
a85ddf258c | ||
|
|
1f3a09b43b | ||
|
|
7b31b280f8 | ||
|
|
6a3ed5c850 | ||
|
|
7ed55682b7 | ||
|
|
37a2eee837 | ||
|
|
353d778988 | ||
|
|
5a1ff5b9e7 | ||
|
|
3dc4a96330 | ||
|
|
65a8a93854 | ||
|
|
eb8a0510e0 | ||
|
|
a4178e4062 | ||
|
|
e7953d8164 | ||
|
|
5ebfc0738f | ||
|
|
bd32cc40e6 | ||
|
|
b31d8d3b10 | ||
|
|
331141ad77 | ||
|
|
c7ae5100fa | ||
|
|
285ed8bac3 | ||
|
|
e59d8c5436 | ||
|
|
8b42902cee | ||
|
|
07a3db153d | ||
|
|
68d35be383 | ||
|
|
99dd428862 | ||
|
|
4d314db750 | ||
|
|
ccea3a0615 | ||
|
|
f4f20c6762 | ||
|
|
a624878973 | ||
|
|
c8b826ea8c | ||
|
|
f7089cde54 | ||
|
|
f42b12646d | ||
|
|
572e04d5fb | ||
|
|
bc49c20434 | ||
|
|
4b085f23e0 | ||
|
|
c4ea25a509 | ||
|
|
312cb75c50 | ||
|
|
ee738e6578 | ||
|
|
fcb7c9ff65 | ||
|
|
49ecbd8fea | ||
|
|
19ee6699d2 | ||
|
|
5fcc9b3244 | ||
|
|
3efc5e54fa | ||
|
|
780c811146 | ||
|
|
4f37f66264 | ||
|
|
8ebfa2950d | ||
|
|
9a60d431c5 | ||
|
|
6e4d86f426 | ||
|
|
87cecd0268 | ||
|
|
388b2bce01 | ||
|
|
a2b5b1f0cb | ||
|
|
9f4b7a1683 | ||
|
|
dd68faef23 | ||
|
|
97cfa0846c | ||
|
|
1b973f7506 | ||
|
|
4b749f1b8f | ||
|
|
7f1f9473a0 | ||
|
|
a32e5d040c | ||
|
|
a82217a5f3 | ||
|
|
09bed2ccde | ||
|
|
3af391eec7 | ||
|
|
204309dd3c | ||
|
|
3fcd6fadf3 | ||
|
|
78136c4368 | ||
|
|
25edbdacc2 | ||
|
|
451532f6f1 | ||
|
|
bc7d603867 | ||
|
|
46015a3dd8 | ||
|
|
1481a3d90f | ||
|
|
96a1d03f08 | ||
|
|
0291105913 | ||
|
|
dbf8829283 | ||
|
|
02184dd055 | ||
|
|
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 | ||
|
|
e31251293b | ||
|
|
7a9ff18260 | ||
|
|
56ed5cc2d9 | ||
|
|
af31e0d969 | ||
|
|
37fa4f7eef | ||
|
|
9aad6dfe1b | ||
|
|
6b8db36a15 | ||
|
|
171060541a | ||
|
|
003547c818 | ||
|
|
c4e1064066 | ||
|
|
d163dbcfcd | ||
|
|
2acfeb1096 | ||
|
|
b3b6d421cc | ||
|
|
cf72b9db3c | ||
|
|
106e308953 | ||
|
|
bf72a126d1 | ||
|
|
28a5d124c3 | ||
|
|
b4045e6adb | ||
|
|
a7bec3340f | ||
|
|
a4e99ecdaf | ||
|
|
dcd20d564f | ||
|
|
59f6ea9b21 | ||
|
|
e44f28bd4f | ||
|
|
929b86e302 | ||
|
|
76d3d58b5c | ||
|
|
548a32c8d4 | ||
|
|
500c75b4f0 | ||
|
|
3567dc4a47 | ||
|
|
7df37c2dbd | ||
|
|
28a4cbc4ef | ||
|
|
21fe4d9ded | ||
|
|
05d149a49b | ||
|
|
97a41a6509 | ||
|
|
a0be85c34c | ||
|
|
a36735b913 | ||
|
|
390bd11f33 | ||
|
|
16768a9998 | ||
|
|
e9d6869290 | ||
|
|
9072e35f08 | ||
|
|
d887027e95 | ||
|
|
168a0f4998 | ||
|
|
f3a37664d5 | ||
|
|
8bcbe68637 | ||
|
|
beb9eac5f7 | ||
|
|
0dcffcd5b0 | ||
|
|
56efbce31e | ||
|
|
e7c42884fc | ||
|
|
08c0405f0f | ||
|
|
470add877c | ||
|
|
aaa310c047 | ||
|
|
0cd24137e8 | ||
|
|
8ffb8cc363 | ||
|
|
7a9854cb06 | ||
|
|
5ee4456c6e | ||
|
|
de31583021 | ||
|
|
38b49aa0f6 | ||
|
|
69761e8a51 | ||
|
|
3431d3d115 | ||
|
|
fe9e027d58 | ||
|
|
33d17957e5 | ||
|
|
25ae5f897e | ||
|
|
624ff09314 | ||
|
|
c8003ae472 | ||
|
|
6bf627bce8 | ||
|
|
08525435e0 | ||
|
|
9e39a56033 | ||
|
|
026cf1130e | ||
|
|
bb14b19922 | ||
|
|
78279fb758 | ||
|
|
38cf90f6db | ||
|
|
544ca062a3 | ||
|
|
be9aa5494a | ||
|
|
0d6af15d1c | ||
|
|
eda9410bce | ||
|
|
a51ed8a5dd | ||
|
|
19bcbf85df | ||
|
|
f49d0e5476 | ||
|
|
9c4c9c5edd | ||
|
|
0f34255359 | ||
|
|
de5fb65cb8 | ||
|
|
e773f84e39 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ apps/ios/*.mobileprovision
|
||||
# Local untracked files
|
||||
.local/
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
||||
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
|
||||
|
||||
12
.oxlintrc.json
Normal file
12
.oxlintrc.json
Normal file
@@ -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.
|
||||
@@ -72,6 +73,7 @@
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## Troubleshooting
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
@@ -107,6 +109,7 @@
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
@@ -115,6 +118,7 @@
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
|
||||
135
CHANGELOG.md
135
CHANGELOG.md
@@ -1,6 +1,120 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.15 (unreleased)
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.17 (Unreleased)
|
||||
|
||||
### Changes
|
||||
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
||||
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
||||
|
||||
## 2026.1.16-1
|
||||
|
||||
### Highlights
|
||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
|
||||
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
|
||||
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
|
||||
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
|
||||
- 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.
|
||||
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
|
||||
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
|
||||
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
|
||||
- Tools: add `process submit` helper to send CR for PTY sessions.
|
||||
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
|
||||
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
|
||||
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
|
||||
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
|
||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||
- 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.
|
||||
- iMessage: add remote attachment support for VM/SSH deployments.
|
||||
- Messages: refresh live directory cache results when resolving targets.
|
||||
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
|
||||
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
|
||||
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
|
||||
- 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.
|
||||
- Hooks: add hook pack installs (npm/path/zip/tar) with `clawdbot.hooks` manifests and `clawdbot hooks install/update`.
|
||||
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
||||
|
||||
### Fixes
|
||||
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
||||
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
|
||||
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||
- Telegram: split long captions into follow-up messages.
|
||||
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
|
||||
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
|
||||
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
|
||||
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
|
||||
- Sessions: preserve overrides on `/new` reset.
|
||||
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
|
||||
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
|
||||
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
|
||||
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
|
||||
- Build: allow `@lydell/node-pty` builds on supported platforms.
|
||||
- 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.
|
||||
- Messages: honor message tool channel when deduping sends.
|
||||
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
|
||||
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
|
||||
- Sessions: repair orphaned user turns before embedded prompts.
|
||||
- Sessions: hard-stop `sessions.delete` cleanup.
|
||||
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
||||
- Channels: normalize object-format capabilities in channel capability parsing.
|
||||
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
|
||||
- Security: redact sensitive text in gateway WS logs.
|
||||
- Tools: cap pending `exec` process output to avoid unbounded buffers.
|
||||
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
|
||||
- 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.
|
||||
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
|
||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
||||
- WhatsApp: include `linked` field in `describeAccount`.
|
||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
||||
- Agents: hide the image tool when the primary model already supports images.
|
||||
- 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.
|
||||
- iMessage: avoid RPC restart loops.
|
||||
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (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.
|
||||
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
|
||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
||||
- Skills: fix skills watcher ignored list typing (tsc).
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Highlights
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
@@ -11,17 +125,24 @@
|
||||
### 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:** 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).
|
||||
- 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.
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||
- TUI: show provider/model labels for the active session and default model.
|
||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||
- 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).
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
@@ -46,6 +167,17 @@
|
||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||
|
||||
### 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.
|
||||
@@ -57,6 +189,7 @@
|
||||
- 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).
|
||||
|
||||
@@ -25,6 +25,8 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV CLAWDBOT_PREFER_PNPM=1
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
|
||||
39
README.md
39
README.md
@@ -474,23 +474,24 @@ Core contributors:
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
||||
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a>
|
||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
||||
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
||||
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
||||
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
|
||||
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
||||
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
|
||||
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a>
|
||||
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||
<a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
||||
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/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/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>
|
||||
|
||||
130
appcast.xml
130
appcast.xml
@@ -2,6 +2,103 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.16-2</title>
|
||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>6273</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.15</title>
|
||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5998</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
||||
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
||||
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
||||
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
||||
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
||||
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
||||
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
||||
<li>TUI: show provider/model labels for the active session and default model.</li>
|
||||
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
||||
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
||||
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
||||
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
||||
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
||||
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
||||
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
||||
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
||||
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
||||
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
||||
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
||||
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
||||
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
||||
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
||||
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
||||
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
||||
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
||||
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
||||
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
||||
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
||||
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
||||
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
||||
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
||||
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
||||
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
||||
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
||||
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
||||
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
||||
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
||||
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
||||
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
||||
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
||||
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
||||
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.14-1</title>
|
||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
||||
@@ -174,38 +271,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.12-2</title>
|
||||
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5534</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.11-3</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5212</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
|
||||
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -257,30 +257,8 @@ final class AppState {
|
||||
|
||||
let configRoot = ClawdbotConfigFile.loadDict()
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let configMode: ConnectionMode? = switch configModeRaw {
|
||||
case "local":
|
||||
.local
|
||||
case "remote":
|
||||
.remote
|
||||
default:
|
||||
nil
|
||||
}
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configHasRemoteUrl = !(configRemoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
||||
configMode
|
||||
} else if configHasRemoteUrl {
|
||||
.remote
|
||||
} else if let storedMode {
|
||||
ConnectionMode(rawValue: storedMode) ?? .local
|
||||
} else {
|
||||
onboardingSeen ? .local : .unconfigured
|
||||
}
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
|
||||
@@ -35,7 +35,7 @@ enum CLIInstaller {
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
let prefix = Self.installPrefix()
|
||||
await statusHandler("Installing clawdbot CLI…")
|
||||
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||
|
||||
363
apps/macos/Sources/Clawdbot/ChannelConfigForm.swift
Normal file
363
apps/macos/Sources/Clawdbot/ChannelConfigForm.swift
Normal file
@@ -0,0 +1,363 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConfigSchemaForm: View {
|
||||
@Bindable var store: ChannelsStore
|
||||
let schema: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
|
||||
var body: some View {
|
||||
self.renderNode(self.schema, path: self.path)
|
||||
}
|
||||
|
||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
||||
let storedValue = self.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(\.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)
|
||||
self.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("")
|
||||
}
|
||||
self.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)
|
||||
self.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
|
||||
self.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)
|
||||
self.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
|
||||
self.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 {
|
||||
self.store.updateConfigValue(path: path, value: nil)
|
||||
} else if let value = Double(trimmed) {
|
||||
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func enumBinding(
|
||||
_ path: ConfigPath,
|
||||
options: [Any],
|
||||
defaultValue: Any?) -> Binding<Int>
|
||||
{
|
||||
Binding(
|
||||
get: {
|
||||
let value = self.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 {
|
||||
self.store.updateConfigValue(path: path, value: nil)
|
||||
return
|
||||
}
|
||||
self.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 = self.store.configValue(at: path) as? [String: Any] ?? [:]
|
||||
guard current[trimmed] == nil else { return }
|
||||
var next = current
|
||||
next[trimmed] = current[key]
|
||||
next.removeValue(forKey: key)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ChannelConfigForm: View {
|
||||
@Bindable var store: ChannelsStore
|
||||
let channelId: String
|
||||
|
||||
var body: some View {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "WhatsApp Web"
|
||||
case "telegram": "Telegram Bot"
|
||||
case "discord": "Discord Bot"
|
||||
case "slack": "Slack Bot"
|
||||
case "signal": "Signal REST"
|
||||
case "imessage": "iMessage"
|
||||
default: self.resolveChannelTitle(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": "message"
|
||||
case "telegram": "paperplane"
|
||||
case "discord": "bubble.left.and.bubble.right"
|
||||
case "slack": "number"
|
||||
case "signal": "antenna.radiowaves.left.and.right"
|
||||
case "imessage": "message.fill"
|
||||
default: "message"
|
||||
}
|
||||
}
|
||||
|
||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||
self.store.snapshot?.channels[id]?.dictionaryValue
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
19
apps/macos/Sources/Clawdbot/ChannelsSettings.swift
Normal file
19
apps/macos/Sources/Clawdbot/ChannelsSettings.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
154
apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift
Normal file
154
apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift
Normal file
@@ -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 let .key(key):
|
||||
guard let dict = current as? [String: Any] else { return nil }
|
||||
current = dict[key]
|
||||
case let .index(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 let .key(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 let .index(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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -385,14 +385,8 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let modeRaw = defaults.string(forKey: connectionModeKey)
|
||||
let mode: AppState.ConnectionMode
|
||||
if let modeRaw {
|
||||
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||
} else {
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
mode = seen ? .local : .unconfigured
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||
|
||||
204
apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift
Normal file
204
apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift
Normal file
@@ -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 let .key(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 let .key(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
|
||||
|
||||
49
apps/macos/Sources/Clawdbot/ConnectionModeResolver.swift
Normal file
49
apps/macos/Sources/Clawdbot/ConnectionModeResolver.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
enum EffectiveConnectionModeSource: Sendable, Equatable {
|
||||
case configMode
|
||||
case configRemoteURL
|
||||
case userDefaults
|
||||
case onboarding
|
||||
}
|
||||
|
||||
struct EffectiveConnectionMode: Sendable, Equatable {
|
||||
let mode: AppState.ConnectionMode
|
||||
let source: EffectiveConnectionModeSource
|
||||
}
|
||||
|
||||
enum ConnectionModeResolver {
|
||||
static func resolve(
|
||||
root: [String: Any],
|
||||
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
|
||||
{
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let configModeRaw = (gateway?["mode"] as? String) ?? ""
|
||||
let configMode = configModeRaw
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
switch configMode {
|
||||
case "local":
|
||||
return EffectiveConnectionMode(mode: .local, source: .configMode)
|
||||
case "remote":
|
||||
return EffectiveConnectionMode(mode: .remote, source: .configMode)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
|
||||
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !remoteURL.isEmpty {
|
||||
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
|
||||
}
|
||||
|
||||
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
|
||||
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
|
||||
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
|
||||
}
|
||||
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
|
||||
}
|
||||
}
|
||||
@@ -1,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"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ConcurrencyExtras
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -16,6 +17,13 @@ actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
private enum EnvOverrideWarningKind: Sendable {
|
||||
case token
|
||||
case password
|
||||
}
|
||||
|
||||
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
|
||||
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
@@ -30,16 +38,18 @@ actor GatewayEndpointStore {
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||
return GatewayEndpointStore.resolveGatewayToken(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
isRemote: isRemote,
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
},
|
||||
password: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
isRemote: isRemote,
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
@@ -68,6 +78,14 @@ actor GatewayEndpointStore {
|
||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
|
||||
!configPassword.isEmpty
|
||||
{
|
||||
self.warnEnvOverrideOnce(
|
||||
kind: .password,
|
||||
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
|
||||
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
@@ -99,6 +117,26 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let password = remote["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveGatewayToken(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
@@ -108,6 +146,14 @@ actor GatewayEndpointStore {
|
||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||
!configToken.isEmpty
|
||||
{
|
||||
self.warnEnvOverrideOnce(
|
||||
kind: .token,
|
||||
envVar: "CLAWDBOT_GATEWAY_TOKEN",
|
||||
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
@@ -139,6 +185,49 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let token = remote["token"] as? String
|
||||
{
|
||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func warnEnvOverrideOnce(
|
||||
kind: EnvOverrideWarningKind,
|
||||
envVar: String,
|
||||
configKey: String)
|
||||
{
|
||||
let shouldWarn = Self.envOverrideWarnings.withValue { state in
|
||||
switch kind {
|
||||
case .token:
|
||||
guard !state.token else { return false }
|
||||
state.token = true
|
||||
return true
|
||||
case .password:
|
||||
guard !state.password else { return false }
|
||||
state.password = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
guard shouldWarn else { return }
|
||||
Self.staticLogger.warning(
|
||||
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
|
||||
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
|
||||
}
|
||||
|
||||
private let deps: Deps
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
|
||||
|
||||
@@ -25,8 +25,10 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
|
||||
let major = Int(parts[0]),
|
||||
let minor = Int(parts[1])
|
||||
else { return nil }
|
||||
let patch = Int(parts[2]) ?? 0
|
||||
return Semver(major: major, minor: minor, patch: patch)
|
||||
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
||||
let patchRaw = String(parts[2])
|
||||
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
|
||||
return Semver(major: major, minor: minor, patch: patchNumeric)
|
||||
}
|
||||
|
||||
func compatible(with required: Semver) -> Bool {
|
||||
@@ -78,8 +80,13 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
static func expectedGatewayVersion() -> Semver? {
|
||||
Semver.parse(self.expectedGatewayVersionString())
|
||||
}
|
||||
|
||||
static func expectedGatewayVersionString() -> String? {
|
||||
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
return Semver.parse(bundleVersion)
|
||||
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (trimmed?.isEmpty == false) ? trimmed : nil
|
||||
}
|
||||
|
||||
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||
@@ -98,6 +105,7 @@ enum GatewayEnvironment {
|
||||
}
|
||||
}
|
||||
let expected = self.expectedGatewayVersion()
|
||||
let expectedString = self.expectedGatewayVersionString()
|
||||
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
@@ -108,8 +116,8 @@ enum GatewayEnvironment {
|
||||
kind: .missingNode,
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
requiredGateway: expectedString,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
case let .success(runtime):
|
||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||
|
||||
@@ -118,7 +126,7 @@ enum GatewayEnvironment {
|
||||
kind: .missingGateway,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
requiredGateway: expectedString,
|
||||
message: "clawdbot CLI not found in PATH; install the CLI.")
|
||||
}
|
||||
|
||||
@@ -126,13 +134,14 @@ enum GatewayEnvironment {
|
||||
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
||||
|
||||
if let expected, let installed, !installed.compatible(with: expected) {
|
||||
let expectedText = expectedString ?? expected.description
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
kind: .incompatible(found: installed.description, required: expectedText),
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
requiredGateway: expectedText,
|
||||
message: """
|
||||
Gateway version \(installed.description) is incompatible with app \(expected.description);
|
||||
Gateway version \(installed.description) is incompatible with app \(expectedText);
|
||||
install or update the global package.
|
||||
""")
|
||||
}
|
||||
@@ -150,7 +159,7 @@ enum GatewayEnvironment {
|
||||
kind: .ok,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
requiredGateway: expectedString,
|
||||
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
||||
}
|
||||
}
|
||||
@@ -218,8 +227,18 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
|
||||
}
|
||||
|
||||
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let target = version?.description ?? "latest"
|
||||
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let target: String
|
||||
if let trimmed, !trimmed.isEmpty {
|
||||
target = trimmed
|
||||
} else {
|
||||
target = "latest"
|
||||
}
|
||||
let npm = CommandResolver.findExecutable(named: "npm")
|
||||
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
||||
let bun = CommandResolver.findExecutable(named: "bun")
|
||||
@@ -278,8 +297,7 @@ enum GatewayEnvironment {
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning(
|
||||
@@ -294,7 +312,6 @@ enum GatewayEnvironment {
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let raw = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Semver.parse(raw)
|
||||
|
||||
@@ -72,11 +72,11 @@ enum LaunchAgentManager {
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
_ = try process.runAndReadToEnd(from: pipe)
|
||||
return process.terminationStatus
|
||||
} catch {
|
||||
return -1
|
||||
|
||||
@@ -16,9 +16,7 @@ enum Launchctl {
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return Result(status: process.terminationStatus, output: output)
|
||||
} catch {
|
||||
|
||||
@@ -580,11 +580,10 @@ final class NodePairingApprovalPrompter {
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
_ = try process.runAndReadToEnd(from: pipe)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
}.value
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ struct OnboardingView: View {
|
||||
|
||||
var canAdvance: Bool { !self.isWizardBlocking }
|
||||
var devLinkCommand: String {
|
||||
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
return "npm install -g clawdbot@\(version)"
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -203,15 +203,13 @@ actor PortGuardian {
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
let data = try proc.runAndReadToEnd(from: pipe)
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func parseListeners(from text: String) -> [Listener] {
|
||||
|
||||
11
apps/macos/Sources/Clawdbot/Process+PipeRead.swift
Normal file
11
apps/macos/Sources/Clawdbot/Process+PipeRead.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
extension Process {
|
||||
/// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers.
|
||||
func runAndReadToEnd(from pipe: Pipe) throws -> Data {
|
||||
try self.run()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
self.waitUntilExit()
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -133,8 +133,7 @@ enum RuntimeLocator {
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning(
|
||||
@@ -149,7 +148,6 @@ enum RuntimeLocator {
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch {
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
|
||||
@@ -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?,
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -427,6 +431,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let deliver: Bool?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -443,6 +448,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
deliver: Bool?,
|
||||
attachments: [AnyCodable]?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -458,6 +464,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.deliver = deliver
|
||||
self.attachments = attachments
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -474,6 +481,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case deliver
|
||||
case attachments
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct GatewayEndpointStoreTests {
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
|
||||
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
@@ -66,4 +73,70 @@ import Testing
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(password == "launchd-pass")
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " local ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverTrimsConfigMode() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " remote ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": "staging",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": " ws://umbrel:18789 ",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,17 @@ import Testing
|
||||
@Test func semverParsesCommonForms() {
|
||||
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
|
||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text
|
||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
|
||||
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
|
||||
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
|
||||
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
|
||||
#expect(Semver.parse(nil) == nil)
|
||||
#expect(Semver.parse("invalid") == nil)
|
||||
#expect(Semver.parse("1.2") == nil)
|
||||
#expect(Semver.parse("1.2.x") == nil)
|
||||
}
|
||||
|
||||
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
|
||||
@@ -36,6 +44,7 @@ import Testing
|
||||
|
||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ Isolated jobs run a dedicated agent turn in session `cron:<jobId>`.
|
||||
|
||||
Key behaviors:
|
||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||
- Each run starts a **fresh session id** (no prior conversation carry-over).
|
||||
- A summary is posted to the main session (prefix `Cron`, configurable).
|
||||
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
|
||||
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
|
||||
@@ -100,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:
|
||||
|
||||
@@ -92,13 +92,13 @@ under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
|
||||
Use the Clawdbot helper to wire everything together (installs deps on macOS via brew):
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail setup \
|
||||
clawdbot webhooks gmail setup \
|
||||
--account clawdbot@gmail.com
|
||||
```
|
||||
|
||||
Defaults:
|
||||
- Uses Tailscale Funnel for the public push endpoint.
|
||||
- Writes `hooks.gmail` config for `clawdbot hooks gmail run`.
|
||||
- Writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
|
||||
- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`).
|
||||
|
||||
Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
|
||||
@@ -124,7 +124,7 @@ Gateway auto-start (recommended):
|
||||
Manual daemon (starts `gog gmail watch serve` + auto-renew):
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail run
|
||||
clawdbot webhooks gmail run
|
||||
```
|
||||
|
||||
## One-time setup
|
||||
@@ -191,7 +191,7 @@ Notes:
|
||||
- `--hook-url` points to Clawdbot `/hooks/gmail` (mapped; isolated run + summary to main).
|
||||
- `--include-body` and `--max-bytes` control the body snippet sent to Clawdbot.
|
||||
|
||||
Recommended: `clawdbot hooks gmail run` wraps the same flow and auto-renews the watch.
|
||||
Recommended: `clawdbot webhooks gmail run` wraps the same flow and auto-renews the watch.
|
||||
|
||||
## Expose the handler (advanced, unsupported)
|
||||
|
||||
|
||||
@@ -16,19 +16,19 @@ read_when:
|
||||
|
||||
```bash
|
||||
# WhatsApp
|
||||
clawdbot message poll --to +15555550123 \
|
||||
clawdbot message poll --target +15555550123 \
|
||||
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
|
||||
clawdbot message poll --to 123456789@g.us \
|
||||
clawdbot message poll --target 123456789@g.us \
|
||||
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
|
||||
|
||||
# Discord
|
||||
clawdbot message poll --channel discord --to channel:123456789 \
|
||||
clawdbot message poll --channel discord --target channel:123456789 \
|
||||
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
|
||||
clawdbot message poll --channel discord --to channel:123456789 \
|
||||
clawdbot message poll --channel discord --target channel:123456789 \
|
||||
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
|
||||
|
||||
# MS Teams
|
||||
clawdbot message poll --channel msteams --to conversation:19:abc@thread.tacv2 \
|
||||
clawdbot message poll --channel msteams --target conversation:19:abc@thread.tacv2 \
|
||||
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
|
||||
```
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ Mapping options (summary):
|
||||
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
|
||||
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
||||
(`channel` defaults to `last` and falls back to WhatsApp).
|
||||
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
|
||||
- `clawdbot webhooks gmail setup` writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
|
||||
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
|
||||
|
||||
## Responses
|
||||
|
||||
@@ -13,6 +13,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
||||
2) Set the token for Clawdbot:
|
||||
- Env: `DISCORD_BOT_TOKEN=...`
|
||||
- Or config: `channels.discord.token: "..."`.
|
||||
- If both are set, config 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.
|
||||
@@ -38,8 +39,8 @@ 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`).
|
||||
4. Run the gateway; it auto-starts the Discord channel when a token is available (env or config) and `channels.discord.enabled` is not `false`.
|
||||
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` (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.
|
||||
@@ -364,6 +365,7 @@ Allowlist matching notes:
|
||||
Native command notes:
|
||||
- The registered commands mirror Clawdbot’s chat commands.
|
||||
- Native commands honor the same allowlists as DMs/guild messages (`channels.discord.dm.allowFrom`, `channels.discord.guilds`, per-channel rules).
|
||||
- Slash commands may still be visible in Discord UI to users who aren’t allowlisted; Clawdbot enforces allowlists on execution and replies “not authorized”.
|
||||
|
||||
## Tool actions
|
||||
The agent can call `discord` with actions like:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -20,6 +20,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -32,6 +32,7 @@ Details: [Plugins](/plugin)
|
||||
2) Configure credentials:
|
||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
|
||||
- Or config: `channels.matrix.*`
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -447,7 +447,7 @@ By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Overri
|
||||
## Polls (Adaptive Cards)
|
||||
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
|
||||
|
||||
- CLI: `clawdbot message poll --channel msteams --to conversation:<id> ...`
|
||||
- CLI: `clawdbot message poll --channel msteams --target conversation:<id> ...`
|
||||
- Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`.
|
||||
- The gateway must stay online to record votes.
|
||||
- Polls do not auto-post result summaries yet (inspect the store file if needed).
|
||||
|
||||
@@ -13,6 +13,7 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
|
||||
2) Set the token:
|
||||
- Env: `TELEGRAM_BOT_TOKEN=...`
|
||||
- Or config: `channels.telegram.botToken: "..."`.
|
||||
- If both are set, config 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,10 +62,11 @@ Example:
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
3) Start the gateway. Telegram starts when a token is resolved (env or config).
|
||||
3) Start the gateway. Telegram starts when a token is resolved (config first, env fallback).
|
||||
4) DM access defaults to pairing. Approve the code when the bot is first contacted.
|
||||
5) For groups: add the bot, decide privacy/admin behavior (below), then set `channels.telegram.groups` to control mention gating + allowlists.
|
||||
|
||||
@@ -219,13 +221,15 @@ Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps
|
||||
|
||||
## Inline Buttons
|
||||
|
||||
Telegram supports inline keyboards with callback buttons. Enable this feature via capabilities:
|
||||
Telegram supports inline keyboards with callback buttons.
|
||||
|
||||
```json5
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"capabilities": ["inlineButtons"]
|
||||
"capabilities": {
|
||||
"inlineButtons": "allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,7 +242,9 @@ For per-account configuration:
|
||||
"telegram": {
|
||||
"accounts": {
|
||||
"main": {
|
||||
"capabilities": ["inlineButtons"]
|
||||
"capabilities": {
|
||||
"inlineButtons": "allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +252,16 @@ For per-account configuration:
|
||||
}
|
||||
```
|
||||
|
||||
Scopes:
|
||||
- `off` — inline buttons disabled
|
||||
- `dm` — only DMs (group targets blocked)
|
||||
- `group` — only groups (DM targets blocked)
|
||||
- `all` — DMs + groups
|
||||
- `allowlist` — DMs + groups, but only senders allowed by `allowFrom`/`groupAllowFrom` (same rules as control commands)
|
||||
|
||||
Default: `allowlist`.
|
||||
Legacy: `capabilities: ["inlineButtons"]` = `inlineButtons: "all"`.
|
||||
|
||||
### Sending buttons
|
||||
|
||||
Use the message tool with the `buttons` parameter:
|
||||
@@ -273,12 +289,12 @@ When a user clicks a button, the callback data is sent back to the agent as a me
|
||||
|
||||
### Configuration options
|
||||
|
||||
Telegram capabilities can be configured at two levels:
|
||||
Telegram capabilities can be configured at two levels (object form shown above; legacy string arrays still supported):
|
||||
|
||||
- `channels.telegram.capabilities`: Global default capability list applied to all Telegram accounts unless overridden.
|
||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override or extend the global defaults for that specific account.
|
||||
- `channels.telegram.capabilities`: Global default capability config applied to all Telegram accounts unless overridden.
|
||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override the global defaults for that specific account.
|
||||
|
||||
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups or has extra capabilities).
|
||||
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups).
|
||||
## Access control (DMs + groups)
|
||||
|
||||
### DM access
|
||||
@@ -344,6 +360,19 @@ To force a voice note bubble in agent replies, include this tag anywhere in the
|
||||
|
||||
The tag is stripped from the delivered text. Other channels ignore this tag.
|
||||
|
||||
For message tool sends, set `asVoice: true` with a voice-compatible audio `media` URL
|
||||
(`message` is optional when media is present):
|
||||
|
||||
```json5
|
||||
{
|
||||
"action": "send",
|
||||
"channel": "telegram",
|
||||
"to": "123456789",
|
||||
"media": "https://example.com/voice.ogg",
|
||||
"asVoice": true
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming (drafts)
|
||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
||||
@@ -397,8 +426,8 @@ The agent sees reactions as **system notifications** in the conversation history
|
||||
|
||||
**Configuration:**
|
||||
- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications
|
||||
- `"off"` — ignore all reactions (default when not set)
|
||||
- `"own"` — notify when users react to bot messages (best-effort; in-memory)
|
||||
- `"off"` — ignore all reactions
|
||||
- `"own"` — notify when users react to bot messages (best-effort; in-memory) (default)
|
||||
- `"all"` — notify for all reactions
|
||||
|
||||
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
|
||||
@@ -428,7 +457,7 @@ The agent sees reactions as **system notifications** in the conversation history
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- Use a chat id (`123456789`) or a username (`@name`) as the target.
|
||||
- Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`.
|
||||
- Example: `clawdbot message send --channel telegram --target 123456789 --message "hi"`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -477,8 +506,8 @@ Provider options:
|
||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- `channels.telegram.capabilities`: Enable channel features (e.g., "inlineButtons").
|
||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
@@ -491,8 +520,8 @@ Provider options:
|
||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `off` when not set).
|
||||
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `ack` when not set).
|
||||
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
|
||||
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
|
||||
|
||||
Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
|
||||
|
||||
@@ -82,15 +82,13 @@ When the wizard asks for your personal WhatsApp number, enter the phone you will
|
||||
"selfChatMode": true,
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": ["+15551234567"]
|
||||
},
|
||||
"messages": {
|
||||
"responsePrefix": "[clawdbot]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tip: set `messages.responsePrefix` explicitly if you want a consistent bot prefix
|
||||
on outbound replies.
|
||||
Self-chat replies default to `[{identity.name}]` when set (otherwise `[clawdbot]`)
|
||||
if `messages.responsePrefix` is unset. Set it explicitly to customize or disable
|
||||
the prefix (use `""` to remove it).
|
||||
|
||||
### Number sourcing tips
|
||||
- **Local eSIM** from your country's mobile carrier (most reliable)
|
||||
@@ -204,9 +202,9 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted
|
||||
- `always`: always triggers.
|
||||
- `/activation mention|always` is owner-only and must be sent as a standalone message.
|
||||
- Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset).
|
||||
- **History injection**:
|
||||
- Recent messages (default 50) inserted under:
|
||||
`[Chat messages since your last reply - for context]`
|
||||
- **History injection** (pending-only):
|
||||
- Recent *unprocessed* messages (default 50) inserted under:
|
||||
`[Chat messages since your last reply - for context]` (messages already in the session are not re-injected)
|
||||
- Current message under:
|
||||
`[Current message - respond to this]`
|
||||
- Sender suffix appended: `[from: Name (+E164)]`
|
||||
|
||||
@@ -124,7 +124,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- Use a chat id as the target.
|
||||
- Example: `clawdbot message send --channel zalo --to 123456789 --message "hi"`.
|
||||
- Example: `clawdbot message send --channel zalo --target 123456789 --message "hi"`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
98
docs/channels/zalouser.md
Normal file
98
docs/channels/zalouser.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration"
|
||||
read_when:
|
||||
- Setting up Zalo Personal for Clawdbot
|
||||
- Debugging Zalo Personal login or message flow
|
||||
---
|
||||
# Zalo Personal (unofficial)
|
||||
|
||||
Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`.
|
||||
|
||||
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
|
||||
|
||||
## Plugin required
|
||||
Zalo Personal ships as a plugin and is not bundled with the core install.
|
||||
- Install via CLI: `clawdbot plugins install @clawdbot/zalouser`
|
||||
- Or from a source checkout: `clawdbot plugins install ./extensions/zalouser`
|
||||
- Details: [Plugins](/plugin)
|
||||
|
||||
## Prerequisite: zca-cli
|
||||
The Gateway machine must have the `zca` binary available in `PATH`.
|
||||
|
||||
- Verify: `zca --version`
|
||||
- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs).
|
||||
|
||||
## Quick setup (beginner)
|
||||
1) Install the plugin (see above).
|
||||
2) Login (QR, on the Gateway machine):
|
||||
- `clawdbot channels login --channel zalouser`
|
||||
- Scan the QR code in the terminal with the Zalo mobile app.
|
||||
3) Enable the channel:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4) Restart the Gateway (or finish onboarding).
|
||||
5) DM access defaults to pairing; approve the pairing code on first contact.
|
||||
|
||||
## What it is
|
||||
- Uses `zca listen` to receive inbound messages.
|
||||
- Uses `zca msg ...` to send replies (text/media/link).
|
||||
- Designed for “personal account” use cases where Zalo Bot API is not available.
|
||||
|
||||
## Naming
|
||||
Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration.
|
||||
|
||||
## Finding IDs (directory)
|
||||
Use the directory CLI to discover peers/groups and their IDs:
|
||||
|
||||
```bash
|
||||
clawdbot directory self --channel zalouser
|
||||
clawdbot directory peers list --channel zalouser --query "name"
|
||||
clawdbot directory groups list --channel zalouser --query "work"
|
||||
```
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to ~2000 characters (Zalo client limits).
|
||||
- Streaming is blocked by default.
|
||||
|
||||
## Access control (DMs)
|
||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||
|
||||
Approve via:
|
||||
- `clawdbot pairing list zalouser`
|
||||
- `clawdbot pairing approve zalouser <code>`
|
||||
|
||||
## Multi-account
|
||||
Accounts map to zca profiles. Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
defaultAccount: "default",
|
||||
accounts: {
|
||||
work: { enabled: true, profile: "work" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`zca` not found:**
|
||||
- Install zca-cli and ensure it’s on `PATH` for the Gateway process.
|
||||
|
||||
**Login doesn’t stick:**
|
||||
- `clawdbot channels status --probe`
|
||||
- Re-login: `clawdbot channels logout --channel zalouser && clawdbot channels login --channel zalouser`
|
||||
60
docs/cli/directory.md
Normal file
60
docs/cli/directory.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot directory` (self, peers, groups)"
|
||||
read_when:
|
||||
- You want to look up contacts/groups/self ids for a channel
|
||||
- You are developing a channel directory adapter
|
||||
---
|
||||
|
||||
# `clawdbot directory`
|
||||
|
||||
Directory lookups for channels that support it (contacts/peers, groups, and “me”).
|
||||
|
||||
## Common flags
|
||||
- `--channel <name>`: channel id/alias (required when multiple channels are configured; auto when only one is configured)
|
||||
- `--account <id>`: account id (default: channel default)
|
||||
- `--json`: output JSON
|
||||
|
||||
## Notes
|
||||
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --target ...`).
|
||||
- For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory.
|
||||
- Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting.
|
||||
|
||||
## Using results with `message send`
|
||||
|
||||
```bash
|
||||
clawdbot directory peers list --channel slack --query "U0"
|
||||
clawdbot message send --channel slack --target user:U012ABCDEF --message "hello"
|
||||
```
|
||||
|
||||
## ID formats (by channel)
|
||||
|
||||
- WhatsApp: `+15551234567` (DM), `1234567890-1234567890@g.us` (group)
|
||||
- Telegram: `@username` or numeric chat id; groups are numeric ids
|
||||
- Slack: `user:U…` and `channel:C…`
|
||||
- Discord: `user:<id>` and `channel:<id>`
|
||||
- Matrix (plugin): `user:@user:server`, `room:!roomId:server`, or `#alias:server`
|
||||
- Microsoft Teams (plugin): `user:<id>` and `conversation:<id>`
|
||||
- Zalo (plugin): user id (Bot API)
|
||||
- Zalo Personal / `zalouser` (plugin): thread id (DM/group) from `zca` (`me`, `friend list`, `group list`)
|
||||
|
||||
## Self (“me”)
|
||||
|
||||
```bash
|
||||
clawdbot directory self --channel zalouser
|
||||
```
|
||||
|
||||
## Peers (contacts/users)
|
||||
|
||||
```bash
|
||||
clawdbot directory peers list --channel zalouser
|
||||
clawdbot directory peers list --channel zalouser --query "name"
|
||||
clawdbot directory peers list --channel zalouser --limit 50
|
||||
```
|
||||
|
||||
## Groups
|
||||
|
||||
```bash
|
||||
clawdbot directory groups list --channel zalouser
|
||||
clawdbot directory groups list --channel zalouser --query "work"
|
||||
clawdbot directory groups members --channel zalouser --group-id <id>
|
||||
```
|
||||
@@ -21,3 +21,14 @@ clawdbot doctor --repair
|
||||
clawdbot doctor --deep
|
||||
```
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||
|
||||
```bash
|
||||
launchctl getenv CLAWDBOT_GATEWAY_TOKEN
|
||||
launchctl getenv CLAWDBOT_GATEWAY_PASSWORD
|
||||
|
||||
launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN
|
||||
launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,22 +1,258 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot hooks` (Gmail Pub/Sub + webhook helpers)"
|
||||
summary: "CLI reference for `clawdbot hooks` (agent hooks)"
|
||||
read_when:
|
||||
- You want to wire Gmail Pub/Sub events into Clawdbot hooks
|
||||
- You want to run the gog watch service and renew loop
|
||||
- You want to manage agent hooks
|
||||
- You want to install or update hooks
|
||||
---
|
||||
|
||||
# `clawdbot hooks`
|
||||
|
||||
Webhook helpers and hook-based integrations.
|
||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
|
||||
Related:
|
||||
- Webhooks: [Webhook](/automation/webhook)
|
||||
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
|
||||
- Hooks: [Hooks](/hooks)
|
||||
|
||||
## Gmail
|
||||
## List All Hooks
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail setup --account you@example.com
|
||||
clawdbot hooks gmail run
|
||||
clawdbot hooks list
|
||||
```
|
||||
|
||||
List all discovered 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:**
|
||||
|
||||
```
|
||||
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 list --verbose
|
||||
```
|
||||
|
||||
Shows missing requirements for ineligible hooks.
|
||||
|
||||
**Example (JSON):**
|
||||
|
||||
```bash
|
||||
clawdbot hooks list --json
|
||||
```
|
||||
|
||||
Returns structured JSON for programmatic use.
|
||||
|
||||
## Get Hook Information
|
||||
|
||||
```bash
|
||||
clawdbot hooks 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 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/hooks#session-memory
|
||||
Events: command:new
|
||||
|
||||
Requirements:
|
||||
Config: ✓ workspace.dir
|
||||
```
|
||||
|
||||
## Check Hooks Eligibility
|
||||
|
||||
```bash
|
||||
clawdbot hooks check
|
||||
```
|
||||
|
||||
Show summary of hook eligibility status (how many are ready vs. not ready).
|
||||
|
||||
**Options:**
|
||||
- `--json`: Output as JSON
|
||||
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Hooks Status
|
||||
|
||||
Total hooks: 2
|
||||
Ready: 2
|
||||
Not ready: 0
|
||||
```
|
||||
|
||||
## Enable a Hook
|
||||
|
||||
```bash
|
||||
clawdbot hooks 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 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 disable <name>
|
||||
```
|
||||
|
||||
Disable a specific hook by updating your config.
|
||||
|
||||
**Arguments:**
|
||||
- `<name>`: Hook name (e.g., `command-logger`)
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks disable command-logger
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
⏸ Disabled hook: 📝 command-logger
|
||||
```
|
||||
|
||||
**After disabling:**
|
||||
- Restart the gateway so hooks reload
|
||||
|
||||
## Install Hooks
|
||||
|
||||
```bash
|
||||
clawdbot hooks install <path-or-spec>
|
||||
```
|
||||
|
||||
Install a hook pack from a local folder/archive or npm.
|
||||
|
||||
**What it does:**
|
||||
- Copies the hook pack into `~/.clawdbot/hooks/<id>`
|
||||
- Enables the installed hooks in `hooks.internal.entries.*`
|
||||
- Records the install under `hooks.internal.installs`
|
||||
|
||||
**Options:**
|
||||
- `-l, --link`: Link a local directory instead of copying (adds it to `hooks.internal.load.extraDirs`)
|
||||
|
||||
**Supported archives:** `.zip`, `.tgz`, `.tar.gz`, `.tar`
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Local directory
|
||||
clawdbot hooks install ./my-hook-pack
|
||||
|
||||
# Local archive
|
||||
clawdbot hooks install ./my-hook-pack.zip
|
||||
|
||||
# NPM package
|
||||
clawdbot hooks install @clawdbot/my-hook-pack
|
||||
|
||||
# Link a local directory without copying
|
||||
clawdbot hooks install -l ./my-hook-pack
|
||||
```
|
||||
|
||||
## Update Hooks
|
||||
|
||||
```bash
|
||||
clawdbot hooks update <id>
|
||||
clawdbot hooks update --all
|
||||
```
|
||||
|
||||
Update installed hook packs (npm installs only).
|
||||
|
||||
**Options:**
|
||||
- `--all`: Update all tracked hook packs
|
||||
- `--dry-run`: Show what would change without writing
|
||||
|
||||
## Bundled Hooks
|
||||
|
||||
### session-memory
|
||||
|
||||
Saves session context to memory when you issue `/new`.
|
||||
|
||||
**Enable:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks enable session-memory
|
||||
```
|
||||
|
||||
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
|
||||
|
||||
**See:** [session-memory documentation](/hooks#session-memory)
|
||||
|
||||
### command-logger
|
||||
|
||||
Logs all command events to a centralized audit file.
|
||||
|
||||
**Enable:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks 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](/hooks#command-logger)
|
||||
|
||||
@@ -40,6 +40,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`dns`](/cli/dns)
|
||||
- [`docs`](/cli/docs)
|
||||
- [`hooks`](/cli/hooks)
|
||||
- [`webhooks`](/cli/webhooks)
|
||||
- [`pairing`](/cli/pairing)
|
||||
- [`plugins`](/cli/plugins) (plugin commands)
|
||||
- [`channels`](/cli/channels)
|
||||
@@ -212,6 +213,14 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
console
|
||||
pdf
|
||||
hooks
|
||||
list
|
||||
info
|
||||
check
|
||||
enable
|
||||
disable
|
||||
install
|
||||
update
|
||||
webhooks
|
||||
gmail setup|run
|
||||
pairing
|
||||
list
|
||||
@@ -283,7 +292,7 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@@ -291,7 +300,9 @@ Options:
|
||||
- `--anthropic-api-key <key>`
|
||||
- `--openai-api-key <key>`
|
||||
- `--openrouter-api-key <key>`
|
||||
- `--ai-gateway-api-key <key>`
|
||||
- `--moonshot-api-key <key>`
|
||||
- `--kimi-code-api-key <key>`
|
||||
- `--gemini-api-key <key>`
|
||||
- `--zai-api-key <key>`
|
||||
- `--minimax-api-key <key>`
|
||||
@@ -413,12 +424,12 @@ Subcommands:
|
||||
- `pairing list <channel> [--json]`
|
||||
- `pairing approve <channel> <code> [--notify]`
|
||||
|
||||
### `hooks gmail`
|
||||
### `webhooks gmail`
|
||||
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
|
||||
|
||||
Subcommands:
|
||||
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
|
||||
- `hooks gmail run` (runtime overrides for the same flags)
|
||||
- `webhooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
|
||||
- `webhooks gmail run` (runtime overrides for the same flags)
|
||||
|
||||
### `dns setup`
|
||||
Wide-area discovery DNS helper (CoreDNS + Tailscale). See [/gateway/discovery](/gateway/discovery).
|
||||
@@ -445,8 +456,8 @@ Subcommands:
|
||||
- `message event <list|create>`
|
||||
|
||||
Examples:
|
||||
- `clawdbot message send --to +15555550123 --message "Hi"`
|
||||
- `clawdbot message poll --channel discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
|
||||
- `clawdbot message send --target +15555550123 --message "Hi"`
|
||||
- `clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
|
||||
|
||||
### `agent`
|
||||
Run one agent turn via the Gateway (or `--local` embedded).
|
||||
@@ -458,7 +469,7 @@ Options:
|
||||
- `--to <dest>` (for session key and optional delivery)
|
||||
- `--session-id <id>`
|
||||
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
|
||||
- `--verbose <on|off>`
|
||||
- `--verbose <on|full|off>`
|
||||
- `--channel <whatsapp|telegram|discord|slack|signal|imessage>`
|
||||
- `--local`
|
||||
- `--deliver`
|
||||
@@ -517,7 +528,7 @@ Surfaces:
|
||||
|
||||
Notes:
|
||||
- Data comes directly from provider usage endpoints (no estimates).
|
||||
- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
|
||||
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled.
|
||||
- If no matching credentials exist, usage is hidden.
|
||||
- Details: see [Usage tracking](/concepts/usage-tracking).
|
||||
|
||||
|
||||
@@ -21,19 +21,25 @@ Channel selection:
|
||||
- If exactly one channel is configured, it becomes the default.
|
||||
- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
|
||||
|
||||
Target formats (`--to`):
|
||||
Target formats (`--target`):
|
||||
- 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>`
|
||||
- `--target <dest>` (target channel or user for send/poll/read/etc)
|
||||
- `--targets <name>` (repeat; broadcast only)
|
||||
- `--json`
|
||||
- `--dry-run`
|
||||
- `--verbose`
|
||||
@@ -44,61 +50,56 @@ Target formats (`--to`):
|
||||
|
||||
- `send`
|
||||
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
||||
- Required: `--to`, plus `--message` or `--media`
|
||||
- Required: `--target`, plus `--message` or `--media`
|
||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`)
|
||||
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
||||
- Telegram only: `--thread-id` (forum topic id)
|
||||
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
||||
- WhatsApp only: `--gif-playback`
|
||||
|
||||
- `poll`
|
||||
- Channels: WhatsApp/Discord/MS Teams
|
||||
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
|
||||
- Required: `--target`, `--poll-question`, `--poll-option` (repeat)
|
||||
- Optional: `--poll-multi`
|
||||
- Discord only: `--poll-duration-hours`, `--message`
|
||||
|
||||
- `react`
|
||||
- Channels: Discord/Slack/Telegram/WhatsApp
|
||||
- Required: `--message-id`, `--to` or `--channel-id`
|
||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
|
||||
- Required: `--message-id`, `--target`
|
||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
|
||||
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
||||
- WhatsApp only: `--participant`, `--from-me`
|
||||
|
||||
- `reactions`
|
||||
- Channels: Discord/Slack
|
||||
- Required: `--message-id`, `--to` or `--channel-id`
|
||||
- Optional: `--limit`, `--channel-id`
|
||||
- Required: `--message-id`, `--target`
|
||||
- Optional: `--limit`
|
||||
|
||||
- `read`
|
||||
- Channels: Discord/Slack
|
||||
- Required: `--to` or `--channel-id`
|
||||
- Optional: `--limit`, `--before`, `--after`, `--channel-id`
|
||||
- Required: `--target`
|
||||
- Optional: `--limit`, `--before`, `--after`
|
||||
- Discord only: `--around`
|
||||
|
||||
- `edit`
|
||||
- Channels: Discord/Slack
|
||||
- Required: `--message-id`, `--message`, `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
- Required: `--message-id`, `--message`, `--target`
|
||||
|
||||
- `delete`
|
||||
- Channels: Discord/Slack/Telegram
|
||||
- Required: `--message-id`, `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
- Required: `--message-id`, `--target`
|
||||
|
||||
- `pin` / `unpin`
|
||||
- Channels: Discord/Slack
|
||||
- Required: `--message-id`, `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
- Required: `--message-id`, `--target`
|
||||
|
||||
- `pins` (list)
|
||||
- Channels: Discord/Slack
|
||||
- Required: `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
- Required: `--target`
|
||||
|
||||
- `permissions`
|
||||
- Channels: Discord
|
||||
- Required: `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
- Required: `--target`
|
||||
|
||||
- `search`
|
||||
- Channels: Discord
|
||||
@@ -109,7 +110,7 @@ Target formats (`--to`):
|
||||
|
||||
- `thread create`
|
||||
- Channels: Discord
|
||||
- Required: `--thread-name`, `--to` (channel id) or `--channel-id`
|
||||
- Required: `--thread-name`, `--target` (channel id)
|
||||
- Optional: `--message-id`, `--auto-archive-min`
|
||||
|
||||
- `thread list`
|
||||
@@ -119,7 +120,7 @@ Target formats (`--to`):
|
||||
|
||||
- `thread reply`
|
||||
- Channels: Discord
|
||||
- Required: `--to` (thread id), `--message`
|
||||
- Required: `--target` (thread id), `--message`
|
||||
- Optional: `--media`, `--reply-to`
|
||||
|
||||
### Emojis
|
||||
@@ -137,7 +138,7 @@ Target formats (`--to`):
|
||||
|
||||
- `sticker send`
|
||||
- Channels: Discord
|
||||
- Required: `--to`, `--sticker-id` (repeat)
|
||||
- Required: `--target`, `--sticker-id` (repeat)
|
||||
- Optional: `--message`
|
||||
|
||||
- `sticker upload`
|
||||
@@ -148,7 +149,7 @@ Target formats (`--to`):
|
||||
|
||||
- `role info` (Discord): `--guild-id`
|
||||
- `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
|
||||
- `channel info` (Discord): `--channel-id`
|
||||
- `channel info` (Discord): `--target`
|
||||
- `channel list` (Discord): `--guild-id`
|
||||
- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
|
||||
- `voice status` (Discord): `--guild-id`, `--user-id`
|
||||
@@ -166,18 +167,25 @@ 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:
|
||||
```
|
||||
clawdbot message send --channel discord \
|
||||
--to channel:123 --message "hi" --reply-to 456
|
||||
--target channel:123 --message "hi" --reply-to 456
|
||||
```
|
||||
|
||||
Create a Discord poll:
|
||||
```
|
||||
clawdbot message poll --channel discord \
|
||||
--to channel:123 \
|
||||
--target channel:123 \
|
||||
--poll-question "Snack?" \
|
||||
--poll-option Pizza --poll-option Sushi \
|
||||
--poll-multi --poll-duration-hours 48
|
||||
@@ -186,13 +194,13 @@ clawdbot message poll --channel discord \
|
||||
Send a Teams proactive message:
|
||||
```
|
||||
clawdbot message send --channel msteams \
|
||||
--to conversation:19:abc@thread.tacv2 --message "hi"
|
||||
--target conversation:19:abc@thread.tacv2 --message "hi"
|
||||
```
|
||||
|
||||
Create a Teams poll:
|
||||
```
|
||||
clawdbot message poll --channel msteams \
|
||||
--to conversation:19:abc@thread.tacv2 \
|
||||
--target conversation:19:abc@thread.tacv2 \
|
||||
--poll-question "Lunch?" \
|
||||
--poll-option Pizza --poll-option Sushi
|
||||
```
|
||||
@@ -200,11 +208,11 @@ clawdbot message poll --channel msteams \
|
||||
React in Slack:
|
||||
```
|
||||
clawdbot message react --channel slack \
|
||||
--to C123 --message-id 456 --emoji "✅"
|
||||
--target C123 --message-id 456 --emoji "✅"
|
||||
```
|
||||
|
||||
Send Telegram inline buttons:
|
||||
```
|
||||
clawdbot message send --channel telegram --to @mychat --message "Choose:" \
|
||||
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
||||
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
|
||||
```
|
||||
|
||||
@@ -25,14 +25,25 @@ clawdbot plugins update <id>
|
||||
clawdbot plugins update --all
|
||||
```
|
||||
|
||||
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
|
||||
activate them.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
clawdbot plugins install <npm-spec>
|
||||
clawdbot plugins install <path-or-spec>
|
||||
```
|
||||
|
||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
```bash
|
||||
clawdbot plugins install -l ./my-plugin
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -15,6 +15,8 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
|
||||
|
||||
```bash
|
||||
clawdbot update
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --tag beta
|
||||
clawdbot update --restart
|
||||
clawdbot update --json
|
||||
clawdbot --update
|
||||
@@ -23,9 +25,13 @@ clawdbot --update
|
||||
## Options
|
||||
|
||||
- `--restart`: restart the Gateway daemon after a successful update.
|
||||
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
Note: downgrades require confirmation because older versions can break configuration.
|
||||
|
||||
## What it does (git checkout)
|
||||
|
||||
High-level:
|
||||
|
||||
23
docs/cli/webhooks.md
Normal file
23
docs/cli/webhooks.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot webhooks` (webhook helpers + Gmail Pub/Sub)"
|
||||
read_when:
|
||||
- You want to wire Gmail Pub/Sub events into Clawdbot
|
||||
- You want webhook helper commands
|
||||
---
|
||||
|
||||
# `clawdbot webhooks`
|
||||
|
||||
Webhook helpers and integrations (Gmail Pub/Sub, webhook helpers).
|
||||
|
||||
Related:
|
||||
- Webhooks: [Webhook](/automation/webhook)
|
||||
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
|
||||
|
||||
## Gmail
|
||||
|
||||
```bash
|
||||
clawdbot webhooks gmail setup --account you@example.com
|
||||
clawdbot webhooks gmail run
|
||||
```
|
||||
|
||||
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
|
||||
@@ -13,7 +13,7 @@ Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
|
||||
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
|
||||
- Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
||||
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
|
||||
- Context injection: **pending-only** group messages (default 50) that *did not* trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected.
|
||||
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
|
||||
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
|
||||
- Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat.
|
||||
|
||||
@@ -177,6 +177,8 @@ Quick mental model (evaluation order for group messages):
|
||||
## Mention gating (default)
|
||||
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
|
||||
|
||||
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
@@ -219,7 +221,7 @@ Notes:
|
||||
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
|
||||
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
|
||||
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
|
||||
- Group history context is wrapped uniformly across channels; use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
||||
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
||||
|
||||
## Group allowlists
|
||||
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
|
||||
|
||||
@@ -85,6 +85,14 @@ When a channel supplies history, it uses a shared wrapper:
|
||||
- `[Chat messages since your last reply - for context]`
|
||||
- `[Current message - respond to this]`
|
||||
|
||||
For **non-direct chats** (groups/channels/rooms), the **current message body** is prefixed with the
|
||||
sender label (same style used for history entries). This keeps real-time and queued/history
|
||||
messages consistent in the agent prompt.
|
||||
|
||||
History buffers are **pending-only**: they include group messages that did *not*
|
||||
trigger a run (for example, mention-gated messages) and **exclude** messages
|
||||
already in the session transcript.
|
||||
|
||||
Directive stripping only applies to the **current message** section so history
|
||||
remains intact. Channels that wrap history should set `CommandBody` (or
|
||||
`RawBody`) to the original message text and keep `Body` as the combined prompt.
|
||||
|
||||
@@ -83,7 +83,12 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
|
||||
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
|
||||
- CLI: `clawdbot onboard --auth-choice antigravity` (others via interactive wizard)
|
||||
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-antigravity-auth`
|
||||
- Login: `clawdbot models auth login --provider google-antigravity --set-default`
|
||||
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
||||
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
### Z.AI (GLM)
|
||||
|
||||
@@ -93,6 +98,13 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
||||
- CLI: `clawdbot onboard --auth-choice zai-api-key`
|
||||
- Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*`
|
||||
|
||||
### Vercel AI Gateway
|
||||
|
||||
- Provider: `vercel-ai-gateway`
|
||||
- Auth: `AI_GATEWAY_API_KEY`
|
||||
- Example model: `vercel-ai-gateway/anthropic/claude-opus-4.5`
|
||||
- CLI: `clawdbot onboard --auth-choice ai-gateway-api-key`
|
||||
|
||||
### Other built-in providers
|
||||
|
||||
- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`)
|
||||
@@ -143,6 +155,34 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
}
|
||||
```
|
||||
|
||||
### Kimi Code
|
||||
|
||||
Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
|
||||
|
||||
- Provider: `kimi-code`
|
||||
- Auth: `KIMICODE_API_KEY`
|
||||
- Example model: `kimi-code/kimi-for-coding`
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { KIMICODE_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: { model: { primary: "kimi-code/kimi-for-coding" } }
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"kimi-code": {
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
apiKey: "${KIMICODE_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "kimi-for-coding", name: "Kimi For Coding" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Synthetic
|
||||
|
||||
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
---
|
||||
summary: "Command queue design that serializes auto-reply command execution"
|
||||
summary: "Command queue design that serializes inbound auto-reply runs"
|
||||
read_when:
|
||||
- Changing auto-reply execution or concurrency
|
||||
---
|
||||
# Command Queue (2026-01-03)
|
||||
# Command Queue (2026-01-16)
|
||||
|
||||
We now serialize command-based auto-replies (WhatsApp Web listener) through a tiny in-process queue to prevent multiple commands from running at once, while allowing safe parallelism across sessions.
|
||||
We serialize inbound auto-reply runs (all channels) through a tiny in-process queue to prevent multiple agent runs from colliding, while still allowing safe parallelism across sessions.
|
||||
|
||||
## Why
|
||||
- Some auto-reply commands are expensive (LLM calls) and can collide when multiple inbound messages arrive close together.
|
||||
- Serializing avoids competing for terminal/stdin, keeps logs readable, and reduces the chance of rate limits from upstream tools.
|
||||
- Auto-reply runs can be expensive (LLM calls) and can collide when multiple inbound messages arrive close together.
|
||||
- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
|
||||
|
||||
## How it works
|
||||
- A lane-aware FIFO queue drains each lane synchronously.
|
||||
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1).
|
||||
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
|
||||
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
|
||||
- When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting.
|
||||
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn.
|
||||
- When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
|
||||
- Typing indicators still fire immediately on enqueue (when supported by the channel) so user experience is unchanged while we wait our turn.
|
||||
|
||||
## Queue modes (per channel)
|
||||
Inbound messages can steer the current run, wait for a followup turn, or do both:
|
||||
- `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup.
|
||||
- `followup`: enqueue for the next agent turn after the current run ends.
|
||||
- `collect`: coalesce all queued messages into a **single** followup turn (default).
|
||||
- `collect`: coalesce all queued messages into a **single** followup turn (default). If messages target different channels/threads, they drain individually to preserve routing.
|
||||
- `steer-backlog` (aka `steer+backlog`): steer now **and** preserve the message for a followup turn.
|
||||
- `interrupt` (legacy): abort the active run for that session, then run the newest message.
|
||||
- `queue` (legacy alias): same as `steer`.
|
||||
@@ -66,9 +66,9 @@ Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
|
||||
- `/queue default` or `/queue reset` clears the session override.
|
||||
|
||||
## Scope and guarantees
|
||||
- Applies only to config-driven command replies; plain text replies are unaffected.
|
||||
- Applies to auto-reply agent runs across all inbound channels that use the gateway reply pipeline (WhatsApp web, Telegram, Slack, Discord, Signal, iMessage, webchat, etc.).
|
||||
- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agents.defaults.maxConcurrent` to allow multiple sessions in parallel.
|
||||
- Additional lanes may exist (e.g. `cron`) so background jobs can run in parallel without blocking inbound replies.
|
||||
- Additional lanes may exist (e.g. `cron`, `subagent`) so background jobs can run in parallel without blocking inbound replies.
|
||||
- Per-session lanes guarantee that only one agent run touches a given session at a time.
|
||||
- No external dependencies or background worker threads; pure TypeScript + promises.
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ Row shape (JSON):
|
||||
- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun`
|
||||
- `sendPolicy` (session override if set)
|
||||
- `lastChannel`, `lastTo`
|
||||
- `deliveryContext` (normalized `{ channel, to, accountId }` when available)
|
||||
- `transcriptPath` (best-effort path derived from store dir + sessionId)
|
||||
- `messages?` (only when `messageLimit > 0`)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Use `session.dmScope` to control how **direct messages** are grouped:
|
||||
- `main` (default): all DMs share the main session for continuity.
|
||||
- `per-peer`: isolate by sender id across channels.
|
||||
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
|
||||
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
||||
|
||||
## Gateway is the source of truth
|
||||
All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
|
||||
@@ -42,6 +43,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
|
||||
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
|
||||
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
|
||||
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
|
||||
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
|
||||
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
|
||||
- Legacy `group:<id>` keys are still recognized for migration.
|
||||
@@ -55,6 +57,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
||||
|
||||
## Send policy (optional)
|
||||
Block delivery for specific session types without listing individual ids.
|
||||
@@ -86,6 +89,9 @@ Send these as standalone messages so they register.
|
||||
session: {
|
||||
scope: "per-sender", // keep group keys separate
|
||||
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
|
||||
identityLinks: {
|
||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||
},
|
||||
idleMinutes: 120,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||
@@ -100,7 +106,7 @@ Send these as standalone messages so they register.
|
||||
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
|
||||
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
|
||||
- Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
|
||||
- Send `/stop` as a standalone message to abort the current run.
|
||||
- Send `/stop` as a standalone message to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count).
|
||||
- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction).
|
||||
- JSONL transcripts can be opened directly to review full turns.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ read_when:
|
||||
- No estimated costs; only the provider-reported windows.
|
||||
|
||||
## Where it shows up
|
||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
|
||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
||||
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
|
||||
@@ -733,6 +733,22 @@
|
||||
"source": "/wizard",
|
||||
"destination": "/start/wizard"
|
||||
},
|
||||
{
|
||||
"source": "/install/node",
|
||||
"destination": "/install#nodejs--npm-path-sanity"
|
||||
},
|
||||
{
|
||||
"source": "/install/node/",
|
||||
"destination": "/install#nodejs--npm-path-sanity"
|
||||
},
|
||||
{
|
||||
"source": "/start/faq",
|
||||
"destination": "/help"
|
||||
},
|
||||
{
|
||||
"source": "/start/faq/",
|
||||
"destination": "/help"
|
||||
},
|
||||
{
|
||||
"source": "/oauth",
|
||||
"destination": "/concepts/oauth"
|
||||
@@ -752,7 +768,6 @@
|
||||
"start/wizard",
|
||||
"start/setup",
|
||||
"start/pairing",
|
||||
"start/faq",
|
||||
"start/clawd",
|
||||
"start/showcase",
|
||||
"start/hubs",
|
||||
@@ -760,6 +775,14 @@
|
||||
"start/lore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Help",
|
||||
"pages": [
|
||||
"help/index",
|
||||
"help/troubleshooting",
|
||||
"help/faq"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Install & Updates",
|
||||
"pages": [
|
||||
@@ -792,6 +815,7 @@
|
||||
"cli/health",
|
||||
"cli/sessions",
|
||||
"cli/channels",
|
||||
"cli/directory",
|
||||
"cli/skills",
|
||||
"cli/plugins",
|
||||
"cli/memory",
|
||||
@@ -870,7 +894,6 @@
|
||||
"gateway/heartbeat",
|
||||
"gateway/doctor",
|
||||
"gateway/logging",
|
||||
"logging",
|
||||
"gateway/security",
|
||||
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
||||
"gateway/sandboxing",
|
||||
@@ -907,6 +930,7 @@
|
||||
"channels/msteams",
|
||||
"channels/matrix",
|
||||
"channels/zalo",
|
||||
"channels/zalouser",
|
||||
"broadcast-groups",
|
||||
"channels/troubleshooting",
|
||||
"channels/location"
|
||||
@@ -945,6 +969,7 @@
|
||||
"tools",
|
||||
"plugin",
|
||||
"plugins/voice-call",
|
||||
"plugins/zalouser",
|
||||
"tools/exec",
|
||||
"tools/web",
|
||||
"tools/apply-patch",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -44,7 +44,7 @@ clawdbot doctor
|
||||
If you’d rather not manage env vars yourself, the onboarding wizard can store
|
||||
API keys for daemon use: `clawdbot onboard`.
|
||||
|
||||
See [/start/faq](/start/faq) for details on env inheritance (`env.shellEnv`,
|
||||
See [Help](/help) for details on env inheritance (`env.shellEnv`,
|
||||
`~/.clawdbot/.env`, systemd/launchd).
|
||||
|
||||
## Anthropic: Claude Code CLI setup-token (supported)
|
||||
|
||||
@@ -17,7 +17,7 @@ Key parameters:
|
||||
- `background` (bool): background immediately
|
||||
- `timeout` (seconds, default 1800): kill the process after this timeout
|
||||
- `elevated` (bool): run on host if elevated mode is enabled/allowed
|
||||
- Need a real TTY? Use the tmux skill.
|
||||
- Need a real TTY? Set `pty: true`.
|
||||
- `workdir`, `env`
|
||||
|
||||
Behavior:
|
||||
@@ -33,12 +33,14 @@ When spawning long-running child processes outside the exec/process tools (for e
|
||||
Environment overrides:
|
||||
- `PI_BASH_YIELD_MS`: default yield (ms)
|
||||
- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars)
|
||||
- `CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars)
|
||||
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
|
||||
|
||||
Config (preferred):
|
||||
- `tools.exec.backgroundMs` (default 10000)
|
||||
- `tools.exec.timeoutSec` (default 1800)
|
||||
- `tools.exec.cleanupMs` (default 1800000)
|
||||
- `tools.exec.notifyOnExit` (default true): enqueue a system event + request heartbeat when a backgrounded exec exits.
|
||||
|
||||
## process tool
|
||||
|
||||
|
||||
@@ -124,10 +124,21 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
|
||||
// Tooling
|
||||
tools: {
|
||||
audio: {
|
||||
transcription: {
|
||||
args: ["--model", "base", "{{MediaPath}}"],
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
// Optional CLI fallback (Whisper binary):
|
||||
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
],
|
||||
timeoutSeconds: 120
|
||||
},
|
||||
video: {
|
||||
enabled: true,
|
||||
maxBytes: 52428800,
|
||||
models: [{ provider: "google", model: "gemini-3-flash-preview" }]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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`).
|
||||
@@ -943,6 +988,7 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
||||
breakPreference: "paragraph" // paragraph | newline | sentence
|
||||
},
|
||||
actions: { reactions: true, sendMessage: true }, // tool action gates (false disables)
|
||||
reactionNotifications: "own", // off | own | all
|
||||
mediaMaxMb: 5,
|
||||
retry: { // outbound retry policy
|
||||
attempts: 3,
|
||||
@@ -1035,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:
|
||||
@@ -1163,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)
|
||||
@@ -1182,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`
|
||||
@@ -1271,7 +1319,9 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context
|
||||
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
|
||||
streaming, final replies) across channels unless already present.
|
||||
|
||||
If `messages.responsePrefix` is unset, no prefix is applied by default.
|
||||
If `messages.responsePrefix` is unset, no prefix is applied by default. WhatsApp self-chat
|
||||
replies are the exception: they default to `[{identity.name}]` when set, otherwise
|
||||
`[clawdbot]`, so same-phone conversations stay legible.
|
||||
Set it to `"auto"` to derive `[{identity.name}]` for the routed agent (when set).
|
||||
|
||||
#### Template variables
|
||||
@@ -1695,10 +1745,10 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||
- `backgroundMs`: time before auto-background (ms, default 10000)
|
||||
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
|
||||
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
|
||||
- `notifyOnExit`: enqueue a system event + request heartbeat when backgrounded exec exits (default true)
|
||||
- `applyPatch.enabled`: enable experimental `apply_patch` (OpenAI/OpenAI Codex only; default false)
|
||||
- `applyPatch.allowModels`: optional allowlist of model ids (e.g. `gpt-5.2` or `openai/gpt-5.2`)
|
||||
Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias).
|
||||
Legacy: `tools.bash` is still accepted as an alias.
|
||||
Note: `applyPatch` is only under `tools.exec`.
|
||||
|
||||
`tools.web` configures web search + fetch tools:
|
||||
- `tools.web.search.enabled` (default: true when key is present)
|
||||
@@ -1706,11 +1756,73 @@ Legacy: `tools.bash` is still accepted as an alias.
|
||||
- `tools.web.search.maxResults` (1–10, default 5)
|
||||
- `tools.web.search.timeoutSeconds` (default 30)
|
||||
- `tools.web.search.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.enabled` (default false; sandboxed sessions auto-enable unless set to false)
|
||||
- `tools.web.fetch.enabled` (default true)
|
||||
- `tools.web.fetch.maxChars` (default 50000)
|
||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.userAgent` (optional override)
|
||||
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
||||
- `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)
|
||||
|
||||
`tools.media` configures inbound media understanding (image/audio/video):
|
||||
- `tools.media.models`: shared model list (capability-tagged; used after per-cap lists).
|
||||
- `tools.media.concurrency`: max concurrent capability runs (default 2).
|
||||
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
|
||||
- `enabled`: opt-out switch (default true when models are configured).
|
||||
- `prompt`: optional prompt override (image/video append a `maxChars` hint automatically).
|
||||
- `maxChars`: max output characters (default 500 for image/video; unset for audio).
|
||||
- `maxBytes`: max media size to send (defaults: image 10MB, audio 20MB, video 50MB).
|
||||
- `timeoutSeconds`: request timeout (defaults: image 60s, audio 60s, video 120s).
|
||||
- `language`: optional audio hint.
|
||||
- `attachments`: attachment policy (`mode`, `maxAttachments`, `prefer`).
|
||||
- `scope`: optional gating (first match wins) with `match.channel`, `match.chatType`, or `match.keyPrefix`.
|
||||
- `models`: ordered list of model entries; failures or oversize media fall back to the next entry.
|
||||
- Each `models[]` entry:
|
||||
- Provider entry (`type: "provider"` or omitted):
|
||||
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
|
||||
- `model`: model id override (required for image; defaults to `whisper-1`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
|
||||
- `profile` / `preferredProfile`: auth profile selection.
|
||||
- CLI entry (`type: "cli"`):
|
||||
- `command`: executable to run.
|
||||
- `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc).
|
||||
- `capabilities`: optional list (`image`, `audio`, `video`) to gate a shared entry. Defaults when omitted: `openai`/`anthropic`/`minimax` → image, `google` → image+audio+video, `groq` → audio.
|
||||
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language` can be overridden per entry.
|
||||
|
||||
If no models are configured (or `enabled: false`), understanding is skipped; the model still receives the original attachments.
|
||||
|
||||
Provider auth follows the standard model auth order (auth profiles, env vars like `OPENAI_API_KEY`/`GROQ_API_KEY`/`GEMINI_API_KEY`, or `models.providers.*.apiKey`).
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
scope: {
|
||||
default: "deny",
|
||||
rules: [{ action: "allow", match: { chatType: "direct" } }]
|
||||
},
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
]
|
||||
},
|
||||
video: {
|
||||
enabled: true,
|
||||
maxBytes: 52428800,
|
||||
models: [{ provider: "google", model: "gemini-3-flash-preview" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
@@ -2122,6 +2234,49 @@ Notes:
|
||||
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
||||
|
||||
### Kimi Code
|
||||
|
||||
Use Kimi Code's dedicated OpenAI-compatible endpoint (separate from Moonshot):
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { KIMICODE_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kimi-code/kimi-for-coding" },
|
||||
models: { "kimi-code/kimi-for-coding": { alias: "Kimi Code" } }
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"kimi-code": {
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
apiKey: "${KIMICODE_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-for-coding",
|
||||
name: "Kimi For Coding",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 262144,
|
||||
maxTokens: 32768,
|
||||
headers: { "User-Agent": "KimiCLI/0.77" },
|
||||
compat: { supportsDeveloperRole: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Set `KIMICODE_API_KEY` in the environment or use `clawdbot onboard --auth-choice kimi-code-api-key`.
|
||||
- Model ref: `kimi-code/kimi-for-coding`.
|
||||
|
||||
### Synthetic (Anthropic-compatible)
|
||||
|
||||
Use Synthetic's Anthropic-compatible endpoint:
|
||||
@@ -2267,6 +2422,9 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
dmScope: "main",
|
||||
identityLinks: {
|
||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||
},
|
||||
idleMinutes: 60,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||
@@ -2295,6 +2453,8 @@ Fields:
|
||||
- `main`: all DMs share the main session for continuity.
|
||||
- `per-peer`: isolate DMs by sender id across channels.
|
||||
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
||||
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
||||
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
|
||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||
@@ -2640,7 +2800,7 @@ Mapping notes:
|
||||
- If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage/MS Teams).
|
||||
- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set).
|
||||
|
||||
Gmail helper config (used by `clawdbot hooks gmail setup` / `run`):
|
||||
Gmail helper config (used by `clawdbot webhooks gmail setup` / `run`):
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -2786,7 +2946,7 @@ clawdbot dns setup --apply
|
||||
|
||||
## Template variables
|
||||
|
||||
Template placeholders are expanded in `tools.audio.transcription.args` (and any future templated argument fields).
|
||||
Template placeholders are expanded in `tools.media.*.models[].args` and `tools.media.models[].args` (and any future templated argument fields).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
@@ -2802,6 +2962,8 @@ Template placeholders are expanded in `tools.audio.transcription.args` (and any
|
||||
| `{{MediaPath}}` | Local media path (if downloaded) |
|
||||
| `{{MediaType}}` | Media type (image/audio/document/…) |
|
||||
| `{{Transcript}}` | Audio transcript (when enabled) |
|
||||
| `{{Prompt}}` | Resolved media prompt for CLI entries |
|
||||
| `{{MaxChars}}` | Resolved max output chars for CLI entries |
|
||||
| `{{ChatType}}` | `"direct"` or `"group"` |
|
||||
| `{{GroupSubject}}` | Group subject (best effort) |
|
||||
| `{{GroupMembers}}` | Group members preview (best effort) |
|
||||
|
||||
@@ -111,7 +111,8 @@ Current migrations:
|
||||
- `routing.bindings` → top-level `bindings`
|
||||
- `routing.agents`/`routing.defaultAgentId` → `agents.list` + `agents.list[].default`
|
||||
- `routing.agentToAgent` → `tools.agentToAgent`
|
||||
- `routing.transcribeAudio` → `tools.audio.transcription`
|
||||
- `routing.transcribeAudio` → `tools.media.audio.models`
|
||||
- `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`
|
||||
|
||||
@@ -281,7 +281,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
|
||||
|
||||
## CLI helpers
|
||||
- `clawdbot gateway health|status` — request health/status over the Gateway WS.
|
||||
- `clawdbot message send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
|
||||
- `clawdbot message send --target <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
|
||||
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
|
||||
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
|
||||
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -133,7 +133,7 @@ By default, Clawdbot routes **all DMs into the main session** so your assistant
|
||||
}
|
||||
```
|
||||
|
||||
This prevents cross-user context leakage while keeping group chats isolated. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
|
||||
This prevents cross-user context leakage while keeping group chats isolated. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
|
||||
|
||||
## Allowlists (DM + groups) — terminology
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ clawdbot channels login
|
||||
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
|
||||
|
||||
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
|
||||
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
|
||||
The repo declares `packageManager: "pnpm@…"`.
|
||||
|
||||
Typical recovery:
|
||||
```bash
|
||||
@@ -376,8 +376,7 @@ pnpm clawdbot doctor
|
||||
clawdbot daemon restart
|
||||
```
|
||||
|
||||
Why: pnpm is the configured package manager for this repo, and the dependency
|
||||
patching workflow relies on it.
|
||||
Why: pnpm is the configured package manager for this repo.
|
||||
|
||||
### How do I switch between git installs and npm installs?
|
||||
|
||||
|
||||
32
docs/help/faq.md
Normal file
32
docs/help/faq.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
summary: "FAQ (concepts): what Clawdbot is and how it fits together"
|
||||
read_when:
|
||||
- You’re new and want the mental model
|
||||
- You’re not debugging a specific error
|
||||
---
|
||||
|
||||
# FAQ (concepts)
|
||||
|
||||
If you’re here because something’s broken, start with: [Troubleshooting](/help/troubleshooting).
|
||||
|
||||
## What is Clawdbot?
|
||||
|
||||
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always‑on control plane; the assistant is the product.
|
||||
|
||||
## What runtime do I need?
|
||||
|
||||
Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** for the Gateway.
|
||||
|
||||
## What’s the recommended setup flow?
|
||||
|
||||
Use the onboarding wizard:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
Then use:
|
||||
|
||||
```bash
|
||||
clawdbot dashboard
|
||||
```
|
||||
20
docs/help/index.md
Normal file
20
docs/help/index.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
summary: "Help hub: common fixes, install sanity, and where to look when something breaks"
|
||||
read_when:
|
||||
- You’re new and want the “what do I click/run” guide
|
||||
- Something broke and you want the fastest path to a fix
|
||||
---
|
||||
|
||||
# Help
|
||||
|
||||
If you want a quick “get unstuck” flow, start here:
|
||||
|
||||
- **Troubleshooting:** [Start here](/help/troubleshooting)
|
||||
- **Install sanity (Node/npm/PATH):** [Install](/install#nodejs--npm-path-sanity)
|
||||
- **Gateway issues:** [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- **Logs:** [Logging](/logging) and [Gateway logging](/gateway/logging)
|
||||
- **Repairs:** [Doctor](/gateway/doctor)
|
||||
|
||||
If you’re looking for conceptual questions (not “something broke”):
|
||||
|
||||
- [FAQ (concepts)](/help/faq)
|
||||
59
docs/help/troubleshooting.md
Normal file
59
docs/help/troubleshooting.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
summary: "Troubleshooting hub: symptoms → checks → fixes"
|
||||
read_when:
|
||||
- You see an error and want the fix path
|
||||
- The installer says “success” but the CLI doesn’t work
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## First 60 seconds
|
||||
|
||||
Run these in order:
|
||||
|
||||
```bash
|
||||
clawdbot status
|
||||
clawdbot status --all
|
||||
clawdbot daemon status
|
||||
clawdbot logs --follow
|
||||
clawdbot doctor
|
||||
```
|
||||
|
||||
If the gateway is reachable, deep probes:
|
||||
|
||||
```bash
|
||||
clawdbot status --deep
|
||||
```
|
||||
|
||||
## Common “it broke” cases
|
||||
|
||||
### `clawdbot: command not found`
|
||||
|
||||
Almost always a Node/npm PATH issue. Start here:
|
||||
|
||||
- [Install (Node/npm PATH sanity)](/install#nodejs--npm-path-sanity)
|
||||
|
||||
### Gateway “unauthorized”, can’t connect, or keeps reconnecting
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- [Gateway authentication](/gateway/authentication)
|
||||
|
||||
### Daemon says running, but RPC probe fails
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- [Background process / daemon](/gateway/background-process)
|
||||
|
||||
### Model/auth failures (rate limit, billing, “all models failed”)
|
||||
|
||||
- [Models](/cli/models)
|
||||
- [OAuth / auth concepts](/concepts/oauth)
|
||||
|
||||
### When filing an issue
|
||||
|
||||
Paste a safe report:
|
||||
|
||||
```bash
|
||||
clawdbot status --all
|
||||
```
|
||||
|
||||
If you can, include the relevant log tail from `clawdbot logs --follow`.
|
||||
817
docs/hooks.md
Normal file
817
docs/hooks.md
Normal file
@@ -0,0 +1,817 @@
|
||||
---
|
||||
summary: "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 hooks
|
||||
---
|
||||
# Hooks
|
||||
|
||||
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:
|
||||
|
||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
||||
|
||||
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 a hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
The 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 list
|
||||
```
|
||||
|
||||
Enable a hook:
|
||||
|
||||
```bash
|
||||
clawdbot hooks enable session-memory
|
||||
```
|
||||
|
||||
Check hook status:
|
||||
|
||||
```bash
|
||||
clawdbot hooks check
|
||||
```
|
||||
|
||||
Get detailed information:
|
||||
|
||||
```bash
|
||||
clawdbot hooks 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)
|
||||
|
||||
Managed hook directories can be either a **single hook** or a **hook pack** (package directory).
|
||||
|
||||
Each hook is a directory containing:
|
||||
|
||||
```
|
||||
my-hook/
|
||||
├── HOOK.md # Metadata + documentation
|
||||
└── handler.ts # Handler implementation
|
||||
```
|
||||
|
||||
## Hook Packs (npm/archives)
|
||||
|
||||
Hook packs are standard npm packages that export one or more hooks via `clawdbot.hooks` in
|
||||
`package.json`. Install them with:
|
||||
|
||||
```bash
|
||||
clawdbot hooks install <path-or-spec>
|
||||
```
|
||||
|
||||
Example `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@acme/my-hooks",
|
||||
"version": "0.1.0",
|
||||
"clawdbot": {
|
||||
"hooks": ["./hooks/my-hook", "./hooks/other-hook"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`).
|
||||
Hook packs can ship dependencies; they will be installed under `~/.clawdbot/hooks/<id>`.
|
||||
|
||||
## 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/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 a `HookHandler` function:
|
||||
|
||||
```typescript
|
||||
import type { HookHandler } from '../../src/hooks/hooks.js';
|
||||
|
||||
const myHandler: HookHandler = 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 { HookHandler } from '../../src/hooks/hooks.js';
|
||||
|
||||
const handler: HookHandler = 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 list
|
||||
|
||||
# Enable it
|
||||
clawdbot hooks 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 list
|
||||
|
||||
# Show only eligible hooks
|
||||
clawdbot hooks list --eligible
|
||||
|
||||
# Verbose output (show missing requirements)
|
||||
clawdbot hooks list --verbose
|
||||
|
||||
# JSON output
|
||||
clawdbot hooks list --json
|
||||
```
|
||||
|
||||
### Hook Information
|
||||
|
||||
```bash
|
||||
# Show detailed info about a hook
|
||||
clawdbot hooks info session-memory
|
||||
|
||||
# JSON output
|
||||
clawdbot hooks info session-memory --json
|
||||
```
|
||||
|
||||
### Check Eligibility
|
||||
|
||||
```bash
|
||||
# Show eligibility summary
|
||||
clawdbot hooks check
|
||||
|
||||
# JSON output
|
||||
clawdbot hooks check --json
|
||||
```
|
||||
|
||||
### Enable/Disable
|
||||
|
||||
```bash
|
||||
# Enable a hook
|
||||
clawdbot hooks enable session-memory
|
||||
|
||||
# Disable a hook
|
||||
clawdbot hooks 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 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 enable command-logger
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Keep Handlers Fast
|
||||
|
||||
Hooks run during command processing. Keep them lightweight:
|
||||
|
||||
```typescript
|
||||
// ✓ Good - async work, returns immediately
|
||||
const handler: HookHandler = async (event) => {
|
||||
void processInBackground(event); // Fire and forget
|
||||
};
|
||||
|
||||
// ✗ Bad - blocks command processing
|
||||
const handler: HookHandler = async (event) => {
|
||||
await slowDatabaseQuery(event);
|
||||
await evenSlowerAPICall(event);
|
||||
};
|
||||
```
|
||||
|
||||
### Handle Errors Gracefully
|
||||
|
||||
Always wrap risky operations:
|
||||
|
||||
```typescript
|
||||
const handler: HookHandler = 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: HookHandler = 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 list --verbose
|
||||
```
|
||||
|
||||
### Check Registration
|
||||
|
||||
In your handler, log when it's called:
|
||||
|
||||
```typescript
|
||||
const handler: HookHandler = 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 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 { createHookEvent } from './src/hooks/hooks.js';
|
||||
import myHandler from './hooks/my-hook/handler.js';
|
||||
|
||||
test('my handler works', async () => {
|
||||
const event = createHookEvent('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-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 list
|
||||
```
|
||||
|
||||
### Hook Not Eligible
|
||||
|
||||
Check requirements:
|
||||
|
||||
```bash
|
||||
clawdbot hooks 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 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 list
|
||||
# Should show: 🎯 my-hook ✓
|
||||
```
|
||||
|
||||
**Benefits of migration**:
|
||||
- Automatic discovery
|
||||
- CLI management
|
||||
- Eligibility checking
|
||||
- Better documentation
|
||||
- Consistent structure
|
||||
|
||||
## See Also
|
||||
|
||||
- [CLI Reference: hooks](/cli/hooks)
|
||||
- [Bundled Hooks README](https://github.com/clawdbot/clawdbot/tree/main/src/hooks/bundled)
|
||||
- [Webhook Hooks](/automation/webhook)
|
||||
- [Configuration](/gateway/configuration#hooks)
|
||||
@@ -137,7 +137,7 @@ clawdbot gateway --port 19001
|
||||
Send a test message (requires a running Gateway):
|
||||
|
||||
```bash
|
||||
clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
|
||||
clawdbot message send --target +15555550123 --message "Hello from Clawdbot"
|
||||
```
|
||||
|
||||
## Configuration (optional)
|
||||
@@ -165,7 +165,7 @@ Example:
|
||||
|
||||
- Start here:
|
||||
- [Docs hubs (all pages linked)](/start/hubs)
|
||||
- [FAQ](/start/faq) ← *common questions answered*
|
||||
- [Help](/help) ← *common fixes + troubleshooting*
|
||||
- [Configuration](/gateway/configuration)
|
||||
- [Configuration examples](/gateway/configuration-examples)
|
||||
- [Slash commands](/tools/slash-commands)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Bun workflow (experimental): installs, patches, and gotchas vs pnpm"
|
||||
summary: "Bun workflow (experimental): installs and gotchas vs pnpm"
|
||||
read_when:
|
||||
- You want the fastest local dev loop (bun + watch)
|
||||
- You hit Bun install/patch/lifecycle script issues
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
# Bun (experimental)
|
||||
|
||||
Goal: run this repo with **Bun** (optional, not recommended for WhatsApp/Telegram)
|
||||
without losing pnpm patch behavior.
|
||||
without diverging from pnpm workflows.
|
||||
|
||||
⚠️ **Not recommended for Gateway runtime** (WhatsApp/Telegram bugs). Use Node for production.
|
||||
|
||||
@@ -39,20 +39,6 @@ bun run build
|
||||
bun run vitest run
|
||||
```
|
||||
|
||||
## pnpm patchedDependencies under Bun
|
||||
|
||||
pnpm supports `package.json#pnpm.patchedDependencies` and records it in `pnpm-lock.yaml`.
|
||||
Bun (and npm/yarn) do not support pnpm patches, so we apply them in `postinstall` when pnpm is **not** the installer:
|
||||
|
||||
- [`scripts/postinstall.js`](https://github.com/clawdbot/clawdbot/blob/main/scripts/postinstall.js) detects the package manager via `npm_config_user_agent` and applies every entry from `package.json#pnpm.patchedDependencies` into `node_modules/...` using a built-in JS patcher (no `git`/system `patch` dependency).
|
||||
- Under pnpm, this fallback is skipped because pnpm already applies `patchedDependencies` itself.
|
||||
|
||||
To add a new patch that works in both pnpm + Bun:
|
||||
|
||||
1. Add an entry to `package.json#pnpm.patchedDependencies`
|
||||
2. Add the patch file under `patches/`
|
||||
3. Run `pnpm install` (updates `pnpm-lock.yaml` patch hash)
|
||||
|
||||
## Bun lifecycle scripts (blocked by default)
|
||||
|
||||
Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`).
|
||||
|
||||
@@ -152,7 +152,6 @@ WORKDIR /app
|
||||
# Cache dependencies unless package metadata changes
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts ./scripts
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -9,6 +9,29 @@ read_when:
|
||||
|
||||
Runtime baseline: **Node >=22**.
|
||||
|
||||
If the installer says it succeeded but you later see `clawdbot: command not found`, it’s usually a Node/npm PATH issue (global npm bin dir not on PATH). See the section below.
|
||||
|
||||
## Node.js + npm (PATH sanity)
|
||||
|
||||
Quick diagnosis:
|
||||
|
||||
```bash
|
||||
node -v
|
||||
npm -v
|
||||
npm bin -g
|
||||
echo "$PATH"
|
||||
```
|
||||
|
||||
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
||||
|
||||
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
|
||||
|
||||
```bash
|
||||
export PATH="/path/from/npm/bin/-g:$PATH"
|
||||
```
|
||||
|
||||
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
|
||||
|
||||
## Recommended (installer script)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,6 +19,8 @@ To see the current flags/behavior, run:
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
||||
|
||||
## install.sh (recommended)
|
||||
|
||||
What it does (high level):
|
||||
@@ -71,9 +73,3 @@ Help:
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
## Patches (npm / pnpm / bun)
|
||||
|
||||
Clawdbot’s `postinstall` script includes a builtin JS patcher that can apply `pnpm.patchedDependencies` patches even when the package manager doesn’t support them (notably Bun). pnpm itself already applies `pnpm.patchedDependencies`, so the fallback skips pnpm installs to avoid double-applying.
|
||||
|
||||
See: [Bun notes](/install/bun).
|
||||
|
||||
74
docs/install/node.md
Normal file
74
docs/install/node.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
summary: "Node.js + npm install sanity: versions, PATH, and global installs"
|
||||
read_when:
|
||||
- You installed Clawdbot but `clawdbot` is “command not found”
|
||||
- You’re setting up Node.js/npm on a new machine
|
||||
- `npm install -g ...` fails with permissions or PATH issues
|
||||
---
|
||||
|
||||
# Node.js + npm (PATH sanity)
|
||||
|
||||
Clawdbot’s runtime baseline is **Node 22+**.
|
||||
|
||||
If you can run `npm install -g clawdbot@latest` but later see `clawdbot: command not found`, it’s almost always a **PATH** issue: the directory where npm puts global binaries isn’t on your shell’s PATH.
|
||||
|
||||
## Quick diagnosis
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node -v
|
||||
npm -v
|
||||
npm bin -g
|
||||
echo "$PATH"
|
||||
```
|
||||
|
||||
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
||||
|
||||
## Fix: put npm’s global bin dir on PATH
|
||||
|
||||
1) Find your global bin directory:
|
||||
|
||||
```bash
|
||||
npm bin -g
|
||||
```
|
||||
|
||||
2) Add it to your shell startup file:
|
||||
|
||||
- zsh: `~/.zshrc`
|
||||
- bash: `~/.bashrc`
|
||||
|
||||
Example (replace the path with your `npm bin -g` output):
|
||||
|
||||
```bash
|
||||
export PATH="/path/from/npm/bin/-g:$PATH"
|
||||
```
|
||||
|
||||
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
|
||||
|
||||
## Fix: avoid `sudo npm install -g` / permission errors (Linux)
|
||||
|
||||
If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory:
|
||||
|
||||
```bash
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
```
|
||||
|
||||
Persist the `export PATH=...` line in your shell startup file.
|
||||
|
||||
## Recommended Node install options
|
||||
|
||||
You’ll have the fewest surprises if Node/npm are installed in a way that:
|
||||
|
||||
- keeps Node updated (22+)
|
||||
- makes `npm bin -g` stable and on PATH in new shells
|
||||
|
||||
Common choices:
|
||||
|
||||
- macOS: Homebrew (`brew install node`) or a version manager
|
||||
- Linux: your preferred version manager, or a distro-supported install that provides Node 22+
|
||||
- Windows: official Node installer, `winget`, or a Windows Node version manager
|
||||
|
||||
If you use a version manager (nvm/fnm/asdf/etc), ensure it’s initialized in the shell you use day-to-day (zsh vs bash) so the PATH it sets is present when you run installers.
|
||||
@@ -50,6 +50,22 @@ pnpm add -g clawdbot@latest
|
||||
```
|
||||
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
|
||||
|
||||
To stay on the beta channel for CLI updates:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel beta
|
||||
```
|
||||
|
||||
Switch back to stable later:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel stable
|
||||
```
|
||||
|
||||
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
||||
|
||||
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
@@ -75,7 +91,7 @@ It runs a safe-ish update flow:
|
||||
- Fetches + rebases against the configured upstream.
|
||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||
|
||||
## Update (Control UI / RPC)
|
||||
|
||||
|
||||
@@ -3,25 +3,73 @@ summary: "How inbound audio/voice notes are downloaded, transcribed, and injecte
|
||||
read_when:
|
||||
- Changing audio transcription or media handling
|
||||
---
|
||||
# Audio / Voice Notes — 2025-12-05
|
||||
# Audio / Voice Notes — 2026-01-17
|
||||
|
||||
## What works
|
||||
- **Optional transcription**: If `tools.audio.transcription` is set in `~/.clawdbot/clawdbot.json`, Clawdbot will:
|
||||
1) Download inbound audio to a temp path when WhatsApp only provides a URL.
|
||||
2) Run the configured CLI args (templated with `{{MediaPath}}`), expecting transcript on stdout.
|
||||
3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both.
|
||||
4) Continue through the normal auto-reply pipeline (templating, sessions, Pi command).
|
||||
- **Verbose logging**: In `--verbose`, we log when transcription runs and when the transcript replaces the body.
|
||||
- **Media understanding (audio)**: If `tools.media.audio` is enabled (or a shared `tools.media.models` entry supports audio), Clawdbot:
|
||||
1) Locates the first audio attachment (local path or URL) and downloads it if needed.
|
||||
2) Enforces `maxBytes` before sending to each model entry.
|
||||
3) Runs the first eligible model entry in order (provider or CLI).
|
||||
4) If it fails or skips (size/timeout), it tries the next entry.
|
||||
5) On success, it replaces `Body` with an `[Audio]` block and sets `{{Transcript}}`.
|
||||
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
|
||||
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
|
||||
|
||||
## Config example (Whisper CLI)
|
||||
Requires `whisper` CLI installed:
|
||||
## Config examples
|
||||
|
||||
### Provider + CLI fallback (OpenAI + Whisper CLI)
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
audio: {
|
||||
transcription: {
|
||||
args: ["--model", "base", "{{MediaPath}}"],
|
||||
timeoutSeconds: 45
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
args: ["--model", "base", "{{MediaPath}}"],
|
||||
timeoutSeconds: 45
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Provider-only with scope gating
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
scope: {
|
||||
default: "allow",
|
||||
rules: [
|
||||
{ action: "deny", match: { chatType: "group" } }
|
||||
]
|
||||
},
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Provider-only (Deepgram)
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,12 +77,17 @@ Requires `whisper` CLI installed:
|
||||
```
|
||||
|
||||
## Notes & limits
|
||||
- We don’t ship a transcriber; you opt in with the Whisper CLI on your PATH.
|
||||
- Size guard: inbound audio must be ≤5 MB (matches the temp media store and transcript pipeline).
|
||||
- Outbound caps: web send supports audio/voice up to 16 MB (sent as a voice note with `ptt: true`).
|
||||
- If transcription fails, we fall back to the original body/media note; replies still go through.
|
||||
- Transcript is available to templates as `{{Transcript}}`; models get both the media path and a `Transcript:` block in the prompt when using command mode.
|
||||
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
|
||||
- Deepgram picks up `DEEPGRAM_API_KEY` when `provider: "deepgram"` is used.
|
||||
- Deepgram setup details: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
||||
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
||||
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
||||
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||
- Transcript is available to templates as `{{Transcript}}`.
|
||||
- CLI stdout is capped (5MB); keep CLI output concise.
|
||||
|
||||
## Gotchas
|
||||
- Scope rules use first-match wins. `chatType` is normalized to `direct`, `group`, or `room`.
|
||||
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
|
||||
- Keep timeouts reasonable (`timeoutSeconds`, default 45s) to avoid blocking the reply queue.
|
||||
- Keep timeouts reasonable (`timeoutSeconds`, default 60s) to avoid blocking the reply queue.
|
||||
|
||||
@@ -38,13 +38,23 @@ The WhatsApp channel runs via **Baileys Web**. This document captures the curren
|
||||
- `{{MediaUrl}}` pseudo-URL for the inbound media.
|
||||
- `{{MediaPath}}` local temp path written before running the command.
|
||||
- When a per-session Docker sandbox is enabled, inbound media is copied into the sandbox workspace and `MediaPath`/`MediaUrl` are rewritten to a relative path like `media/inbound/<filename>`.
|
||||
- Audio transcription (if configured via `tools.audio.transcription`) runs before templating and can replace `Body` with the transcript.
|
||||
- Media understanding (if configured via `tools.media.*` or shared `tools.media.models`) runs before templating and can insert `[Image]`, `[Audio]`, and `[Video]` blocks into `Body`.
|
||||
- Audio sets `{{Transcript}}` and uses the transcript for command parsing so slash commands still work.
|
||||
- Video and image descriptions preserve any caption text for command parsing.
|
||||
- By default only the first matching image/audio/video attachment is processed; set `tools.media.<cap>.attachments` to process multiple attachments.
|
||||
|
||||
## Limits & Errors
|
||||
**Outbound send caps (WhatsApp web send)**
|
||||
- Images: ~6 MB cap after recompression.
|
||||
- Audio/voice/video: 16 MB cap; documents: 100 MB cap.
|
||||
- Oversize or unreadable media → clear error in logs and the reply is skipped.
|
||||
|
||||
**Media understanding caps (transcription/description)**
|
||||
- Image default: 10 MB (`tools.media.image.maxBytes`).
|
||||
- Audio default: 20 MB (`tools.media.audio.maxBytes`).
|
||||
- Video default: 50 MB (`tools.media.video.maxBytes`).
|
||||
- Oversize media skips understanding, but replies still go through with the original body.
|
||||
|
||||
## Notes for Tests
|
||||
- Cover send + reply flows for image/audio/document cases.
|
||||
- Validate recompression for images (size bound) and voice-note flag for audio.
|
||||
|
||||
279
docs/nodes/media-understanding.md
Normal file
279
docs/nodes/media-understanding.md
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
summary: "Inbound image/audio/video understanding (optional) with provider + CLI fallbacks"
|
||||
read_when:
|
||||
- Designing or refactoring media understanding
|
||||
- Tuning inbound audio/video/image preprocessing
|
||||
---
|
||||
# Media Understanding (Inbound) — 2026-01-17
|
||||
|
||||
Clawdbot can optionally **summarize inbound media** (image/audio/video) before the reply pipeline runs. This is **opt-in** and separate from the base attachment flow—if understanding is off, models still receive the original files/URLs as usual.
|
||||
|
||||
## Goals
|
||||
- Optional: pre‑digest inbound media into short text for faster routing + better command parsing.
|
||||
- Preserve original media delivery to the model (always).
|
||||
- Support **provider APIs** and **CLI fallbacks**.
|
||||
- Allow multiple models with ordered fallback (error/size/timeout).
|
||||
|
||||
## High‑level behavior
|
||||
1) Collect inbound attachments (`MediaPaths`, `MediaUrls`, `MediaTypes`).
|
||||
2) For each enabled capability (image/audio/video), select attachments per policy (default: **first**).
|
||||
3) Choose the first eligible model entry (size + capability + auth).
|
||||
4) If a model fails or the media is too large, **fall back to the next entry**.
|
||||
5) On success:
|
||||
- `Body` becomes `[Image]`, `[Audio]`, or `[Video]` block.
|
||||
- Audio sets `{{Transcript}}`; command parsing uses caption text when present,
|
||||
otherwise the transcript.
|
||||
- Captions are preserved as `User text:` inside the block.
|
||||
|
||||
If understanding fails or is disabled, **the reply flow continues** with the original body + attachments.
|
||||
|
||||
## Config overview
|
||||
`tools.media` supports **shared models** plus per‑capability overrides:
|
||||
- `tools.media.models`: shared model list (use `capabilities` to gate).
|
||||
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
|
||||
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
|
||||
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
|
||||
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
|
||||
- optional **per‑capability `models` list** (preferred before shared models)
|
||||
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
|
||||
- `scope` (optional gating by channel/chatType/session key)
|
||||
- `tools.media.concurrency`: max concurrent capability runs (default **2**).
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
models: [ /* shared list */ ],
|
||||
image: { /* optional overrides */ },
|
||||
audio: { /* optional overrides */ },
|
||||
video: { /* optional overrides */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model entries
|
||||
Each `models[]` entry can be **provider** or **CLI**:
|
||||
|
||||
```json5
|
||||
{
|
||||
type: "provider", // default if omitted
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
prompt: "Describe the image in <= 500 chars.",
|
||||
maxChars: 500,
|
||||
maxBytes: 10485760,
|
||||
timeoutSeconds: 60,
|
||||
capabilities: ["image"], // optional, used for multi‑modal entries
|
||||
profile: "vision-profile",
|
||||
preferredProfile: "vision-fallback"
|
||||
}
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
type: "cli",
|
||||
command: "gemini",
|
||||
args: [
|
||||
"-m",
|
||||
"gemini-3-flash",
|
||||
"--allowed-tools",
|
||||
"read_file",
|
||||
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
|
||||
],
|
||||
maxChars: 500,
|
||||
maxBytes: 52428800,
|
||||
timeoutSeconds: 120,
|
||||
capabilities: ["video", "image"]
|
||||
}
|
||||
```
|
||||
|
||||
## Defaults and limits
|
||||
Recommended defaults:
|
||||
- `maxChars`: **500** for image/video (short, command‑friendly)
|
||||
- `maxChars`: **unset** for audio (full transcript unless you set a limit)
|
||||
- `maxBytes`:
|
||||
- image: **10MB**
|
||||
- audio: **20MB**
|
||||
- video: **50MB**
|
||||
|
||||
Rules:
|
||||
- If media exceeds `maxBytes`, that model is skipped and the **next model is tried**.
|
||||
- If the model returns more than `maxChars`, output is trimmed.
|
||||
- `prompt` defaults to simple “Describe the {media}.” plus the `maxChars` guidance (image/video only).
|
||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||
**active reply model** when its provider supports the capability.
|
||||
|
||||
## Capabilities (optional)
|
||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||
lists, Clawdbot can infer defaults:
|
||||
- `openai`, `anthropic`, `minimax`: **image**
|
||||
- `google` (Gemini API): **image + audio + video**
|
||||
- `groq`: **audio**
|
||||
- `deepgram`: **audio**
|
||||
|
||||
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
|
||||
If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
|
||||
## Provider support matrix (Clawdbot integrations)
|
||||
| Capability | Provider integration | Notes |
|
||||
|------------|----------------------|-------|
|
||||
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
||||
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
|
||||
| Video | Google (Gemini API) | Provider video understanding. |
|
||||
|
||||
## Recommended providers
|
||||
**Image**
|
||||
- Prefer your active model if it supports images.
|
||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
||||
|
||||
**Audio**
|
||||
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper` binary.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
|
||||
- CLI fallback: `gemini` CLI (supports `read_file` on video/audio).
|
||||
|
||||
## Attachment policy
|
||||
Per‑capability `attachments` controls which attachments are processed:
|
||||
- `mode`: `first` (default) or `all`
|
||||
- `maxAttachments`: cap the number processed (default **1**)
|
||||
- `prefer`: `first`, `last`, `path`, `url`
|
||||
|
||||
When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
|
||||
|
||||
## Config examples
|
||||
|
||||
### 1) Shared models list + overrides
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-5.2", capabilities: ["image"] },
|
||||
{ provider: "google", model: "gemini-3-flash-preview", capabilities: ["image", "audio", "video"] },
|
||||
{
|
||||
type: "cli",
|
||||
command: "gemini",
|
||||
args: [
|
||||
"-m",
|
||||
"gemini-3-flash",
|
||||
"--allowed-tools",
|
||||
"read_file",
|
||||
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
|
||||
],
|
||||
capabilities: ["image", "video"]
|
||||
}
|
||||
],
|
||||
audio: {
|
||||
attachments: { mode: "all", maxAttachments: 2 }
|
||||
},
|
||||
video: {
|
||||
maxChars: 500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2) Audio + Video only (image off)
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
args: ["--model", "base", "{{MediaPath}}"]
|
||||
}
|
||||
]
|
||||
},
|
||||
video: {
|
||||
enabled: true,
|
||||
maxChars: 500,
|
||||
models: [
|
||||
{ provider: "google", model: "gemini-3-flash-preview" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "gemini",
|
||||
args: [
|
||||
"-m",
|
||||
"gemini-3-flash",
|
||||
"--allowed-tools",
|
||||
"read_file",
|
||||
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3) Optional image understanding
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
image: {
|
||||
enabled: true,
|
||||
maxBytes: 10485760,
|
||||
maxChars: 500,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-5.2" },
|
||||
{ provider: "anthropic", model: "claude-opus-4-5" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "gemini",
|
||||
args: [
|
||||
"-m",
|
||||
"gemini-3-flash",
|
||||
"--allowed-tools",
|
||||
"read_file",
|
||||
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4) Multi‑modal single entry (explicit capabilities)
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
image: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] },
|
||||
audio: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] },
|
||||
video: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Status output
|
||||
When media understanding runs, `/status` includes a short summary line:
|
||||
|
||||
```
|
||||
📎 Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)
|
||||
```
|
||||
|
||||
This shows per‑capability outcomes and the chosen provider/model when applicable.
|
||||
|
||||
## Notes
|
||||
- Understanding is **best‑effort**. Errors do not block replies.
|
||||
- Attachments are still passed to models even when understanding is disabled.
|
||||
- Use `scope` to limit where understanding runs (e.g. only DMs).
|
||||
|
||||
## Related docs
|
||||
- [Configuration](/gateway/configuration)
|
||||
- [Image & Media Support](/nodes/images)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user