Compare commits
3 Commits
copilot
...
fix/token-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97805e63be | ||
|
|
01f44f13a1 | ||
|
|
5cfe6ff673 |
118
CHANGELOG.md
118
CHANGELOG.md
@@ -2,96 +2,80 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
### Changes
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
## 2026.1.22 (unreleased)
|
||||
|
||||
### Fixes
|
||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||
- Auto-reply: keep cached context token count in sync after compaction. (#1440) Thanks @robbyczgw-cla.
|
||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
||||
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
|
||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
||||
- Agents: make tool summaries more readable and only show optional params when set.
|
||||
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
||||
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
|
||||
- CLI: prefer `~` for home paths in output.
|
||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
||||
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
|
||||
|
||||
## 2026.1.21-2
|
||||
|
||||
### Fixes
|
||||
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
||||
- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447)
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
### Highlights
|
||||
- Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
||||
- Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning
|
||||
- Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated
|
||||
- Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams
|
||||
- `/models` UX refresh + `clawdbot update wizard`. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update
|
||||
|
||||
### Changes
|
||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
|
||||
- Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
|
||||
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
|
||||
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
|
||||
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
|
||||
- CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
|
||||
- Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
|
||||
- Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
|
||||
- Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
|
||||
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
|
||||
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
|
||||
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
|
||||
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
|
||||
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).
|
||||
- Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.
|
||||
- Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
- Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.
|
||||
- Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.
|
||||
- Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.
|
||||
- Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.
|
||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||
- Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)
|
||||
- Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
||||
- UI/config: export `SECTION_META` for config form modules. (#1418) Thanks @MaudeBot.
|
||||
- macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.
|
||||
- BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.
|
||||
- Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit `/model` list output. (#1376, #1416)
|
||||
- Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.
|
||||
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
||||
- Cache: restore the 1h cache TTL option and reset the pruning window.
|
||||
- Zalo Personal: tolerate ANSI/log-prefixed JSON output from `zca`. (#1379) Thanks @ptn1411.
|
||||
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
|
||||
- Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.
|
||||
- Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when `gateway.mode` is unset. (#900)
|
||||
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
|
||||
- Logs/Status: align rolling log filenames with local time and report sandboxed runtime in `clawdbot status`. (#1343)
|
||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
||||
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
|
||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||
- Model picker: list the full catalog when no model allowlist is configured.
|
||||
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
||||
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||
- Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
|
||||
37
README.md
37
README.md
@@ -477,29 +477,28 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
|
||||
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/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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></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/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/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/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/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/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/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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></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/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/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/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/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/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/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/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/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/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></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/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/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/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/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/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/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/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></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/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/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/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></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/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/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/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/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/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/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></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/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></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/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a>
|
||||
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></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/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/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></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/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/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/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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></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/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/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/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/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/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></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/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></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/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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></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/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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></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/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/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/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/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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></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/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></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/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></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/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></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/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></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/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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></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/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></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/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></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>
|
||||
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></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/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/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/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/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></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/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/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/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
|
||||
<a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/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/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></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/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></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/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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></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/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/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/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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></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/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></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/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></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/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></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/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></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/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/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></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/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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a>
|
||||
<a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></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/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></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>
|
||||
|
||||
@@ -16,8 +16,6 @@ struct DebugSettings: View {
|
||||
@State private var modelsError: String?
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let healthStore = HealthStore.shared
|
||||
@State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
||||
@State private var launchAgentWriteError: String?
|
||||
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||
@State private var sessionStoreSaveError: String?
|
||||
@@ -49,7 +47,6 @@ struct DebugSettings: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
|
||||
self.launchdSection
|
||||
self.appInfoSection
|
||||
self.gatewaySection
|
||||
self.logsSection
|
||||
@@ -82,41 +79,6 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var launchdSection: some View {
|
||||
GroupBox("Gateway startup") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled)
|
||||
.onChange(of: self.launchAgentWriteDisabled) { _, newValue in
|
||||
self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue)
|
||||
if self.launchAgentWriteError != nil {
|
||||
self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
||||
return
|
||||
}
|
||||
if newValue {
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
enabled: false,
|
||||
bundlePath: Bundle.main.bundlePath,
|
||||
port: GatewayEnvironment.gatewayPort())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
|
||||
"It will only attach to an existing Gateway.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let launchAgentWriteError {
|
||||
Text(launchAgentWriteError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
|
||||
@@ -4,46 +4,11 @@ enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||
|
||||
private static var disableLaunchAgentMarkerURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
}
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
static func isLaunchAgentWriteDisabled() -> Bool {
|
||||
FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path)
|
||||
}
|
||||
|
||||
static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
|
||||
let marker = self.disableLaunchAgentMarkerURL
|
||||
if disabled {
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: marker.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if !FileManager().fileExists(atPath: marker.path) {
|
||||
FileManager().createFile(atPath: marker.path, contents: nil)
|
||||
}
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if FileManager().fileExists(atPath: marker.path) {
|
||||
do {
|
||||
try FileManager().removeItem(at: marker)
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isLoaded() async -> Bool {
|
||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||
return loaded
|
||||
@@ -101,6 +66,12 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
extension GatewayLaunchAgentManager {
|
||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
||||
let marker = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
return FileManager().fileExists(atPath: marker.path)
|
||||
}
|
||||
|
||||
private static func readDaemonLoaded() async -> Bool? {
|
||||
let result = await self.runDaemonCommandResult(
|
||||
["status", "--json", "--no-probe"],
|
||||
|
||||
@@ -79,11 +79,6 @@ final class GatewayProcessManager {
|
||||
|
||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
|
||||
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
|
||||
return
|
||||
}
|
||||
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||
guard !enabled else { return }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
@@ -242,17 +237,19 @@ final class GatewayProcessManager {
|
||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||
let linkId = order.first(where: { snap.channels[$0]?.linked == true })
|
||||
?? order.first(where: { snap.channels[$0]?.linked != nil })
|
||||
guard let linkId else {
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
let linked = snap.channels[linkId]?.linked ?? false
|
||||
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
let linkId = snap.channelOrder?.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.channels.keys.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
snap.channelLabels?[linkId] ??
|
||||
linkId.capitalized
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"channel"
|
||||
let linkText = linked ? "linked" : "not linked"
|
||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
@@ -311,15 +308,6 @@ final class GatewayProcessManager {
|
||||
return
|
||||
}
|
||||
|
||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||
let message = "Launchd disabled; start the Gateway manually or disable attach-only."
|
||||
self.status = .failed(message)
|
||||
self.lastFailureReason = "launchd disabled"
|
||||
self.appendLog("[gateway] launchd disabled; skipping auto-start\n")
|
||||
self.logger.info("gateway launchd enable skipped (disable marker set)")
|
||||
return
|
||||
}
|
||||
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||
|
||||
@@ -166,11 +166,6 @@ final class HealthStore {
|
||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||
{
|
||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||
for id in order {
|
||||
if let summary = snap.channels[id], summary.linked == true {
|
||||
return (id: id, summary: summary)
|
||||
}
|
||||
}
|
||||
for id in order {
|
||||
if let summary = snap.channels[id], summary.linked != nil {
|
||||
return (id: id, summary: summary)
|
||||
|
||||
@@ -3,7 +3,6 @@ import Darwin
|
||||
import Foundation
|
||||
import MenuBarExtraAccess
|
||||
import Observation
|
||||
import OSLog
|
||||
import Security
|
||||
import SwiftUI
|
||||
|
||||
@@ -11,7 +10,6 @@ import SwiftUI
|
||||
struct ClawdbotApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@State private var state: AppState
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "app")
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
@@ -33,7 +31,6 @@ struct ClawdbotApp: App {
|
||||
|
||||
init() {
|
||||
ClawdbotLogging.bootstrapIfNeeded()
|
||||
Self.applyAttachOnlyOverrideIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
}
|
||||
|
||||
@@ -94,22 +91,6 @@ struct ClawdbotApp: App {
|
||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||
}
|
||||
|
||||
private static func applyAttachOnlyOverrideIfNeeded() {
|
||||
let args = CommandLine.arguments
|
||||
guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
|
||||
if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
|
||||
Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
enabled: false,
|
||||
bundlePath: Bundle.main.bundlePath,
|
||||
port: GatewayEnvironment.gatewayPort())
|
||||
}
|
||||
Self.logger.info("attach-only flag enabled")
|
||||
}
|
||||
|
||||
private var isGatewaySleeping: Bool {
|
||||
if self.state.isPaused { return false }
|
||||
switch self.state.connectionMode {
|
||||
|
||||
@@ -121,7 +121,7 @@ Resolution priority:
|
||||
|
||||
### Delivery (channel + target)
|
||||
Isolated jobs can deliver output to a channel. The job payload can specify:
|
||||
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`
|
||||
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
|
||||
- `to`: channel-specific recipient target
|
||||
|
||||
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
||||
@@ -133,7 +133,7 @@ Delivery notes:
|
||||
- Use `deliver: false` to keep output internal even if a `to` is present.
|
||||
|
||||
Target format reminders:
|
||||
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||
- Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||
- Telegram topics should use the `:topic:` form (see below).
|
||||
|
||||
#### Telegram delivery targets (topics / forum threads)
|
||||
|
||||
@@ -71,8 +71,8 @@ Payload:
|
||||
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
|
||||
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
||||
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
||||
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
||||
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
||||
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
||||
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
||||
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
||||
|
||||
@@ -147,8 +147,7 @@ Available actions:
|
||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
|
||||
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
|
||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
|
||||
|
||||
### Message IDs (short vs full)
|
||||
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
||||
|
||||
@@ -15,7 +15,6 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
---
|
||||
summary: "Mattermost bot setup and Clawdbot config"
|
||||
read_when:
|
||||
- Setting up Mattermost
|
||||
- Debugging Mattermost routing
|
||||
---
|
||||
|
||||
# Mattermost (plugin)
|
||||
|
||||
Status: supported via plugin (bot token + WebSocket events). Channels, groups, and DMs are supported.
|
||||
Mattermost is a self-hostable team messaging platform; see the official site at
|
||||
[mattermost.com](https://mattermost.com) for product details and downloads.
|
||||
|
||||
## Plugin required
|
||||
Mattermost ships as a plugin and is not bundled with the core install.
|
||||
|
||||
Install via CLI (npm registry):
|
||||
```bash
|
||||
clawdbot plugins install @clawdbot/mattermost
|
||||
```
|
||||
|
||||
Local checkout (when running from a git repo):
|
||||
```bash
|
||||
clawdbot plugins install ./extensions/mattermost
|
||||
```
|
||||
|
||||
If you choose Mattermost during configure/onboarding and a git checkout is detected,
|
||||
Clawdbot will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
|
||||
## Quick setup
|
||||
1) Install the Mattermost plugin.
|
||||
2) Create a Mattermost bot account and copy the **bot token**.
|
||||
3) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
|
||||
4) Configure Clawdbot and start the gateway.
|
||||
|
||||
Minimal config:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "mm-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables (default account)
|
||||
Set these on the gateway host if you prefer env vars:
|
||||
|
||||
- `MATTERMOST_BOT_TOKEN=...`
|
||||
- `MATTERMOST_URL=https://chat.example.com`
|
||||
|
||||
Env vars apply only to the **default** account (`default`). Other accounts must use config values.
|
||||
|
||||
## Chat modes
|
||||
Mattermost responds to DMs automatically. Channel behavior is controlled by `chatmode`:
|
||||
|
||||
- `oncall` (default): respond only when @mentioned in channels.
|
||||
- `onmessage`: respond to every channel message.
|
||||
- `onchar`: respond when a message starts with a trigger prefix.
|
||||
|
||||
Config example:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
chatmode: "onchar",
|
||||
oncharPrefixes: [">", "!"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `onchar` still responds to explicit @mentions.
|
||||
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
||||
|
||||
## Access control (DMs)
|
||||
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).
|
||||
- Approve via:
|
||||
- `clawdbot pairing list mattermost`
|
||||
- `clawdbot pairing approve mattermost <CODE>`
|
||||
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
|
||||
|
||||
## Channels (groups)
|
||||
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
|
||||
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`).
|
||||
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
|
||||
|
||||
## Targets for outbound delivery
|
||||
Use these target formats with `clawdbot message send` or cron/webhooks:
|
||||
|
||||
- `channel:<id>` for a channel
|
||||
- `user:<id>` for a DM
|
||||
- `@username` for a DM (resolved via the Mattermost API)
|
||||
|
||||
Bare IDs are treated as channels.
|
||||
|
||||
## Multi-account
|
||||
Mattermost supports multiple accounts under `channels.mattermost.accounts`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
accounts: {
|
||||
default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" },
|
||||
alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
|
||||
- Auth errors: check the bot token, base URL, and whether the account is enabled.
|
||||
- Multi-account issues: env vars only apply to the `default` account.
|
||||
@@ -304,8 +304,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
||||
"policy": "pairing",
|
||||
"allowFrom": ["U123", "U456", "*"],
|
||||
"groupEnabled": false,
|
||||
"groupChannels": ["G123"],
|
||||
"replyToMode": "all"
|
||||
"groupChannels": ["G123"]
|
||||
},
|
||||
"channels": {
|
||||
"C123": { "allow": true, "requireMention": true },
|
||||
@@ -362,73 +361,6 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
|
||||
|
||||
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||
|
||||
### Per-chat-type threading
|
||||
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off", // default for channels
|
||||
replyToModeByChatType: {
|
||||
direct: "all", // DMs always thread
|
||||
group: "first" // group DMs/MPIM thread first reply
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported chat types:
|
||||
- `direct`: 1:1 DMs (Slack `im`)
|
||||
- `group`: group DMs / MPIMs (Slack `mpim`)
|
||||
- `channel`: standard channels (public/private)
|
||||
|
||||
Precedence:
|
||||
1) `replyToModeByChatType.<chatType>`
|
||||
2) `replyToMode`
|
||||
3) Provider default (`off`)
|
||||
|
||||
Legacy `channels.slack.dm.replyToMode` is still accepted as a fallback for `direct` when no chat-type override is set.
|
||||
|
||||
Examples:
|
||||
|
||||
Thread DMs only:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Thread group DMs but keep channels in the root:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { group: "first" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Make channels thread, keep DMs in the root:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
replyToModeByChatType: { direct: "off", group: "off" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual threading tags
|
||||
For fine-grained control, use these tags in agent responses:
|
||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot channels` (accounts, status, login/logout, logs)"
|
||||
read_when:
|
||||
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage)
|
||||
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage)
|
||||
- You want to check channel status or tail channel logs
|
||||
---
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ clawdbot gateway probe --ssh user@gateway-host
|
||||
Options:
|
||||
- `--ssh <target>`: `user@host` or `user@host:port` (port defaults to `22`).
|
||||
- `--ssh-identity <path>`: identity file.
|
||||
- `--ssh-auto`: pick the first discovered gateway host as SSH target (LAN/WAB only).
|
||||
- `--ssh-auto`: pick the first discovered bridge host as SSH target (LAN/WAB only).
|
||||
|
||||
Config (optional, used as defaults):
|
||||
- `gateway.remote.sshTarget`
|
||||
|
||||
@@ -293,7 +293,7 @@ Options:
|
||||
- `--reset` (reset config + credentials + sessions + workspace before wizard)
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||
- `--flow <quickstart|advanced>`
|
||||
- `--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`)
|
||||
@@ -352,7 +352,7 @@ Options:
|
||||
## Channel helpers
|
||||
|
||||
### `channels`
|
||||
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
|
||||
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams).
|
||||
|
||||
Subcommands:
|
||||
- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included).
|
||||
@@ -365,7 +365,7 @@ Subcommands:
|
||||
- `channels logout`: log out of a channel session (if supported).
|
||||
|
||||
Common options:
|
||||
- `--channel <name>`: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams`
|
||||
- `--channel <name>`: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
|
||||
- `--account <id>`: channel account id (default `default`)
|
||||
- `--name <label>`: display name for the account
|
||||
|
||||
@@ -472,7 +472,7 @@ Options:
|
||||
- `--session-id <id>`
|
||||
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
|
||||
- `--verbose <on|full|off>`
|
||||
- `--channel <whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams>`
|
||||
- `--channel <whatsapp|telegram|discord|slack|signal|imessage>`
|
||||
- `--local`
|
||||
- `--deliver`
|
||||
- `--json`
|
||||
@@ -791,10 +791,11 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
||||
[`clawdbot node`](/cli/node).
|
||||
|
||||
Subcommands:
|
||||
- `node run --host <gateway-host> --port 18789`
|
||||
- `node run --host <gateway-host> --port 18790`
|
||||
- `node status`
|
||||
- `node install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||
- `node uninstall`
|
||||
- `node run`
|
||||
- `node stop`
|
||||
- `node restart`
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
# `clawdbot message`
|
||||
|
||||
Single outbound command for sending messages and channel actions
|
||||
(Discord/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
||||
(Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -19,14 +19,13 @@ clawdbot message <subcommand> [flags]
|
||||
Channel selection:
|
||||
- `--channel` required if more than one channel is configured.
|
||||
- If exactly one channel is configured, it becomes the default.
|
||||
- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin)
|
||||
- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
|
||||
|
||||
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 treated as channels)
|
||||
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
||||
- Mattermost (plugin): `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
|
||||
- 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>`
|
||||
@@ -50,7 +49,7 @@ Name lookup:
|
||||
### Core
|
||||
|
||||
- `send`
|
||||
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams
|
||||
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
||||
- Required: `--target`, plus `--message` or `--media`
|
||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# `clawdbot node`
|
||||
|
||||
Run a **headless node host** that connects to the Gateway WebSocket and exposes
|
||||
Run a **headless node host** that connects to the Gateway bridge and exposes
|
||||
`system.run` / `system.which` on this machine.
|
||||
|
||||
## Why use a node host?
|
||||
@@ -26,14 +26,14 @@ node host, so you can keep command access scoped and explicit.
|
||||
## Run (foreground)
|
||||
|
||||
```bash
|
||||
clawdbot node run --host <gateway-host> --port 18789
|
||||
clawdbot node run --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--host <host>`: Gateway WebSocket host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway WebSocket port (default: `18789`)
|
||||
- `--tls`: Use TLS for the gateway connection
|
||||
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
|
||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||
- `--tls`: Use TLS for the bridge connection
|
||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||
- `--node-id <id>`: Override node id (clears pairing token)
|
||||
- `--display-name <name>`: Override the node display name
|
||||
|
||||
@@ -42,14 +42,14 @@ Options:
|
||||
Install a headless node host as a user service.
|
||||
|
||||
```bash
|
||||
clawdbot node install --host <gateway-host> --port 18789
|
||||
clawdbot node install --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--host <host>`: Gateway WebSocket host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway WebSocket port (default: `18789`)
|
||||
- `--tls`: Use TLS for the gateway connection
|
||||
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
|
||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||
- `--tls`: Use TLS for the bridge connection
|
||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||
- `--node-id <id>`: Override node id (clears pairing token)
|
||||
- `--display-name <name>`: Override the node display name
|
||||
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
|
||||
@@ -59,15 +59,12 @@ Manage the service:
|
||||
|
||||
```bash
|
||||
clawdbot node status
|
||||
clawdbot node run
|
||||
clawdbot node stop
|
||||
clawdbot node restart
|
||||
clawdbot node uninstall
|
||||
```
|
||||
|
||||
Use `clawdbot node run` for a foreground node host (no service).
|
||||
|
||||
Service commands accept `--json` for machine-readable output.
|
||||
|
||||
## Pairing
|
||||
|
||||
The first connection creates a pending node pair request on the Gateway.
|
||||
@@ -78,8 +75,7 @@ clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
The node host stores its node id, token, display name, and gateway connection info in
|
||||
`~/.clawdbot/node.json`.
|
||||
The node host stores its node id + token in `~/.clawdbot/node.json`.
|
||||
|
||||
## Exec approvals
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@ Related:
|
||||
- Camera: [Camera nodes](/nodes/camera)
|
||||
- Images: [Image nodes](/nodes/images)
|
||||
|
||||
Common options:
|
||||
- `--url`, `--token`, `--timeout`, `--json`
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
@@ -43,11 +40,6 @@ clawdbot nodes run --raw "git status"
|
||||
clawdbot nodes run --agent main --node <id|name|ip> --raw "git status"
|
||||
```
|
||||
|
||||
Invoke flags:
|
||||
- `--params <json>`: JSON object string (default `{}`).
|
||||
- `--invoke-timeout <ms>`: node invoke timeout (default `15000`).
|
||||
- `--idempotency-key <key>`: optional idempotency key.
|
||||
|
||||
### Exec-style defaults
|
||||
|
||||
`nodes run` mirrors the model’s exec behavior (defaults + approvals):
|
||||
@@ -55,14 +47,8 @@ Invoke flags:
|
||||
- Reads `tools.exec.*` (plus `agents.list[].tools.exec.*` overrides).
|
||||
- Uses exec approvals (`exec.approval.request`) before invoking `system.run`.
|
||||
- `--node` can be omitted when `tools.exec.node` is set.
|
||||
- Requires a node that advertises `system.run` (macOS companion app or headless node host).
|
||||
|
||||
Flags:
|
||||
- `--cwd <path>`: working directory.
|
||||
- `--env <key=val>`: env override (repeatable).
|
||||
- `--command-timeout <ms>`: command timeout.
|
||||
- `--invoke-timeout <ms>`: node invoke timeout (default `30000`).
|
||||
- `--needs-screen-recording`: require screen recording permission.
|
||||
- `--raw <command>`: run a shell string (`/bin/sh -lc` or `cmd.exe /c`).
|
||||
- `--agent <id>`: agent-scoped approvals/allowlists (defaults to configured agent).
|
||||
- `--ask <off|on-miss|always>`, `--security <deny|allowlist|full>`: overrides.
|
||||
|
||||
@@ -16,10 +16,6 @@ Related:
|
||||
```bash
|
||||
clawdbot onboard
|
||||
clawdbot onboard --flow quickstart
|
||||
clawdbot onboard --flow manual
|
||||
clawdbot onboard --mode remote --remote-url ws://gateway-host:18789
|
||||
```
|
||||
|
||||
Flow notes:
|
||||
- `quickstart`: minimal prompts, auto-generates a gateway token.
|
||||
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
|
||||
|
||||
@@ -53,6 +53,7 @@ Clients can send richer periodic beacons via the `system-event` method. The mac
|
||||
app uses this to report host name, IP, and `lastInputSeconds`.
|
||||
|
||||
### 4) Node connects (role: node)
|
||||
|
||||
When a node connects over the Gateway WebSocket with `role: node`, the Gateway
|
||||
upserts a presence entry for that node (same flow as other WS clients).
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Goal: small, hard-to-misuse tool set so agents can list sessions, fetch history,
|
||||
- Group chats use `agent:<agentId>:<channel>:group:<id>` or `agent:<agentId>:<channel>:channel:<id>` (pass the full key).
|
||||
- Cron jobs use `cron:<job.id>`.
|
||||
- Hooks use `hook:<uuid>` unless explicitly set.
|
||||
- Node sessions use `node-<nodeId>` unless explicitly set.
|
||||
- Node bridge uses `node-<nodeId>` unless explicitly set.
|
||||
|
||||
`global` and `unknown` are reserved values and are never listed. If `session.scope = "global"`, we alias it to `main` for all tools so callers never see `global`.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Other sources:
|
||||
- Cron jobs: `cron:<job.id>`
|
||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
||||
- Node runs: `node-<nodeId>`
|
||||
- Node bridge runs: `node-<nodeId>`
|
||||
|
||||
## Lifecycle
|
||||
- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
|
||||
|
||||
@@ -46,7 +46,7 @@ Common methods + events:
|
||||
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
|
||||
| Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these |
|
||||
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
|
||||
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | Gateway WS + node actions |
|
||||
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
|
||||
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
|
||||
|
||||
Authoritative list lives in `src/gateway/server.ts` (`METHODS`, `EVENTS`).
|
||||
|
||||
@@ -70,7 +70,7 @@ What this does:
|
||||
- `CLAWDBOT_PROFILE=dev`
|
||||
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
|
||||
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
|
||||
- `CLAWDBOT_GATEWAY_PORT=19001` (browser/canvas shift accordingly)
|
||||
- `CLAWDBOT_GATEWAY_PORT=19001` (bridge/canvas/browser shift accordingly)
|
||||
|
||||
2) **Dev bootstrap** (`gateway --dev`)
|
||||
- Writes a minimal config if missing (`gateway.mode=local`, bind loopback).
|
||||
|
||||
@@ -109,14 +109,6 @@
|
||||
"source": "/opencode/",
|
||||
"destination": "/providers/opencode"
|
||||
},
|
||||
{
|
||||
"source": "/mattermost",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/mattermost/",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/glm",
|
||||
"destination": "/providers/glm"
|
||||
@@ -173,14 +165,6 @@
|
||||
"source": "/providers/location/",
|
||||
"destination": "/channels/location"
|
||||
},
|
||||
{
|
||||
"source": "/providers/mattermost",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/providers/mattermost/",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/providers/msteams",
|
||||
"destination": "/channels/msteams"
|
||||
@@ -946,7 +930,6 @@
|
||||
"channels/grammy",
|
||||
"channels/discord",
|
||||
"channels/slack",
|
||||
"channels/mattermost",
|
||||
"channels/signal",
|
||||
"channels/imessage",
|
||||
"channels/msteams",
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
# Bonjour / mDNS discovery
|
||||
|
||||
Clawdbot uses Bonjour (mDNS / DNS‑SD) as a **LAN‑only convenience** to discover
|
||||
an active Gateway (WebSocket endpoint). It is best‑effort and does **not** replace SSH or
|
||||
an active Gateway bridge. It is best‑effort and does **not** replace SSH or
|
||||
Tailnet-based connectivity.
|
||||
|
||||
## Wide‑area Bonjour (Unicast DNS‑SD) over Tailscale
|
||||
@@ -31,7 +31,7 @@ browse both `local.` and `clawdbot.internal.` automatically.
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: { bind: "tailnet" }, // tailnet-only (recommended)
|
||||
bridge: { bind: "tailnet" }, // tailnet-only (recommended)
|
||||
discovery: { wideArea: { enabled: true } } // enables clawdbot.internal DNS-SD publishing
|
||||
}
|
||||
```
|
||||
@@ -63,13 +63,13 @@ In the Tailscale admin console:
|
||||
Once clients accept tailnet DNS, iOS nodes can browse
|
||||
`_clawdbot-gw._tcp` in `clawdbot.internal.` without multicast.
|
||||
|
||||
### Gateway listener security (recommended)
|
||||
### Bridge listener security (recommended)
|
||||
|
||||
The Gateway WS port (default `18789`) binds to loopback by default. For LAN/tailnet
|
||||
access, bind explicitly and keep auth enabled.
|
||||
The bridge port (default `18790`) is a plain TCP service. By default it binds to
|
||||
`0.0.0.0`, which makes it reachable from any interface on the gateway host.
|
||||
|
||||
For tailnet‑only setups:
|
||||
- Set `gateway.bind: "tailnet"` in `~/.clawdbot/clawdbot.json`.
|
||||
- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json`.
|
||||
- Restart the Gateway (or restart the macOS menubar app).
|
||||
|
||||
## What advertises
|
||||
@@ -87,12 +87,11 @@ The Gateway advertises small non‑secret hints to make UI flows convenient:
|
||||
- `role=gateway`
|
||||
- `displayName=<friendly name>`
|
||||
- `lanHost=<hostname>.local`
|
||||
- `gatewayPort=<port>` (Gateway WS + HTTP)
|
||||
- `gatewayTls=1` (only when TLS is enabled)
|
||||
- `gatewayTlsSha256=<sha256>` (only when TLS is enabled and fingerprint is available)
|
||||
- `gatewayPort=<port>` (informational; Gateway WS is usually loopback‑only)
|
||||
- `bridgePort=<port>` (only when bridge is enabled)
|
||||
- `canvasPort=<port>` (only when the canvas host is enabled; default `18793`)
|
||||
- `sshPort=<port>` (defaults to 22 when not overridden)
|
||||
- `transport=gateway`
|
||||
- `transport=bridge`
|
||||
- `cliPath=<path>` (optional; absolute path to a runnable `clawdbot` entrypoint)
|
||||
- `tailnetDns=<magicdns>` (optional hint when Tailnet is available)
|
||||
|
||||
@@ -126,8 +125,8 @@ The Gateway writes a rolling log file (printed on startup as
|
||||
The iOS node uses `NWBrowser` to discover `_clawdbot-gw._tcp`.
|
||||
|
||||
To capture logs:
|
||||
- Settings → Gateway → Advanced → **Discovery Debug Logs**
|
||||
- Settings → Gateway → Advanced → **Discovery Logs** → reproduce → **Copy**
|
||||
- Settings → Bridge → Advanced → **Discovery Debug Logs**
|
||||
- Settings → Bridge → Advanced → **Discovery Logs** → reproduce → **Copy**
|
||||
|
||||
The log includes browser state transitions and result‑set changes.
|
||||
|
||||
@@ -137,7 +136,7 @@ The log includes browser state transitions and result‑set changes.
|
||||
- **Multicast blocked**: some Wi‑Fi networks disable mDNS.
|
||||
- **Sleep / interface churn**: macOS may temporarily drop mDNS results; retry.
|
||||
- **Browse works but resolve fails**: keep machine names simple (avoid emojis or
|
||||
punctuation), then restart the Gateway. The service instance name derives from
|
||||
punctuation), then restart the Gateway. The bridge instance name derives from
|
||||
the host name, so overly complex names can confuse some resolvers.
|
||||
|
||||
## Escaped instance names (`\032`)
|
||||
@@ -151,7 +150,9 @@ sequences (e.g. spaces become `\032`).
|
||||
## Disabling / configuration
|
||||
|
||||
- `CLAWDBOT_DISABLE_BONJOUR=1` disables advertising.
|
||||
- `gateway.bind` in `~/.clawdbot/clawdbot.json` controls the Gateway bind mode.
|
||||
- `CLAWDBOT_BRIDGE_ENABLED=0` disables the bridge listener (and the bridge beacon).
|
||||
- `bridge.bind` / `bridge.port` in `~/.clawdbot/clawdbot.json` control bridge bind/port.
|
||||
- `CLAWDBOT_BRIDGE_HOST` / `CLAWDBOT_BRIDGE_PORT` still work as back‑compat overrides.
|
||||
- `CLAWDBOT_SSH_PORT` overrides the SSH port advertised in TXT.
|
||||
- `CLAWDBOT_TAILNET_DNS` publishes a MagicDNS hint in TXT.
|
||||
- `CLAWDBOT_CLI_PATH` overrides the advertised CLI path.
|
||||
|
||||
@@ -14,9 +14,6 @@ should use the unified Gateway WebSocket protocol instead.
|
||||
If you are building an operator or node client, use the
|
||||
[Gateway protocol](/gateway/protocol).
|
||||
|
||||
**Note:** Current Clawdbot builds no longer ship the TCP bridge listener; this document is kept for historical reference.
|
||||
Legacy `bridge.*` config keys are no longer part of the config schema.
|
||||
|
||||
## Why we have both
|
||||
|
||||
- **Security boundary**: the bridge exposes a small allowlist instead of the
|
||||
@@ -31,7 +28,7 @@ Legacy `bridge.*` config keys are no longer part of the config schema.
|
||||
|
||||
- TCP, one JSON object per line (JSONL).
|
||||
- Optional TLS (when `bridge.tls.enabled` is true).
|
||||
- Legacy default listener port was `18790` (current builds do not start a TCP bridge).
|
||||
- Gateway owns the listener (default `18790`).
|
||||
|
||||
When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
||||
`bridgeTlsSha256` so nodes can pin the certificate.
|
||||
@@ -57,7 +54,7 @@ Gateway → Client:
|
||||
- `event`: chat updates for subscribed sessions
|
||||
- `ping` / `pong`: keepalive
|
||||
|
||||
Legacy allowlist enforcement lived in `src/gateway/server-bridge.ts` (removed).
|
||||
Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
||||
|
||||
## Exec lifecycle events
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
// Optional CLI fallback (Whisper binary):
|
||||
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
],
|
||||
@@ -568,5 +568,5 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
|
||||
- If you set `dmPolicy: "open"`, the matching `allowFrom` list must include `"*"`.
|
||||
- Provider IDs differ (phone numbers, user IDs, channel IDs). Use the provider docs to confirm the format.
|
||||
- Optional sections to add later: `web`, `browser`, `ui`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`.
|
||||
- Optional sections to add later: `web`, `browser`, `ui`, `bridge`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`.
|
||||
- See [Providers](/channels/whatsapp) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes.
|
||||
|
||||
@@ -543,7 +543,7 @@ Notes:
|
||||
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
|
||||
- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`.
|
||||
|
||||
### `channels.telegram.accounts` / `channels.discord.accounts` / `channels.slack.accounts` / `channels.mattermost.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
|
||||
### `channels.telegram.accounts` / `channels.discord.accounts` / `channels.slack.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
|
||||
|
||||
Run multiple accounts per channel (each account has its own `accountId` and optional `name`):
|
||||
|
||||
@@ -1204,44 +1204,6 @@ Slack action groups (gate `slack` tool actions):
|
||||
| memberInfo | enabled | Member info |
|
||||
| emojiList | enabled | Custom emoji list |
|
||||
|
||||
### `channels.mattermost` (bot token)
|
||||
|
||||
Mattermost ships as a plugin and is not bundled with the core install.
|
||||
Install it first: `clawdbot plugins install @clawdbot/mattermost` (or `./extensions/mattermost` from a git checkout).
|
||||
|
||||
Mattermost requires a bot token plus the base URL for your server:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "mm-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
dmPolicy: "pairing",
|
||||
chatmode: "oncall", // oncall | onmessage | onchar
|
||||
oncharPrefixes: [">", "!"],
|
||||
textChunkLimit: 4000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clawdbot starts Mattermost when the account is configured (bot token + base URL) and enabled. The token + base URL are resolved from `channels.mattermost.botToken` + `channels.mattermost.baseUrl` or `MATTERMOST_BOT_TOKEN` + `MATTERMOST_URL` for the default account (unless `channels.mattermost.enabled` is `false`).
|
||||
|
||||
Chat modes:
|
||||
- `oncall` (default): respond to channel messages only when @mentioned.
|
||||
- `onmessage`: respond to every channel message.
|
||||
- `onchar`: respond when a message starts with a trigger prefix (`channels.mattermost.oncharPrefixes`, default `[">", "!"]`).
|
||||
|
||||
Access control:
|
||||
- Default DMs: `channels.mattermost.dmPolicy="pairing"` (unknown senders get a pairing code).
|
||||
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
|
||||
- Groups: `channels.mattermost.groupPolicy="allowlist"` by default (mention-gated). Use `channels.mattermost.groupAllowFrom` to restrict senders.
|
||||
|
||||
Multi-account support lives under `channels.mattermost.accounts` (see the multi-account section above). Env vars only apply to the default account.
|
||||
Use `channel:<id>` or `user:<id>` (or `@username`) when specifying delivery targets; bare ids are treated as channel ids.
|
||||
|
||||
### `channels.signal` (signal-cli)
|
||||
|
||||
Signal reactions can emit system events (shared reaction tooling):
|
||||
@@ -1728,7 +1690,7 @@ auto-compaction, instructing the model to store durable memories on disk (e.g.
|
||||
`memory/YYYY-MM-DD.md`). It triggers when the session token estimate crosses a
|
||||
soft threshold below the compaction limit.
|
||||
|
||||
Legacy defaults:
|
||||
Defaults:
|
||||
- `memoryFlush.enabled`: `true`
|
||||
- `memoryFlush.softThresholdTokens`: `4000`
|
||||
- `memoryFlush.prompt` / `memoryFlush.systemPrompt`: built-in defaults with `NO_REPLY`
|
||||
@@ -1773,9 +1735,8 @@ Block streaming:
|
||||
with `maxChars` capped to the channel text limit. Signal/Slack/Discord default
|
||||
to `minChars: 1500` unless overridden.
|
||||
Channel overrides: `channels.whatsapp.blockStreamingCoalesce`, `channels.telegram.blockStreamingCoalesce`,
|
||||
`channels.discord.blockStreamingCoalesce`, `channels.slack.blockStreamingCoalesce`, `channels.mattermost.blockStreamingCoalesce`,
|
||||
`channels.signal.blockStreamingCoalesce`, `channels.imessage.blockStreamingCoalesce`, `channels.msteams.blockStreamingCoalesce`
|
||||
(and per-account variants).
|
||||
`channels.discord.blockStreamingCoalesce`, `channels.slack.blockStreamingCoalesce`, `channels.signal.blockStreamingCoalesce`,
|
||||
`channels.imessage.blockStreamingCoalesce`, `channels.msteams.blockStreamingCoalesce` (and per-account variants).
|
||||
- `agents.defaults.humanDelay`: randomized pause between **block replies** after the first.
|
||||
Modes: `off` (default), `natural` (800–2500ms), `custom` (use `minMs`/`maxMs`).
|
||||
Per-agent override: `agents.list[].humanDelay`.
|
||||
@@ -1865,7 +1826,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
||||
- 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 `gpt-4o-mini-transcribe`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
|
||||
- `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.
|
||||
@@ -1890,7 +1851,7 @@ Example:
|
||||
rules: [{ action: "allow", match: { chatType: "direct" } }]
|
||||
},
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
]
|
||||
},
|
||||
@@ -2823,7 +2784,7 @@ Hot-applied (no full gateway restart):
|
||||
|
||||
Requires full Gateway restart:
|
||||
- `gateway` (port/bind/auth/control UI/tailscale)
|
||||
- `bridge` (legacy)
|
||||
- `bridge`
|
||||
- `discovery`
|
||||
- `canvasHost`
|
||||
- `plugins`
|
||||
@@ -2841,7 +2802,7 @@ Convenience flags (CLI):
|
||||
- `clawdbot --dev …` → uses `~/.clawdbot-dev` + shifts ports from base `19001`
|
||||
- `clawdbot --profile <name> …` → uses `~/.clawdbot-<name>` (port via config/env/flags)
|
||||
|
||||
See [Gateway runbook](/gateway) for the derived port mapping (gateway/browser/canvas).
|
||||
See [Gateway runbook](/gateway) for the derived port mapping (gateway/bridge/browser/canvas).
|
||||
See [Multiple gateways](/gateway/multiple-gateways) for browser/CDP port isolation details.
|
||||
|
||||
Example:
|
||||
@@ -2960,7 +2921,7 @@ The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can
|
||||
|
||||
Default root: `~/clawd/canvas`
|
||||
Default port: `18793` (chosen to avoid the clawd browser CDP port `18792`)
|
||||
The server listens on the **gateway bind host** (LAN or Tailnet) so nodes can reach it.
|
||||
The server listens on the **bridge bind host** (LAN or Tailnet) so nodes can reach it.
|
||||
|
||||
The server:
|
||||
- serves files under `canvasHost.root`
|
||||
@@ -2989,13 +2950,9 @@ Disable with:
|
||||
- config: `canvasHost: { enabled: false }`
|
||||
- env: `CLAWDBOT_SKIP_CANVAS_HOST=1`
|
||||
|
||||
### `bridge` (legacy TCP bridge, removed)
|
||||
### `bridge` (node bridge server)
|
||||
|
||||
Current builds no longer include the TCP bridge listener; `bridge.*` config keys are ignored.
|
||||
Nodes connect over the Gateway WebSocket. This section is kept for historical reference.
|
||||
|
||||
Legacy behavior:
|
||||
- The Gateway could expose a simple TCP bridge for nodes (iOS/Android), typically on port `18790`.
|
||||
The Gateway can expose a simple TCP bridge for nodes (iOS/Android), typically on port `18790`.
|
||||
|
||||
Defaults:
|
||||
- enabled: `true`
|
||||
|
||||
@@ -3,7 +3,7 @@ summary: "Node discovery and transports (Bonjour, Tailscale, SSH) for finding th
|
||||
read_when:
|
||||
- Implementing or changing Bonjour discovery/advertising
|
||||
- Adjusting remote connection modes (direct vs SSH)
|
||||
- Designing node discovery + pairing for remote nodes
|
||||
- Designing bridge + pairing for remote nodes
|
||||
---
|
||||
# Discovery & transports
|
||||
|
||||
@@ -17,18 +17,17 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
|
||||
## Terms
|
||||
|
||||
- **Gateway**: a single long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. Most setups use one per host; isolated multi-gateway setups are possible.
|
||||
- **Gateway WS (control plane)**: the WebSocket endpoint on `127.0.0.1:18789` by default; can be bound to LAN/tailnet via `gateway.bind`.
|
||||
- **Direct WS transport**: a LAN/tailnet-facing Gateway WS endpoint (no SSH).
|
||||
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
|
||||
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
|
||||
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
|
||||
- **Legacy TCP bridge (deprecated/removed)**: older node transport (see [Bridge protocol](/gateway/bridge-protocol)); no longer advertised for discovery.
|
||||
|
||||
Protocol details:
|
||||
- [Gateway protocol](/gateway/protocol)
|
||||
- [Bridge protocol (legacy)](/gateway/bridge-protocol)
|
||||
- [Bridge protocol](/gateway/bridge-protocol)
|
||||
|
||||
## Why we keep both “direct” and SSH
|
||||
|
||||
- **Direct WS** is the best UX on the same network and within a tailnet:
|
||||
- **Direct bridge** is the best UX on the same network and within a tailnet:
|
||||
- auto-discovery on LAN via Bonjour
|
||||
- pairing tokens + ACLs owned by the gateway
|
||||
- no shell access required; protocol surface can stay tight and auditable
|
||||
@@ -44,7 +43,7 @@ Protocol details:
|
||||
Bonjour is best-effort and does not cross networks. It is only used for “same LAN” convenience.
|
||||
|
||||
Target direction:
|
||||
- The **gateway** advertises its WS endpoint via Bonjour.
|
||||
- The **gateway** advertises its bridge via Bonjour.
|
||||
- Clients browse and show a “pick a gateway” list, then store the chosen endpoint.
|
||||
|
||||
Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
|
||||
@@ -57,19 +56,19 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
|
||||
- `role=gateway`
|
||||
- `lanHost=<hostname>.local`
|
||||
- `sshPort=22` (or whatever is advertised)
|
||||
- `gatewayPort=18789` (Gateway WS + HTTP)
|
||||
- `gatewayTls=1` (only when TLS is enabled)
|
||||
- `gatewayTlsSha256=<sha256>` (only when TLS is enabled and fingerprint is available)
|
||||
- `gatewayPort=18789` (loopback WS port; informational)
|
||||
- `bridgePort=18790` (when bridge is enabled)
|
||||
- `canvasPort=18793` (default canvas host port; serves `/__clawdbot__/canvas/`)
|
||||
- `cliPath=<path>` (optional; absolute path to a runnable `clawdbot` entrypoint or binary)
|
||||
- `tailnetDns=<magicdns>` (optional hint; auto-detected when Tailscale is available)
|
||||
|
||||
Disable/override:
|
||||
- `CLAWDBOT_DISABLE_BONJOUR=1` disables advertising.
|
||||
- `gateway.bind` in `~/.clawdbot/clawdbot.json` controls the Gateway bind mode.
|
||||
- `CLAWDBOT_SSH_PORT` overrides the SSH port advertised in TXT (defaults to 22).
|
||||
- `CLAWDBOT_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS).
|
||||
- `CLAWDBOT_CLI_PATH` overrides the advertised CLI path.
|
||||
- `CLAWDBOT_BRIDGE_ENABLED=0` disables the bridge listener.
|
||||
- `bridge.bind` / `bridge.port` in `~/.clawdbot/clawdbot.json` control bridge bind/port (preferred).
|
||||
- `CLAWDBOT_BRIDGE_HOST` / `CLAWDBOT_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set.
|
||||
- `CLAWDBOT_SSH_PORT` overrides the SSH port advertised in the bridge beacon (defaults to 22).
|
||||
- `CLAWDBOT_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the bridge beacon (auto-detected if unset).
|
||||
|
||||
### 2) Tailnet (cross-network)
|
||||
|
||||
@@ -98,13 +97,13 @@ Recommended client behavior:
|
||||
The gateway is the source of truth for node/client admission.
|
||||
|
||||
- Pairing requests are created/approved/rejected in the gateway (see [Gateway pairing](/gateway/pairing)).
|
||||
- The gateway enforces:
|
||||
- The bridge enforces:
|
||||
- auth (token / keypair)
|
||||
- scopes/ACLs (the gateway is not a raw proxy to every method)
|
||||
- scopes/ACLs (bridge is not a raw proxy to every gateway method)
|
||||
- rate limits
|
||||
|
||||
## Responsibilities by component
|
||||
|
||||
- **Gateway**: advertises discovery beacons, owns pairing decisions, and hosts the WS endpoint.
|
||||
- **Gateway**: advertises discovery beacons, owns pairing decisions, runs the bridge listener.
|
||||
- **macOS app**: helps you pick a gateway, shows pairing prompts, and uses SSH only as a fallback.
|
||||
- **iOS/Android nodes**: browse Bonjour as a convenience and connect to the paired Gateway WS.
|
||||
- **iOS/Android nodes**: browse Bonjour as a convenience and connect via the paired bridge.
|
||||
|
||||
@@ -82,12 +82,14 @@ Defaults (can be overridden via env/flags/config):
|
||||
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
|
||||
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
|
||||
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
|
||||
- `bridge.port=19002` (derived: `gateway.port+1`)
|
||||
- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
|
||||
- `canvasHost.port=19005` (derived: `gateway.port+4`)
|
||||
- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
|
||||
|
||||
Derived ports (rules of thumb):
|
||||
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
|
||||
- `bridge.port = base + 1` (or `CLAWDBOT_BRIDGE_PORT` / config override)
|
||||
- `browser.controlUrl port = base + 2` (or `CLAWDBOT_BROWSER_CONTROL_URL` / config override)
|
||||
- `canvasHost.port = base + 4` (or `CLAWDBOT_CANVAS_HOST_PORT` / config override)
|
||||
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
|
||||
@@ -112,7 +114,7 @@ CLAWDBOT_CONFIG_PATH=~/.clawdbot/b.json CLAWDBOT_STATE_DIR=~/.clawdbot-b clawdbo
|
||||
```
|
||||
|
||||
## Protocol (operator view)
|
||||
- Full docs: [Gateway protocol](/gateway/protocol) and [Bridge protocol (legacy)](/gateway/bridge-protocol).
|
||||
- Full docs: [Gateway protocol](/gateway/protocol) and [Bridge protocol](/gateway/bridge-protocol).
|
||||
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{id,displayName?,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId?}, caps, auth?, locale?, userAgent? } }`.
|
||||
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
|
||||
- After handshake:
|
||||
@@ -128,7 +130,7 @@ CLAWDBOT_CONFIG_PATH=~/.clawdbot/b.json CLAWDBOT_STATE_DIR=~/.clawdbot-b clawdbo
|
||||
- `system-event` — post a presence/system note (structured).
|
||||
- `send` — send a message via the active channel(s).
|
||||
- `agent` — run an agent turn (streams events back on same connection).
|
||||
- `node.list` — list paired + currently-connected nodes (includes `caps`, `deviceFamily`, `modelIdentifier`, `paired`, `connected`, and advertised `commands`).
|
||||
- `node.list` — list paired + currently-connected bridge nodes (includes `caps`, `deviceFamily`, `modelIdentifier`, `paired`, `connected`, and advertised `commands`).
|
||||
- `node.describe` — describe a node (capabilities + supported `node.invoke` commands; works for paired nodes and for currently-connected unpaired nodes).
|
||||
- `node.invoke` — invoke a command on a node (e.g. `canvas.*`, `camera.*`).
|
||||
- `node.pair.*` — pairing lifecycle (`request`, `list`, `approve`, `reject`, `verify`).
|
||||
|
||||
@@ -51,7 +51,7 @@ You can tune console verbosity independently via:
|
||||
|
||||
## Tool summary redaction
|
||||
|
||||
Verbose tool summaries (e.g. `🛠️ Exec: ...`) can mask sensitive tokens before they hit the
|
||||
Verbose tool summaries (e.g. `🛠️ exec: ...`) can mask sensitive tokens before they hit the
|
||||
console stream. This is **tools-only** and does not alter file logs.
|
||||
|
||||
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
|
||||
|
||||
@@ -13,7 +13,7 @@ Most setups should use one Gateway because a single Gateway can handle multiple
|
||||
- `CLAWDBOT_STATE_DIR` — per-instance sessions, creds, caches
|
||||
- `agents.defaults.workspace` — per-instance workspace root
|
||||
- `gateway.port` (or `--port`) — unique per instance
|
||||
- Derived ports (browser/canvas) must not overlap
|
||||
- Derived ports (bridge/browser/canvas) must not overlap
|
||||
|
||||
If these are shared, you will hit config races and port conflicts.
|
||||
|
||||
@@ -47,7 +47,7 @@ Run a second Gateway on the same host with its own:
|
||||
|
||||
This keeps the rescue bot isolated from the main bot so it can debug or apply config changes if the primary bot is down.
|
||||
|
||||
Port spacing: leave at least 20 ports between base ports so the derived browser/canvas/CDP ports never collide.
|
||||
Port spacing: leave at least 20 ports between base ports so the derived bridge/browser/canvas/CDP ports never collide.
|
||||
|
||||
### How to install (rescue bot)
|
||||
|
||||
@@ -73,6 +73,7 @@ clawdbot --profile rescue gateway install
|
||||
|
||||
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
|
||||
|
||||
- `bridge.port = base + 1`
|
||||
- `browser.controlUrl port = base + 2`
|
||||
- `canvasHost.port = base + 4`
|
||||
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
|
||||
|
||||
@@ -19,12 +19,12 @@ Only clients that explicitly call `node.pair.*` use this flow.
|
||||
|
||||
- **Pending request**: a node asked to join; requires approval.
|
||||
- **Paired node**: approved node with an issued auth token.
|
||||
- **Transport**: the Gateway WS endpoint forwards requests but does not decide
|
||||
membership. (Legacy TCP bridge support is deprecated/removed.)
|
||||
- **Bridge**: transport endpoint only; it forwards requests but does not decide
|
||||
membership.
|
||||
|
||||
## How pairing works
|
||||
|
||||
1. A node connects to the Gateway WS and requests pairing.
|
||||
1. A node connects to the bridge and requests pairing.
|
||||
2. The Gateway stores a **pending request** and emits `node.pair.requested`.
|
||||
3. You approve or reject the request (CLI or UI).
|
||||
4. On approval, the Gateway issues a **new token** (tokens are rotated on re‑pair).
|
||||
@@ -85,8 +85,9 @@ Security notes:
|
||||
- Tokens are secrets; treat `paired.json` as sensitive.
|
||||
- Rotating a token requires re-approval (or deleting the node entry).
|
||||
|
||||
## Transport behavior
|
||||
## Bridge behavior
|
||||
|
||||
- The transport is **stateless**; it does not store membership.
|
||||
- The bridge is **transport only**; it does not store membership.
|
||||
- If the Gateway is offline or pairing is disabled, nodes cannot pair.
|
||||
- If the Gateway is in remote mode, pairing still happens against the remote Gateway’s store.
|
||||
- If the bridge is running but the Gateway is in remote mode, pairing still
|
||||
happens against the remote Gateway’s store.
|
||||
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
This repo supports “remote over SSH” by keeping a single Gateway (the master) running on a dedicated host (desktop/server) and connecting clients to it.
|
||||
|
||||
- For **operators (you / the macOS app)**: SSH tunneling is the universal fallback.
|
||||
- For **nodes (iOS/Android and future devices)**: connect to the Gateway **WebSocket** (LAN/tailnet or SSH tunnel as needed).
|
||||
- For **nodes (iOS/Android and future devices)**: prefer the Gateway **Bridge** when on the same LAN/tailnet (see [Discovery](/gateway/discovery)).
|
||||
|
||||
## The core idea
|
||||
|
||||
@@ -55,12 +55,12 @@ One gateway service owns state + channels. Nodes are peripherals.
|
||||
Flow example (Telegram → node):
|
||||
- Telegram message arrives at the **Gateway**.
|
||||
- Gateway runs the **agent** and decides whether to call a node tool.
|
||||
- Gateway calls the **node** over the Gateway WebSocket (`node.*` RPC).
|
||||
- Gateway calls the **node** over the Bridge (`node.*` RPC).
|
||||
- Node returns the result; Gateway replies back out to Telegram.
|
||||
|
||||
Notes:
|
||||
- **Nodes do not run the gateway service.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
|
||||
- macOS app “node mode” is just a node client over the Gateway WebSocket.
|
||||
- macOS app “node mode” is just a node client over the Bridge.
|
||||
|
||||
## SSH tunnel (CLI + tools)
|
||||
|
||||
|
||||
@@ -94,8 +94,8 @@ clawdbot gateway --tailscale funnel --auth password
|
||||
or `tailscale funnel` configuration on shutdown.
|
||||
- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel).
|
||||
- `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only.
|
||||
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
|
||||
the same Gateway WS endpoint, so Serve can work for node access.
|
||||
- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
|
||||
uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
|
||||
|
||||
## Browser control server (remote Gateway + local browser)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ read_when:
|
||||
|
||||
<p align="center">
|
||||
<strong>Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).</strong><br />
|
||||
Plugins add Mattermost and more.
|
||||
Send a message, get an agent response — from your pocket.
|
||||
</p>
|
||||
|
||||
@@ -24,7 +23,7 @@ read_when:
|
||||
<a href="/start/clawd">Clawdbot assistant setup</a>
|
||||
</p>
|
||||
|
||||
Clawdbot bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono). Plugins add Mattermost (Bot API + WebSocket) and more.
|
||||
Clawdbot bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono).
|
||||
Clawdbot also powers [Clawd](https://clawd.me), the space‑lobster assistant.
|
||||
|
||||
## Start here
|
||||
@@ -45,12 +44,12 @@ Remote access: [Web surfaces](/web) and [Tailscale](/gateway/tailscale)
|
||||
## How it works
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Discord / iMessage (+ plugins)
|
||||
WhatsApp / Telegram / Discord
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789 (loopback-only)
|
||||
│ (single source) │
|
||||
│ (single source) │ tcp://0.0.0.0:18790 (Bridge)
|
||||
│ │ http://<gateway-host>:18793
|
||||
│ │ /__clawdbot__/canvas/ (Canvas host)
|
||||
└───────────┬───────────────┘
|
||||
@@ -59,8 +58,8 @@ WhatsApp / Telegram / Discord / iMessage (+ plugins)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ Chat UI (SwiftUI)
|
||||
├─ macOS app (Clawdbot.app)
|
||||
├─ iOS node via Gateway WS + pairing
|
||||
└─ Android node via Gateway WS + pairing
|
||||
├─ iOS node via Bridge + pairing
|
||||
└─ Android node via Bridge + pairing
|
||||
```
|
||||
|
||||
Most operations flow through the **Gateway** (`clawdbot gateway`), a single long-running process that owns channel connections and the WebSocket control plane.
|
||||
@@ -71,7 +70,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
|
||||
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
|
||||
- The wizard now generates a gateway token by default (even for loopback).
|
||||
- For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).
|
||||
- **Nodes**: connect to the Gateway WebSocket (LAN/tailnet/SSH as needed); legacy TCP bridge is deprecated/removed.
|
||||
- **Bridge for nodes**: optional LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable).
|
||||
- **Canvas host**: HTTP file server on `canvasHost.port` (default `18793`), serving `/__clawdbot__/canvas/` for node WebViews; see [Gateway configuration](/gateway/configuration) (`canvasHost`).
|
||||
- **Remote use**: SSH tunnel or tailnet/VPN; see [Remote access](/gateway/remote) and [Discovery](/gateway/discovery).
|
||||
|
||||
@@ -80,7 +79,6 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
|
||||
- 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol
|
||||
- ✈️ **Telegram Bot** — DMs + groups via grammY
|
||||
- 🎮 **Discord Bot** — DMs + guild channels via channels.discord.js
|
||||
- 🧩 **Mattermost Bot (plugin)** — Bot token + WebSocket events
|
||||
- 💬 **iMessage** — Local imsg CLI integration (macOS)
|
||||
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
|
||||
- ⏱️ **Streaming + chunking** — Block streaming + Telegram draft streaming details ([/concepts/streaming](/concepts/streaming))
|
||||
@@ -192,7 +190,6 @@ Example:
|
||||
- [Control UI (browser)](/web/control-ui)
|
||||
- [Telegram](/channels/telegram)
|
||||
- [Discord](/channels/discord)
|
||||
- [Mattermost (plugin)](/channels/mattermost)
|
||||
- [iMessage](/channels/imessage)
|
||||
- [Groups](/concepts/groups)
|
||||
- [WhatsApp group messages](/concepts/group-messages)
|
||||
|
||||
@@ -36,7 +36,7 @@ Local trust:
|
||||
- [Remote access (SSH)](/gateway/remote)
|
||||
- [Tailscale](/gateway/tailscale)
|
||||
|
||||
## Nodes + transports
|
||||
## Nodes + bridge
|
||||
|
||||
- [Nodes overview](/nodes)
|
||||
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
|
||||
|
||||
@@ -6,7 +6,7 @@ read_when:
|
||||
# Audio / Voice Notes — 2026-01-17
|
||||
|
||||
## What works
|
||||
- **Media understanding (audio)**: If audio understanding is enabled (or auto‑detected), Clawdbot:
|
||||
- **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).
|
||||
@@ -15,21 +15,6 @@ read_when:
|
||||
- **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.
|
||||
|
||||
## Auto-detection (default)
|
||||
If you **don’t configure models** and `tools.media.audio.enabled` is **not** set to `false`,
|
||||
Clawdbot auto-detects in this order and stops at the first working option:
|
||||
|
||||
1) **Local CLIs** (if installed)
|
||||
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
|
||||
- `whisper-cli` (from `whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
|
||||
- `whisper` (Python CLI; downloads models automatically)
|
||||
2) **Gemini CLI** (`gemini`) using `read_many_files`
|
||||
3) **Provider keys** (OpenAI → Groq → Deepgram → Google)
|
||||
|
||||
To disable auto-detection, set `tools.media.audio.enabled: false`.
|
||||
To customize, set `tools.media.audio.models`.
|
||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
||||
|
||||
## Config examples
|
||||
|
||||
### Provider + CLI fallback (OpenAI + Whisper CLI)
|
||||
@@ -41,7 +26,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
@@ -69,7 +54,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
]
|
||||
},
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" }
|
||||
{ provider: "openai", model: "whisper-1" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -98,7 +83,6 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
- 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.
|
||||
- OpenAI auto default is `gpt-4o-mini-transcribe`; set `model: "gpt-4o-transcribe"` for higher accuracy.
|
||||
- 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.
|
||||
|
||||
@@ -138,7 +138,7 @@ Notes:
|
||||
## Safety + practical limits
|
||||
|
||||
- Camera and microphone access trigger the usual OS permission prompts (and require usage strings in Info.plist).
|
||||
- Video clips are capped (currently `<= 60s`) to avoid oversized node payloads (base64 overhead + message limits).
|
||||
- Video clips are capped (currently `<= 60s`) to avoid oversized bridge payloads (base64 overhead + message limits).
|
||||
|
||||
## macOS screen video (OS-level)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ read_when:
|
||||
|
||||
A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
|
||||
|
||||
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; deprecated/removed for current nodes).
|
||||
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; for older node clients only).
|
||||
|
||||
macOS can also run in **node mode**: the menubar app connects to the Gateway’s WS server and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
|
||||
|
||||
@@ -61,7 +61,7 @@ clawdbot node run --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
|
||||
```bash
|
||||
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
clawdbot node restart
|
||||
clawdbot node start
|
||||
```
|
||||
|
||||
### Pair + name
|
||||
@@ -243,7 +243,6 @@ Notes:
|
||||
- `system.notify` respects notification permission state on the macOS app.
|
||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||
- macOS nodes drop `PATH` overrides; headless node hosts only accept `PATH` when it prepends the node host PATH.
|
||||
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
|
||||
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
|
||||
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
|
||||
@@ -286,12 +285,12 @@ or for running a minimal node alongside a server.
|
||||
Start it:
|
||||
|
||||
```bash
|
||||
clawdbot node run --host <gateway-host> --port 18789
|
||||
clawdbot node run --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Pairing is still required (the Gateway will show a node approval prompt).
|
||||
- The node host stores its node id, token, display name, and gateway connection info in `~/.clawdbot/node.json`.
|
||||
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
||||
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
||||
(see [Exec approvals](/tools/exec-approvals)).
|
||||
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
|
||||
|
||||
@@ -6,7 +6,7 @@ read_when:
|
||||
---
|
||||
# Media Understanding (Inbound) — 2026-01-17
|
||||
|
||||
Clawdbot can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual.
|
||||
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.
|
||||
@@ -88,11 +88,6 @@ Each `models[]` entry can be **provider** or **CLI**:
|
||||
}
|
||||
```
|
||||
|
||||
CLI templates can also use:
|
||||
- `{{MediaDir}}` (directory containing the media file)
|
||||
- `{{OutputDir}}` (scratch dir created for this run)
|
||||
- `{{OutputBase}}` (scratch file base path, no extension)
|
||||
|
||||
## Defaults and limits
|
||||
Recommended defaults:
|
||||
- `maxChars`: **500** for image/video (short, command‑friendly)
|
||||
@@ -109,22 +104,17 @@ Rules:
|
||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||
**active reply model** when its provider supports the capability.
|
||||
|
||||
### Auto-detect media understanding (default)
|
||||
If `tools.media.<capability>.enabled` is **not** set to `false` and you haven’t
|
||||
configured models, Clawdbot auto-detects in this order and **stops at the first
|
||||
working option**:
|
||||
### Auto-enable audio (when keys exist)
|
||||
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
||||
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
||||
even when you haven’t listed models explicitly.
|
||||
|
||||
1) **Local CLIs** (audio only; if installed)
|
||||
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
|
||||
- `whisper-cli` (`whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
|
||||
- `whisper` (Python CLI; downloads models automatically)
|
||||
2) **Gemini CLI** (`gemini`) using `read_many_files`
|
||||
3) **Provider keys**
|
||||
- Audio: OpenAI → Groq → Deepgram → Google
|
||||
- Image: OpenAI → Anthropic → Google → MiniMax
|
||||
- Video: Google
|
||||
Providers checked (in order):
|
||||
1) OpenAI
|
||||
2) Groq
|
||||
3) Deepgram
|
||||
|
||||
To disable auto-detection, set:
|
||||
To disable this behavior, set:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
@@ -136,7 +126,6 @@ To disable auto-detection, set:
|
||||
}
|
||||
}
|
||||
```
|
||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
||||
|
||||
## Capabilities (optional)
|
||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||
@@ -153,7 +142,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
| Capability | Provider integration | Notes |
|
||||
|------------|----------------------|-------|
|
||||
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
||||
| Audio | OpenAI, Groq, Deepgram, Google | Provider transcription (Whisper/Deepgram/Gemini). |
|
||||
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
|
||||
| Video | Google (Gemini API) | Provider video understanding. |
|
||||
|
||||
## Recommended providers
|
||||
@@ -162,8 +151,8 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
||||
|
||||
**Audio**
|
||||
- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`.
|
||||
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper` binary.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
@@ -220,7 +209,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
|
||||
@@ -41,7 +41,7 @@ Notes:
|
||||
|
||||
Who receives it:
|
||||
- All WebSocket clients (macOS app, WebChat, etc.)
|
||||
- All connected nodes (iOS/Android), and also on node connect as an initial “current state” push.
|
||||
- All connected bridge nodes (iOS/Android), and also on node connect as an initial “current state” push.
|
||||
|
||||
## Client behavior
|
||||
|
||||
@@ -53,9 +53,9 @@ Who receives it:
|
||||
### iOS node
|
||||
|
||||
- Uses the global list for `VoiceWakeManager` trigger detection.
|
||||
- Editing Wake Words in Settings calls `voicewake.set` (over the Gateway WS) and also keeps local wake-word detection responsive.
|
||||
- Editing Wake Words in Settings calls `voicewake.set` (over the bridge) and also keeps local wake-word detection responsive.
|
||||
|
||||
### Android node
|
||||
|
||||
- Exposes a Wake Words editor in Settings.
|
||||
- Calls `voicewake.set` over the Gateway WS so edits sync everywhere.
|
||||
- Calls `voicewake.set` over the bridge so edits sync everywhere.
|
||||
|
||||
@@ -129,6 +129,7 @@ CLAWDBOT_IMAGE=clawdbot:latest
|
||||
CLAWDBOT_GATEWAY_TOKEN=change-me-now
|
||||
CLAWDBOT_GATEWAY_BIND=lan
|
||||
CLAWDBOT_GATEWAY_PORT=18789
|
||||
CLAWDBOT_BRIDGE_PORT=18790
|
||||
|
||||
CLAWDBOT_CONFIG_DIR=/root/.clawdbot
|
||||
CLAWDBOT_WORKSPACE_DIR=/root/clawd
|
||||
@@ -165,6 +166,7 @@ services:
|
||||
- TERM=xterm-256color
|
||||
- CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
|
||||
- CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
|
||||
- CLAWDBOT_BRIDGE_PORT=${CLAWDBOT_BRIDGE_PORT}
|
||||
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
|
||||
- GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
|
||||
- XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
|
||||
@@ -177,8 +179,9 @@ services:
|
||||
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
|
||||
- "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789"
|
||||
|
||||
# Optional: only if you run iOS/Android nodes against this VPS and need Canvas host.
|
||||
# If you expose this publicly, read /gateway/security and firewall accordingly.
|
||||
# Optional: only if you run iOS/Android nodes against this VPS.
|
||||
# If you expose these publicly, read /gateway/security and firewall accordingly.
|
||||
# - "${CLAWDBOT_BRIDGE_PORT}:18790"
|
||||
# - "18793:18793"
|
||||
command:
|
||||
[
|
||||
|
||||
@@ -40,7 +40,7 @@ node commands return `CANVAS_DISABLED`.
|
||||
|
||||
## Agent API surface
|
||||
|
||||
Canvas is exposed via the **Gateway WebSocket**, so the agent can:
|
||||
Canvas is exposed via the **node bridge**, so the agent can:
|
||||
|
||||
- show/hide the panel
|
||||
- navigate to a path or URL
|
||||
|
||||
@@ -45,13 +45,6 @@ present. To reset manually:
|
||||
rm ~/.clawdbot/disable-launchagent
|
||||
```
|
||||
|
||||
## Attach-only mode
|
||||
|
||||
To force the macOS app to **never install or manage launchd**, launch it with
|
||||
`--attach-only` (or `--no-launchd`). This sets `~/.clawdbot/disable-launchagent`,
|
||||
so the app only attaches to an already running Gateway. You can toggle the same
|
||||
behavior in Debug Settings.
|
||||
|
||||
## Remote mode
|
||||
|
||||
Remote mode never starts a local Gateway. The app uses an SSH tunnel to the
|
||||
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
## What is shown
|
||||
- We surface the current agent work state in the menu bar icon and in the first status row of the menu.
|
||||
- Health status is hidden while work is active; it returns when all sessions are idle.
|
||||
- The “Nodes” block in the menu lists **devices** only (paired nodes via `node.list`), not client/presence entries.
|
||||
- The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries.
|
||||
- A “Usage” section appears under Context when provider usage snapshots are available.
|
||||
|
||||
## State model
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "macOS IPC architecture for Clawdbot app, gateway node transport, and PeekabooBridge"
|
||||
summary: "macOS IPC architecture for Clawdbot app, gateway node bridge, and PeekabooBridge"
|
||||
read_when:
|
||||
- Editing IPC contracts or menu bar app IPC
|
||||
---
|
||||
@@ -13,21 +13,21 @@ read_when:
|
||||
- Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick.
|
||||
|
||||
## How it works
|
||||
### Gateway + node transport
|
||||
### Gateway + node bridge
|
||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||
|
||||
### Node service + app IPC
|
||||
- A headless node host service connects to the Gateway WebSocket.
|
||||
- A headless node host service connects to the Gateway bridge.
|
||||
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
||||
- The app performs the exec in UI context, prompts if needed, and returns output.
|
||||
|
||||
Diagram (SCI):
|
||||
```
|
||||
Agent -> Gateway -> Node Service (WS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
```
|
||||
|
||||
### PeekabooBridge (UI automation)
|
||||
|
||||
@@ -62,7 +62,7 @@ Node service + app IPC:
|
||||
|
||||
Diagram (SCI):
|
||||
```
|
||||
Gateway -> Node Service (WS)
|
||||
Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
@@ -99,7 +99,7 @@ Example:
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `allowlist` entries are glob patterns for resolved binary paths.
|
||||
- `allowlist` entries are JSON-encoded argv arrays.
|
||||
- Choosing “Always Allow” in the prompt adds that command to the allowlist.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the app’s environment.
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ Plugins can register:
|
||||
- CLI commands
|
||||
- Background services
|
||||
- Optional config validation
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
|
||||
@@ -34,7 +34,6 @@ Optional keys:
|
||||
- `kind` (string): plugin kind (example: `"memory"`).
|
||||
- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`).
|
||||
- `providers` (array): provider ids registered by this plugin.
|
||||
- `skills` (array): skill directories to load (relative to the plugin root).
|
||||
- `name` (string): display name for the plugin.
|
||||
- `description` (string): short plugin summary.
|
||||
- `uiHints` (object): config field labels/placeholders/sensitive flags for UI rendering.
|
||||
|
||||
131
docs/prose.md
131
docs/prose.md
@@ -1,131 +0,0 @@
|
||||
---
|
||||
summary: "OpenProse: .prose workflows, slash commands, and state in Clawdbot"
|
||||
read_when:
|
||||
- You want to run or write .prose workflows
|
||||
- You want to enable the OpenProse plugin
|
||||
- You need to understand state storage
|
||||
---
|
||||
# OpenProse
|
||||
|
||||
OpenProse is a portable, markdown-first workflow format for orchestrating AI sessions. In Clawdbot it ships as a plugin that installs an OpenProse skill pack plus a `/prose` slash command. Programs live in `.prose` files and can spawn multiple sub-agents with explicit control flow.
|
||||
|
||||
Official site: https://www.prose.md
|
||||
|
||||
## What it can do
|
||||
|
||||
- Multi-agent research + synthesis with explicit parallelism.
|
||||
- Repeatable approval-safe workflows (code review, incident triage, content pipelines).
|
||||
- Reusable `.prose` programs you can run across supported agent runtimes.
|
||||
|
||||
## Install + enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable OpenProse:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable open-prose
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling the plugin.
|
||||
|
||||
Dev/local checkout: `clawdbot plugins install ./extensions/open-prose`
|
||||
|
||||
Related docs: [Plugins](/plugin), [Plugin manifest](/plugins/manifest), [Skills](/tools/skills).
|
||||
|
||||
## Slash command
|
||||
|
||||
OpenProse registers `/prose` as a user-invocable skill command. It routes to the OpenProse VM instructions and uses Clawdbot tools under the hood.
|
||||
|
||||
Common commands:
|
||||
|
||||
```
|
||||
/prose help
|
||||
/prose run <file.prose>
|
||||
/prose run <handle/slug>
|
||||
/prose run <https://example.com/file.prose>
|
||||
/prose compile <file.prose>
|
||||
/prose examples
|
||||
/prose update
|
||||
```
|
||||
|
||||
## Example: a simple `.prose` file
|
||||
|
||||
```prose
|
||||
# Research + synthesis with two agents running in parallel.
|
||||
|
||||
input topic: "What should we research?"
|
||||
|
||||
agent researcher:
|
||||
model: sonnet
|
||||
prompt: "You research thoroughly and cite sources."
|
||||
|
||||
agent writer:
|
||||
model: opus
|
||||
prompt: "You write a concise summary."
|
||||
|
||||
parallel:
|
||||
findings = session: researcher
|
||||
prompt: "Research {topic}."
|
||||
draft = session: writer
|
||||
prompt: "Summarize {topic}."
|
||||
|
||||
session "Merge the findings + draft into a final answer."
|
||||
context: { findings, draft }
|
||||
```
|
||||
|
||||
## File locations
|
||||
|
||||
OpenProse keeps state under `.prose/` in your workspace:
|
||||
|
||||
```
|
||||
.prose/
|
||||
├── .env
|
||||
├── runs/
|
||||
│ └── {YYYYMMDD}-{HHMMSS}-{random}/
|
||||
│ ├── program.prose
|
||||
│ ├── state.md
|
||||
│ ├── bindings/
|
||||
│ └── agents/
|
||||
└── agents/
|
||||
```
|
||||
|
||||
User-level persistent agents live at:
|
||||
|
||||
```
|
||||
~/.prose/agents/
|
||||
```
|
||||
|
||||
## State modes
|
||||
|
||||
OpenProse supports multiple state backends:
|
||||
|
||||
- **filesystem** (default): `.prose/runs/...`
|
||||
- **in-context**: transient, for small programs
|
||||
- **sqlite** (experimental): requires `sqlite3` binary
|
||||
- **postgres** (experimental): requires `psql` and a connection string
|
||||
|
||||
Notes:
|
||||
- sqlite/postgres are opt-in and experimental.
|
||||
- postgres credentials flow into subagent logs; use a dedicated, least-privileged DB.
|
||||
|
||||
## Remote programs
|
||||
|
||||
`/prose run <handle/slug>` resolves to `https://p.prose.md/<handle>/<slug>`.
|
||||
Direct URLs are fetched as-is. This uses the `web_fetch` tool (or `exec` for POST).
|
||||
|
||||
## Clawdbot runtime mapping
|
||||
|
||||
OpenProse programs map to Clawdbot primitives:
|
||||
|
||||
| OpenProse concept | Clawdbot tool |
|
||||
| --- | --- |
|
||||
| Spawn session / Task tool | `sessions_spawn` |
|
||||
| File read/write | `read` / `write` |
|
||||
| Web fetch | `web_fetch` |
|
||||
|
||||
If your tool allowlist blocks these tools, OpenProse programs will fail. See [Skills config](/tools/skills-config).
|
||||
|
||||
## Security + approvals
|
||||
|
||||
Treat `.prose` files like code. Review before running. Use Clawdbot tool allowlists and approval gates to control side effects.
|
||||
|
||||
For deterministic, approval-gated workflows, compare with [Lobster](/tools/lobster).
|
||||
@@ -16,9 +16,9 @@ provider in two different ways.
|
||||
|
||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||
|
||||
Use the native device-login flow to obtain a GitHub token and use it directly
|
||||
against the Copilot API. This is the **default** and simplest path because it
|
||||
does not require VS Code. Enterprise domains are supported.
|
||||
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
||||
because it does not require VS Code.
|
||||
|
||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||
|
||||
@@ -39,8 +39,6 @@ clawdbot models auth login-github-copilot
|
||||
|
||||
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||
open until it completes.
|
||||
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
|
||||
domain (for example `company.ghe.com`).
|
||||
|
||||
### Optional flags
|
||||
|
||||
@@ -68,7 +66,5 @@ clawdbot models set github-copilot/gpt-4o
|
||||
- Requires an interactive TTY; run it directly in a terminal.
|
||||
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||
another ID (for example `github-copilot/gpt-4.1`).
|
||||
- The login stores a GitHub token in the auth profile store and uses it directly
|
||||
for Copilot API calls.
|
||||
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
|
||||
for GitHub Enterprise.
|
||||
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
||||
Copilot API token when Clawdbot runs.
|
||||
|
||||
@@ -9,7 +9,7 @@ read_when:
|
||||
Clawdbot can use many LLM providers. Pick a provider, authenticate, then set the
|
||||
default model as `provider/model`.
|
||||
|
||||
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
|
||||
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Channels](/channels).
|
||||
|
||||
## Quick start
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ This document explains how Clawdbot manages sessions end-to-end:
|
||||
- **Session routing** (how inbound messages map to a `sessionKey`)
|
||||
- **Session store** (`sessions.json`) and what it tracks
|
||||
- **Transcript persistence** (`*.jsonl`) and its structure
|
||||
- **Transcript hygiene** (provider-specific fixups before runs)
|
||||
- **Context limits** (context window vs tracked tokens)
|
||||
- **Compaction** (manual + auto-compaction) and where to hook pre-compaction work
|
||||
- **Silent housekeeping** (e.g. memory writes that shouldn’t produce user-visible output)
|
||||
@@ -21,7 +20,6 @@ If you want a higher-level overview first, start with:
|
||||
- [/concepts/session](/concepts/session)
|
||||
- [/concepts/compaction](/concepts/compaction)
|
||||
- [/concepts/session-pruning](/concepts/session-pruning)
|
||||
- [/reference/transcript-hygiene](/reference/transcript-hygiene)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
summary: "Reference: provider-specific transcript sanitization and repair rules"
|
||||
read_when:
|
||||
- You are debugging provider request rejections tied to transcript shape
|
||||
- You are changing transcript sanitization or tool-call repair logic
|
||||
- You are investigating tool-call id mismatches across providers
|
||||
---
|
||||
# Transcript Hygiene (Provider Fixups)
|
||||
|
||||
This document describes **provider-specific fixes** applied to transcripts before a run
|
||||
(building model context). These are **in-memory** adjustments used to satisfy strict
|
||||
provider requirements. They do **not** rewrite the stored JSONL transcript on disk.
|
||||
|
||||
Scope includes:
|
||||
- Tool call id sanitization
|
||||
- Tool result pairing repair
|
||||
- Turn validation / ordering
|
||||
- Thought signature cleanup
|
||||
- Image payload sanitization
|
||||
|
||||
If you need transcript storage details, see:
|
||||
- [/reference/session-management-compaction](/reference/session-management-compaction)
|
||||
|
||||
---
|
||||
|
||||
## Where this runs
|
||||
|
||||
All transcript hygiene is centralized in the embedded runner:
|
||||
- Policy selection: `src/agents/transcript-policy.ts`
|
||||
- Sanitization/repair application: `sanitizeSessionHistory` in `src/agents/pi-embedded-runner/google.ts`
|
||||
|
||||
The policy uses `provider`, `modelApi`, and `modelId` to decide what to apply.
|
||||
|
||||
---
|
||||
|
||||
## Global rule: image sanitization
|
||||
|
||||
Image payloads are always sanitized to prevent provider-side rejection due to size
|
||||
limits (downscale/recompress oversized base64 images).
|
||||
|
||||
Implementation:
|
||||
- `sanitizeSessionMessagesImages` in `src/agents/pi-embedded-helpers/images.ts`
|
||||
- `sanitizeContentBlocksImages` in `src/agents/tool-images.ts`
|
||||
|
||||
---
|
||||
|
||||
## Provider matrix (current behavior)
|
||||
|
||||
**OpenAI / OpenAI Codex**
|
||||
- Image sanitization only.
|
||||
- No tool call id sanitization.
|
||||
- No tool result pairing repair.
|
||||
- No turn validation or reordering.
|
||||
- No synthetic tool results.
|
||||
- No thought signature stripping.
|
||||
|
||||
**Google (Generative AI / Gemini CLI / Antigravity)**
|
||||
- Tool call id sanitization: strict alphanumeric.
|
||||
- Tool result pairing repair and synthetic tool results.
|
||||
- Turn validation (Gemini-style turn alternation).
|
||||
- Google turn ordering fixup (prepend a tiny user bootstrap if history starts with assistant).
|
||||
- Antigravity Claude: normalize thinking signatures; drop unsigned thinking blocks.
|
||||
|
||||
**Anthropic / Minimax (Anthropic-compatible)**
|
||||
- Tool result pairing repair and synthetic tool results.
|
||||
- Turn validation (merge consecutive user turns to satisfy strict alternation).
|
||||
|
||||
**Mistral (including model-id based detection)**
|
||||
- Tool call id sanitization: strict9 (alphanumeric length 9).
|
||||
|
||||
**OpenRouter Gemini**
|
||||
- Thought signature cleanup: strip non-base64 `thought_signature` values (keep base64).
|
||||
|
||||
**Everything else**
|
||||
- Image sanitization only.
|
||||
|
||||
---
|
||||
|
||||
## Historical behavior (pre-2026.1.22)
|
||||
|
||||
Before the 2026.1.22 release, Clawdbot applied multiple layers of transcript hygiene:
|
||||
|
||||
- A **transcript-sanitize extension** ran on every context build and could:
|
||||
- Repair tool use/result pairing.
|
||||
- Sanitize tool call ids (including a non-strict mode that preserved `_`/`-`).
|
||||
- The runner also performed provider-specific sanitization, which duplicated work.
|
||||
- Additional mutations occurred outside the provider policy, including:
|
||||
- Stripping `<final>` tags from assistant text before persistence.
|
||||
- Dropping empty assistant error turns.
|
||||
- Trimming assistant content after tool calls.
|
||||
|
||||
This complexity caused cross-provider regressions (notably `openai-responses`
|
||||
`call_id|fc_id` pairing). The 2026.1.22 cleanup removed the extension, centralized
|
||||
logic in the runner, and made OpenAI **no-touch** beyond image sanitization.
|
||||
@@ -6,14 +6,14 @@ read_when:
|
||||
---
|
||||
# Building a personal assistant with Clawdbot (Clawd-style)
|
||||
|
||||
Clawdbot is a WhatsApp + Telegram + Discord + iMessage gateway for **Pi** agents. Plugins add Mattermost. This guide is the "personal assistant" setup: one dedicated WhatsApp number that behaves like your always-on agent.
|
||||
Clawdbot is a WhatsApp + Telegram + Discord gateway for **Pi** agents. This guide is the “personal assistant” setup: one dedicated WhatsApp number that behaves like your always-on agent.
|
||||
|
||||
## ⚠️ Safety first
|
||||
|
||||
You’re putting an agent in a position to:
|
||||
- run commands on your machine (depending on your Pi tool setup)
|
||||
- read/write files in your workspace
|
||||
- send messages back out via WhatsApp/Telegram/Discord/Mattermost (plugin)
|
||||
- send messages back out via WhatsApp/Telegram/Discord
|
||||
|
||||
Start conservative:
|
||||
- Always set `channels.whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||
|
||||
@@ -178,7 +178,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
|
||||
### What is Clawdbot, in one paragraph?
|
||||
|
||||
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), 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.
|
||||
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.
|
||||
|
||||
## Quick start and first-run setup
|
||||
|
||||
@@ -235,7 +235,7 @@ Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** fo
|
||||
- **Model/auth setup** (Anthropic **setup-token** recommended for Claude subscriptions, OpenAI Codex OAuth supported, API keys optional, LM Studio local models supported)
|
||||
- **Workspace** location + bootstrap files
|
||||
- **Gateway settings** (bind/port/auth/tailscale)
|
||||
- **Providers** (WhatsApp, Telegram, Discord, Mattermost (plugin), Signal, iMessage)
|
||||
- **Providers** (WhatsApp, Telegram, Discord, Signal, iMessage)
|
||||
- **Daemon install** (LaunchAgent on macOS; systemd user unit on Linux/WSL2)
|
||||
- **Health checks** and **skills** selection
|
||||
|
||||
@@ -363,7 +363,7 @@ lowest friction and you’re okay with sleep/restarts, run it locally.
|
||||
- **Pros:** always‑on, stable network, no laptop sleep issues, easier to keep running.
|
||||
- **Cons:** often run headless (use screenshots), remote file access only, you must SSH for updates.
|
||||
|
||||
**Clawdbot-specific note:** WhatsApp/Telegram/Slack/Mattermost (plugin)/Discord all work fine from a VPS. The only real trade-off is **headless browser** vs a visible window. See [Browser](/tools/browser).
|
||||
**Clawdbot‑specific note:** WhatsApp/Telegram/Slack/Discord all work fine from a VPS. The only real trade‑off is **headless browser** vs a visible window. See [Browser](/tools/browser).
|
||||
|
||||
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
|
||||
|
||||
@@ -719,42 +719,43 @@ See the full config examples in [Browser](/tools/browser#use-brave-or-another-ch
|
||||
### How do commands propagate between Telegram, the gateway, and nodes?
|
||||
|
||||
Telegram messages are handled by the **gateway**. The gateway runs the agent and
|
||||
only then calls nodes over the **Gateway WebSocket** when a node tool is needed:
|
||||
only then calls nodes over the **Bridge** when a node tool is needed:
|
||||
|
||||
Telegram → Gateway → Agent → `node.*` → Node → Gateway → Telegram
|
||||
|
||||
Nodes don’t see inbound provider traffic; they only receive node RPC calls.
|
||||
Nodes don’t see inbound provider traffic; they only receive bridge RPC calls.
|
||||
|
||||
### How can my agent access my computer if the Gateway is hosted remotely?
|
||||
|
||||
Short answer: **pair your computer as a node**. The Gateway runs elsewhere, but it can
|
||||
call `node.*` tools (screen, camera, system) on your local machine over the Gateway WebSocket.
|
||||
call `node.*` tools (screen, camera, system) on your local machine over the Bridge.
|
||||
|
||||
Typical setup:
|
||||
1) Run the Gateway on the always‑on host (VPS/home server).
|
||||
2) Put the Gateway host + your computer on the same tailnet.
|
||||
3) Ensure the Gateway WS is reachable (tailnet bind or SSH tunnel).
|
||||
4) Open the macOS app locally and connect in **Remote over SSH** mode (or direct tailnet)
|
||||
so it can register as a node.
|
||||
3) Enable the bridge on the Gateway host:
|
||||
```json5
|
||||
{ bridge: { enabled: true, bind: "auto" } }
|
||||
```
|
||||
4) Open the macOS app locally and connect in **Remote over SSH** mode so it can tunnel
|
||||
the bridge port and register as a node.
|
||||
5) Approve the node on the Gateway:
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
No separate TCP bridge is required; nodes connect over the Gateway WebSocket.
|
||||
|
||||
Security reminder: pairing a macOS node allows `system.run` on that machine. Only
|
||||
pair devices you trust, and review [Security](/gateway/security).
|
||||
|
||||
Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
||||
Docs: [Nodes](/nodes), [Bridge protocol](/gateway/bridge-protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
||||
|
||||
### Do nodes run a gateway service?
|
||||
|
||||
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
|
||||
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
||||
|
||||
A full restart is required for `gateway`, `discovery`, and `canvasHost` changes.
|
||||
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
||||
|
||||
### Is there an API / RPC way to apply config?
|
||||
|
||||
@@ -796,19 +797,26 @@ This keeps the gateway bound to loopback and exposes HTTPS via Tailscale. See [T
|
||||
|
||||
### How do I connect a Mac node to a remote Gateway (Tailscale Serve)?
|
||||
|
||||
Serve exposes the **Gateway Control UI + WS**. Nodes connect over the same Gateway WS endpoint.
|
||||
Serve only exposes the **Gateway Control UI**. Nodes use the **bridge port**.
|
||||
|
||||
Recommended setup:
|
||||
1) **Make sure the VPS + Mac are on the same tailnet**.
|
||||
2) **Use the macOS app in Remote mode** (SSH target can be the tailnet hostname).
|
||||
The app will tunnel the Gateway port and connect as a node.
|
||||
3) **Approve the node** on the gateway:
|
||||
1) **Enable the bridge on the gateway host**:
|
||||
```json5
|
||||
{
|
||||
bridge: { enabled: true, bind: "auto" }
|
||||
}
|
||||
```
|
||||
`auto` prefers a tailnet IP when Tailscale is present.
|
||||
2) **Make sure the VPS + Mac are on the same tailnet**.
|
||||
3) **Use the macOS app in Remote mode** (SSH target can be the tailnet hostname).
|
||||
The app will tunnel the bridge port and connect as a node.
|
||||
4) **Approve the node** on the gateway:
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
Docs: [Gateway protocol](/gateway/protocol), [Discovery](/gateway/discovery), [macOS remote mode](/platforms/mac/remote).
|
||||
Docs: [Bridge protocol](/gateway/bridge-protocol), [Discovery](/gateway/discovery), [macOS remote mode](/platforms/mac/remote).
|
||||
|
||||
## Env vars and .env loading
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Goal: go from **zero** → **first working chat** (with sane defaults) as quickl
|
||||
Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up:
|
||||
- model/auth (OAuth recommended)
|
||||
- gateway settings
|
||||
- channels (WhatsApp/Telegram/Discord/Mattermost (plugin)/...)
|
||||
- channels (WhatsApp/Telegram/Discord/…)
|
||||
- pairing defaults (secure DMs)
|
||||
- workspace bootstrap + skills
|
||||
- optional background service
|
||||
@@ -80,7 +80,7 @@ clawdbot onboard --install-daemon
|
||||
What you’ll choose:
|
||||
- **Local vs Remote** gateway
|
||||
- **Auth**: OpenAI Code (Codex) subscription (OAuth) or API keys. For Anthropic we recommend an API key; `claude setup-token` is also supported.
|
||||
- **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, Mattermost plugin tokens, etc.
|
||||
- **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, etc.
|
||||
- **Daemon**: background install (launchd/systemd; WSL2 uses systemd)
|
||||
- **Runtime**: Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**.
|
||||
- **Gateway token**: the wizard generates one by default (even on loopback) and stores it in `gateway.auth.token`.
|
||||
@@ -140,7 +140,6 @@ WhatsApp doc: [WhatsApp](/channels/whatsapp)
|
||||
The wizard can write tokens/config for you. If you prefer manual config, start with:
|
||||
- Telegram: [Telegram](/channels/telegram)
|
||||
- Discord: [Discord](/channels/discord)
|
||||
- Mattermost (plugin): [Mattermost](/channels/mattermost)
|
||||
|
||||
**Telegram DM tip:** your first DM returns a pairing code. Approve it (see next step) or the bot won’t respond.
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [Telegram (grammY notes)](/channels/grammy)
|
||||
- [Slack](/channels/slack)
|
||||
- [Discord](/channels/discord)
|
||||
- [Mattermost](/channels/mattermost) (plugin)
|
||||
- [Signal](/channels/signal)
|
||||
- [iMessage](/channels/imessage)
|
||||
- [Location parsing](/channels/location)
|
||||
@@ -97,7 +96,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
## Tools + automation
|
||||
|
||||
- [Tools surface](/tools)
|
||||
- [OpenProse](/prose)
|
||||
- [CLI reference](/cli)
|
||||
- [Exec tool](/tools/exec)
|
||||
- [Elevated mode](/tools/elevated)
|
||||
|
||||
@@ -48,7 +48,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or setup-token (paste), plus MiniMax/GLM/Moonshot/AI Gateway options)
|
||||
- Workspace location + bootstrap files
|
||||
- Gateway settings (port/bind/auth/tailscale)
|
||||
- Providers (Telegram, WhatsApp, Discord, Mattermost (plugin), Signal)
|
||||
- Providers (Telegram, WhatsApp, Discord, Signal)
|
||||
- Daemon install (LaunchAgent / systemd user unit)
|
||||
- Health check
|
||||
- Skills (recommended)
|
||||
@@ -117,7 +117,6 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- WhatsApp: optional QR login.
|
||||
- Telegram: bot token.
|
||||
- Discord: bot token.
|
||||
- Mattermost (plugin): bot token + base URL.
|
||||
- Signal: optional `signal-cli` install + account config.
|
||||
- iMessage: local `imsg` CLI path + DB access.
|
||||
- DM security: default is pairing. First DM sends a code; approve via `clawdbot pairing approve <channel> <code>` or use allowlists.
|
||||
|
||||
@@ -22,7 +22,7 @@ Exec approvals are enforced locally on the execution host:
|
||||
- **gateway host** → `clawdbot` process on the gateway machine
|
||||
- **node host** → node runner (macOS companion app or headless node host)
|
||||
|
||||
macOS split:
|
||||
Planned macOS split:
|
||||
- **node host service** forwards `system.run` to the **macOS app** over local IPC.
|
||||
- **macOS app** enforces approvals + executes the command in UI context.
|
||||
|
||||
@@ -103,8 +103,8 @@ Each allowlist entry tracks:
|
||||
## Auto-allow skill CLIs
|
||||
|
||||
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
||||
are treated as allowlisted on nodes (macOS node or headless node host). This uses
|
||||
`skills.bins` over the Gateway RPC to fetch the skill bin list. Disable this if you want strict manual allowlists.
|
||||
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
|
||||
gateway for the skill bin list. Disable this if you want strict manual allowlists.
|
||||
|
||||
## Safe bins (stdin-only)
|
||||
|
||||
@@ -113,9 +113,6 @@ that can run in allowlist mode **without** explicit allowlist entries. Safe bins
|
||||
positional file args and path-like tokens, so they can only operate on the incoming stream.
|
||||
Shell chaining and redirections are not auto-allowed in allowlist mode.
|
||||
|
||||
Shell chaining (`&&`, `||`, `;`) is allowed when every top-level segment satisfies the allowlist
|
||||
(including safe bins or skill auto-allow). Redirections remain unsupported in allowlist mode.
|
||||
|
||||
Default safe bins: `jq`, `grep`, `cut`, `sort`, `uniq`, `head`, `tail`, `tr`, `wc`.
|
||||
|
||||
## Control UI editing
|
||||
@@ -154,12 +151,12 @@ Actions:
|
||||
- **Always allow** → add to allowlist + run
|
||||
- **Deny** → block
|
||||
|
||||
### macOS IPC flow
|
||||
### macOS IPC flow (planned)
|
||||
```
|
||||
Gateway -> Node Service (WS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + approvals + system.run)
|
||||
Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + approvals + system.run)
|
||||
```
|
||||
|
||||
Security notes:
|
||||
|
||||
@@ -66,8 +66,8 @@ Example:
|
||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
|
||||
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
||||
if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
|
||||
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
|
||||
if the exec call already sets `env.PATH`. Node PATH overrides are accepted only when they prepend
|
||||
the node host PATH (no replacement).
|
||||
|
||||
Per-agent node binding (use the agent list index in config):
|
||||
|
||||
|
||||
@@ -11,10 +11,6 @@ read_when:
|
||||
|
||||
Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints.
|
||||
|
||||
## Hook
|
||||
|
||||
Your assistant can build the tools that manage itself. Ask for a workflow, and 30 minutes later you have a CLI plus pipelines that run as one call. Lobster is the missing piece: deterministic pipelines, explicit approvals, and resumable state.
|
||||
|
||||
## Why
|
||||
|
||||
Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime:
|
||||
@@ -28,73 +24,6 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs
|
||||
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
|
||||
If the pipeline pauses for approval, the tool returns a `resumeToken` so you can continue later.
|
||||
|
||||
## Pattern: small CLI + JSON pipes + approvals
|
||||
|
||||
Build tiny commands that speak JSON, then chain them into a single Lobster call. (Example command names below — swap in your own.)
|
||||
|
||||
```bash
|
||||
inbox list --json
|
||||
inbox categorize --json
|
||||
inbox apply --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "run",
|
||||
"pipeline": "exec --json --shell 'inbox list --json' | exec --stdin json --shell 'inbox categorize --json' | exec --stdin json --shell 'inbox apply --json' | approve --preview-from-stdin --limit 5 --prompt 'Apply changes?'",
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
```
|
||||
|
||||
If the pipeline requests approval, resume with the token:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "resume",
|
||||
"token": "<resumeToken>",
|
||||
"approve": true
|
||||
}
|
||||
```
|
||||
|
||||
AI triggers the workflow; Lobster executes the steps. Approval gates keep side effects explicit and auditable.
|
||||
|
||||
Example: map input items into tool calls:
|
||||
|
||||
```bash
|
||||
gog.gmail.search --query 'newer_than:1d' \
|
||||
| clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'
|
||||
```
|
||||
|
||||
## Workflow files (.lobster)
|
||||
|
||||
Lobster can run YAML/JSON workflow files with `name`, `args`, `steps`, `env`, `condition`, and `approval` fields. In Clawdbot tool calls, set `pipeline` to the file path.
|
||||
|
||||
```yaml
|
||||
name: inbox-triage
|
||||
args:
|
||||
tag:
|
||||
default: "family"
|
||||
steps:
|
||||
- id: collect
|
||||
command: inbox list --json
|
||||
- id: categorize
|
||||
command: inbox categorize --json
|
||||
stdin: $collect.stdout
|
||||
- id: approve
|
||||
command: inbox apply --approve
|
||||
stdin: $categorize.stdout
|
||||
approval: required
|
||||
- id: execute
|
||||
command: inbox apply --execute
|
||||
stdin: $categorize.stdout
|
||||
condition: $approve.approved
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `stdin: $step.stdout` and `stdin: $step.json` pass a prior step’s output.
|
||||
- `condition` (or `when`) can gate steps on `$step.approved`.
|
||||
|
||||
## Install Lobster
|
||||
|
||||
Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/clawdbot/lobster)), and ensure `lobster` is on `PATH`.
|
||||
@@ -186,16 +115,6 @@ Run a pipeline in tool mode.
|
||||
}
|
||||
```
|
||||
|
||||
Run a workflow file with args:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "run",
|
||||
"pipeline": "/path/to/inbox-triage.lobster",
|
||||
"argsJson": "{\"tag\":\"family\"}"
|
||||
}
|
||||
```
|
||||
|
||||
### `resume`
|
||||
|
||||
Continue a halted workflow after approval.
|
||||
@@ -214,7 +133,6 @@ Continue a halted workflow after approval.
|
||||
- `cwd`: Working directory for the pipeline (defaults to the current process working directory).
|
||||
- `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000).
|
||||
- `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000).
|
||||
- `argsJson`: JSON string passed to `lobster run --args-json` (workflow files only).
|
||||
|
||||
## Output envelope
|
||||
|
||||
@@ -233,12 +151,6 @@ If `requiresApproval` is present, inspect the prompt and decide:
|
||||
- `approve: true` → resume and continue side effects
|
||||
- `approve: false` → cancel and finalize the workflow
|
||||
|
||||
Use `approve --preview-from-stdin --limit N` to attach a JSON preview to approval requests without custom jq/heredoc glue. Resume tokens are now compact: Lobster stores workflow resume state under its state dir and hands back a small token key.
|
||||
|
||||
## OpenProse
|
||||
|
||||
OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, then run a Lobster pipeline for deterministic approvals. If a Prose program needs Lobster, allow the `lobster` tool for sub-agents via `tools.subagents.tools`. See [OpenProse](/prose).
|
||||
|
||||
## Safety
|
||||
|
||||
- **Local subprocess only** — no network calls from the plugin itself.
|
||||
@@ -257,10 +169,3 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep,
|
||||
|
||||
- [Plugins](/plugin)
|
||||
- [Plugin tool authoring](/plugins/agent-tools)
|
||||
|
||||
## Case study: community workflows
|
||||
|
||||
One public example: a “second brain” CLI + Lobster pipelines that manage three Markdown vaults (personal, partner, shared). The CLI emits JSON for stats, inbox listings, and stale scans; Lobster chains those commands into workflows like `weekly-review`, `inbox-triage`, `memory-consolidation`, and `shared-task-sync`, each with approval gates. AI handles judgment (categorization) when available and falls back to deterministic rules when not.
|
||||
|
||||
- Thread: https://x.com/plattenschieber/status/2014508656335770033
|
||||
- Repo: https://github.com/bloomedai/brain-cli
|
||||
|
||||
@@ -38,12 +38,10 @@ applies: workspace wins, then managed/local, then bundled.
|
||||
|
||||
## Plugins + skills
|
||||
|
||||
Plugins can ship their own skills by listing `skills` directories in
|
||||
`clawdbot.plugin.json` (paths relative to the plugin root). Plugin skills load
|
||||
when the plugin is enabled and participate in the normal skill precedence rules.
|
||||
You can gate them via `metadata.clawdbot.requires.config` on the plugin’s config
|
||||
entry. See [Plugins](/plugin) for discovery/config and [Tools](/tools) for the
|
||||
tool surface those skills teach.
|
||||
Plugins can ship their own skills (for example, `voice-call`) and gate them via
|
||||
`metadata.clawdbot.requires.config` on the plugin’s config entry. See
|
||||
[Plugins](/plugin) for plugin discovery/config and [Tools](/tools) for the tool
|
||||
surface those skills teach.
|
||||
|
||||
## ClawdHub (install + sync)
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ Notes:
|
||||
- `/skill <name> [input]` runs a skill by name (useful when native command limits prevent per-skill commands).
|
||||
- By default, skill commands are forwarded to the model as a normal request.
|
||||
- Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model).
|
||||
- Example: `/prose` (OpenProse plugin) — see [OpenProse](/prose).
|
||||
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
|
||||
|
||||
## Usage surfaces (what shows where)
|
||||
|
||||
@@ -88,12 +88,6 @@ Session lifecycle:
|
||||
- `/settings`
|
||||
- `/exit`
|
||||
|
||||
## Local shell commands
|
||||
- Prefix a line with `!` to run a local shell command on the TUI host.
|
||||
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
|
||||
- Commands run in a fresh, non-interactive shell in the TUI working directory (no persistent `cd`/env).
|
||||
- A lone `!` is sent as a normal message; leading spaces do not trigger local exec.
|
||||
|
||||
## Tool output
|
||||
- Tool calls show as cards with args + results.
|
||||
- Ctrl+O toggles between collapsed/expanded views.
|
||||
|
||||
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
## What it can do (today)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Channels: WhatsApp/Telegram status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
|
||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||
|
||||
@@ -521,42 +521,6 @@ describe("bluebubblesMessageActions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes asVoice through sendAttachment", async () => {
|
||||
const { sendBlueBubblesAttachment } = await import("./attachments.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const base64Buffer = Buffer.from("voice").toString("base64");
|
||||
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "sendAttachment",
|
||||
params: {
|
||||
to: "+15551234567",
|
||||
filename: "voice.mp3",
|
||||
buffer: base64Buffer,
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: "voice.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when buffer is missing for setGroupIcon", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BLUEBUBBLES_ACTIONS,
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readBooleanParam,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
@@ -50,17 +51,6 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
||||
}
|
||||
|
||||
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const raw = params[key];
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Supported action names for BlueBubbles */
|
||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||
|
||||
@@ -366,7 +356,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
const caption = readStringParam(params, "caption");
|
||||
const contentType =
|
||||
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
||||
const asVoice = readBooleanParam(params, "asVoice");
|
||||
|
||||
// Buffer can come from params.buffer (base64) or params.path (file path)
|
||||
const base64Buffer = readStringParam(params, "buffer");
|
||||
@@ -391,7 +380,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
filename,
|
||||
contentType: contentType ?? undefined,
|
||||
caption: caption ?? undefined,
|
||||
asVoice: asVoice ?? undefined,
|
||||
opts,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
@@ -238,109 +238,3 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
expect(result.buffer).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function decodeBody(body: Uint8Array) {
|
||||
return Buffer.from(body).toString("utf8");
|
||||
}
|
||||
|
||||
it("marks voice memos when asVoice is true and mp3 is provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "voice.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('name="isAudioMessage"');
|
||||
expect(bodyText).toContain("true");
|
||||
expect(bodyText).toContain('filename="voice.mp3"');
|
||||
});
|
||||
|
||||
it("normalizes mp3 filenames for voice memos", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "voice",
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('filename="voice.mp3"');
|
||||
expect(bodyText).toContain('name="voice.mp3"');
|
||||
});
|
||||
|
||||
it("throws when asVoice is true but media is not audio", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "image.png",
|
||||
contentType: "image/png",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
}),
|
||||
).rejects.toThrow("voice messages require audio");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when asVoice is true but audio is not mp3 or caf", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "voice.wav",
|
||||
contentType: "audio/wav",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
}),
|
||||
).rejects.toThrow("require mp3 or caf");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitizes filenames before sending", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "../evil.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('filename="evil.mp3"');
|
||||
expect(bodyText).toContain('name="evil.mp3"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
@@ -20,30 +19,6 @@ export type BlueBubblesAttachmentOpts = {
|
||||
};
|
||||
|
||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
|
||||
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
||||
|
||||
function sanitizeFilename(input: string | undefined, fallback: string): string {
|
||||
const trimmed = input?.trim() ?? "";
|
||||
const base = trimmed ? path.basename(trimmed) : "";
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
||||
const currentExt = path.extname(filename);
|
||||
if (currentExt.toLowerCase() === extension) return filename;
|
||||
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
|
||||
return `${base || fallbackBase}${extension}`;
|
||||
}
|
||||
|
||||
function resolveVoiceInfo(filename: string, contentType?: string) {
|
||||
const normalizedType = contentType?.trim().toLowerCase();
|
||||
const extension = path.extname(filename).toLowerCase();
|
||||
const isMp3 = extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
|
||||
const isCaf = extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
|
||||
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
|
||||
return { isAudio, isMp3, isCaf };
|
||||
}
|
||||
|
||||
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
@@ -129,7 +104,6 @@ function extractMessageId(payload: unknown): string {
|
||||
/**
|
||||
* Send an attachment via BlueBubbles API.
|
||||
* Supports sending media files (images, videos, audio, documents) to a chat.
|
||||
* When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo.
|
||||
*/
|
||||
export async function sendBlueBubblesAttachment(params: {
|
||||
to: string;
|
||||
@@ -139,37 +113,12 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
caption?: string;
|
||||
replyToMessageGuid?: string;
|
||||
replyToPartIndex?: number;
|
||||
asVoice?: boolean;
|
||||
opts?: BlueBubblesAttachmentOpts;
|
||||
}): Promise<SendBlueBubblesAttachmentResult> {
|
||||
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
|
||||
let { buffer, filename, contentType } = params;
|
||||
const wantsVoice = asVoice === true;
|
||||
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
||||
filename = sanitizeFilename(filename, fallbackName);
|
||||
contentType = contentType?.trim() || undefined;
|
||||
const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
|
||||
params;
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
|
||||
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
||||
const isAudioMessage = wantsVoice;
|
||||
if (isAudioMessage) {
|
||||
const voiceInfo = resolveVoiceInfo(filename, contentType);
|
||||
if (!voiceInfo.isAudio) {
|
||||
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
|
||||
}
|
||||
if (voiceInfo.isMp3) {
|
||||
filename = ensureExtension(filename, ".mp3", fallbackName);
|
||||
contentType = contentType ?? "audio/mpeg";
|
||||
} else if (voiceInfo.isCaf) {
|
||||
filename = ensureExtension(filename, ".caf", fallbackName);
|
||||
contentType = contentType ?? "audio/x-caf";
|
||||
} else {
|
||||
throw new Error(
|
||||
"BlueBubbles voice messages require mp3 or caf audio (convert before sending).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const target = resolveSendTarget(to);
|
||||
const chatGuid = await resolveChatGuidForTarget({
|
||||
baseUrl,
|
||||
@@ -221,11 +170,6 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
||||
addField("method", "private-api");
|
||||
|
||||
// Add isAudioMessage flag for voice memos
|
||||
if (isAudioMessage) {
|
||||
addField("isAudioMessage", "true");
|
||||
}
|
||||
|
||||
const trimmedReplyTo = replyToMessageGuid?.trim();
|
||||
if (trimmedReplyTo) {
|
||||
addField("selectedMessageGuid", trimmedReplyTo);
|
||||
|
||||
@@ -59,7 +59,6 @@ export async function sendBlueBubblesMedia(params: {
|
||||
caption?: string;
|
||||
replyToId?: string | null;
|
||||
accountId?: string;
|
||||
asVoice?: boolean;
|
||||
}) {
|
||||
const {
|
||||
cfg,
|
||||
@@ -72,7 +71,6 @@ export async function sendBlueBubblesMedia(params: {
|
||||
caption,
|
||||
replyToId,
|
||||
accountId,
|
||||
asVoice,
|
||||
} = params;
|
||||
const core = getBlueBubblesRuntime();
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
@@ -148,7 +146,6 @@ export async function sendBlueBubblesMedia(params: {
|
||||
filename: resolvedFilename ?? "attachment",
|
||||
contentType: resolvedContentType ?? undefined,
|
||||
replyToMessageGuid,
|
||||
asVoice,
|
||||
opts: {
|
||||
cfg,
|
||||
accountId,
|
||||
|
||||
@@ -159,7 +159,6 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
|
||||
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
|
||||
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
|
||||
pipeline: Type.Optional(Type.String()),
|
||||
argsJson: Type.Optional(Type.String()),
|
||||
token: Type.Optional(Type.String()),
|
||||
approve: Type.Optional(Type.Boolean()),
|
||||
lobsterPath: Type.Optional(Type.String()),
|
||||
@@ -182,12 +181,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
|
||||
if (action === "run") {
|
||||
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
||||
if (!pipeline.trim()) throw new Error("pipeline required");
|
||||
const argv = ["run", "--mode", "tool", pipeline];
|
||||
const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
|
||||
if (argsJson.trim()) {
|
||||
argv.push("--args-json", argsJson);
|
||||
}
|
||||
return argv;
|
||||
return ["run", "--mode", "tool", pipeline];
|
||||
}
|
||||
if (action === "resume") {
|
||||
const token = typeof params.token === "string" ? params.token : "";
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("../credentials.js");
|
||||
} = await import("./credentials.js");
|
||||
|
||||
const cached = loadMatrixCredentials(env);
|
||||
const cachedCredentials =
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MatrixClient } from "matrix-bot-sdk";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
|
||||
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
|
||||
});
|
||||
|
||||
describe("resolveMatrixRoomId", () => {
|
||||
it("uses m.direct when available", async () => {
|
||||
const userId = "@user:example.org";
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockResolvedValue({
|
||||
[userId]: ["!room:example.org"],
|
||||
}),
|
||||
getJoinedRooms: vi.fn(),
|
||||
getJoinedRoomMembers: vi.fn(),
|
||||
setAccountData: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const roomId = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(roomId).toBe("!room:example.org");
|
||||
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to joined rooms and persists m.direct", async () => {
|
||||
const userId = "@fallback:example.org";
|
||||
const roomId = "!room:example.org";
|
||||
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||
"@bot:example.org",
|
||||
userId,
|
||||
]),
|
||||
setAccountData,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
expect(setAccountData).toHaveBeenCalledWith(
|
||||
EventType.Direct,
|
||||
expect.objectContaining({ [userId]: [roomId] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues when a room member lookup fails", async () => {
|
||||
const userId = "@continue:example.org";
|
||||
const roomId = "!good:example.org";
|
||||
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||
const getJoinedRoomMembers = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce(["@bot:example.org", userId]);
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
|
||||
getJoinedRoomMembers,
|
||||
setAccountData,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
expect(setAccountData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows larger rooms when no 1:1 match exists", async () => {
|
||||
const userId = "@group:example.org";
|
||||
const roomId = "!group:example.org";
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||
"@bot:example.org",
|
||||
userId,
|
||||
"@extra:example.org",
|
||||
]),
|
||||
setAccountData: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeThreadId", () => {
|
||||
it("returns null for empty thread ids", () => {
|
||||
expect(normalizeThreadId(" ")).toBeNull();
|
||||
expect(normalizeThreadId("$thread")).toBe("$thread");
|
||||
});
|
||||
});
|
||||
@@ -16,100 +16,22 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
const directRoomCache = new Map<string, string>();
|
||||
|
||||
async function persistDirectRoom(
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
): Promise<void> {
|
||||
let directContent: MatrixDirectAccountData | null = null;
|
||||
try {
|
||||
directContent = (await client.getAccountData(
|
||||
EventType.Direct,
|
||||
)) as MatrixDirectAccountData | null;
|
||||
} catch {
|
||||
// Ignore fetch errors and fall back to an empty map.
|
||||
}
|
||||
const existing =
|
||||
directContent && !Array.isArray(directContent) ? directContent : {};
|
||||
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
||||
if (current[0] === roomId) return;
|
||||
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
||||
try {
|
||||
await client.setAccountData(EventType.Direct, {
|
||||
...existing,
|
||||
[userId]: next,
|
||||
});
|
||||
} catch {
|
||||
// Ignore persistence errors.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectRoomId(
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
|
||||
const trimmed = userId.trim();
|
||||
if (!trimmed.startsWith("@")) {
|
||||
throw new Error(
|
||||
`Matrix user IDs must be fully qualified (got "${trimmed}")`,
|
||||
);
|
||||
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
|
||||
}
|
||||
|
||||
const cached = directRoomCache.get(trimmed);
|
||||
if (cached) return cached;
|
||||
|
||||
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
||||
// matrix-bot-sdk: use getAccountData to retrieve m.direct
|
||||
try {
|
||||
const directContent = (await client.getAccountData(
|
||||
EventType.Direct,
|
||||
)) as MatrixDirectAccountData | null;
|
||||
const list = Array.isArray(directContent?.[trimmed])
|
||||
? directContent[trimmed]
|
||||
: [];
|
||||
if (list.length > 0) {
|
||||
directRoomCache.set(trimmed, list[0]);
|
||||
return list[0];
|
||||
}
|
||||
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
|
||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
||||
if (list.length > 0) return list[0];
|
||||
} catch {
|
||||
// Ignore and fall back.
|
||||
// Ignore errors, try fetching from server
|
||||
}
|
||||
|
||||
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
|
||||
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
|
||||
let fallbackRoom: string | null = null;
|
||||
try {
|
||||
const rooms = await client.getJoinedRooms();
|
||||
for (const roomId of rooms) {
|
||||
let members: string[];
|
||||
try {
|
||||
members = await client.getJoinedRoomMembers(roomId);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!members.includes(trimmed)) continue;
|
||||
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
||||
if (members.length === 2) {
|
||||
directRoomCache.set(trimmed, roomId);
|
||||
await persistDirectRoom(client, trimmed, roomId);
|
||||
return roomId;
|
||||
}
|
||||
if (!fallbackRoom) {
|
||||
fallbackRoom = roomId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back.
|
||||
}
|
||||
|
||||
if (fallbackRoom) {
|
||||
directRoomCache.set(trimmed, fallbackRoom);
|
||||
await persistDirectRoom(client, trimmed, fallbackRoom);
|
||||
return fallbackRoom;
|
||||
}
|
||||
|
||||
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
|
||||
throw new Error(
|
||||
`No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveMatrixRoomId(
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "mattermost",
|
||||
"channels": [
|
||||
"mattermost"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { mattermostPlugin } from "./src/channel.js";
|
||||
import { setMattermostRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "mattermost",
|
||||
name: "Mattermost",
|
||||
description: "Mattermost channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMattermostRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: mattermostPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "@clawdbot/mattermost",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Mattermost channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "mattermost",
|
||||
"label": "Mattermost",
|
||||
"selectionLabel": "Mattermost (plugin)",
|
||||
"docsPath": "/channels/mattermost",
|
||||
"docsLabel": "mattermost",
|
||||
"blurb": "self-hosted Slack-style chat; install the plugin to enable.",
|
||||
"order": 65
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@clawdbot/mattermost",
|
||||
"localPath": "extensions/mattermost",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mattermostPlugin } from "./channel.js";
|
||||
|
||||
describe("mattermostPlugin", () => {
|
||||
describe("messaging", () => {
|
||||
it("keeps @username targets", () => {
|
||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) return;
|
||||
|
||||
expect(normalize("@Alice")).toBe("@Alice");
|
||||
expect(normalize("@alice")).toBe("@alice");
|
||||
});
|
||||
|
||||
it("normalizes mattermost: prefix to user:", () => {
|
||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) return;
|
||||
|
||||
expect(normalize("mattermost:USER123")).toBe("user:USER123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairing", () => {
|
||||
it("normalizes allowlist entries", () => {
|
||||
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
|
||||
if (!normalize) return;
|
||||
|
||||
expect(normalize("@Alice")).toBe("alice");
|
||||
expect(normalize("user:USER123")).toBe("user123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
it("formats allowFrom entries", () => {
|
||||
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom;
|
||||
|
||||
const formatted = formatAllowFrom({
|
||||
allowFrom: ["@Alice", "user:USER123", "mattermost:BOT999"],
|
||||
});
|
||||
expect(formatted).toEqual(["@alice", "user123", "bot999"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { MattermostConfigSchema } from "./config-schema.js";
|
||||
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
|
||||
import {
|
||||
looksLikeMattermostTargetId,
|
||||
normalizeMattermostMessagingTarget,
|
||||
} from "./normalize.js";
|
||||
import { mattermostOnboardingAdapter } from "./onboarding.js";
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
type ResolvedMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
||||
import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||
import { probeMattermost } from "./mattermost/probe.js";
|
||||
import { sendMessageMattermost } from "./mattermost/send.js";
|
||||
import { getMattermostRuntime } from "./runtime.js";
|
||||
|
||||
const meta = {
|
||||
id: "mattermost",
|
||||
label: "Mattermost",
|
||||
selectionLabel: "Mattermost (plugin)",
|
||||
detailLabel: "Mattermost Bot",
|
||||
docsPath: "/channels/mattermost",
|
||||
docsLabel: "mattermost",
|
||||
blurb: "self-hosted Slack-style chat; install the plugin to enable.",
|
||||
systemImage: "bubble.left.and.bubble.right",
|
||||
order: 65,
|
||||
quickstartAllowFrom: true,
|
||||
} as const;
|
||||
|
||||
function normalizeAllowEntry(entry: string): string {
|
||||
return entry
|
||||
.trim()
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function formatAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.startsWith("@")) {
|
||||
const username = trimmed.slice(1).trim();
|
||||
return username ? `@${username.toLowerCase()}` : "";
|
||||
}
|
||||
return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase();
|
||||
}
|
||||
|
||||
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
id: "mattermost",
|
||||
meta: {
|
||||
...meta,
|
||||
},
|
||||
onboarding: mattermostOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "mattermostUserId",
|
||||
normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
|
||||
notifyApproval: async ({ id }) => {
|
||||
console.log(`[mattermost] User ${id} approved for pairing`);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "group", "thread"],
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.mattermost"] },
|
||||
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listMattermostAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "mattermost",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "mattermost",
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "baseUrl", "name"],
|
||||
}),
|
||||
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.botToken && account.baseUrl),
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveMattermostAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
||||
String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => formatAllowEntry(String(entry)))
|
||||
.filter(Boolean),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(cfg.channels?.mattermost?.accounts?.[resolvedAccountId]);
|
||||
const basePath = useAccountPath
|
||||
? `channels.mattermost.accounts.${resolvedAccountId}.`
|
||||
: "channels.mattermost.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: formatPairingApproveHint("mattermost"),
|
||||
normalizeEntry: (raw) => normalizeAllowEntry(raw),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") return [];
|
||||
return [
|
||||
`- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`,
|
||||
];
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMattermostMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeMattermostTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||
const result = await sendMessageMattermost(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "mattermost", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const result = await sendMessageMattermost(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
replyToId: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "mattermost", ...result };
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
connected: false,
|
||||
lastConnectedAt: null,
|
||||
lastDisconnect: null,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
botTokenSource: snapshot.botTokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
connected: snapshot.connected ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
baseUrl: snapshot.baseUrl ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) => {
|
||||
const token = account.botToken?.trim();
|
||||
const baseUrl = account.baseUrl?.trim();
|
||||
if (!token || !baseUrl) {
|
||||
return { ok: false, error: "bot token or baseUrl missing" };
|
||||
}
|
||||
return await probeMattermost(baseUrl, token, timeoutMs);
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.botToken && account.baseUrl),
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
running: runtime?.running ?? false,
|
||||
connected: runtime?.connected ?? false,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
lastDisconnect: runtime?.lastDisconnect ?? null,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "mattermost",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "Mattermost env vars can only be used for the default account.";
|
||||
}
|
||||
const token = input.botToken ?? input.token;
|
||||
const baseUrl = input.httpUrl;
|
||||
if (!input.useEnv && (!token || !baseUrl)) {
|
||||
return "Mattermost requires --bot-token and --http-url (or --use-env).";
|
||||
}
|
||||
if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) {
|
||||
return "Mattermost --http-url must include a valid base URL.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const token = input.botToken ?? input.token;
|
||||
const baseUrl = input.httpUrl?.trim();
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "mattermost",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "mattermost",
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
...(input.useEnv
|
||||
? {}
|
||||
: {
|
||||
...(token ? { botToken: token } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.mattermost?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.mattermost?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...(token ? { botToken: token } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
baseUrl: account.baseUrl,
|
||||
botTokenSource: account.botTokenSource,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting channel`);
|
||||
return monitorMattermostProvider({
|
||||
botToken: account.botToken ?? undefined,
|
||||
baseUrl: account.baseUrl ?? undefined,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
|
||||
oncharPrefixes: z.array(z.string()).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMattermostAccount } from "./mattermost/accounts.js";
|
||||
|
||||
export function resolveMattermostGroupRequireMention(
|
||||
params: ChannelGroupContext,
|
||||
): boolean | undefined {
|
||||
const account = resolveMattermostAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (typeof account.requireMention === "boolean") return account.requireMention;
|
||||
return true;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
|
||||
import { normalizeMattermostBaseUrl } from "./client.js";
|
||||
|
||||
export type MattermostTokenSource = "env" | "config" | "none";
|
||||
export type MattermostBaseUrlSource = "env" | "config" | "none";
|
||||
|
||||
export type ResolvedMattermostAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
botTokenSource: MattermostTokenSource;
|
||||
baseUrlSource: MattermostBaseUrlSource;
|
||||
config: MattermostAccountConfig;
|
||||
chatmode?: MattermostChatMode;
|
||||
oncharPrefixes?: string[];
|
||||
requireMention?: boolean;
|
||||
textChunkLimit?: number;
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = cfg.channels?.mattermost?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listMattermostAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||
return ids.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultMattermostAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listMattermostAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig | undefined {
|
||||
const accounts = cfg.channels?.mattermost?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as MattermostAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeMattermostAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ??
|
||||
{}) as MattermostAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
||||
if (config.chatmode === "oncall") return true;
|
||||
if (config.chatmode === "onmessage") return false;
|
||||
if (config.chatmode === "onchar") return true;
|
||||
return config.requireMention;
|
||||
}
|
||||
|
||||
export function resolveMattermostAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedMattermostAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
|
||||
const merged = mergeMattermostAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
|
||||
const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
|
||||
const configToken = merged.botToken?.trim();
|
||||
const configUrl = merged.baseUrl?.trim();
|
||||
const botToken = configToken || envToken;
|
||||
const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
|
||||
const requireMention = resolveMattermostRequireMention(merged);
|
||||
|
||||
const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
|
||||
const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
botToken,
|
||||
baseUrl,
|
||||
botTokenSource,
|
||||
baseUrlSource,
|
||||
config: merged,
|
||||
chatmode: merged.chatmode,
|
||||
oncharPrefixes: merged.oncharPrefixes,
|
||||
requireMention,
|
||||
textChunkLimit: merged.textChunkLimit,
|
||||
blockStreaming: merged.blockStreaming,
|
||||
blockStreamingCoalesce: merged.blockStreamingCoalesce,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMattermostAccount[] {
|
||||
return listMattermostAccountIds(cfg)
|
||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
export type MattermostClient = {
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
token: string;
|
||||
request: <T>(path: string, init?: RequestInit) => Promise<T>;
|
||||
};
|
||||
|
||||
export type MattermostUser = {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
nickname?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
};
|
||||
|
||||
export type MattermostChannel = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
display_name?: string | null;
|
||||
type?: string | null;
|
||||
team_id?: string | null;
|
||||
};
|
||||
|
||||
export type MattermostPost = {
|
||||
id: string;
|
||||
user_id?: string | null;
|
||||
channel_id?: string | null;
|
||||
message?: string | null;
|
||||
file_ids?: string[] | null;
|
||||
type?: string | null;
|
||||
root_id?: string | null;
|
||||
create_at?: number | null;
|
||||
props?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type MattermostFileInfo = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
mime_type?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const withoutTrailing = trimmed.replace(/\/+$/, "");
|
||||
return withoutTrailing.replace(/\/api\/v4$/i, "");
|
||||
}
|
||||
|
||||
function buildMattermostApiUrl(baseUrl: string, path: string): string {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) throw new Error("Mattermost baseUrl is required");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${normalized}/api/v4${suffix}`;
|
||||
}
|
||||
|
||||
async function readMattermostError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await res.json()) as { message?: string } | undefined;
|
||||
if (data?.message) return data.message;
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export function createMattermostClient(params: {
|
||||
baseUrl: string;
|
||||
botToken: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): MattermostClient {
|
||||
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
|
||||
if (!baseUrl) throw new Error("Mattermost baseUrl is required");
|
||||
const apiBaseUrl = `${baseUrl}/api/v4`;
|
||||
const token = params.botToken.trim();
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const url = buildMattermostApiUrl(baseUrl, path);
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (typeof init?.body === "string" && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const res = await fetchImpl(url, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(
|
||||
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
return { baseUrl, apiBaseUrl, token, request };
|
||||
}
|
||||
|
||||
export async function fetchMattermostMe(client: MattermostClient): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>("/users/me");
|
||||
}
|
||||
|
||||
export async function fetchMattermostUser(
|
||||
client: MattermostClient,
|
||||
userId: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostUserByUsername(
|
||||
client: MattermostClient,
|
||||
username: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/username/${encodeURIComponent(username)}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostChannel(
|
||||
client: MattermostClient,
|
||||
channelId: string,
|
||||
): Promise<MattermostChannel> {
|
||||
return await client.request<MattermostChannel>(`/channels/${channelId}`);
|
||||
}
|
||||
|
||||
export async function sendMattermostTyping(
|
||||
client: MattermostClient,
|
||||
params: { channelId: string; parentId?: string },
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = {
|
||||
channel_id: params.channelId,
|
||||
};
|
||||
const parentId = params.parentId?.trim();
|
||||
if (parentId) payload.parent_id = parentId;
|
||||
await client.request<Record<string, unknown>>("/users/me/typing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMattermostDirectChannel(
|
||||
client: MattermostClient,
|
||||
userIds: string[],
|
||||
): Promise<MattermostChannel> {
|
||||
return await client.request<MattermostChannel>("/channels/direct", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userIds),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMattermostPost(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
message: string;
|
||||
rootId?: string;
|
||||
fileIds?: string[];
|
||||
},
|
||||
): Promise<MattermostPost> {
|
||||
const payload: Record<string, string> = {
|
||||
channel_id: params.channelId,
|
||||
message: params.message,
|
||||
};
|
||||
if (params.rootId) payload.root_id = params.rootId;
|
||||
if (params.fileIds?.length) {
|
||||
(payload as Record<string, unknown>).file_ids = params.fileIds;
|
||||
}
|
||||
return await client.request<MattermostPost>("/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMattermostFile(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
contentType?: string;
|
||||
},
|
||||
): Promise<MattermostFileInfo> {
|
||||
const form = new FormData();
|
||||
const fileName = params.fileName?.trim() || "upload";
|
||||
const bytes = Uint8Array.from(params.buffer);
|
||||
const blob = params.contentType
|
||||
? new Blob([bytes], { type: params.contentType })
|
||||
: new Blob([bytes]);
|
||||
form.append("files", blob, fileName);
|
||||
form.append("channel_id", params.channelId);
|
||||
|
||||
const res = await fetch(`${client.apiBaseUrl}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { file_infos?: MattermostFileInfo[] };
|
||||
const info = data.file_infos?.[0];
|
||||
if (!info?.id) {
|
||||
throw new Error("Mattermost file upload failed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export {
|
||||
listEnabledMattermostAccounts,
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
} from "./accounts.js";
|
||||
export { monitorMattermostProvider } from "./monitor.js";
|
||||
export { probeMattermost } from "./probe.js";
|
||||
export { sendMessageMattermost } from "./send.js";
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import type WebSocket from "ws";
|
||||
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type ResponsePrefixContext = {
|
||||
model?: string;
|
||||
modelFull?: string;
|
||||
provider?: string;
|
||||
thinkingLevel?: string;
|
||||
identityName?: string;
|
||||
};
|
||||
|
||||
export function extractShortModelName(fullModel: string): string {
|
||||
const slash = fullModel.lastIndexOf("/");
|
||||
const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
|
||||
return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
|
||||
}
|
||||
|
||||
export function formatInboundFromLabel(params: {
|
||||
isGroup: boolean;
|
||||
groupLabel?: string;
|
||||
groupId?: string;
|
||||
directLabel: string;
|
||||
directId?: string;
|
||||
groupFallback?: string;
|
||||
}): string {
|
||||
if (params.isGroup) {
|
||||
const label = params.groupLabel?.trim() || params.groupFallback || "Group";
|
||||
const id = params.groupId?.trim();
|
||||
return id ? `${label} id:${id}` : label;
|
||||
}
|
||||
|
||||
const directLabel = params.directLabel.trim();
|
||||
const directId = params.directId?.trim();
|
||||
if (!directId || directId === directLabel) return directLabel;
|
||||
return `${directLabel} id:${directId}`;
|
||||
}
|
||||
|
||||
type DedupeCache = {
|
||||
check: (key: string | undefined | null, now?: number) => boolean;
|
||||
};
|
||||
|
||||
export function createDedupeCache(options: { ttlMs: number; maxSize: number }): DedupeCache {
|
||||
const ttlMs = Math.max(0, options.ttlMs);
|
||||
const maxSize = Math.max(0, Math.floor(options.maxSize));
|
||||
const cache = new Map<string, number>();
|
||||
|
||||
const touch = (key: string, now: number) => {
|
||||
cache.delete(key);
|
||||
cache.set(key, now);
|
||||
};
|
||||
|
||||
const prune = (now: number) => {
|
||||
const cutoff = ttlMs > 0 ? now - ttlMs : undefined;
|
||||
if (cutoff !== undefined) {
|
||||
for (const [entryKey, entryTs] of cache) {
|
||||
if (entryTs < cutoff) {
|
||||
cache.delete(entryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxSize <= 0) {
|
||||
cache.clear();
|
||||
return;
|
||||
}
|
||||
while (cache.size > maxSize) {
|
||||
const oldestKey = cache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
check: (key, now = Date.now()) => {
|
||||
if (!key) return false;
|
||||
const existing = cache.get(key);
|
||||
if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
|
||||
touch(key, now);
|
||||
return true;
|
||||
}
|
||||
touch(key, now);
|
||||
prune(now);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function rawDataToString(
|
||||
data: WebSocket.RawData,
|
||||
encoding: BufferEncoding = "utf8",
|
||||
): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString(encoding);
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString(encoding);
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString(encoding);
|
||||
}
|
||||
return Buffer.from(String(data)).toString(encoding);
|
||||
}
|
||||
|
||||
function normalizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return "main";
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.slice(0, 64) || "main"
|
||||
);
|
||||
}
|
||||
|
||||
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
|
||||
|
||||
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
|
||||
const list = cfg.agents?.list;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
||||
}
|
||||
|
||||
function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
|
||||
}
|
||||
|
||||
export function resolveIdentityName(cfg: ClawdbotConfig, agentId: string): string | undefined {
|
||||
const entry = resolveAgentEntry(cfg, agentId);
|
||||
return entry?.identity?.name?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function resolveThreadSessionKeys(params: {
|
||||
baseSessionKey: string;
|
||||
threadId?: string | null;
|
||||
parentSessionKey?: string;
|
||||
useSuffix?: boolean;
|
||||
}): { sessionKey: string; parentSessionKey?: string } {
|
||||
const threadId = (params.threadId ?? "").trim();
|
||||
if (!threadId) {
|
||||
return { sessionKey: params.baseSessionKey, parentSessionKey: undefined };
|
||||
}
|
||||
const useSuffix = params.useSuffix ?? true;
|
||||
const sessionKey = useSuffix
|
||||
? `${params.baseSessionKey}:thread:${threadId}`
|
||||
: params.baseSessionKey;
|
||||
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
||||
}
|
||||
@@ -1,905 +0,0 @@
|
||||
import WebSocket from "ws";
|
||||
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ClawdbotConfig,
|
||||
ReplyPayload,
|
||||
RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntries,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
recordPendingHistoryEntry,
|
||||
resolveChannelMediaMaxBytes,
|
||||
type HistoryEntry,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostChannel,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUser,
|
||||
normalizeMattermostBaseUrl,
|
||||
sendMattermostTyping,
|
||||
type MattermostChannel,
|
||||
type MattermostPost,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
import {
|
||||
createDedupeCache,
|
||||
extractShortModelName,
|
||||
formatInboundFromLabel,
|
||||
rawDataToString,
|
||||
resolveIdentityName,
|
||||
resolveThreadSessionKeys,
|
||||
type ResponsePrefixContext,
|
||||
} from "./monitor-helpers.js";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
|
||||
export type MonitorMattermostOpts = {
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
||||
};
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||
|
||||
type MattermostEventPayload = {
|
||||
event?: string;
|
||||
data?: {
|
||||
post?: string;
|
||||
channel_id?: string;
|
||||
channel_name?: string;
|
||||
channel_display_name?: string;
|
||||
channel_type?: string;
|
||||
sender_name?: string;
|
||||
team_id?: string;
|
||||
};
|
||||
broadcast?: {
|
||||
channel_id?: string;
|
||||
team_id?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
|
||||
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
|
||||
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
||||
const USER_CACHE_TTL_MS = 10 * 60_000;
|
||||
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
|
||||
|
||||
const recentInboundMessages = createDedupeCache({
|
||||
ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS,
|
||||
maxSize: RECENT_MATTERMOST_MESSAGE_MAX,
|
||||
});
|
||||
|
||||
function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
|
||||
return (
|
||||
opts.runtime ?? {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMention(text: string, mention: string | undefined): string {
|
||||
if (!mention) return text.trim();
|
||||
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`@${escaped}\\b`, "gi");
|
||||
return text.replace(re, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
|
||||
const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES;
|
||||
return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
|
||||
}
|
||||
|
||||
function stripOncharPrefix(
|
||||
text: string,
|
||||
prefixes: string[],
|
||||
): { triggered: boolean; stripped: string } {
|
||||
const trimmed = text.trimStart();
|
||||
for (const prefix of prefixes) {
|
||||
if (!prefix) continue;
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return {
|
||||
triggered: true,
|
||||
stripped: trimmed.slice(prefix.length).trimStart(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { triggered: false, stripped: text };
|
||||
}
|
||||
|
||||
function isSystemPost(post: MattermostPost): boolean {
|
||||
const type = post.type?.trim();
|
||||
return Boolean(type);
|
||||
}
|
||||
|
||||
function channelKind(channelType?: string | null): "dm" | "group" | "channel" {
|
||||
if (!channelType) return "channel";
|
||||
const normalized = channelType.trim().toUpperCase();
|
||||
if (normalized === "D") return "dm";
|
||||
if (normalized === "G") return "group";
|
||||
return "channel";
|
||||
}
|
||||
|
||||
function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" {
|
||||
if (kind === "dm") return "direct";
|
||||
if (kind === "group") return "group";
|
||||
return "channel";
|
||||
}
|
||||
|
||||
function normalizeAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed === "*") return "*";
|
||||
return trimmed
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeAllowList(entries: Array<string | number>): string[] {
|
||||
const normalized = entries
|
||||
.map((entry) => normalizeAllowEntry(String(entry)))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
function isSenderAllowed(params: {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
allowFrom: string[];
|
||||
}): boolean {
|
||||
const allowFrom = params.allowFrom;
|
||||
if (allowFrom.length === 0) return false;
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedSenderId = normalizeAllowEntry(params.senderId);
|
||||
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
||||
return allowFrom.some(
|
||||
(entry) =>
|
||||
entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName),
|
||||
);
|
||||
}
|
||||
|
||||
type MattermostMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
kind: MediaKind;
|
||||
};
|
||||
|
||||
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
|
||||
if (mediaList.length === 0) return "";
|
||||
if (mediaList.length === 1) {
|
||||
const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
|
||||
return `<media:${kind}>`;
|
||||
}
|
||||
const allImages = mediaList.every((media) => media.kind === "image");
|
||||
const label = allImages ? "image" : "file";
|
||||
const suffix = mediaList.length === 1 ? label : `${label}s`;
|
||||
const tag = allImages ? "<media:image>" : "<media:document>";
|
||||
return `${tag} (${mediaList.length} ${suffix})`;
|
||||
}
|
||||
|
||||
function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
const first = mediaList[0];
|
||||
const mediaPaths = mediaList.map((media) => media.path);
|
||||
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
||||
return {
|
||||
MediaPath: first?.path,
|
||||
MediaType: first?.contentType,
|
||||
MediaUrl: first?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMattermostWsUrl(baseUrl: string): string {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) throw new Error("Mattermost baseUrl is required");
|
||||
const wsBase = normalized.replace(/^http/i, "ws");
|
||||
return `${wsBase}/api/v4/websocket`;
|
||||
}
|
||||
|
||||
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise<void> {
|
||||
const core = getMattermostRuntime();
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? core.config.loadConfig();
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const botToken = opts.botToken?.trim() || account.botToken?.trim();
|
||||
if (!botToken) {
|
||||
throw new Error(
|
||||
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken });
|
||||
const botUser = await fetchMattermostMe(client);
|
||||
const botUserId = botUser.id;
|
||||
const botUsername = botUser.username?.trim() || undefined;
|
||||
runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`);
|
||||
|
||||
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) return;
|
||||
logger.debug?.(message);
|
||||
};
|
||||
const mediaMaxBytes =
|
||||
resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: () => undefined,
|
||||
accountId: account.accountId,
|
||||
}) ?? 8 * 1024 * 1024;
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const channelHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
const fetchWithAuth: FetchLike = (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${client.token}`);
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
|
||||
const resolveMattermostMedia = async (
|
||||
fileIds?: string[] | null,
|
||||
): Promise<MattermostMediaInfo[]> => {
|
||||
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean) as string[];
|
||||
if (ids.length === 0) return [];
|
||||
const out: MattermostMediaInfo[] = [];
|
||||
for (const fileId of ids) {
|
||||
try {
|
||||
const fetched = await core.channel.media.fetchRemoteMedia({
|
||||
url: `${client.apiBaseUrl}/files/${fileId}`,
|
||||
fetchImpl: fetchWithAuth,
|
||||
filePathHint: fileId,
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType ?? undefined,
|
||||
"inbound",
|
||||
mediaMaxBytes,
|
||||
);
|
||||
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType,
|
||||
kind: core.media.mediaKindFromMime(contentType),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
|
||||
try {
|
||||
await sendMattermostTyping(client, { channelId, parentId });
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
||||
const cached = channelCache.get(channelId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
try {
|
||||
const info = await fetchMattermostChannel(client, channelId);
|
||||
channelCache.set(channelId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
|
||||
channelCache.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
||||
const cached = userCache.get(userId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
try {
|
||||
const info = await fetchMattermostUser(client, userId);
|
||||
userCache.set(userId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
|
||||
userCache.set(userId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePost = async (
|
||||
post: MattermostPost,
|
||||
payload: MattermostEventPayload,
|
||||
messageIds?: string[],
|
||||
) => {
|
||||
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
|
||||
if (!channelId) return;
|
||||
|
||||
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
|
||||
if (allMessageIds.length === 0) return;
|
||||
const dedupeEntries = allMessageIds.map((id) =>
|
||||
recentInboundMessages.check(`${account.accountId}:${id}`),
|
||||
);
|
||||
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) return;
|
||||
|
||||
const senderId = post.user_id ?? payload.broadcast?.user_id;
|
||||
if (!senderId) return;
|
||||
if (senderId === botUserId) return;
|
||||
if (isSystemPost(post)) return;
|
||||
|
||||
const channelInfo = await resolveChannelInfo(channelId);
|
||||
const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
|
||||
const kind = channelKind(channelType);
|
||||
const chatType = channelChatType(kind);
|
||||
|
||||
const senderName =
|
||||
payload.data?.sender_name?.trim() ||
|
||||
(await resolveUserInfo(senderId))?.username?.trim() ||
|
||||
senderId;
|
||||
const rawText = post.message?.trim() || "";
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
|
||||
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
|
||||
const storeAllowFrom = normalizeAllowList(
|
||||
await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
||||
);
|
||||
const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
|
||||
const effectiveGroupAllowFrom = Array.from(
|
||||
new Set([
|
||||
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
||||
...storeAllowFrom,
|
||||
]),
|
||||
);
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "mattermost",
|
||||
});
|
||||
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: effectiveAllowFrom,
|
||||
});
|
||||
const groupAllowedForCommands = isSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
});
|
||||
const commandAuthorized =
|
||||
kind === "dm"
|
||||
? dmPolicy === "open" || senderAllowedForCommands
|
||||
: core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
{
|
||||
configured: effectiveGroupAllowFrom.length > 0,
|
||||
allowed: groupAllowedForCommands,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (kind === "dm") {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`);
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open" && !senderAllowedForCommands) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "mattermost",
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
logVerboseMessage(
|
||||
`mattermost: pairing request sender=${senderId} created=${created}`,
|
||||
);
|
||||
if (created) {
|
||||
try {
|
||||
await sendMessageMattermost(
|
||||
`user:${senderId}`,
|
||||
core.channel.pairing.buildPairingReply({
|
||||
channel: "mattermost",
|
||||
idLine: `Your Mattermost user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
{ accountId: account.accountId },
|
||||
);
|
||||
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerboseMessage(
|
||||
`mattermost: pairing reply failed for ${senderId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerboseMessage(
|
||||
`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)");
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
logVerboseMessage("mattermost: drop group message (no group allowlist)");
|
||||
return;
|
||||
}
|
||||
if (!groupAllowedForCommands) {
|
||||
logVerboseMessage(
|
||||
`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kind !== "dm" && isControlCommand && !commandAuthorized) {
|
||||
logVerboseMessage(
|
||||
`mattermost: drop control command from unauthorized sender ${senderId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
|
||||
const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
|
||||
const channelDisplay =
|
||||
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
|
||||
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
teamId,
|
||||
peer: {
|
||||
kind,
|
||||
id: kind === "dm" ? senderId : channelId,
|
||||
},
|
||||
});
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const threadRootId = post.root_id?.trim() || undefined;
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId: threadRootId,
|
||||
parentSessionKey: threadRootId ? baseSessionKey : undefined,
|
||||
});
|
||||
const sessionKey = threadKeys.sessionKey;
|
||||
const historyKey = kind === "dm" ? null : sessionKey;
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
||||
const wasMentioned =
|
||||
kind !== "dm" &&
|
||||
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
|
||||
core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes));
|
||||
const pendingBody =
|
||||
rawText ||
|
||||
(post.file_ids?.length
|
||||
? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
|
||||
: "");
|
||||
const pendingSender = senderName;
|
||||
const recordPendingHistory = () => {
|
||||
if (!historyKey || historyLimit <= 0) return;
|
||||
const trimmed = pendingBody.trim();
|
||||
if (!trimmed) return;
|
||||
recordPendingHistoryEntry({
|
||||
historyMap: channelHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: pendingSender,
|
||||
body: trimmed,
|
||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
messageId: post.id ?? undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
|
||||
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
|
||||
const oncharResult = oncharEnabled
|
||||
? stripOncharPrefix(rawText, oncharPrefixes)
|
||||
: { triggered: false, stripped: rawText };
|
||||
const oncharTriggered = oncharResult.triggered;
|
||||
|
||||
const shouldRequireMention =
|
||||
kind !== "dm" &&
|
||||
core.channel.groups.resolveRequireMention({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
groupId: channelId,
|
||||
}) !== false;
|
||||
const shouldBypassMention =
|
||||
isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
|
||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||
|
||||
if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) {
|
||||
recordPendingHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind !== "dm" && shouldRequireMention && canDetectMention) {
|
||||
if (!effectiveWasMentioned) {
|
||||
recordPendingHistory();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const mediaList = await resolveMattermostMedia(post.file_ids);
|
||||
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
|
||||
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
|
||||
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
|
||||
const bodyText = normalizeMention(baseText, botUsername);
|
||||
if (!bodyText) return;
|
||||
|
||||
core.channel.activity.record({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
const fromLabel = formatInboundFromLabel({
|
||||
isGroup: kind !== "dm",
|
||||
groupLabel: channelDisplay || roomLabel,
|
||||
groupId: channelId,
|
||||
groupFallback: roomLabel || "Channel",
|
||||
directLabel: senderName,
|
||||
directId: senderId,
|
||||
});
|
||||
|
||||
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel =
|
||||
kind === "dm"
|
||||
? `Mattermost DM from ${senderName}`
|
||||
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
||||
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey,
|
||||
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
|
||||
});
|
||||
|
||||
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
||||
const body = core.channel.reply.formatInboundEnvelope({
|
||||
channel: "Mattermost",
|
||||
from: fromLabel,
|
||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
body: textWithId,
|
||||
chatType,
|
||||
sender: { name: senderName, id: senderId },
|
||||
});
|
||||
let combinedBody = body;
|
||||
if (historyKey && historyLimit > 0) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: channelHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatInboundEnvelope({
|
||||
channel: "Mattermost",
|
||||
from: fromLabel,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.body}${
|
||||
entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : ""
|
||||
}`,
|
||||
chatType,
|
||||
senderLabel: entry.sender,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
|
||||
const mediaPayload = buildMattermostMediaPayload(mediaList);
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
From:
|
||||
kind === "dm"
|
||||
? `mattermost:${senderId}`
|
||||
: kind === "group"
|
||||
? `mattermost:group:${channelId}`
|
||||
: `mattermost:channel:${channelId}`,
|
||||
To: to,
|
||||
SessionKey: sessionKey,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: chatType,
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: kind !== "dm" ? channelDisplay || roomLabel : undefined,
|
||||
GroupChannel: channelName ? `#${channelName}` : undefined,
|
||||
GroupSpace: teamId,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
Provider: "mattermost" as const,
|
||||
Surface: "mattermost" as const,
|
||||
MessageSid: post.id ?? undefined,
|
||||
MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined,
|
||||
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
|
||||
MessageSidLast:
|
||||
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
|
||||
ReplyToId: threadRootId,
|
||||
MessageThreadId: threadRootId,
|
||||
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
WasMentioned: kind !== "dm" ? effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
OriginatingChannel: "mattermost" as const,
|
||||
OriginatingTo: to,
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
if (kind === "dm") {
|
||||
const sessionCfg = cfg.session;
|
||||
const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
await core.channel.session.updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
deliveryContext: {
|
||||
channel: "mattermost",
|
||||
to,
|
||||
accountId: route.accountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(
|
||||
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
||||
);
|
||||
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
||||
fallbackLimit: account.textChunkLimit ?? 4000,
|
||||
});
|
||||
|
||||
let prefixContext: ResponsePrefixContext = {
|
||||
identityName: resolveIdentityName(cfg, route.agentId),
|
||||
};
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||
.responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
if (mediaUrls.length === 0) {
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||
if (!chunk) continue;
|
||||
await sendMessageMattermost(to, chunk, {
|
||||
accountId: account.accountId,
|
||||
replyToId: threadRootId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaUrls) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await sendMessageMattermost(to, caption, {
|
||||
accountId: account.accountId,
|
||||
mediaUrl,
|
||||
replyToId: threadRootId,
|
||||
});
|
||||
}
|
||||
}
|
||||
runtime.log?.(`delivered reply to ${to}`);
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
prefixContext.provider = ctx.provider;
|
||||
prefixContext.model = extractShortModelName(ctx.model);
|
||||
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
|
||||
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
|
||||
},
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
if (historyKey && historyLimit > 0) {
|
||||
clearHistoryEntries({ historyMap: channelHistories, historyKey });
|
||||
}
|
||||
};
|
||||
|
||||
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
});
|
||||
const debouncer = core.channel.debounce.createInboundDebouncer<{
|
||||
post: MattermostPost;
|
||||
payload: MattermostEventPayload;
|
||||
}>({
|
||||
debounceMs: inboundDebounceMs,
|
||||
buildKey: (entry) => {
|
||||
const channelId =
|
||||
entry.post.channel_id ??
|
||||
entry.payload.data?.channel_id ??
|
||||
entry.payload.broadcast?.channel_id;
|
||||
if (!channelId) return null;
|
||||
const threadId = entry.post.root_id?.trim();
|
||||
const threadKey = threadId ? `thread:${threadId}` : "channel";
|
||||
return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
if (entry.post.file_ids && entry.post.file_ids.length > 0) return false;
|
||||
const text = entry.post.message?.trim() ?? "";
|
||||
if (!text) return false;
|
||||
return !core.channel.text.hasControlCommand(text, cfg);
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) return;
|
||||
if (entries.length === 1) {
|
||||
await handlePost(last.post, last.payload);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.post.message?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const mergedPost: MattermostPost = {
|
||||
...last.post,
|
||||
message: combinedText,
|
||||
file_ids: [],
|
||||
};
|
||||
const ids = entries.map((entry) => entry.post.id).filter(Boolean) as string[];
|
||||
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(`mattermost debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const wsUrl = buildMattermostWsUrl(baseUrl);
|
||||
let seq = 1;
|
||||
|
||||
const connectOnce = async (): Promise<void> => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const onAbort = () => ws.close();
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
ws.on("open", () => {
|
||||
opts.statusSink?.({
|
||||
connected: true,
|
||||
lastConnectedAt: Date.now(),
|
||||
lastError: null,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
seq: seq++,
|
||||
action: "authentication_challenge",
|
||||
data: { token: botToken },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
const raw = rawDataToString(data);
|
||||
let payload: MattermostEventPayload;
|
||||
try {
|
||||
payload = JSON.parse(raw) as MattermostEventPayload;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (payload.event !== "posted") return;
|
||||
const postData = payload.data?.post;
|
||||
if (!postData) return;
|
||||
let post: MattermostPost | null = null;
|
||||
if (typeof postData === "string") {
|
||||
try {
|
||||
post = JSON.parse(postData) as MattermostPost;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
} else if (typeof postData === "object") {
|
||||
post = postData as MattermostPost;
|
||||
}
|
||||
if (!post) return;
|
||||
try {
|
||||
await debouncer.enqueue({ post, payload });
|
||||
} catch (err) {
|
||||
runtime.error?.(`mattermost handler failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", (code, reason) => {
|
||||
const message = reason.length > 0 ? reason.toString("utf8") : "";
|
||||
opts.statusSink?.({
|
||||
connected: false,
|
||||
lastDisconnect: {
|
||||
at: Date.now(),
|
||||
status: code,
|
||||
error: message || undefined,
|
||||
},
|
||||
});
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
runtime.error?.(`mattermost websocket error: ${String(err)}`);
|
||||
opts.statusSink?.({
|
||||
lastError: String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
while (!opts.abortSignal?.aborted) {
|
||||
await connectOnce();
|
||||
if (opts.abortSignal?.aborted) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js";
|
||||
|
||||
export type MattermostProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: MattermostUser;
|
||||
};
|
||||
|
||||
async function readMattermostError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await res.json()) as { message?: string } | undefined;
|
||||
if (data?.message) return data.message;
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export async function probeMattermost(
|
||||
baseUrl: string,
|
||||
botToken: string,
|
||||
timeoutMs = 2500,
|
||||
): Promise<MattermostProbe> {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "baseUrl missing" };
|
||||
}
|
||||
const url = `${normalized}/api/v4/users/me`;
|
||||
const start = Date.now();
|
||||
const controller = timeoutMs > 0 ? new AbortController() : undefined;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (controller) {
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
signal: controller?.signal,
|
||||
});
|
||||
const elapsedMs = Date.now() - start;
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error: detail || res.statusText,
|
||||
elapsedMs,
|
||||
};
|
||||
}
|
||||
const bot = (await res.json()) as MattermostUser;
|
||||
return {
|
||||
ok: true,
|
||||
status: res.status,
|
||||
elapsedMs,
|
||||
bot,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: message,
|
||||
elapsedMs: Date.now() - start,
|
||||
};
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
createMattermostDirectChannel,
|
||||
createMattermostPost,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUserByUsername,
|
||||
normalizeMattermostBaseUrl,
|
||||
uploadMattermostFile,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
};
|
||||
|
||||
export type MattermostSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
type MattermostTarget =
|
||||
| { kind: "channel"; id: string }
|
||||
| { kind: "user"; id?: string; username?: string };
|
||||
|
||||
const botUserCache = new Map<string, MattermostUser>();
|
||||
const userByNameCache = new Map<string, MattermostUser>();
|
||||
|
||||
const getCore = () => getMattermostRuntime();
|
||||
|
||||
function cacheKey(baseUrl: string, token: string): string {
|
||||
return `${baseUrl}::${token}`;
|
||||
}
|
||||
|
||||
function normalizeMessage(text: string, mediaUrl?: string): string {
|
||||
const trimmed = text.trim();
|
||||
const media = mediaUrl?.trim();
|
||||
return [trimmed, media].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function parseMattermostTarget(raw: string): MattermostTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) throw new Error("Recipient is required for Mattermost sends");
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
if (!id) throw new Error("Channel id is required for Mattermost sends");
|
||||
return { kind: "channel", id };
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
if (!id) throw new Error("User id is required for Mattermost sends");
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
if (!id) throw new Error("User id is required for Mattermost sends");
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const username = trimmed.slice(1).trim();
|
||||
if (!username) {
|
||||
throw new Error("Username is required for Mattermost sends");
|
||||
}
|
||||
return { kind: "user", username };
|
||||
}
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> {
|
||||
const key = cacheKey(baseUrl, token);
|
||||
const cached = botUserCache.get(key);
|
||||
if (cached) return cached;
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
const user = await fetchMattermostMe(client);
|
||||
botUserCache.set(key, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function resolveUserIdByUsername(params: {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
username: string;
|
||||
}): Promise<string> {
|
||||
const { baseUrl, token, username } = params;
|
||||
const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`;
|
||||
const cached = userByNameCache.get(key);
|
||||
if (cached?.id) return cached.id;
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
const user = await fetchMattermostUserByUsername(client, username);
|
||||
userByNameCache.set(key, user);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function resolveTargetChannelId(params: {
|
||||
target: MattermostTarget;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
}): Promise<string> {
|
||||
if (params.target.kind === "channel") return params.target.id;
|
||||
const userId = params.target.id
|
||||
? params.target.id
|
||||
: await resolveUserIdByUsername({
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
username: params.target.username ?? "",
|
||||
});
|
||||
const botUser = await resolveBotUser(params.baseUrl, params.token);
|
||||
const client = createMattermostClient({
|
||||
baseUrl: params.baseUrl,
|
||||
botToken: params.token,
|
||||
});
|
||||
const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
|
||||
return channel.id;
|
||||
}
|
||||
|
||||
export async function sendMessageMattermost(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
): Promise<MattermostSendResult> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const cfg = core.config.loadConfig();
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = opts.botToken?.trim() || account.botToken?.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const target = parseMattermostTarget(to);
|
||||
const channelId = await resolveTargetChannelId({
|
||||
target,
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
let message = text?.trim() ?? "";
|
||||
let fileIds: string[] | undefined;
|
||||
let uploadError: Error | undefined;
|
||||
const mediaUrl = opts.mediaUrl?.trim();
|
||||
if (mediaUrl) {
|
||||
try {
|
||||
const media = await core.media.loadWebMedia(mediaUrl);
|
||||
const fileInfo = await uploadMattermostFile(client, {
|
||||
channelId,
|
||||
buffer: media.buffer,
|
||||
fileName: media.fileName ?? "upload",
|
||||
contentType: media.contentType ?? undefined,
|
||||
});
|
||||
fileIds = [fileInfo.id];
|
||||
} catch (err) {
|
||||
uploadError = err instanceof Error ? err : new Error(String(err));
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug?.(
|
||||
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : "");
|
||||
}
|
||||
}
|
||||
|
||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
||||
if (uploadError) {
|
||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
||||
}
|
||||
throw new Error("Mattermost message is empty");
|
||||
}
|
||||
|
||||
const post = await createMattermostPost(client, {
|
||||
channelId,
|
||||
message,
|
||||
rootId: opts.replyToId,
|
||||
fileIds,
|
||||
});
|
||||
|
||||
core.channel.activity.record({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: post.id ?? "unknown",
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("group:")) {
|
||||
const id = trimmed.slice("group:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `@${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
return `channel:${trimmed}`;
|
||||
}
|
||||
|
||||
export function looksLikeMattermostTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(user|channel|group|mattermost):/i.test(trimmed)) return true;
|
||||
if (/^[@#]/.test(trimmed)) return true;
|
||||
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
type PromptAccountIdParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
currentId?: string;
|
||||
listAccountIds: (cfg: ClawdbotConfig) => string[];
|
||||
defaultAccountId: string;
|
||||
};
|
||||
|
||||
export async function promptAccountId(params: PromptAccountIdParams): Promise<string> {
|
||||
const existingIds = params.listAccountIds(params.cfg);
|
||||
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
|
||||
const choice = (await params.prompter.select({
|
||||
message: `${params.label} account`,
|
||||
options: [
|
||||
...existingIds.map((id) => ({
|
||||
value: id,
|
||||
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
||||
})),
|
||||
{ value: "__new__", label: "Add a new account" },
|
||||
],
|
||||
initialValue: initial,
|
||||
})) as string;
|
||||
|
||||
if (choice !== "__new__") return normalizeAccountId(choice);
|
||||
|
||||
const entered = await params.prompter.text({
|
||||
message: `New ${params.label} account id`,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const normalized = normalizeAccountId(String(entered));
|
||||
if (String(entered).trim() !== normalized) {
|
||||
await params.prompter.note(
|
||||
`Normalized account id to "${normalized}".`,
|
||||
`${params.label} account`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import type { ChannelOnboardingAdapter, ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
import { promptAccountId } from "./onboarding-helpers.js";
|
||||
|
||||
const channel = "mattermost" as const;
|
||||
|
||||
async function noteMattermostSetup(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Mattermost System Console -> Integrations -> Bot Accounts",
|
||||
"2) Create a bot + copy its token",
|
||||
"3) Use your server base URL (e.g., https://chat.example.com)",
|
||||
"Tip: the bot must be a member of any channel you want it to monitor.",
|
||||
"Docs: https://docs.clawd.bot/channels/mattermost",
|
||||
].join("\n"),
|
||||
"Mattermost bot token",
|
||||
);
|
||||
}
|
||||
|
||||
export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listMattermostAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveMattermostAccount({ cfg, accountId });
|
||||
return Boolean(account.botToken && account.baseUrl);
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Mattermost: ${configured ? "configured" : "needs token + url"}`],
|
||||
selectionHint: configured ? "configured" : "needs setup",
|
||||
quickstartScore: configured ? 2 : 1,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
const override = accountOverrides.mattermost?.trim();
|
||||
const defaultAccountId = resolveDefaultMattermostAccountId(cfg);
|
||||
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Mattermost",
|
||||
currentId: accountId,
|
||||
listAccountIds: listMattermostAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveMattermostAccount({
|
||||
cfg: next,
|
||||
accountId,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl);
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv =
|
||||
allowEnv &&
|
||||
Boolean(process.env.MATTERMOST_BOT_TOKEN?.trim()) &&
|
||||
Boolean(process.env.MATTERMOST_URL?.trim());
|
||||
const hasConfigValues =
|
||||
Boolean(resolvedAccount.config.botToken) || Boolean(resolvedAccount.config.baseUrl);
|
||||
|
||||
let botToken: string | null = null;
|
||||
let baseUrl: string | null = null;
|
||||
|
||||
if (!accountConfigured) {
|
||||
await noteMattermostSetup(prompter);
|
||||
}
|
||||
|
||||
if (canUseEnv && !hasConfigValues) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (accountConfigured) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Mattermost credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (botToken || baseUrl) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
...(botToken ? { botToken } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.mattermost?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.mattermost?.accounts?.[accountId],
|
||||
enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true,
|
||||
...(botToken ? { botToken } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
disable: (cfg: ClawdbotConfig) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMattermostRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMattermostRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Mattermost runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
||||
|
||||
export type MattermostAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/** If false, do not start this Mattermost account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Bot token for Mattermost. */
|
||||
botToken?: string;
|
||||
/** Base URL for the Mattermost server (e.g., https://chat.example.com). */
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Controls when channel messages trigger replies.
|
||||
* - "oncall": only respond when mentioned
|
||||
* - "onmessage": respond to every channel message
|
||||
* - "onchar": respond when a trigger character prefixes the message
|
||||
*/
|
||||
chatmode?: MattermostChatMode;
|
||||
/** Prefix characters that trigger onchar mode (default: [">", "!"]). */
|
||||
oncharPrefixes?: string[];
|
||||
/** Require @mention to respond in channels. Default: true. */
|
||||
requireMention?: boolean;
|
||||
/** Direct message policy (pairing/allowlist/open/disabled). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/** Allowlist for direct messages (user ids or @usernames). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Allowlist for group messages (user ids or @usernames). */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/** Group message policy (allowlist/open/disabled). */
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
};
|
||||
|
||||
export type MattermostConfig = {
|
||||
/** Optional per-account Mattermost configuration (multi-account). */
|
||||
accounts?: Record<string, MattermostAccountConfig>;
|
||||
} & MattermostAccountConfig;
|
||||
@@ -14,12 +14,11 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key";
|
||||
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
|
||||
const liveEnabled = HAS_OPENAI_KEY && process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const describeLive = liveEnabled ? describe : describe.skip;
|
||||
// Skip if no OpenAI API key
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
|
||||
|
||||
describe("memory plugin e2e", () => {
|
||||
describeWithKey("memory plugin e2e", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
@@ -160,7 +159,7 @@ describe("memory plugin e2e", () => {
|
||||
});
|
||||
|
||||
// Live tests that require OpenAI API key and actually use LanceDB
|
||||
describeLive("memory plugin live tests", () => {
|
||||
describeWithKey("memory plugin live tests", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
@@ -177,7 +176,6 @@ describeLive("memory plugin live tests", () => {
|
||||
|
||||
test("memory tools work end-to-end", async () => {
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
const liveApiKey = process.env.OPENAI_API_KEY ?? "";
|
||||
|
||||
// Mock plugin API
|
||||
const registeredTools: any[] = [];
|
||||
@@ -193,7 +191,7 @@ describeLive("memory plugin live tests", () => {
|
||||
config: {},
|
||||
pluginConfig: {
|
||||
embedding: {
|
||||
apiKey: liveApiKey,
|
||||
apiKey: OPENAI_API_KEY,
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dbPath,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# OpenProse (plugin)
|
||||
|
||||
Adds the OpenProse skill pack and `/prose` slash command.
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"open-prose": { "enabled": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## What you get
|
||||
|
||||
- `/prose` slash command (user-invocable skill)
|
||||
- OpenProse VM semantics (`.prose` programs + multi-agent orchestration)
|
||||
- Telemetry support (best-effort, per OpenProse spec)
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "open-prose",
|
||||
"name": "OpenProse",
|
||||
"description": "OpenProse VM skill pack with a /prose slash command.",
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user