mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 00:11:53 +08:00
Compare commits
5 Commits
fix-models
...
ci/build-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac064139d | ||
|
|
2045812a65 | ||
|
|
fe3dd0c5e3 | ||
|
|
1e7737b732 | ||
|
|
41929129ce |
16
.github/workflows/docker-release.yml
vendored
16
.github/workflows/docker-release.yml
vendored
@@ -41,12 +41,8 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{version}},suffix=-amd64
|
||||
type=semver,pattern={{version}},suffix=-arm64
|
||||
type=ref,event=branch,suffix=-amd64
|
||||
type=ref,event=branch,suffix=-arm64
|
||||
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
@@ -90,11 +86,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{version}},suffix=-amd64
|
||||
type=semver,pattern={{version}},suffix=-arm64
|
||||
type=ref,event=branch,suffix=-amd64
|
||||
type=ref,event=branch,suffix=-arm64
|
||||
|
||||
- name: Build and push arm64 image
|
||||
@@ -118,6 +110,9 @@ jobs:
|
||||
contents: read
|
||||
needs: [build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -133,11 +128,12 @@ jobs:
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_tag}}
|
||||
|
||||
- name: Create and push manifest
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
${{ needs.build-amd64.outputs.image-digest }} \
|
||||
${{ needs.build-arm64.outputs.image-digest }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-amd64.outputs.image-digest }} \
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-arm64.outputs.image-digest }}
|
||||
env:
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -4,59 +4,20 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.24
|
||||
|
||||
### Highlights
|
||||
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
|
||||
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
|
||||
|
||||
### Changes
|
||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||
- Docs: update Fly.io guide notes.
|
||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||
|
||||
### Fixes
|
||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||
- Auto-reply: don't treat `/models` as a `/model` directive. (#1753) Thanks @uos-status.
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||
- Agents: use the active auth profile for auto-compaction recovery.
|
||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
||||
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
||||
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
|
||||
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||
|
||||
## 2026.1.23-1
|
||||
|
||||
### Fixes
|
||||
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
|
||||
- CI: publish multi-arch Docker images on main and release tags. (#1602) Thanks @denysvitali.
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
|
||||
69
README.md
69
README.md
@@ -17,7 +17,7 @@
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
@@ -65,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
@@ -106,7 +106,7 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
|
||||
|
||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||
@@ -116,7 +116,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
@@ -138,7 +138,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||
|
||||
### Channels
|
||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Google Chat](https://docs.clawd.bot/channels/googlechat) (Chat API), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
||||
|
||||
### Apps + nodes
|
||||
@@ -169,7 +169,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
@@ -252,7 +252,7 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
@@ -477,32 +477,31 @@ 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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></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/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/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/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/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/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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/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/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/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/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/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/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></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/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/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a>
|
||||
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/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/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/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></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/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/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/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/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/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/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/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/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/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/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/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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/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/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/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/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/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></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/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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></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/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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></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/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></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/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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/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/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/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></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/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/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/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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></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/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/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/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/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/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/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/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></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/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/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/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/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/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/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/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/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/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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/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/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/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/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/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></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/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/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/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></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/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>
|
||||
|
||||
@@ -24,11 +24,6 @@ final class AppState {
|
||||
case remote
|
||||
}
|
||||
|
||||
enum RemoteTransport: String {
|
||||
case ssh
|
||||
case direct
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||
}
|
||||
@@ -171,10 +166,6 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var remoteTransport: RemoteTransport {
|
||||
didSet { self.syncGatewayConfigIfNeeded() }
|
||||
}
|
||||
|
||||
var canvasEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
@@ -209,10 +200,6 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var remoteUrl: String {
|
||||
didSet { self.syncGatewayConfigIfNeeded() }
|
||||
}
|
||||
|
||||
var remoteIdentity: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||
}
|
||||
@@ -276,15 +263,13 @@ final class AppState {
|
||||
}
|
||||
|
||||
let configRoot = ClawdbotConfigFile.loadDict()
|
||||
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
||||
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.remoteTransport = configRemoteTransport
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
if resolvedConnectionMode == .remote,
|
||||
configRemoteTransport != .direct,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||
{
|
||||
@@ -292,7 +277,6 @@ final class AppState {
|
||||
} else {
|
||||
self.remoteTarget = storedRemoteTarget
|
||||
}
|
||||
self.remoteUrl = configRemoteUrl ?? ""
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
@@ -370,11 +354,10 @@ final class AppState {
|
||||
private func applyConfigOverrides(_ root: [String: Any]) {
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
|
||||
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let hasRemoteUrl = !(remoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
|
||||
|
||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||
case "local":
|
||||
@@ -395,17 +378,8 @@ final class AppState {
|
||||
self.connectionMode = .remote
|
||||
}
|
||||
|
||||
if remoteTransport != self.remoteTransport {
|
||||
self.remoteTransport = remoteTransport
|
||||
}
|
||||
let remoteUrlText = remoteUrl ?? ""
|
||||
if remoteUrlText != self.remoteUrl {
|
||||
self.remoteUrl = remoteUrlText
|
||||
}
|
||||
|
||||
let targetMode = desiredMode ?? self.connectionMode
|
||||
if targetMode == .remote,
|
||||
remoteTransport != .direct,
|
||||
let host = AppState.remoteHost(from: remoteUrl)
|
||||
{
|
||||
self.updateRemoteTarget(host: host)
|
||||
@@ -428,8 +402,6 @@ final class AppState {
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let remoteIdentity = self.remoteIdentity
|
||||
let remoteTransport = self.remoteTransport
|
||||
let remoteUrl = self.remoteUrl
|
||||
let desiredMode: String? = switch connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
@@ -463,63 +435,39 @@ final class AppState {
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
var remoteChanged = false
|
||||
|
||||
if remoteTransport == .direct {
|
||||
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedUrl.isEmpty {
|
||||
if remote["url"] != nil {
|
||||
remote.removeValue(forKey: "url")
|
||||
remoteChanged = true
|
||||
}
|
||||
} else {
|
||||
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
|
||||
if (remote["url"] as? String) != normalizedUrl {
|
||||
remote["url"] = normalizedUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
|
||||
remote["transport"] = RemoteTransport.direct.rawValue
|
||||
if let host = remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
} else {
|
||||
if remote["transport"] != nil {
|
||||
remote.removeValue(forKey: "transport")
|
||||
remoteChanged = true
|
||||
}
|
||||
if let host = remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
if remoteChanged {
|
||||
@@ -673,10 +621,8 @@ extension AppState {
|
||||
state.iconOverride = .system
|
||||
state.heartbeatsEnabled = true
|
||||
state.connectionMode = .local
|
||||
state.remoteTransport = .ssh
|
||||
state.canvasEnabled = true
|
||||
state.remoteTarget = "user@example.com"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||
state.remoteCliPath = ""
|
||||
|
||||
@@ -40,16 +40,6 @@ extension ChannelsSettings {
|
||||
return .orange
|
||||
}
|
||||
|
||||
var googlechatTint: Color {
|
||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
var signalTint: Color {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return .secondary }
|
||||
@@ -95,14 +85,6 @@ extension ChannelsSettings {
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var googlechatSummary: String {
|
||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var signalSummary: String {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return "Checking…" }
|
||||
@@ -211,37 +193,6 @@ extension ChannelsSettings {
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var googlechatDetails: String? {
|
||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let source = status.credentialSource {
|
||||
lines.append("Credential: \(source)")
|
||||
}
|
||||
if let audienceType = status.audienceType {
|
||||
let audience = status.audience ?? ""
|
||||
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
|
||||
lines.append("Audience: \(label)")
|
||||
}
|
||||
if let probe = status.probe {
|
||||
if probe.ok {
|
||||
if let elapsed = probe.elapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var signalDetails: String? {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return nil }
|
||||
@@ -293,7 +244,7 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
var orderedChannels: [ChannelItem] {
|
||||
let fallback = ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage"]
|
||||
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
|
||||
let order = self.store.snapshot?.channelOrder ?? fallback
|
||||
let channels = order.enumerated().map { index, id in
|
||||
ChannelItem(
|
||||
@@ -356,8 +307,6 @@ extension ChannelsSettings {
|
||||
return self.telegramTint
|
||||
case "discord":
|
||||
return self.discordTint
|
||||
case "googlechat":
|
||||
return self.googlechatTint
|
||||
case "signal":
|
||||
return self.signalTint
|
||||
case "imessage":
|
||||
@@ -377,8 +326,6 @@ extension ChannelsSettings {
|
||||
return self.telegramSummary
|
||||
case "discord":
|
||||
return self.discordSummary
|
||||
case "googlechat":
|
||||
return self.googlechatSummary
|
||||
case "signal":
|
||||
return self.signalSummary
|
||||
case "imessage":
|
||||
@@ -398,8 +345,6 @@ extension ChannelsSettings {
|
||||
return self.telegramDetails
|
||||
case "discord":
|
||||
return self.discordDetails
|
||||
case "googlechat":
|
||||
return self.googlechatDetails
|
||||
case "signal":
|
||||
return self.signalDetails
|
||||
case "imessage":
|
||||
@@ -432,10 +377,6 @@ extension ChannelsSettings {
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.lastProbeAt)
|
||||
case "googlechat":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)?
|
||||
.lastProbeAt)
|
||||
case "signal":
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||
@@ -470,10 +411,6 @@ extension ChannelsSettings {
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case "googlechat":
|
||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case "signal":
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
|
||||
@@ -85,28 +85,6 @@ struct ChannelsStatusSnapshot: Codable {
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct GoogleChatProbe: Codable {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
}
|
||||
|
||||
struct GoogleChatStatus: Codable {
|
||||
let configured: Bool
|
||||
let credentialSource: String?
|
||||
let audienceType: String?
|
||||
let audience: String?
|
||||
let webhookPath: String?
|
||||
let webhookUrl: String?
|
||||
let running: Bool
|
||||
let lastStartAt: Double?
|
||||
let lastStopAt: Double?
|
||||
let lastError: String?
|
||||
let probe: GoogleChatProbe?
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct SignalProbe: Codable {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
|
||||
@@ -11,7 +11,6 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
case googlechat
|
||||
case slack
|
||||
case signal
|
||||
case imessage
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import ClawdbotDiscovery
|
||||
import Foundation
|
||||
|
||||
enum GatewayDiscoveryHelpers {
|
||||
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||
let user = NSUserName()
|
||||
var target = "\(user)@\(host)"
|
||||
if gateway.sshPort != 22 {
|
||||
target += ":\(gateway.sshPort)"
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
self.directGatewayUrl(
|
||||
tailnetDns: gateway.tailnetDns,
|
||||
lanHost: gateway.lanHost,
|
||||
gatewayPort: gateway.gatewayPort)
|
||||
}
|
||||
|
||||
static func directGatewayUrl(
|
||||
tailnetDns: String?,
|
||||
lanHost: String?,
|
||||
gatewayPort: Int?) -> String?
|
||||
{
|
||||
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
|
||||
return "wss://\(tailnetDns)"
|
||||
}
|
||||
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
|
||||
let port = gatewayPort ?? 18789
|
||||
return "ws://\(lanHost):\(port)"
|
||||
}
|
||||
|
||||
static func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
|
||||
return nil
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
private static func trimmed(_ value: String?) -> String? {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import SwiftUI
|
||||
struct GatewayDiscoveryInlineList: View {
|
||||
var discovery: GatewayDiscoveryModel
|
||||
var currentTarget: String?
|
||||
var currentUrl: String?
|
||||
var transport: AppState.RemoteTransport
|
||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||
|
||||
@@ -27,8 +25,9 @@ struct GatewayDiscoveryInlineList: View {
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||
let display = self.displayInfo(for: gateway)
|
||||
let selected = display.selected
|
||||
let target = self.suggestedSSHTarget(gateway)
|
||||
let selected = (target != nil && self.currentTarget?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
@@ -41,7 +40,7 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text(display.label)
|
||||
Text(target ?? "Gateway pairing only")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -84,26 +83,27 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
}
|
||||
.help(self.transport == .direct
|
||||
? "Click a discovered gateway to fill the gateway URL."
|
||||
: "Click a discovered gateway to fill the SSH target.")
|
||||
.help("Click a discovered gateway to fill the SSH target.")
|
||||
}
|
||||
|
||||
private func displayInfo(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
|
||||
{
|
||||
switch self.transport {
|
||||
case .direct:
|
||||
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
|
||||
let label = url ?? "Gateway pairing only"
|
||||
let selected = url != nil && self.trimmed(self.currentUrl) == url
|
||||
return (label, selected)
|
||||
case .ssh:
|
||||
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
|
||||
let label = target ?? "Gateway pairing only"
|
||||
let selected = target != nil && self.trimmed(self.currentTarget) == target
|
||||
return (label, selected)
|
||||
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||
guard let host else { return nil }
|
||||
let user = NSUserName()
|
||||
return GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
}
|
||||
|
||||
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host else { return nil }
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||
@@ -111,10 +111,6 @@ struct GatewayDiscoveryInlineList: View {
|
||||
if hovered { return Color.secondary.opacity(0.08) }
|
||||
return Color.clear
|
||||
}
|
||||
|
||||
private func trimmed(_ value: String?) -> String {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayDiscoveryMenu: View {
|
||||
|
||||
@@ -311,19 +311,6 @@ actor GatewayEndpointStore {
|
||||
token: token,
|
||||
password: password))
|
||||
case .remote:
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.unavailable(
|
||||
mode: .remote,
|
||||
reason: "gateway.remote.url missing or invalid for direct transport"))
|
||||
return
|
||||
}
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return
|
||||
}
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||
@@ -354,25 +341,6 @@ actor GatewayEndpointStore {
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
guard let port = GatewayRemoteConfig.defaultPort(for: url),
|
||||
let portInt = UInt16(exactly: port)
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
|
||||
}
|
||||
self.logger.info("remote transport direct; skipping SSH tunnel")
|
||||
return portInt
|
||||
}
|
||||
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||
throw NSError(
|
||||
@@ -433,21 +401,6 @@ actor GatewayEndpointStore {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
}
|
||||
|
||||
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||
guard let ensure = self.remoteEnsure else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayRemoteConfig {
|
||||
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let raw = remote["transport"] as? String
|
||||
else {
|
||||
return .ssh
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
|
||||
}
|
||||
|
||||
static func resolveUrlString(root: [String: Any]) -> String? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let urlRaw = remote["url"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
|
||||
guard let raw = self.resolveUrlString(root: root) else { return nil }
|
||||
return self.normalizeGatewayUrl(raw)
|
||||
}
|
||||
|
||||
static func normalizeGatewayUrlString(_ raw: String) -> String? {
|
||||
self.normalizeGatewayUrl(raw)?.absoluteString
|
||||
}
|
||||
|
||||
static func normalizeGatewayUrl(_ raw: String) -> URL? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !host.isEmpty else { return nil }
|
||||
if scheme == "ws", url.port == nil {
|
||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return url
|
||||
}
|
||||
components.port = 18789
|
||||
return components.url
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
static func defaultPort(for url: URL) -> Int? {
|
||||
if let port = url.port { return port }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
switch scheme {
|
||||
case "wss":
|
||||
return 443
|
||||
case "ws":
|
||||
return 18789
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ struct GeneralSettings: View {
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
private var remoteLabelWidth: CGFloat { 88 }
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
@@ -105,7 +104,7 @@ struct GeneralSettings: View {
|
||||
Picker("Mode", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
@@ -137,51 +136,60 @@ struct GeneralSettings: View {
|
||||
|
||||
private var remoteCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.remoteTransportRow
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
self.remoteSshRow
|
||||
} else {
|
||||
self.remoteDirectRow
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: 48, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
GatewayDiscoveryInlineList(
|
||||
discovery: self.gatewayDiscovery,
|
||||
currentTarget: self.state.remoteTarget,
|
||||
currentUrl: self.state.remoteUrl,
|
||||
transport: self.state.remoteTransport)
|
||||
currentTarget: self.state.remoteTarget)
|
||||
{ gateway in
|
||||
self.applyDiscoveredGateway(gateway)
|
||||
}
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.leading, 58)
|
||||
|
||||
self.remoteStatusView
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.leading, 58)
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
@@ -211,89 +219,16 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity)
|
||||
.onAppear { self.gatewayDiscovery.start() }
|
||||
.onDisappear { self.gatewayDiscovery.stop() }
|
||||
}
|
||||
|
||||
private var remoteTransportRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Transport")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteSshRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteDirectRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Gateway")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var controlStatusLine: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: "Connected"
|
||||
@@ -523,36 +458,24 @@ extension GeneralSettings {
|
||||
func testRemote() async {
|
||||
self.remoteStatus = .checking
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
if self.state.remoteTransport == .direct {
|
||||
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedUrl.isEmpty else {
|
||||
self.remoteStatus = .failed("Set a gateway URL first")
|
||||
return
|
||||
}
|
||||
guard Self.isValidWsUrl(trimmedUrl) else {
|
||||
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
guard !settings.target.isEmpty else {
|
||||
self.remoteStatus = .failed("Set an SSH target first")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: basic SSH reachability check
|
||||
let sshResult = await ShellExecutor.run(
|
||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 8)
|
||||
|
||||
guard sshResult.ok else {
|
||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||
return
|
||||
}
|
||||
guard !settings.target.isEmpty else {
|
||||
self.remoteStatus = .failed("Set an SSH target first")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: control channel health check
|
||||
// Step 1: basic SSH reachability check
|
||||
let sshResult = await ShellExecutor.run(
|
||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 8)
|
||||
|
||||
guard sshResult.ok else {
|
||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: control channel health over tunnel
|
||||
let originalMode = AppStateStore.shared.connectionMode
|
||||
do {
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
@@ -579,14 +502,6 @@ extension GeneralSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isValidWsUrl(_ raw: String) -> Bool {
|
||||
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
guard scheme == "ws" || scheme == "wss" else { return false }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return !host.isEmpty
|
||||
}
|
||||
|
||||
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
||||
var args: [String] = [
|
||||
"/usr/bin/ssh",
|
||||
@@ -655,18 +570,12 @@ extension GeneralSettings {
|
||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||
guard let host else { return }
|
||||
let user = NSUserName()
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
} else {
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,9 +598,7 @@ extension GeneralSettings {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .remote
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@host:2222"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteIdentity = "/tmp/id_ed25519"
|
||||
state.remoteProjectRoot = "/tmp/clawdbot"
|
||||
state.remoteCliPath = "/tmp/clawdbot"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@@ -518,25 +517,11 @@ extension MenuSessionsInjector {
|
||||
switch mode {
|
||||
case .remote:
|
||||
platform = "remote"
|
||||
if AppStateStore.shared.remoteTransport == .direct {
|
||||
let trimmedUrl = AppStateStore.shared.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
|
||||
if let port = url.port {
|
||||
host = "\(urlHost):\(port)"
|
||||
} else {
|
||||
host = urlHost
|
||||
}
|
||||
} else {
|
||||
host = trimmedUrl.nonEmpty
|
||||
}
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
}
|
||||
host = target.nonEmpty
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
|
||||
@@ -25,11 +25,7 @@ extension OnboardingView {
|
||||
self.preferredGatewayID = gateway.stableID
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
let user = NSUserName()
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
|
||||
@@ -177,67 +177,42 @@ extension OnboardingView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Text("Transport")
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: fieldWidth)
|
||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
if self.state.remoteTransport == .direct {
|
||||
GridRow {
|
||||
Text("Gateway URL")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
if self.state.remoteTransport == .ssh {
|
||||
GridRow {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Project root")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("CLI path")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField(
|
||||
"/Applications/Clawdbot.app/.../clawdbot",
|
||||
text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Project root")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("CLI path")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField(
|
||||
"/Applications/Clawdbot.app/.../clawdbot",
|
||||
text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
}
|
||||
|
||||
Text(self.state.remoteTransport == .direct
|
||||
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
|
||||
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -250,10 +225,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if self.state.remoteTransport == .direct {
|
||||
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
||||
}
|
||||
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||
return "\(host)\(portSuffix)"
|
||||
}
|
||||
|
||||
@@ -1167,29 +1167,17 @@ public struct ConfigApplyParams: Codable, Sendable {
|
||||
public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let basehash: String?
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?
|
||||
basehash: String?
|
||||
) {
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case raw
|
||||
case basehash = "baseHash"
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import Testing
|
||||
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||
}
|
||||
@@ -20,7 +19,6 @@ import Testing
|
||||
#expect(GatewayAgentChannel(raw: nil) == .last)
|
||||
#expect(GatewayAgentChannel(raw: " ") == .last)
|
||||
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
||||
#expect(GatewayAgentChannel(raw: "googlechat") == .googlechat)
|
||||
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||
}
|
||||
|
||||
@@ -175,10 +175,4 @@ import Testing
|
||||
customBindHost: "192.168.1.10")
|
||||
#expect(host == "192.168.1.10")
|
||||
}
|
||||
|
||||
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
|
||||
#expect(url?.port == 18789)
|
||||
#expect(url?.absoluteString == "ws://gateway:18789")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1167,29 +1167,17 @@ public struct ConfigApplyParams: Codable, Sendable {
|
||||
public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let basehash: String?
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?
|
||||
basehash: String?
|
||||
) {
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case raw
|
||||
case basehash = "baseHash"
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -75,10 +75,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
||||
auth: "aws-sdk",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
name: "Claude Opus 4.5 (Bedrock)",
|
||||
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
name: "Claude 3.7 Sonnet (Bedrock)",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192
|
||||
@@ -89,75 +89,12 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
|
||||
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## EC2 Instance Roles
|
||||
|
||||
When running Clawdbot on an EC2 instance with an IAM role attached, the AWS SDK
|
||||
will automatically use the instance metadata service (IMDS) for authentication.
|
||||
However, Clawdbot's credential detection currently only checks for environment
|
||||
variables, not IMDS credentials.
|
||||
|
||||
**Workaround:** Set `AWS_PROFILE=default` to signal that AWS credentials are
|
||||
available. The actual authentication still uses the instance role via IMDS.
|
||||
|
||||
```bash
|
||||
# Add to ~/.bashrc or your shell profile
|
||||
export AWS_PROFILE=default
|
||||
export AWS_REGION=us-east-1
|
||||
```
|
||||
|
||||
**Required IAM permissions** for the EC2 instance role:
|
||||
- `bedrock:InvokeModel`
|
||||
- `bedrock:InvokeModelWithResponseStream`
|
||||
- `bedrock:ListFoundationModels` (for automatic discovery)
|
||||
|
||||
Or attach the managed policy `AmazonBedrockFullAccess`.
|
||||
|
||||
**Quick setup:**
|
||||
|
||||
```bash
|
||||
# 1. Create IAM role and instance profile
|
||||
aws iam create-role --role-name EC2-Bedrock-Access \
|
||||
--assume-role-policy-document '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "ec2.amazonaws.com"},
|
||||
"Action": "sts:AssumeRole"
|
||||
}]
|
||||
}'
|
||||
|
||||
aws iam attach-role-policy --role-name EC2-Bedrock-Access \
|
||||
--policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
|
||||
|
||||
aws iam create-instance-profile --instance-profile-name EC2-Bedrock-Access
|
||||
aws iam add-role-to-instance-profile \
|
||||
--instance-profile-name EC2-Bedrock-Access \
|
||||
--role-name EC2-Bedrock-Access
|
||||
|
||||
# 2. Attach to your EC2 instance
|
||||
aws ec2 associate-iam-instance-profile \
|
||||
--instance-id i-xxxxx \
|
||||
--iam-instance-profile Name=EC2-Bedrock-Access
|
||||
|
||||
# 3. On the EC2 instance, enable discovery
|
||||
clawdbot config set models.bedrockDiscovery.enabled true
|
||||
clawdbot config set models.bedrockDiscovery.region us-east-1
|
||||
|
||||
# 4. Set the workaround env vars
|
||||
echo 'export AWS_PROFILE=default' >> ~/.bashrc
|
||||
echo 'export AWS_REGION=us-east-1' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 5. Verify models are discovered
|
||||
clawdbot models list
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Bedrock requires **model access** enabled in your AWS account/region.
|
||||
|
||||
@@ -196,7 +196,6 @@ Provider options:
|
||||
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
||||
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
||||
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
||||
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline and sends each line immediately during streaming.
|
||||
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
||||
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
||||
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
||||
|
||||
@@ -205,7 +205,6 @@ Notes:
|
||||
## Capabilities & limits
|
||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
||||
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on each line before length chunking.
|
||||
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
||||
- Mention-gated guild replies by default to avoid noisy bots.
|
||||
- Reply context is injected when a message references another message (quoted content + ids).
|
||||
@@ -307,7 +306,6 @@ ack reaction after the bot replies.
|
||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
||||
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking.
|
||||
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
---
|
||||
summary: "Google Chat app support status, capabilities, and configuration"
|
||||
read_when:
|
||||
- Working on Google Chat channel features
|
||||
---
|
||||
# Google Chat (Chat API)
|
||||
|
||||
Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
|
||||
|
||||
## Quick setup (beginner)
|
||||
1) Create a Google Cloud project and enable the **Google Chat API**.
|
||||
- Go to: [Google Chat API Credentials](https://console.cloud.google.com/apis/api/chat.googleapis.com/credentials)
|
||||
- Enable the API if it is not already enabled.
|
||||
2) Create a **Service Account**:
|
||||
- Press **Create Credentials** > **Service Account**.
|
||||
- Name it whatever you want (e.g., `clawdbot-chat`).
|
||||
- Leave permissions blank (press **Continue**).
|
||||
- Leave principals with access blank (press **Done**).
|
||||
3) Create and download the **JSON Key**:
|
||||
- In the list of service accounts, click on the one you just created.
|
||||
- Go to the **Keys** tab.
|
||||
- Click **Add Key** > **Create new key**.
|
||||
- Select **JSON** and press **Create**.
|
||||
4) Store the downloaded JSON file on your gateway host (e.g., `~/.clawdbot/googlechat-service-account.json`).
|
||||
5) Create a Google Chat app in the [Google Cloud Console Chat Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat):
|
||||
- Fill in the **Application info**:
|
||||
- **App name**: (e.g. `Clawdbot`)
|
||||
- **Avatar URL**: (e.g. `https://clawd.bot/logo.png`)
|
||||
- **Description**: (e.g. `Personal AI Assistant`)
|
||||
- Enable **Interactive features**.
|
||||
- Under **Functionality**, check **Join spaces and group conversations**.
|
||||
- Under **Connection settings**, select **HTTP endpoint URL**.
|
||||
- Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`.
|
||||
- *Tip: Run `clawdbot status` to find your gateway's public URL.*
|
||||
- Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**.
|
||||
- Enter your email address (e.g. `user@example.com`) in the text box.
|
||||
- Click **Save** at the bottom.
|
||||
6) **Enable the app status**:
|
||||
- After saving, **refresh the page**.
|
||||
- Look for the **App status** section (usually near the top or bottom after saving).
|
||||
- Change the status to **Live - available to users**.
|
||||
- Click **Save** again.
|
||||
7) Configure Clawdbot with the service account path + webhook audience:
|
||||
- Env: `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE=/path/to/service-account.json`
|
||||
- Or config: `channels.googlechat.serviceAccountFile: "/path/to/service-account.json"`.
|
||||
8) Set the webhook audience type + value (matches your Chat app config).
|
||||
9) Start the gateway. Google Chat will POST to your webhook path.
|
||||
|
||||
## Add to Google Chat
|
||||
Once the gateway is running and your email is added to the visibility list:
|
||||
1) Go to [Google Chat](https://chat.google.com/).
|
||||
2) Click the **+** (plus) icon next to **Direct Messages**.
|
||||
3) In the search bar (where you usually add people), type the **App name** you configured in the Google Cloud Console.
|
||||
- **Note**: The bot will *not* appear in the "Marketplace" browse list because it is a private app. You must search for it by name.
|
||||
4) Select your bot from the results.
|
||||
5) Click **Add** or **Chat** to start a 1:1 conversation.
|
||||
6) Send "Hello" to trigger the assistant!
|
||||
|
||||
## Public URL (Webhook-only)
|
||||
Google Chat webhooks require a public HTTPS endpoint. For security, **only expose the `/googlechat` path** to the internet. Keep the Clawdbot dashboard and other sensitive endpoints on your private network.
|
||||
|
||||
### Option A: Tailscale Funnel (Recommended)
|
||||
Use Tailscale Serve for the private dashboard and Funnel for the public webhook path. This keeps `/` private while exposing only `/googlechat`.
|
||||
|
||||
1. **Check what address your gateway is bound to:**
|
||||
```bash
|
||||
ss -tlnp | grep 18789
|
||||
```
|
||||
Note the IP address (e.g., `127.0.0.1`, `0.0.0.0`, or your Tailscale IP like `100.x.x.x`).
|
||||
|
||||
2. **Expose the dashboard to the tailnet only (port 8443):**
|
||||
```bash
|
||||
# If bound to localhost (127.0.0.1 or 0.0.0.0):
|
||||
tailscale serve --bg --https 8443 http://127.0.0.1:18789
|
||||
|
||||
# If bound to Tailscale IP only (e.g., 100.106.161.80):
|
||||
tailscale serve --bg --https 8443 http://100.106.161.80:18789
|
||||
```
|
||||
|
||||
3. **Expose only the webhook path publicly:**
|
||||
```bash
|
||||
# If bound to localhost (127.0.0.1 or 0.0.0.0):
|
||||
tailscale funnel --bg --set-path /googlechat http://127.0.0.1:18789/googlechat
|
||||
|
||||
# If bound to Tailscale IP only (e.g., 100.106.161.80):
|
||||
tailscale funnel --bg --set-path /googlechat http://100.106.161.80:18789/googlechat
|
||||
```
|
||||
|
||||
4. **Authorize the node for Funnel access:**
|
||||
If prompted, visit the authorization URL shown in the output to enable Funnel for this node in your tailnet policy.
|
||||
|
||||
5. **Verify the configuration:**
|
||||
```bash
|
||||
tailscale serve status
|
||||
tailscale funnel status
|
||||
```
|
||||
|
||||
Your public webhook URL will be:
|
||||
`https://<node-name>.<tailnet>.ts.net/googlechat`
|
||||
|
||||
Your private dashboard stays tailnet-only:
|
||||
`https://<node-name>.<tailnet>.ts.net:8443/`
|
||||
|
||||
Use the public URL (without `:8443`) in the Google Chat app config.
|
||||
|
||||
> Note: This configuration persists across reboots. To remove it later, run `tailscale funnel reset` and `tailscale serve reset`.
|
||||
|
||||
### Option B: Reverse Proxy (Caddy)
|
||||
If you use a reverse proxy like Caddy, only proxy the specific path:
|
||||
```caddy
|
||||
your-domain.com {
|
||||
reverse_proxy /googlechat* localhost:18789
|
||||
}
|
||||
```
|
||||
With this config, any request to `your-domain.com/` will be ignored or returned as 404, while `your-domain.com/googlechat` is safely routed to Clawdbot.
|
||||
|
||||
### Option C: Cloudflare Tunnel
|
||||
Configure your tunnel's ingress rules to only route the webhook path:
|
||||
- **Path**: `/googlechat` -> `http://localhost:18789/googlechat`
|
||||
- **Default Rule**: HTTP 404 (Not Found)
|
||||
|
||||
## How it works
|
||||
|
||||
1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer <token>` header.
|
||||
2. Clawdbot verifies the token against the configured `audienceType` + `audience`:
|
||||
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
|
||||
- `audienceType: "project-number"` → audience is the Cloud project number.
|
||||
3. Messages are routed by space:
|
||||
- DMs use session key `agent:<agentId>:googlechat:dm:<spaceId>`.
|
||||
- Spaces use session key `agent:<agentId>:googlechat:group:<spaceId>`.
|
||||
4. DM access is pairing by default. Unknown senders receive a pairing code; approve with:
|
||||
- `clawdbot pairing approve googlechat <code>`
|
||||
5. Group spaces require @-mention by default. Use `botUser` if mention detection needs the app’s user name.
|
||||
|
||||
## Targets
|
||||
Use these identifiers for delivery and allowlists:
|
||||
- Direct messages: `users/<userId>` or `users/<email>` (email addresses are accepted).
|
||||
- Spaces: `spaces/<spaceId>`.
|
||||
|
||||
## Config highlights
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
"googlechat": {
|
||||
enabled: true,
|
||||
serviceAccountFile: "/path/to/service-account.json",
|
||||
audienceType: "app-url",
|
||||
audience: "https://gateway.example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
botUser: "users/1234567890", // optional; helps mention detection
|
||||
dm: {
|
||||
policy: "pairing",
|
||||
allowFrom: ["users/1234567890", "name@example.com"]
|
||||
},
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"spaces/AAAA": {
|
||||
allow: true,
|
||||
requireMention: true,
|
||||
users: ["users/1234567890"],
|
||||
systemPrompt: "Short answers only."
|
||||
}
|
||||
},
|
||||
actions: { reactions: true },
|
||||
typingIndicator: "message",
|
||||
mediaMaxMb: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
|
||||
- Default webhook path is `/googlechat` if `webhookPath` isn’t set.
|
||||
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
|
||||
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
|
||||
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 405 Method Not Allowed
|
||||
If Google Cloud Logs Explorer shows errors like:
|
||||
```
|
||||
status code: 405, reason phrase: HTTP error response: HTTP/1.1 405 Method Not Allowed
|
||||
```
|
||||
|
||||
This means the webhook handler isn't registered. Common causes:
|
||||
1. **Channel not configured**: The `channels.googlechat` section is missing from your config. Verify with:
|
||||
```bash
|
||||
clawdbot config get channels.googlechat
|
||||
```
|
||||
If it returns "Config path not found", add the configuration (see [Config highlights](#config-highlights)).
|
||||
|
||||
2. **Plugin not enabled**: Check plugin status:
|
||||
```bash
|
||||
clawdbot plugins list | grep googlechat
|
||||
```
|
||||
If it shows "disabled", add `plugins.entries.googlechat.enabled: true` to your config.
|
||||
|
||||
3. **Gateway not restarted**: After adding config, restart the gateway:
|
||||
```bash
|
||||
clawdbot gateway restart
|
||||
```
|
||||
|
||||
Verify the channel is running:
|
||||
```bash
|
||||
clawdbot channels status
|
||||
# Should show: Google Chat default: enabled, configured, ...
|
||||
```
|
||||
|
||||
### Other issues
|
||||
- Check `clawdbot channels status --probe` for auth errors or missing audience config.
|
||||
- If no messages arrive, confirm the Chat app's webhook URL + event subscriptions.
|
||||
- If mention gating blocks replies, set `botUser` to the app's user resource name and verify `requireMention`.
|
||||
- Use `clawdbot logs --follow` while sending a test message to see if requests reach the gateway.
|
||||
|
||||
Related docs:
|
||||
- [Gateway configuration](/gateway/configuration)
|
||||
- [Security](/gateway/security)
|
||||
- [Reactions](/tools/reactions)
|
||||
@@ -17,7 +17,7 @@ read_when:
|
||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
|
||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||
|
||||
|
||||
@@ -219,7 +219,6 @@ This is useful when you want an isolated personality/model for a specific thread
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
||||
|
||||
## Addressing / delivery targets
|
||||
@@ -254,7 +253,6 @@ Provider options:
|
||||
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
|
||||
Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
||||
|
||||
@@ -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.
|
||||
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
|
||||
- [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).
|
||||
@@ -32,8 +31,6 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
## Notes
|
||||
|
||||
- Channels can run simultaneously; configure multiple and Clawdbot will route per chat.
|
||||
- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and
|
||||
stores more state on disk.
|
||||
- Group behavior varies by channel; see [Groups](/concepts/groups).
|
||||
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
||||
- Telegram internals: [grammY notes](/channels/grammy).
|
||||
|
||||
@@ -215,7 +215,6 @@ Provider options:
|
||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||
|
||||
@@ -415,7 +415,6 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||
|
||||
@@ -114,7 +114,6 @@ Provider options:
|
||||
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
||||
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
||||
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
||||
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
||||
|
||||
@@ -74,22 +74,6 @@ Example:
|
||||
|
||||
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
## External daemon mode (httpUrl)
|
||||
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
signal: {
|
||||
httpUrl: "http://127.0.0.1:8080",
|
||||
autoStart: false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
|
||||
|
||||
## Access control (DMs + groups)
|
||||
DMs:
|
||||
- Default: `channels.signal.dmPolicy = "pairing"`.
|
||||
@@ -111,7 +95,6 @@ Groups:
|
||||
|
||||
## Media + limits
|
||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Attachments supported (base64 fetched from `signal-cli`).
|
||||
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
||||
@@ -122,29 +105,8 @@ Groups:
|
||||
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
||||
- Signal-cli does not expose read receipts for groups.
|
||||
|
||||
## Reactions (message tool)
|
||||
- Use `message action=react` with `channel=signal`.
|
||||
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
|
||||
- `messageId` is the Signal timestamp for the message you’re reacting to.
|
||||
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
|
||||
|
||||
Examples:
|
||||
```
|
||||
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
|
||||
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
|
||||
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
|
||||
```
|
||||
|
||||
Config:
|
||||
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
|
||||
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
|
||||
- `off`/`ack` disables agent reactions (message tool `react` will error).
|
||||
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
|
||||
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- DMs: `signal:+15551234567` (or plain E.164).
|
||||
- UUID DMs: `uuid:<id>` (or bare UUID).
|
||||
- Groups: `signal:group:<groupId>`.
|
||||
- Usernames: `username:<name>` (if supported by your Signal account).
|
||||
|
||||
@@ -158,7 +120,6 @@ Provider options:
|
||||
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
||||
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
||||
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
||||
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
|
||||
- `channels.signal.receiveMode`: `on-start | manual`.
|
||||
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||
@@ -170,7 +131,6 @@ Provider options:
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
|
||||
Related global options:
|
||||
|
||||
@@ -349,7 +349,6 @@ ack reaction after the bot replies.
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
||||
|
||||
## Reply threading
|
||||
|
||||
@@ -120,13 +120,6 @@ You can add custom commands to the menu via config:
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `setMyCommands failed` in logs usually means outbound HTTPS/DNS is blocked to `api.telegram.org`.
|
||||
- If you see `sendMessage` or `sendChatAction` failures, check IPv6 routing and DNS.
|
||||
|
||||
More help: [Channel troubleshooting](/channels/troubleshooting).
|
||||
|
||||
Notes:
|
||||
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
||||
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
||||
@@ -135,7 +128,6 @@ Notes:
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
||||
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
|
||||
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
@@ -524,8 +516,6 @@ Provider options:
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
|
||||
@@ -22,4 +22,3 @@ clawdbot channels status --probe
|
||||
|
||||
## Telegram quick fixes
|
||||
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
||||
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
|
||||
|
||||
@@ -271,13 +271,12 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
|
||||
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
||||
|
||||
## Outbound send (text + media)
|
||||
- Uses active web listener; error if gateway not running.
|
||||
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`).
|
||||
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`).
|
||||
- Media:
|
||||
- Image/video/audio/document supported.
|
||||
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
||||
|
||||
@@ -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/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage)
|
||||
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage)
|
||||
- You want to check channel status or tail channel logs
|
||||
---
|
||||
|
||||
|
||||
@@ -355,7 +355,7 @@ Options:
|
||||
## Channel helpers
|
||||
|
||||
### `channels`
|
||||
Manage chat channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
|
||||
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
|
||||
|
||||
Subcommands:
|
||||
- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included).
|
||||
@@ -368,7 +368,7 @@ Subcommands:
|
||||
- `channels logout`: log out of a channel session (if supported).
|
||||
|
||||
Common options:
|
||||
- `--channel <name>`: `whatsapp|telegram|discord|googlechat|slack|mattermost|signal|imessage|msteams`
|
||||
- `--channel <name>`: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams`
|
||||
- `--account <id>`: channel account id (default `default`)
|
||||
- `--name <label>`: display name for the account
|
||||
|
||||
@@ -666,7 +666,7 @@ Subcommands:
|
||||
|
||||
Common RPCs:
|
||||
- `config.apply` (validate + write config + restart + wake)
|
||||
- `config.patch` (merge a partial update + restart + wake)
|
||||
- `config.patch` (merge a partial update without clobbering unrelated keys)
|
||||
- `update.run` (run update + restart + wake)
|
||||
|
||||
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
|
||||
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
# `clawdbot message`
|
||||
|
||||
Single outbound command for sending messages and channel actions
|
||||
(Discord/Google Chat/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
||||
(Discord/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -19,13 +19,12 @@ 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|googlechat|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin)
|
||||
- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin)
|
||||
|
||||
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)
|
||||
- Google Chat: `spaces/<spaceId>` or `users/<userId>`
|
||||
- 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>`
|
||||
@@ -51,7 +50,7 @@ Name lookup:
|
||||
### Core
|
||||
|
||||
- `send`
|
||||
- Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams
|
||||
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/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)
|
||||
@@ -66,15 +65,14 @@ Name lookup:
|
||||
- Discord only: `--poll-duration-hours`, `--message`
|
||||
|
||||
- `react`
|
||||
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
|
||||
- Channels: Discord/Slack/Telegram/WhatsApp
|
||||
- Required: `--message-id`, `--target`
|
||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid`
|
||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
|
||||
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
||||
- WhatsApp only: `--participant`, `--from-me`
|
||||
- Signal group reactions: `--target-author` or `--target-author-uuid` required
|
||||
|
||||
- `reactions`
|
||||
- Channels: Discord/Google Chat/Slack
|
||||
- Channels: Discord/Slack
|
||||
- Required: `--message-id`, `--target`
|
||||
- Optional: `--limit`
|
||||
|
||||
@@ -214,13 +212,6 @@ clawdbot message react --channel slack \
|
||||
--target C123 --message-id 456 --emoji "✅"
|
||||
```
|
||||
|
||||
React in a Signal group:
|
||||
```
|
||||
clawdbot message react --channel signal \
|
||||
--target signal:group:abc123 --message-id 1737630212345 \
|
||||
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
|
||||
```
|
||||
|
||||
Send Telegram inline buttons:
|
||||
```
|
||||
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
||||
|
||||
@@ -17,7 +17,7 @@ clawdbot status --usage
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Google Chat + Slack + Signal).
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- Output includes per-agent session stores when multiple agents are configured.
|
||||
- Overview includes Gateway + node host service install/runtime status when available.
|
||||
- Overview includes update channel + git SHA (for source checkouts).
|
||||
|
||||
@@ -31,8 +31,6 @@ These files live under the workspace (`agents.defaults.workspace`, default
|
||||
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
||||
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
|
||||
- If someone says "remember this," write it down (do not keep it in RAM).
|
||||
- This area is still evolving. It helps to remind the model to store memories; it will know what to do.
|
||||
- If you want something to stick, **ask the bot to write it** into memory.
|
||||
|
||||
## Automatic memory flush (pre-compaction ping)
|
||||
|
||||
|
||||
@@ -89,8 +89,6 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
||||
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
||||
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
|
||||
- Note: you do **not** paste a client id or secret into `clawdbot.json`. The CLI login flow stores
|
||||
tokens in auth profiles on the gateway host.
|
||||
|
||||
### Z.AI (GLM)
|
||||
|
||||
@@ -238,30 +236,6 @@ MiniMax is configured via `models.providers` because it uses custom endpoints:
|
||||
|
||||
See [/providers/minimax](/providers/minimax) for setup details, model options, and config snippets.
|
||||
|
||||
### Ollama
|
||||
|
||||
Ollama is a local LLM runtime that provides an OpenAI-compatible API:
|
||||
|
||||
- Provider: `ollama`
|
||||
- Auth: None required (local server)
|
||||
- Example model: `ollama/llama3.3`
|
||||
- Installation: https://ollama.ai
|
||||
|
||||
```bash
|
||||
# Install Ollama, then pull a model:
|
||||
ollama pull llama3.3
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "ollama/llama3.3" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ollama is automatically detected when running locally at `http://127.0.0.1:11434/v1`. See [/providers/ollama](/providers/ollama) for model recommendations and custom configuration.
|
||||
|
||||
### Local proxies (LM Studio, vLLM, LiteLLM, etc.)
|
||||
|
||||
Example (OpenAI‑compatible):
|
||||
@@ -297,16 +271,6 @@ Example (OpenAI‑compatible):
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- For custom providers, `reasoning`, `input`, `cost`, `contextWindow`, and `maxTokens` are optional.
|
||||
When omitted, Clawdbot defaults to:
|
||||
- `reasoning: false`
|
||||
- `input: ["text"]`
|
||||
- `cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }`
|
||||
- `contextWindow: 200000`
|
||||
- `maxTokens: 8192`
|
||||
- Recommended: set explicit values that match your proxy/model limits.
|
||||
|
||||
## CLI examples
|
||||
|
||||
```bash
|
||||
|
||||
@@ -38,7 +38,6 @@ Legend:
|
||||
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
||||
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
||||
- Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`).
|
||||
- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on each line before length chunking).
|
||||
- Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
||||
|
||||
**Boundary semantics:**
|
||||
|
||||
@@ -149,14 +149,6 @@
|
||||
"source": "/providers/discord/",
|
||||
"destination": "/channels/discord"
|
||||
},
|
||||
{
|
||||
"source": "/providers/googlechat",
|
||||
"destination": "/channels/googlechat"
|
||||
},
|
||||
{
|
||||
"source": "/providers/googlechat/",
|
||||
"destination": "/channels/googlechat"
|
||||
},
|
||||
{
|
||||
"source": "/providers/grammy",
|
||||
"destination": "/channels/grammy"
|
||||
@@ -780,14 +772,6 @@
|
||||
{
|
||||
"source": "/plugins",
|
||||
"destination": "/plugin"
|
||||
},
|
||||
{
|
||||
"source": "/install/railway",
|
||||
"destination": "/railway"
|
||||
},
|
||||
{
|
||||
"source": "/install/railway/",
|
||||
"destination": "/railway"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
@@ -826,7 +810,6 @@
|
||||
"install/ansible",
|
||||
"install/nix",
|
||||
"install/docker",
|
||||
"railway",
|
||||
"install/bun"
|
||||
]
|
||||
},
|
||||
@@ -960,7 +943,6 @@
|
||||
"channels/grammy",
|
||||
"channels/discord",
|
||||
"channels/slack",
|
||||
"channels/googlechat",
|
||||
"channels/mattermost",
|
||||
"channels/signal",
|
||||
"channels/imessage",
|
||||
@@ -1048,7 +1030,6 @@
|
||||
"pages": [
|
||||
"platforms",
|
||||
"platforms/macos",
|
||||
"platforms/macos-vm",
|
||||
"platforms/ios",
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
|
||||
@@ -46,14 +46,10 @@ better forms without hard-coding config knowledge.
|
||||
Use `config.apply` to validate + write the full config and restart the Gateway in one step.
|
||||
It writes a restart sentinel and pings the last active session after the Gateway comes back.
|
||||
|
||||
Warning: `config.apply` replaces the **entire config**. If you want to change only a few keys,
|
||||
use `config.patch` or `clawdbot config set`. Keep a backup of `~/.clawdbot/clawdbot.json`.
|
||||
|
||||
Params:
|
||||
- `raw` (string) — JSON5 payload for the entire config
|
||||
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
||||
- `sessionKey` (optional) — last active session key for the wake-up ping
|
||||
- `note` (optional) — note to include in the restart sentinel
|
||||
- `restartDelayMs` (optional) — delay before restart (default 2000)
|
||||
|
||||
Example (via `gateway call`):
|
||||
@@ -75,15 +71,10 @@ unrelated keys. It applies JSON merge patch semantics:
|
||||
- objects merge recursively
|
||||
- `null` deletes a key
|
||||
- arrays replace
|
||||
Like `config.apply`, it validates, writes the config, stores a restart sentinel, and schedules
|
||||
the Gateway restart (with an optional wake when `sessionKey` is provided).
|
||||
|
||||
Params:
|
||||
- `raw` (string) — JSON5 payload containing just the keys to change
|
||||
- `baseHash` (required) — config hash from `config.get`
|
||||
- `sessionKey` (optional) — last active session key for the wake-up ping
|
||||
- `note` (optional) — note to include in the restart sentinel
|
||||
- `restartDelayMs` (optional) — delay before restart (default 2000)
|
||||
|
||||
Example:
|
||||
|
||||
@@ -91,9 +82,7 @@ Example:
|
||||
clawdbot gateway call config.get --params '{}' # capture payload.hash
|
||||
clawdbot gateway call config.patch --params '{
|
||||
"raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n",
|
||||
"baseHash": "<hash-from-config.get>",
|
||||
"sessionKey": "agent:main:whatsapp:dm:+15555550123",
|
||||
"restartDelayMs": 1000
|
||||
"baseHash": "<hash-from-config.get>"
|
||||
}'
|
||||
```
|
||||
|
||||
@@ -410,7 +399,7 @@ Optional per-agent identity used for defaults and UX. This is written by the mac
|
||||
|
||||
If set, Clawdbot derives defaults (only when you haven’t set them explicitly):
|
||||
- `messages.ackReaction` from the **active agent**’s `identity.emoji` (falls back to 👀)
|
||||
- `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/Google Chat/iMessage/WhatsApp)
|
||||
- `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
|
||||
- `identity.avatar` accepts a workspace-relative image path or a remote URL/data URL. Local files must live inside the agent workspace.
|
||||
|
||||
`identity.avatar` accepts:
|
||||
@@ -507,7 +496,6 @@ For groups, use `channels.whatsapp.groupPolicy` + `channels.whatsapp.groupAllowF
|
||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["+15555550123", "+447700900123"],
|
||||
textChunkLimit: 4000, // optional outbound chunk size (chars)
|
||||
chunkMode: "length", // optional chunking mode (length | newline)
|
||||
mediaMaxMb: 50 // optional inbound media cap (MB)
|
||||
}
|
||||
}
|
||||
@@ -555,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.googlechat.accounts` / `channels.slack.accounts` / `channels.mattermost.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
|
||||
### `channels.telegram.accounts` / `channels.discord.accounts` / `channels.slack.accounts` / `channels.mattermost.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
|
||||
|
||||
Run multiple accounts per channel (each account has its own `accountId` and optional `name`):
|
||||
|
||||
@@ -586,7 +574,7 @@ Notes:
|
||||
|
||||
### Group chat mention gating (`agents.list[].groupChat` + `messages.groupChat`)
|
||||
|
||||
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
|
||||
|
||||
**Mention types:**
|
||||
- **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `channels.whatsapp.allowFrom`).
|
||||
@@ -1021,7 +1009,6 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
||||
],
|
||||
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||
replyToMode: "first", // off | first | all
|
||||
linkPreview: true, // toggle outbound link previews
|
||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
||||
draftChunk: { // optional; only for streamMode=block
|
||||
minChars: 200,
|
||||
@@ -1110,7 +1097,6 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc
|
||||
},
|
||||
historyLimit: 20, // include last N guild messages as context
|
||||
textChunkLimit: 2000, // optional outbound text chunk size (chars)
|
||||
chunkMode: "length", // optional chunking mode (length | newline)
|
||||
maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
|
||||
retry: { // outbound retry policy
|
||||
attempts: 3,
|
||||
@@ -1131,47 +1117,9 @@ Reaction notification modes:
|
||||
- `own`: reactions on the bot's own messages (default).
|
||||
- `all`: all reactions on all messages.
|
||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on line boundaries before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
|
||||
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
|
||||
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||
|
||||
### `channels.googlechat` (Chat API webhook)
|
||||
|
||||
Google Chat runs over HTTP webhooks with app-level auth (service account).
|
||||
Multi-account support lives under `channels.googlechat.accounts` (see the multi-account section above). Env vars only apply to the default account.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
"googlechat": {
|
||||
enabled: true,
|
||||
serviceAccountFile: "/path/to/service-account.json",
|
||||
audienceType: "app-url", // app-url | project-number
|
||||
audience: "https://gateway.example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
botUser: "users/1234567890", // optional; improves mention detection
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["users/1234567890"] // optional; "open" requires ["*"]
|
||||
},
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"spaces/AAAA": { allow: true, requireMention: true }
|
||||
},
|
||||
actions: { reactions: true },
|
||||
typingIndicator: "message",
|
||||
mediaMaxMb: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Service account JSON can be inline (`serviceAccount`) or file-based (`serviceAccountFile`).
|
||||
- Env fallbacks for the default account: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
|
||||
- `audienceType` + `audience` must match the Chat app’s webhook auth config.
|
||||
- Use `spaces/<spaceId>` or `users/<userId|email>` when setting delivery targets.
|
||||
|
||||
### `channels.slack` (socket mode)
|
||||
|
||||
Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
@@ -1224,7 +1172,6 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
ephemeral: true
|
||||
},
|
||||
textChunkLimit: 4000,
|
||||
chunkMode: "length",
|
||||
mediaMaxMb: 20
|
||||
}
|
||||
}
|
||||
@@ -1274,8 +1221,7 @@ Mattermost requires a bot token plus the base URL for your server:
|
||||
dmPolicy: "pairing",
|
||||
chatmode: "oncall", // oncall | onmessage | onchar
|
||||
oncharPrefixes: [">", "!"],
|
||||
textChunkLimit: 4000,
|
||||
chunkMode: "length"
|
||||
textChunkLimit: 4000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1488,7 +1434,7 @@ WhatsApp inbound prefix is configured via `channels.whatsapp.messagePrefix` (dep
|
||||
agent has `identity.name` set.
|
||||
|
||||
`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages
|
||||
on channels that support reactions (Slack/Discord/Telegram/Google Chat). Defaults to the
|
||||
on channels that support reactions (Slack/Discord/Telegram). Defaults to the
|
||||
active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable.
|
||||
|
||||
`ackReactionScope` controls when reactions fire:
|
||||
@@ -1498,7 +1444,7 @@ active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t
|
||||
- `all`: all messages
|
||||
|
||||
`removeAckAfterReply` removes the bot’s ack reaction after a reply is sent
|
||||
(Slack/Discord/Telegram/Google Chat only). Default: `false`.
|
||||
(Slack/Discord/Telegram only). Default: `false`.
|
||||
|
||||
#### `messages.tts`
|
||||
|
||||
@@ -1510,7 +1456,7 @@ voice notes; other channels send MP3 audio.
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always", // off | always | inbound | tagged
|
||||
enabled: true,
|
||||
mode: "final", // final | all (include tool/block replies)
|
||||
provider: "elevenlabs",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
@@ -1547,10 +1493,8 @@ voice notes; other channels send MP3 audio.
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `messages.tts.auto` controls auto‑TTS (`off`, `always`, `inbound`, `tagged`).
|
||||
- `/tts off|always|inbound|tagged` sets the per‑session auto mode (overrides config).
|
||||
- `messages.tts.enabled` is legacy; doctor migrates it to `messages.tts.auto`.
|
||||
- `prefsPath` stores local overrides (provider/limit/summarize).
|
||||
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
|
||||
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
|
||||
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
||||
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
||||
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
||||
@@ -1885,12 +1829,11 @@ Block streaming:
|
||||
```
|
||||
- `agents.defaults.blockStreamingCoalesce`: merge streamed blocks before sending.
|
||||
Defaults to `{ idleMs: 1000 }` and inherits `minChars` from `blockStreamingChunk`
|
||||
with `maxChars` capped to the channel text limit. Signal/Slack/Discord/Google Chat default
|
||||
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`,
|
||||
`channels.googlechat.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`).
|
||||
@@ -2856,11 +2799,6 @@ Related docs:
|
||||
- [Tailscale](/gateway/tailscale)
|
||||
- [Remote access](/gateway/remote)
|
||||
|
||||
Trusted proxies:
|
||||
- `gateway.trustedProxies`: list of reverse proxy IPs that terminate TLS in front of the Gateway.
|
||||
- When a connection comes from one of these IPs, Clawdbot uses `x-forwarded-for` (or `x-real-ip`) to determine the client IP for local pairing checks and HTTP auth/local checks.
|
||||
- Only list proxies you fully control, and ensure they **overwrite** incoming `x-forwarded-for`.
|
||||
|
||||
Notes:
|
||||
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||
@@ -2887,14 +2825,13 @@ Auth and Tailscale:
|
||||
|
||||
Remote client defaults (CLI):
|
||||
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
|
||||
- `gateway.remote.transport` selects the macOS remote transport (`ssh` default, `direct` for ws/wss). When `direct`, `gateway.remote.url` must be `ws://` or `wss://`. `ws://host` defaults to port `18789`.
|
||||
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
||||
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
|
||||
|
||||
macOS app behavior:
|
||||
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
||||
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
|
||||
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` + `gateway.remote.transport` in remote mode) back to the config file.
|
||||
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -2909,21 +2846,6 @@ macOS app behavior:
|
||||
}
|
||||
```
|
||||
|
||||
Direct transport example (macOS app):
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
transport: "direct",
|
||||
url: "wss://gateway.example.ts.net",
|
||||
token: "your-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `gateway.reload` (Config hot reload)
|
||||
|
||||
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
|
||||
@@ -3042,7 +2964,7 @@ Mapping notes:
|
||||
- Templates like `{{messages[0].subject}}` read from the payload.
|
||||
- `transform` can point to a JS/TS module that returns a hook action.
|
||||
- `deliver: true` sends the final reply to a channel; `channel` defaults to `last` (falls back to WhatsApp).
|
||||
- If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Google Chat/Slack/Signal/iMessage/MS Teams).
|
||||
- If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage/MS Teams).
|
||||
- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set).
|
||||
|
||||
Gmail helper config (used by `clawdbot webhooks gmail setup` / `run`):
|
||||
@@ -3218,7 +3140,7 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
|
||||
| `{{GroupMembers}}` | Group members preview (best effort) |
|
||||
| `{{SenderName}}` | Sender display name (best effort) |
|
||||
| `{{SenderE164}}` | Sender phone number (best effort) |
|
||||
| `{{Provider}}` | Provider hint (whatsapp|telegram|discord|googlechat|slack|signal|imessage|msteams|webchat|…) |
|
||||
| `{{Provider}}` | Provider hint (whatsapp|telegram|discord|slack|signal|imessage|msteams|webchat|…) |
|
||||
|
||||
## Cron (Gateway scheduler)
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ Example: two agents, only the second agent runs heartbeats.
|
||||
- Session key formats: see [Sessions](/concepts/session) and [Groups](/concepts/groups).
|
||||
- `target`:
|
||||
- `last` (default): deliver to the last used external channel.
|
||||
- explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`.
|
||||
- explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `msteams` / `signal` / `imessage`.
|
||||
- `none`: run the heartbeat but **do not deliver** externally.
|
||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id).
|
||||
- `prompt`: overrides the default prompt body (not merged).
|
||||
|
||||
@@ -29,8 +29,6 @@ Clawdbot is both a product and an experiment: you’re wiring frontier-model beh
|
||||
- where the bot is allowed to act
|
||||
- what the bot can touch
|
||||
|
||||
Start with the smallest access that still works, then widen it as you gain confidence.
|
||||
|
||||
### What the audit checks (high level)
|
||||
|
||||
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
||||
@@ -324,11 +322,6 @@ Tailscale.
|
||||
you terminate TLS or proxy in front of the gateway, disable
|
||||
`gateway.auth.allowTailscale` and use token/password auth instead.
|
||||
|
||||
Trusted proxies:
|
||||
- If you terminate TLS in front of the Gateway, set `gateway.trustedProxies` to your proxy IPs.
|
||||
- Clawdbot will trust `x-forwarded-for` (or `x-real-ip`) from those IPs to determine the client IP for local pairing checks and HTTP auth/local checks.
|
||||
- Ensure your proxy **overwrites** `x-forwarded-for` and blocks direct access to the Gateway port.
|
||||
|
||||
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||
|
||||
### 0.6.1) Browser control server over Tailscale (recommended)
|
||||
|
||||
@@ -31,24 +31,6 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
|
||||
|
||||
## Common Issues
|
||||
|
||||
### No API key found for provider "anthropic"
|
||||
|
||||
This means the **agent’s auth store is empty** or missing Anthropic credentials.
|
||||
Auth is **per agent**, so a new agent won’t inherit the main agent’s keys.
|
||||
|
||||
Fix options:
|
||||
- Re-run onboarding and choose **Anthropic** for that agent.
|
||||
- Or paste a setup-token on the **gateway host**:
|
||||
```bash
|
||||
clawdbot models auth setup-token --provider anthropic
|
||||
```
|
||||
- Or copy `auth-profiles.json` from the main agent dir to the new agent dir.
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
clawdbot models status
|
||||
```
|
||||
|
||||
### OAuth token refresh failed (Anthropic Claude subscription)
|
||||
|
||||
This means the stored Anthropic OAuth token expired and the refresh failed.
|
||||
|
||||
1174
docs/help/faq.md
1174
docs/help/faq.md
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,10 @@ Last updated: 2026-01-21
|
||||
|
||||
Clawdbot ships three update channels:
|
||||
|
||||
- **stable**: npm dist-tag `latest`.
|
||||
- **beta**: npm dist-tag `beta` (builds under test).
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
|
||||
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
|
||||
|
||||
We ship builds to **beta**, test them, then **promote a vetted build to `latest`**
|
||||
without changing the version number — dist-tags are the source of truth for npm installs.
|
||||
|
||||
## Switching channels
|
||||
|
||||
Git checkout:
|
||||
@@ -28,7 +25,7 @@ clawdbot update --channel beta
|
||||
clawdbot update --channel dev
|
||||
```
|
||||
|
||||
- `stable`/`beta` check out the latest matching tag (often the same tag).
|
||||
- `stable`/`beta` check out the latest matching tag.
|
||||
- `dev` switches to `main` and rebases on the upstream.
|
||||
|
||||
npm/pnpm global install:
|
||||
@@ -59,11 +56,12 @@ When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sour
|
||||
|
||||
## Tagging best practices
|
||||
|
||||
- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
|
||||
- Keep tags immutable: never move or reuse a tag.
|
||||
- npm dist-tags remain the source of truth for npm installs:
|
||||
- Publish dist-tags alongside git tags:
|
||||
- `latest` → stable
|
||||
- `beta` → candidate build
|
||||
- `beta` → prerelease
|
||||
- `dev` → main snapshot (optional)
|
||||
|
||||
## macOS app availability
|
||||
|
||||
@@ -114,9 +114,3 @@ Git requirement:
|
||||
|
||||
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
||||
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||
|
||||
Common Windows issues:
|
||||
|
||||
- **npm error spawn git / ENOENT**: install Git for Windows and reopen PowerShell, then rerun the installer.
|
||||
- **"clawdbot" is not recognized**: your npm global bin folder is not on PATH. Most systems use
|
||||
`%AppData%\\npm`. You can also run `npm config get prefix` and add `\\bin` to PATH, then reopen PowerShell.
|
||||
|
||||
@@ -23,8 +23,6 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
||||
|
||||
## VPS & hosting
|
||||
|
||||
- VPS hub: [VPS hosting](/vps)
|
||||
- Railway (one-click): [Railway](/railway)
|
||||
- Fly.io: [Fly.io](/platforms/fly)
|
||||
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
|
||||
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
|
||||
@@ -10,13 +10,7 @@ This flow lets the macOS app act as a full remote control for a Clawdbot gateway
|
||||
|
||||
## Modes
|
||||
- **Local (this Mac)**: Everything runs on the laptop. No SSH involved.
|
||||
- **Remote over SSH (default)**: Clawdbot commands are executed on the remote host. The mac app opens an SSH connection with `-o BatchMode` plus your chosen identity/key and a local port-forward.
|
||||
- **Remote direct (ws/wss)**: No SSH tunnel. The mac app connects to the gateway URL directly (for example, via Tailscale Serve or a public HTTPS reverse proxy).
|
||||
|
||||
## Remote transports
|
||||
Remote mode supports two transports:
|
||||
- **SSH tunnel** (default): Uses `ssh -N -L ...` to forward the gateway port to localhost. The gateway will see the node’s IP as `127.0.0.1` because the tunnel is loopback.
|
||||
- **Direct (ws/wss)**: Connects straight to the gateway URL. The gateway sees the real client IP.
|
||||
- **Remote over SSH**: Clawdbot commands are executed on the remote host. The mac app opens an SSH connection with `-o BatchMode` plus your chosen identity/key.
|
||||
|
||||
## Prereqs on the remote host
|
||||
1) Install Node + pnpm and build/install the Clawdbot CLI (`pnpm install && pnpm build && pnpm link --global`).
|
||||
@@ -26,19 +20,16 @@ Remote mode supports two transports:
|
||||
## macOS app setup
|
||||
1) Open *Settings → General*.
|
||||
2) Under **Clawdbot runs**, pick **Remote over SSH** and set:
|
||||
- **Transport**: **SSH tunnel** or **Direct (ws/wss)**.
|
||||
- **SSH target**: `user@host` (optional `:port`).
|
||||
- If the gateway is on the same LAN and advertises Bonjour, pick it from the discovered list to auto-fill this field.
|
||||
- **Gateway URL** (Direct only): `wss://gateway.example.ts.net` (or `ws://...` for local/LAN).
|
||||
- **Identity file** (advanced): path to your key.
|
||||
- **Project root** (advanced): remote checkout path used for commands.
|
||||
- **CLI path** (advanced): optional path to a runnable `clawdbot` entrypoint/binary (auto-filled when advertised).
|
||||
3) Hit **Test remote**. Success indicates the remote `clawdbot status --json` runs correctly. Failures usually mean PATH/CLI issues; exit 127 means the CLI isn’t found remotely.
|
||||
4) Health checks and Web Chat will now run through this SSH tunnel automatically.
|
||||
|
||||
## Web Chat
|
||||
- **SSH tunnel**: Web Chat connects to the gateway over the forwarded WebSocket control port (default 18789).
|
||||
- **Direct (ws/wss)**: Web Chat connects straight to the configured gateway URL.
|
||||
## Web Chat over SSH
|
||||
- Web Chat connects to the gateway over the forwarded WebSocket control port (default 18789).
|
||||
- There is no separate WebChat HTTP server anymore.
|
||||
|
||||
## Permissions
|
||||
@@ -58,7 +49,6 @@ Remote mode supports two transports:
|
||||
- **exit 127 / not found**: `clawdbot` isn’t on PATH for non-login shells. Add it to `/etc/paths`, your shell rc, or symlink into `/usr/local/bin`/`/opt/homebrew/bin`.
|
||||
- **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`clawdbot status --json`).
|
||||
- **Web Chat stuck**: confirm the gateway is running on the remote host and the forwarded port matches the gateway WS port; the UI requires a healthy WS connection.
|
||||
- **Node IP shows 127.0.0.1**: expected with the SSH tunnel. Switch **Transport** to **Direct (ws/wss)** if you want the gateway to see the real client IP.
|
||||
- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed.
|
||||
|
||||
## Notification sounds
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
---
|
||||
summary: "Run Clawdbot in a sandboxed macOS VM (local or hosted) when you need isolation or iMessage"
|
||||
read_when:
|
||||
- You want Clawdbot isolated from your main macOS environment
|
||||
- You want iMessage integration (BlueBubbles) in a sandbox
|
||||
- You want a resettable macOS environment you can clone
|
||||
- You want to compare local vs hosted macOS VM options
|
||||
---
|
||||
|
||||
# Clawdbot on macOS VMs (Sandboxing)
|
||||
|
||||
## Recommended default (most users)
|
||||
|
||||
- **Small Linux VPS** for an always-on Gateway and low cost. See [VPS hosting](/vps).
|
||||
- **Dedicated hardware** (Mac mini or Linux box) if you want full control and a **residential IP** for browser automation. Many sites block data center IPs, so local browsing often works better.
|
||||
- **Hybrid:** keep the Gateway on a cheap VPS, and connect your Mac as a **node** when you need browser/UI automation. See [Nodes](/nodes) and [Gateway remote](/gateway/remote).
|
||||
|
||||
Use a macOS VM when you specifically need macOS-only capabilities (iMessage/BlueBubbles) or want strict isolation from your daily Mac.
|
||||
|
||||
## macOS VM options
|
||||
|
||||
### Local VM on your Apple Silicon Mac (Lume)
|
||||
|
||||
Run Clawdbot in a sandboxed macOS VM on your existing Apple Silicon Mac using [Lume](https://cua.ai/docs/lume).
|
||||
|
||||
This gives you:
|
||||
- Full macOS environment in isolation (your host stays clean)
|
||||
- iMessage support via BlueBubbles (impossible on Linux/Windows)
|
||||
- Instant reset by cloning VMs
|
||||
- No extra hardware or cloud costs
|
||||
|
||||
### Hosted Mac providers (cloud)
|
||||
|
||||
If you want macOS in the cloud, hosted Mac providers work too:
|
||||
- [MacStadium](https://www.macstadium.com/) (hosted Macs)
|
||||
- Other hosted Mac vendors also work; follow their VM + SSH docs
|
||||
|
||||
Once you have SSH access to a macOS VM, continue at step 6 below.
|
||||
|
||||
---
|
||||
|
||||
## Quick path (Lume, experienced users)
|
||||
|
||||
1. Install Lume
|
||||
2. `lume create clawdbot --os macos --ipsw latest`
|
||||
3. Complete Setup Assistant, enable Remote Login (SSH)
|
||||
4. `lume run clawdbot --no-display`
|
||||
5. SSH in, install Clawdbot, configure channels
|
||||
6. Done
|
||||
|
||||
---
|
||||
|
||||
## What you need (Lume)
|
||||
|
||||
- Apple Silicon Mac (M1/M2/M3/M4)
|
||||
- macOS Sequoia or later on the host
|
||||
- ~60 GB free disk space per VM
|
||||
- ~20 minutes
|
||||
|
||||
---
|
||||
|
||||
## 1) Install Lume
|
||||
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)"
|
||||
```
|
||||
|
||||
If `~/.local/bin` isn't in your PATH:
|
||||
|
||||
```bash
|
||||
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.zshrc && source ~/.zshrc
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
lume --version
|
||||
```
|
||||
|
||||
Docs: [Lume Installation](https://cua.ai/docs/lume/guide/getting-started/installation)
|
||||
|
||||
---
|
||||
|
||||
## 2) Create the macOS VM
|
||||
|
||||
```bash
|
||||
lume create clawdbot --os macos --ipsw latest
|
||||
```
|
||||
|
||||
This downloads macOS and creates the VM. A VNC window opens automatically.
|
||||
|
||||
Note: The download can take a while depending on your connection.
|
||||
|
||||
---
|
||||
|
||||
## 3) Complete Setup Assistant
|
||||
|
||||
In the VNC window:
|
||||
1. Select language and region
|
||||
2. Skip Apple ID (or sign in if you want iMessage later)
|
||||
3. Create a user account (remember the username and password)
|
||||
4. Skip all optional features
|
||||
|
||||
After setup completes, enable SSH:
|
||||
1. Open System Settings → General → Sharing
|
||||
2. Enable "Remote Login"
|
||||
|
||||
---
|
||||
|
||||
## 4) Get the VM's IP address
|
||||
|
||||
```bash
|
||||
lume get clawdbot
|
||||
```
|
||||
|
||||
Look for the IP address (usually `192.168.64.x`).
|
||||
|
||||
---
|
||||
|
||||
## 5) SSH into the VM
|
||||
|
||||
```bash
|
||||
ssh youruser@192.168.64.X
|
||||
```
|
||||
|
||||
Replace `youruser` with the account you created, and the IP with your VM's IP.
|
||||
|
||||
---
|
||||
|
||||
## 6) Install Clawdbot
|
||||
|
||||
Inside the VM:
|
||||
|
||||
```bash
|
||||
npm install -g clawdbot@latest
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 7) Configure channels
|
||||
|
||||
Edit the config file:
|
||||
|
||||
```bash
|
||||
nano ~/.clawdbot/clawdbot.json
|
||||
```
|
||||
|
||||
Add your channels:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": ["+15551234567"]
|
||||
},
|
||||
"telegram": {
|
||||
"botToken": "YOUR_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then login to WhatsApp (scan QR):
|
||||
|
||||
```bash
|
||||
clawdbot channels login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Run the VM headlessly
|
||||
|
||||
Stop the VM and restart without display:
|
||||
|
||||
```bash
|
||||
lume stop clawdbot
|
||||
lume run clawdbot --no-display
|
||||
```
|
||||
|
||||
The VM runs in the background. Clawdbot's daemon keeps the gateway running.
|
||||
|
||||
To check status:
|
||||
|
||||
```bash
|
||||
ssh youruser@192.168.64.X "clawdbot status"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bonus: iMessage integration
|
||||
|
||||
This is the killer feature of running on macOS. Use [BlueBubbles](https://bluebubbles.app) to add iMessage to Clawdbot.
|
||||
|
||||
Inside the VM:
|
||||
|
||||
1. Download BlueBubbles from bluebubbles.app
|
||||
2. Sign in with your Apple ID
|
||||
3. Enable the Web API and set a password
|
||||
4. Point BlueBubbles webhooks at your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`)
|
||||
|
||||
Add to your Clawdbot config:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"bluebubbles": {
|
||||
"serverUrl": "http://localhost:1234",
|
||||
"password": "your-api-password",
|
||||
"webhookPath": "/bluebubbles-webhook"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the gateway. Now your agent can send and receive iMessages.
|
||||
|
||||
Full setup details: [BlueBubbles channel](/channels/bluebubbles)
|
||||
|
||||
---
|
||||
|
||||
## Save a golden image
|
||||
|
||||
Before customizing further, snapshot your clean state:
|
||||
|
||||
```bash
|
||||
lume stop clawdbot
|
||||
lume clone clawdbot clawdbot-golden
|
||||
```
|
||||
|
||||
Reset anytime:
|
||||
|
||||
```bash
|
||||
lume stop clawdbot && lume delete clawdbot
|
||||
lume clone clawdbot-golden clawdbot
|
||||
lume run clawdbot --no-display
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running 24/7
|
||||
|
||||
Keep the VM running by:
|
||||
- Keeping your Mac plugged in
|
||||
- Disabling sleep in System Settings → Energy Saver
|
||||
- Using `caffeinate` if needed
|
||||
|
||||
For true always-on, consider a dedicated Mac mini or a small VPS. See [VPS hosting](/vps).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Can't SSH into VM | Check "Remote Login" is enabled in VM's System Settings |
|
||||
| VM IP not showing | Wait for VM to fully boot, run `lume get clawdbot` again |
|
||||
| Lume command not found | Add `~/.local/bin` to your PATH |
|
||||
| WhatsApp QR not scanning | Ensure you're logged into the VM (not host) when running `clawdbot channels login` |
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [VPS hosting](/vps)
|
||||
- [Nodes](/nodes)
|
||||
- [Gateway remote](/gateway/remote)
|
||||
- [BlueBubbles channel](/channels/bluebubbles)
|
||||
- [Lume Quickstart](https://cua.ai/docs/lume/guide/getting-started/quickstart)
|
||||
- [Lume CLI Reference](https://cua.ai/docs/lume/reference/cli-reference)
|
||||
- [Unattended VM Setup](https://cua.ai/docs/lume/guide/fundamentals/unattended-setup) (advanced)
|
||||
- [Docker Sandboxing](/install/docker) (alternative isolation approach)
|
||||
@@ -180,9 +180,6 @@ components can talk to a remote Gateway as if it were on localhost.
|
||||
or restarts it if needed.
|
||||
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
|
||||
ExitOnForwardFailure + keepalive options.
|
||||
- **IP reporting:** the SSH tunnel uses loopback, so the gateway will see the node
|
||||
IP as `127.0.0.1`. Use **Direct (ws/wss)** transport if you want the real client
|
||||
IP to appear (see [macOS remote access](/platforms/mac/remote)).
|
||||
|
||||
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
|
||||
details, see [Gateway protocol](/gateway/protocol).
|
||||
|
||||
@@ -7,8 +7,7 @@ read_when:
|
||||
# Windows (WSL2)
|
||||
|
||||
Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The
|
||||
CLI + Gateway run inside Linux, which keeps the runtime consistent and makes
|
||||
tooling far more compatible (Node/Bun/pnpm, Linux binaries, skills). Native
|
||||
CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
|
||||
Windows installs are untested and more problematic.
|
||||
|
||||
Native Windows companion apps are planned.
|
||||
|
||||
@@ -67,22 +67,6 @@ Plugins can register:
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
|
||||
## Runtime helpers
|
||||
|
||||
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
|
||||
|
||||
```ts
|
||||
const result = await api.runtime.tts.textToSpeechTelephony({
|
||||
text: "Hello from Clawdbot",
|
||||
cfg: api.config,
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Uses core `messages.tts` configuration (OpenAI or ElevenLabs).
|
||||
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
|
||||
- Edge TTS is not supported for telephony.
|
||||
|
||||
## Discovery & precedence
|
||||
|
||||
Clawdbot scans, in order:
|
||||
|
||||
@@ -104,87 +104,6 @@ Notes:
|
||||
- `mock` is a local dev provider (no network calls).
|
||||
- `skipSignatureVerification` is for local testing only.
|
||||
|
||||
## TTS for calls
|
||||
|
||||
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
|
||||
streaming speech on calls. You can override it under the plugin config with the
|
||||
**same shape** — it deep‑merges with `messages.tts`.
|
||||
|
||||
```json5
|
||||
{
|
||||
tts: {
|
||||
provider: "elevenlabs",
|
||||
elevenlabs: {
|
||||
voiceId: "pMsXgVXv3BLzUgSXRplE",
|
||||
modelId: "eleven_multilingual_v2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- **Edge TTS is ignored for voice calls** (telephony audio needs PCM; Edge output is unreliable).
|
||||
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
|
||||
|
||||
### More examples
|
||||
|
||||
Use core TTS only (no override):
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
provider: "openai",
|
||||
openai: { voice: "alloy" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Override to ElevenLabs just for calls (keep core default elsewhere):
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": {
|
||||
config: {
|
||||
tts: {
|
||||
provider: "elevenlabs",
|
||||
elevenlabs: {
|
||||
apiKey: "elevenlabs_key",
|
||||
voiceId: "pMsXgVXv3BLzUgSXRplE",
|
||||
modelId: "eleven_multilingual_v2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Override only the OpenAI model for calls (deep‑merge example):
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": {
|
||||
config: {
|
||||
tts: {
|
||||
openai: {
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "marin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Inbound calls
|
||||
|
||||
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
||||
|
||||
@@ -105,26 +105,3 @@ clawdbot onboard --auth-choice claude-cli
|
||||
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
|
||||
auto-migrated on load.
|
||||
- Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**401 errors / token suddenly invalid**
|
||||
- Claude subscription auth can expire or be revoked. Re-run `claude setup-token`
|
||||
and paste it into the **gateway host**.
|
||||
- If the Claude CLI login lives on a different machine, use
|
||||
`clawdbot models auth paste-token --provider anthropic` on the gateway host.
|
||||
|
||||
**No API key found for provider "anthropic"**
|
||||
- Auth is **per agent**. New agents don’t inherit the main agent’s keys.
|
||||
- Re-run onboarding for that agent, or paste a setup-token / API key on the
|
||||
gateway host, then verify with `clawdbot models status`.
|
||||
|
||||
**No credentials found for profile `anthropic:default` or `anthropic:claude-cli`**
|
||||
- Run `clawdbot models status` to see which auth profile is active.
|
||||
- Re-run onboarding, or paste a setup-token / API key for that profile.
|
||||
|
||||
**No available auth profile (all in cooldown/unavailable)**
|
||||
- Check `clawdbot models status --json` for `auth.unusableProfiles`.
|
||||
- Add another Anthropic profile or wait for cooldown.
|
||||
|
||||
More: [/gateway/troubleshooting](/gateway/troubleshooting) and [/help/faq](/help/faq).
|
||||
|
||||
@@ -11,15 +11,6 @@ default model as `provider/model`.
|
||||
|
||||
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
|
||||
|
||||
## Highlight: Venius (Venice AI)
|
||||
|
||||
Venius is our recommended Venice AI setup for privacy-first inference with an option to use Opus for hard tasks.
|
||||
|
||||
- Default: `venice/llama-3.3-70b`
|
||||
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
|
||||
|
||||
See [Venice AI](/providers/venice).
|
||||
|
||||
## Quick start
|
||||
|
||||
1) Authenticate with the provider (usually via `clawdbot onboard`).
|
||||
@@ -44,8 +35,6 @@ See [Venice AI](/providers/venice).
|
||||
- [Z.AI](/providers/zai)
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Venius (Venice AI, privacy-focused)](/providers/venice)
|
||||
- [Ollama (local models)](/providers/ollama)
|
||||
|
||||
## Transcription providers
|
||||
|
||||
|
||||
@@ -9,15 +9,6 @@ read_when:
|
||||
Clawdbot can use many LLM providers. Pick one, authenticate, then set the default
|
||||
model as `provider/model`.
|
||||
|
||||
## Highlight: Venius (Venice AI)
|
||||
|
||||
Venius is our recommended Venice AI setup for privacy-first inference with an option to use Opus for the hardest tasks.
|
||||
|
||||
- Default: `venice/llama-3.3-70b`
|
||||
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
|
||||
|
||||
See [Venice AI](/providers/venice).
|
||||
|
||||
## Quick start (two steps)
|
||||
|
||||
1) Authenticate with the provider (usually via `clawdbot onboard`).
|
||||
@@ -41,7 +32,6 @@ See [Venice AI](/providers/venice).
|
||||
- [Z.AI](/providers/zai)
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Venius (Venice AI)](/providers/venice)
|
||||
- [Amazon Bedrock](/bedrock)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
---
|
||||
summary: "Run Clawdbot with Ollama (local LLM runtime)"
|
||||
read_when:
|
||||
- You want to run Clawdbot with local models via Ollama
|
||||
- You need Ollama setup and configuration guidance
|
||||
---
|
||||
# Ollama
|
||||
|
||||
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. Clawdbot integrates with Ollama's OpenAI-compatible API and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
|
||||
|
||||
## Quick start
|
||||
|
||||
1) Install Ollama: https://ollama.ai
|
||||
|
||||
2) Pull a model:
|
||||
|
||||
```bash
|
||||
ollama pull llama3.3
|
||||
# or
|
||||
ollama pull qwen2.5-coder:32b
|
||||
# or
|
||||
ollama pull deepseek-r1:32b
|
||||
```
|
||||
|
||||
3) Enable Ollama for Clawdbot (any value works; Ollama doesn't require a real key):
|
||||
|
||||
```bash
|
||||
# Set environment variable
|
||||
export OLLAMA_API_KEY="ollama-local"
|
||||
|
||||
# Or configure in your config file
|
||||
clawdbot config set models.providers.ollama.apiKey "ollama-local"
|
||||
```
|
||||
|
||||
4) Use Ollama models:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "ollama/llama3.3" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Model discovery (implicit provider)
|
||||
|
||||
When you set `OLLAMA_API_KEY` (or an auth profile) and **do not** define `models.providers.ollama`, Clawdbot discovers models from the local Ollama instance at `http://127.0.0.1:11434`:
|
||||
|
||||
- Queries `/api/tags` and `/api/show`
|
||||
- Keeps only models that report `tools` capability
|
||||
- Marks `reasoning` when the model reports `thinking`
|
||||
- Reads `contextWindow` from `model_info["<arch>.context_length"]` when available
|
||||
- Sets `maxTokens` to 10× the context window
|
||||
- Sets all costs to `0`
|
||||
|
||||
This avoids manual model entries while keeping the catalog aligned with Ollama's capabilities.
|
||||
|
||||
To see what models are available:
|
||||
|
||||
```bash
|
||||
ollama list
|
||||
clawdbot models list
|
||||
```
|
||||
|
||||
To add a new model, simply pull it with Ollama:
|
||||
|
||||
```bash
|
||||
ollama pull mistral
|
||||
```
|
||||
|
||||
The new model will be automatically discovered and available to use.
|
||||
|
||||
If you set `models.providers.ollama` explicitly, auto-discovery is skipped and you must define models manually (see below).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic setup (implicit discovery)
|
||||
|
||||
The simplest way to enable Ollama is via environment variable:
|
||||
|
||||
```bash
|
||||
export OLLAMA_API_KEY="ollama-local"
|
||||
```
|
||||
|
||||
### Explicit setup (manual models)
|
||||
|
||||
Use explicit config when:
|
||||
- Ollama runs on another host/port.
|
||||
- You want to force specific context windows or model lists.
|
||||
- You want to include models that do not report tool support.
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
// Use a host that includes /v1 for OpenAI-compatible APIs
|
||||
baseUrl: "http://ollama-host:11434/v1",
|
||||
apiKey: "ollama-local",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "llama3.3",
|
||||
name: "Llama 3.3",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 8192 * 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `OLLAMA_API_KEY` is set, you can omit `apiKey` in the provider entry and Clawdbot will fill it for availability checks.
|
||||
|
||||
### Custom base URL (explicit config)
|
||||
|
||||
If Ollama is running on a different host or port (explicit config disables auto-discovery, so define models manually):
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
apiKey: "ollama-local",
|
||||
baseUrl: "http://ollama-host:11434/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model selection
|
||||
|
||||
Once configured, all your Ollama models are available:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "ollama/llama3.3",
|
||||
fallback: ["ollama/qwen2.5-coder:32b"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced
|
||||
|
||||
### Reasoning models
|
||||
|
||||
Clawdbot marks models as reasoning-capable when Ollama reports `thinking` in `/api/show`:
|
||||
|
||||
```bash
|
||||
ollama pull deepseek-r1:32b
|
||||
```
|
||||
|
||||
### Model Costs
|
||||
|
||||
Ollama is free and runs locally, so all model costs are set to $0.
|
||||
|
||||
### Context windows
|
||||
|
||||
For auto-discovered models, Clawdbot uses the context window reported by Ollama when available, otherwise it defaults to `8192`. You can override `contextWindow` and `maxTokens` in explicit provider config.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Ollama not detected
|
||||
|
||||
Make sure Ollama is running and that you set `OLLAMA_API_KEY` (or an auth profile), and that you did **not** define an explicit `models.providers.ollama` entry:
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
```
|
||||
|
||||
And that the API is accessible:
|
||||
|
||||
```bash
|
||||
curl http://localhost:11434/api/tags
|
||||
```
|
||||
|
||||
### No models available
|
||||
|
||||
Clawdbot only auto-discovers models that report tool support. If your model isn't listed, either:
|
||||
- Pull a tool-capable model, or
|
||||
- Define the model explicitly in `models.providers.ollama`.
|
||||
|
||||
To add models:
|
||||
|
||||
```bash
|
||||
ollama list # See what's installed
|
||||
ollama pull llama3.3 # Pull a model
|
||||
```
|
||||
|
||||
### Connection refused
|
||||
|
||||
Check that Ollama is running on the correct port:
|
||||
|
||||
```bash
|
||||
# Check if Ollama is running
|
||||
ps aux | grep ollama
|
||||
|
||||
# Or restart Ollama
|
||||
ollama serve
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Model Providers](/concepts/model-providers) - Overview of all providers
|
||||
- [Model Selection](/concepts/models) - How to choose models
|
||||
- [Configuration](/gateway/configuration) - Full config reference
|
||||
@@ -1,264 +0,0 @@
|
||||
---
|
||||
summary: "Use Venice AI privacy-focused models in Clawdbot"
|
||||
read_when:
|
||||
- You want privacy-focused inference in Clawdbot
|
||||
- You want Venice AI setup guidance
|
||||
---
|
||||
# Venice AI (Venius highlight)
|
||||
|
||||
**Venius** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
|
||||
|
||||
Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging.
|
||||
|
||||
## Why Venice in Clawdbot
|
||||
|
||||
- **Private inference** for open-source models (no logging).
|
||||
- **Uncensored models** when you need them.
|
||||
- **Anonymized access** to proprietary models (Opus/GPT/Gemini) when quality matters.
|
||||
- OpenAI-compatible `/v1` endpoints.
|
||||
|
||||
## Privacy Modes
|
||||
|
||||
Venice offers two privacy levels — understanding this is key to choosing your model:
|
||||
|
||||
| Mode | Description | Models |
|
||||
|------|-------------|--------|
|
||||
| **Private** | Fully private. Prompts/responses are **never stored or logged**. Ephemeral. | Llama, Qwen, DeepSeek, Venice Uncensored, etc. |
|
||||
| **Anonymized** | Proxied through Venice with metadata stripped. The underlying provider (OpenAI, Anthropic) sees anonymized requests. | Claude, GPT, Gemini, Grok, Kimi, MiniMax |
|
||||
|
||||
## Features
|
||||
|
||||
- **Privacy-focused**: Choose between "private" (fully private) and "anonymized" (proxied) modes
|
||||
- **Uncensored models**: Access to models without content restrictions
|
||||
- **Major model access**: Use Claude, GPT-5.2, Gemini, Grok via Venice's anonymized proxy
|
||||
- **OpenAI-compatible API**: Standard `/v1` endpoints for easy integration
|
||||
- **Streaming**: ✅ Supported on all models
|
||||
- **Function calling**: ✅ Supported on select models (check model capabilities)
|
||||
- **Vision**: ✅ Supported on models with vision capability
|
||||
- **No hard rate limits**: Fair-use throttling may apply for extreme usage
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Get API Key
|
||||
|
||||
1. Sign up at [venice.ai](https://venice.ai)
|
||||
2. Go to **Settings → API Keys → Create new key**
|
||||
3. Copy your API key (format: `vapi_xxxxxxxxxxxx`)
|
||||
|
||||
### 2. Configure Clawdbot
|
||||
|
||||
**Option A: Environment Variable**
|
||||
|
||||
```bash
|
||||
export VENICE_API_KEY="vapi_xxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
**Option B: Interactive Setup (Recommended)**
|
||||
|
||||
```bash
|
||||
clawdbot onboard --auth-choice venice-api-key
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Prompt for your API key (or use existing `VENICE_API_KEY`)
|
||||
2. Show all available Venice models
|
||||
3. Let you pick your default model
|
||||
4. Configure the provider automatically
|
||||
|
||||
**Option C: Non-interactive**
|
||||
|
||||
```bash
|
||||
clawdbot onboard --non-interactive \
|
||||
--auth-choice venice-api-key \
|
||||
--venice-api-key "vapi_xxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
### 3. Verify Setup
|
||||
|
||||
```bash
|
||||
clawdbot chat --model venice/llama-3.3-70b "Hello, are you working?"
|
||||
```
|
||||
|
||||
## Model Selection
|
||||
|
||||
After setup, Clawdbot shows all available Venice models. Pick based on your needs:
|
||||
|
||||
- **Default (our pick)**: `venice/llama-3.3-70b` for private, balanced performance.
|
||||
- **Best overall quality**: `venice/claude-opus-45` for hard jobs (Opus remains the strongest).
|
||||
- **Privacy**: Choose "private" models for fully private inference.
|
||||
- **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy.
|
||||
|
||||
Change your default model anytime:
|
||||
|
||||
```bash
|
||||
clawdbot models set venice/claude-opus-45
|
||||
clawdbot models set venice/llama-3.3-70b
|
||||
```
|
||||
|
||||
List all available models:
|
||||
|
||||
```bash
|
||||
clawdbot models list | grep venice
|
||||
```
|
||||
|
||||
## Configure via `clawdbot configure`
|
||||
|
||||
1. Run `clawdbot configure`
|
||||
2. Select **Model/auth**
|
||||
3. Choose **Venice AI**
|
||||
|
||||
## Which Model Should I Use?
|
||||
|
||||
| Use Case | Recommended Model | Why |
|
||||
|----------|-------------------|-----|
|
||||
| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
|
||||
| **Best overall quality** | `claude-opus-45` | Opus remains the strongest for hard tasks |
|
||||
| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
|
||||
| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
|
||||
| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
|
||||
| **Uncensored** | `venice-uncensored` | No content restrictions |
|
||||
| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
|
||||
| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
|
||||
|
||||
## Available Models (25 Total)
|
||||
|
||||
### Private Models (15) — Fully Private, No Logging
|
||||
|
||||
| Model ID | Name | Context (tokens) | Features |
|
||||
|----------|------|------------------|----------|
|
||||
| `llama-3.3-70b` | Llama 3.3 70B | 131k | General |
|
||||
| `llama-3.2-3b` | Llama 3.2 3B | 131k | Fast, lightweight |
|
||||
| `hermes-3-llama-3.1-405b` | Hermes 3 Llama 3.1 405B | 131k | Complex tasks |
|
||||
| `qwen3-235b-a22b-thinking-2507` | Qwen3 235B Thinking | 131k | Reasoning |
|
||||
| `qwen3-235b-a22b-instruct-2507` | Qwen3 235B Instruct | 131k | General |
|
||||
| `qwen3-coder-480b-a35b-instruct` | Qwen3 Coder 480B | 262k | Code |
|
||||
| `qwen3-next-80b` | Qwen3 Next 80B | 262k | General |
|
||||
| `qwen3-vl-235b-a22b` | Qwen3 VL 235B | 262k | Vision |
|
||||
| `qwen3-4b` | Venice Small (Qwen3 4B) | 32k | Fast, reasoning |
|
||||
| `deepseek-v3.2` | DeepSeek V3.2 | 163k | Reasoning |
|
||||
| `venice-uncensored` | Venice Uncensored | 32k | Uncensored |
|
||||
| `mistral-31-24b` | Venice Medium (Mistral) | 131k | Vision |
|
||||
| `google-gemma-3-27b-it` | Gemma 3 27B Instruct | 202k | Vision |
|
||||
| `openai-gpt-oss-120b` | OpenAI GPT OSS 120B | 131k | General |
|
||||
| `zai-org-glm-4.7` | GLM 4.7 | 202k | Reasoning, multilingual |
|
||||
|
||||
### Anonymized Models (10) — Via Venice Proxy
|
||||
|
||||
| Model ID | Original | Context (tokens) | Features |
|
||||
|----------|----------|------------------|----------|
|
||||
| `claude-opus-45` | Claude Opus 4.5 | 202k | Reasoning, vision |
|
||||
| `claude-sonnet-45` | Claude Sonnet 4.5 | 202k | Reasoning, vision |
|
||||
| `openai-gpt-52` | GPT-5.2 | 262k | Reasoning |
|
||||
| `openai-gpt-52-codex` | GPT-5.2 Codex | 262k | Reasoning, vision |
|
||||
| `gemini-3-pro-preview` | Gemini 3 Pro | 202k | Reasoning, vision |
|
||||
| `gemini-3-flash-preview` | Gemini 3 Flash | 262k | Reasoning, vision |
|
||||
| `grok-41-fast` | Grok 4.1 Fast | 262k | Reasoning, vision |
|
||||
| `grok-code-fast-1` | Grok Code Fast 1 | 262k | Reasoning, code |
|
||||
| `kimi-k2-thinking` | Kimi K2 Thinking | 262k | Reasoning |
|
||||
| `minimax-m21` | MiniMax M2.1 | 202k | Reasoning |
|
||||
|
||||
## Model Discovery
|
||||
|
||||
Clawdbot automatically discovers models from the Venice API when `VENICE_API_KEY` is set. If the API is unreachable, it falls back to a static catalog.
|
||||
|
||||
The `/models` endpoint is public (no auth needed for listing), but inference requires a valid API key.
|
||||
|
||||
## Streaming & Tool Support
|
||||
|
||||
| Feature | Support |
|
||||
|---------|---------|
|
||||
| **Streaming** | ✅ All models |
|
||||
| **Function calling** | ✅ Most models (check `supportsFunctionCalling` in API) |
|
||||
| **Vision/Images** | ✅ Models marked with "Vision" feature |
|
||||
| **JSON mode** | ✅ Supported via `response_format` |
|
||||
|
||||
## Pricing
|
||||
|
||||
Venice uses a credit-based system. Check [venice.ai/pricing](https://venice.ai/pricing) for current rates:
|
||||
|
||||
- **Private models**: Generally lower cost
|
||||
- **Anonymized models**: Similar to direct API pricing + small Venice fee
|
||||
|
||||
## Comparison: Venice vs Direct API
|
||||
|
||||
| Aspect | Venice (Anonymized) | Direct API |
|
||||
|--------|---------------------|------------|
|
||||
| **Privacy** | Metadata stripped, anonymized | Your account linked |
|
||||
| **Latency** | +10-50ms (proxy) | Direct |
|
||||
| **Features** | Most features supported | Full features |
|
||||
| **Billing** | Venice credits | Provider billing |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Use default private model
|
||||
clawdbot chat --model venice/llama-3.3-70b
|
||||
|
||||
# Use Claude via Venice (anonymized)
|
||||
clawdbot chat --model venice/claude-opus-45
|
||||
|
||||
# Use uncensored model
|
||||
clawdbot chat --model venice/venice-uncensored
|
||||
|
||||
# Use vision model with image
|
||||
clawdbot chat --model venice/qwen3-vl-235b-a22b
|
||||
|
||||
# Use coding model
|
||||
clawdbot chat --model venice/qwen3-coder-480b-a35b-instruct
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API key not recognized
|
||||
|
||||
```bash
|
||||
echo $VENICE_API_KEY
|
||||
clawdbot models list | grep venice
|
||||
```
|
||||
|
||||
Ensure the key starts with `vapi_`.
|
||||
|
||||
### Model not available
|
||||
|
||||
The Venice model catalog updates dynamically. Run `clawdbot models list` to see currently available models. Some models may be temporarily offline.
|
||||
|
||||
### Connection issues
|
||||
|
||||
Venice API is at `https://api.venice.ai/api/v1`. Ensure your network allows HTTPS connections.
|
||||
|
||||
## Config file example
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { VENICE_API_KEY: "vapi_..." },
|
||||
agents: { defaults: { model: { primary: "venice/llama-3.3-70b" } } },
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
venice: {
|
||||
baseUrl: "https://api.venice.ai/api/v1",
|
||||
apiKey: "${VENICE_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "llama-3.3-70b",
|
||||
name: "Llama 3.3 70B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- [Venice AI](https://venice.ai)
|
||||
- [API Documentation](https://docs.venice.ai)
|
||||
- [Pricing](https://venice.ai/pricing)
|
||||
- [Status](https://status.venice.ai)
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
title: Deploy on Railway
|
||||
---
|
||||
|
||||
Deploy Clawdbot on Railway with a one-click template and finish setup in your browser.
|
||||
This is the easiest “no terminal on the server” path: Railway runs the Gateway for you,
|
||||
and you configure everything via the `/setup` web wizard.
|
||||
|
||||
## Quick checklist (new users)
|
||||
|
||||
1) Click **Deploy on Railway** (below).
|
||||
2) Add a **Volume** mounted at `/data`.
|
||||
3) Set the required **Variables** (at least `SETUP_PASSWORD`).
|
||||
4) Enable **HTTP Proxy** on port `8080`.
|
||||
5) Open `https://<your-railway-domain>/setup` and finish the wizard.
|
||||
|
||||
## One-click deploy
|
||||
|
||||
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
||||
|
||||
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
||||
|
||||
Railway will either:
|
||||
- give you a generated domain (often `https://<something>.up.railway.app`), or
|
||||
- use your custom domain if you attached one.
|
||||
|
||||
Then open:
|
||||
|
||||
- `https://<your-railway-domain>/setup` — setup wizard (password protected)
|
||||
- `https://<your-railway-domain>/clawdbot` — Control UI
|
||||
|
||||
## What you get
|
||||
|
||||
- Hosted Clawdbot Gateway + Control UI
|
||||
- Web setup wizard at `/setup` (no terminal commands)
|
||||
- Persistent storage via Railway Volume (`/data`) so config/credentials/workspace survive redeploys
|
||||
- Backup export at `/setup/export` to migrate off Railway later
|
||||
|
||||
## Required Railway settings
|
||||
|
||||
### Public Networking
|
||||
|
||||
Enable **HTTP Proxy** for the service.
|
||||
|
||||
- Port: `8080`
|
||||
|
||||
### Volume (required)
|
||||
|
||||
Attach a volume mounted at:
|
||||
|
||||
- `/data`
|
||||
|
||||
### Variables
|
||||
|
||||
Set these variables on the service:
|
||||
|
||||
- `SETUP_PASSWORD` (required)
|
||||
- `PORT=8080` (required — must match the port in Public Networking)
|
||||
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
|
||||
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
|
||||
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
|
||||
|
||||
## Setup flow
|
||||
|
||||
1) Visit `https://<your-railway-domain>/setup` and enter your `SETUP_PASSWORD`.
|
||||
2) Choose a model/auth provider and paste your key.
|
||||
3) (Optional) Add Telegram/Discord/Slack tokens.
|
||||
4) Click **Run setup**.
|
||||
|
||||
If Telegram DMs are set to pairing, the setup wizard can approve the pairing code.
|
||||
|
||||
## Getting chat tokens
|
||||
|
||||
### Telegram bot token
|
||||
|
||||
1) Message `@BotFather` in Telegram
|
||||
2) Run `/newbot`
|
||||
3) Copy the token (looks like `123456789:AA...`)
|
||||
4) Paste it into `/setup`
|
||||
|
||||
### Discord bot token
|
||||
|
||||
1) Go to https://discord.com/developers/applications
|
||||
2) **New Application** → choose a name
|
||||
3) **Bot** → **Add Bot**
|
||||
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
|
||||
5) Copy the **Bot Token** and paste into `/setup`
|
||||
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
||||
|
||||
## Backups & migration
|
||||
|
||||
Download a backup at:
|
||||
|
||||
- `https://<your-railway-domain>/setup/export`
|
||||
|
||||
This exports your Clawdbot state + workspace so you can migrate to another host without losing config or memory.
|
||||
@@ -45,7 +45,7 @@ run on host, set an explicit per-agent override:
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
|
||||
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested, more problematic, and has poorer tool compatibility. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
|
||||
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
|
||||
|
||||
## 1) Install the CLI (recommended)
|
||||
|
||||
|
||||
@@ -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, Google Chat, Mattermost (plugin), Signal)
|
||||
- Providers (Telegram, WhatsApp, Discord, Mattermost (plugin), Signal)
|
||||
- Daemon install (LaunchAgent / systemd user unit)
|
||||
- Health check
|
||||
- Skills (recommended)
|
||||
@@ -114,11 +114,10 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- Non‑loopback binds still require auth.
|
||||
|
||||
5) **Channels**
|
||||
- WhatsApp: optional QR login.
|
||||
- Telegram: bot token.
|
||||
- Discord: bot token.
|
||||
- Google Chat: service account JSON + webhook audience.
|
||||
- Mattermost (plugin): bot token + base URL.
|
||||
- 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.
|
||||
@@ -314,5 +313,5 @@ will prompt to install it (npm or a local path) before it can be configured.
|
||||
|
||||
- macOS app onboarding: [Onboarding](/start/onboarding)
|
||||
- Config reference: [Gateway configuration](/gateway/configuration)
|
||||
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Google Chat](/channels/googlechat), [Signal](/channels/signal), [iMessage](/channels/imessage)
|
||||
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Signal](/channels/signal), [iMessage](/channels/imessage)
|
||||
- Skills: [Skills](/tools/skills), [Skills config](/tools/skills-config)
|
||||
|
||||
@@ -41,7 +41,7 @@ clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel
|
||||
|
||||
- `--local`: run locally (requires model provider API keys in your shell)
|
||||
- `--deliver`: send the reply to the chosen channel
|
||||
- `--channel`: delivery channel (`whatsapp|telegram|discord|googlechat|slack|signal|imessage`, default: `whatsapp`)
|
||||
- `--channel`: delivery channel (`whatsapp|telegram|discord|slack|signal|imessage`, default: `whatsapp`)
|
||||
- `--reply-to`: delivery target override
|
||||
- `--reply-channel`: delivery channel override
|
||||
- `--reply-account`: delivery account id override
|
||||
|
||||
@@ -6,10 +6,9 @@ read_when:
|
||||
# Elevated Mode (/elevated directives)
|
||||
|
||||
## What it does
|
||||
- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
|
||||
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
|
||||
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
|
||||
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
|
||||
- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies.
|
||||
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
|
||||
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
|
||||
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
|
||||
@@ -19,8 +18,8 @@ read_when:
|
||||
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
|
||||
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
|
||||
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
||||
- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`.
|
||||
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
|
||||
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
|
||||
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
|
||||
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
||||
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
||||
|
||||
|
||||
@@ -157,36 +157,6 @@ Actions:
|
||||
- **Always allow** → add to allowlist + run
|
||||
- **Deny** → block
|
||||
|
||||
## Approval forwarding to chat channels
|
||||
|
||||
You can forward exec approval prompts to any chat channel (including plugin channels) and approve
|
||||
them with `/approve`. This uses the normal outbound delivery pipeline.
|
||||
|
||||
Config:
|
||||
```json5
|
||||
{
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "session", // "session" | "targets" | "both"
|
||||
agentFilter: ["main"],
|
||||
sessionFilter: ["discord"], // substring or regex
|
||||
targets: [
|
||||
{ channel: "slack", to: "U12345678" },
|
||||
{ channel: "telegram", to: "123456789" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reply in chat:
|
||||
```
|
||||
/approve <id> allow-once
|
||||
/approve <id> allow-always
|
||||
/approve <id> deny
|
||||
```
|
||||
|
||||
### macOS IPC flow
|
||||
```
|
||||
Gateway -> Node Service (WS)
|
||||
|
||||
@@ -24,7 +24,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
|
||||
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
|
||||
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
|
||||
- `node` (string): node id/name for `host=node`
|
||||
- `elevated` (bool): request elevated mode (gateway host); `security=full` is only forced when elevated resolves to `full`
|
||||
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
|
||||
|
||||
Notes:
|
||||
- `host` defaults to `sandbox`.
|
||||
|
||||
@@ -325,7 +325,7 @@ Notes:
|
||||
- Uses the image model directly (independent of the main chat model).
|
||||
|
||||
### `message`
|
||||
Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
|
||||
Send messages and channel actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
|
||||
|
||||
Core actions:
|
||||
- `send` (text + optional media; MS Teams also supports `card` for Adaptive Cards)
|
||||
@@ -368,7 +368,6 @@ Core actions:
|
||||
- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `clawdbot gateway` restart in-place)
|
||||
- `config.get` / `config.schema`
|
||||
- `config.apply` (validate + write config + restart + wake)
|
||||
- `config.patch` (merge partial update + restart + wake)
|
||||
- `update.run` (run update + restart + wake)
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -23,16 +23,6 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs
|
||||
- **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved.
|
||||
- **Resumable**: Halted workflows return a token; approve and resume without re-running everything.
|
||||
|
||||
## Why a DSL instead of plain programs?
|
||||
|
||||
Lobster is intentionally small. The goal is not "a new language," it's a predictable, AI-friendly pipeline spec with first-class approvals and resume tokens.
|
||||
|
||||
- **Approve/resume is built in**: A normal program can prompt a human, but it can’t *pause and resume* with a durable token without you inventing that runtime yourself.
|
||||
- **Determinism + auditability**: Pipelines are data, so they’re easy to log, diff, replay, and review.
|
||||
- **Constrained surface for AI**: A tiny grammar + JSON piping reduces “creative” code paths and makes validation realistic.
|
||||
- **Safety policy baked in**: Timeouts, output caps, sandbox checks, and allowlists are enforced by the runtime, not each script.
|
||||
- **Still programmable**: Each step can call any CLI or script. If you want JS/TS, generate `.lobster` files from code.
|
||||
|
||||
## How it works
|
||||
|
||||
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
|
||||
|
||||
@@ -14,7 +14,6 @@ Shared reaction semantics across channels:
|
||||
Channel notes:
|
||||
|
||||
- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji.
|
||||
- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji.
|
||||
- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
|
||||
- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
|
||||
- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled.
|
||||
|
||||
@@ -39,7 +39,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
||||
```
|
||||
|
||||
- `commands.text` (default `true`) enables parsing `/...` in chat messages.
|
||||
- On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/Google Chat/MS Teams), text commands still work even if you set this to `false`.
|
||||
- On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/MS Teams), text commands still work even if you set this to `false`.
|
||||
- `commands.native` (default `"auto"`) registers native commands.
|
||||
- Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support.
|
||||
- Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`).
|
||||
@@ -61,14 +61,13 @@ Text + native (when enabled):
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
- `/tts off|always|inbound|tagged|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
||||
- `/stop`
|
||||
- `/restart`
|
||||
|
||||
@@ -26,10 +26,6 @@ Primary goals:
|
||||
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
|
||||
- Avoid nested fan-out: sub-agents cannot spawn sub-agents.
|
||||
|
||||
Cost note: each sub-agent has its **own** context and token usage. For heavy or repetitive
|
||||
tasks, set a cheaper model for sub-agents and keep your main agent on a higher-quality model.
|
||||
You can configure this via `agents.defaults.subagents.model` or per-agent overrides.
|
||||
|
||||
## Tool
|
||||
|
||||
Use `sessions_spawn`:
|
||||
|
||||
@@ -174,7 +174,6 @@ Search the web using your configured provider.
|
||||
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
|
||||
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
|
||||
- `ui_lang` (optional): ISO language code for UI elements
|
||||
- `freshness` (optional, Brave only): filter by discovery time (`pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`)
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -194,12 +193,6 @@ await web_search({
|
||||
search_lang: "fr",
|
||||
ui_lang: "fr"
|
||||
});
|
||||
|
||||
// Recent results (past week)
|
||||
await web_search({
|
||||
query: "TMBG interview",
|
||||
freshness: "pw"
|
||||
});
|
||||
```
|
||||
|
||||
## web_fetch
|
||||
|
||||
125
docs/tts.md
125
docs/tts.md
@@ -8,37 +8,21 @@ read_when:
|
||||
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
Clawdbot can convert outbound replies into audio using ElevenLabs, OpenAI, or Edge TTS.
|
||||
Clawdbot can convert outbound replies into audio using ElevenLabs or OpenAI.
|
||||
It works anywhere Clawdbot can send audio; Telegram gets a round voice-note bubble.
|
||||
|
||||
## Supported services
|
||||
|
||||
- **ElevenLabs** (primary or fallback provider)
|
||||
- **OpenAI** (primary or fallback provider; also used for summaries)
|
||||
- **Edge TTS** (primary or fallback provider; uses `node-edge-tts`, default when no API keys)
|
||||
|
||||
### Edge TTS notes
|
||||
## Required keys
|
||||
|
||||
Edge TTS uses Microsoft Edge's online neural TTS service via the `node-edge-tts`
|
||||
library. It's a hosted service (not local), uses Microsoft’s endpoints, and does
|
||||
not require an API key. `node-edge-tts` exposes speech configuration options and
|
||||
output formats, but not all options are supported by the Edge service. citeturn2search0
|
||||
|
||||
Because Edge TTS is a public web service without a published SLA or quota, treat it
|
||||
as best-effort. If you need guaranteed limits and support, use OpenAI or ElevenLabs.
|
||||
Microsoft's Speech REST API documents a 10‑minute audio limit per request; Edge TTS
|
||||
does not publish limits, so assume similar or lower limits. citeturn0search3
|
||||
|
||||
## Optional keys
|
||||
|
||||
If you want OpenAI or ElevenLabs:
|
||||
At least one of:
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
Edge TTS does **not** require an API key. If no API keys are found, Clawdbot defaults
|
||||
to Edge TTS (unless disabled via `messages.tts.edge.enabled=false`).
|
||||
|
||||
If multiple providers are configured, the selected provider is used first and the others are fallback options.
|
||||
If both are configured, the selected provider is used first and the other is a fallback.
|
||||
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
|
||||
so that provider must also be authenticated if you enable summaries.
|
||||
|
||||
@@ -48,16 +32,11 @@ so that provider must also be authenticated if you enable summaries.
|
||||
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
|
||||
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
|
||||
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
|
||||
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
|
||||
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
|
||||
|
||||
## Is it enabled by default?
|
||||
|
||||
No. Auto‑TTS is **off** by default. Enable it in config with
|
||||
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
|
||||
|
||||
Edge TTS **is** enabled by default once TTS is on, and is used automatically
|
||||
when no OpenAI or ElevenLabs API keys are available.
|
||||
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
|
||||
which writes a local preference override.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -70,7 +49,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
enabled: true,
|
||||
provider: "elevenlabs"
|
||||
}
|
||||
}
|
||||
@@ -83,7 +62,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
enabled: true,
|
||||
provider: "openai",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
modelOverrides: {
|
||||
@@ -115,48 +94,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Edge TTS primary (no API key)
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "edge",
|
||||
edge: {
|
||||
enabled: true,
|
||||
voice: "en-US-MichelleNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
rate: "+10%",
|
||||
pitch: "-5%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Edge TTS
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
edge: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom limits + prefs path
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
enabled: true,
|
||||
maxTextLength: 4000,
|
||||
timeoutMs: 30000,
|
||||
prefsPath: "~/.clawdbot/settings/tts.json"
|
||||
@@ -165,25 +109,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Only reply with audio after an inbound voice note
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "inbound"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disable auto-summary for long replies
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always"
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,20 +129,15 @@ Then run:
|
||||
|
||||
### Notes on fields
|
||||
|
||||
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
||||
- `inbound` only sends audio after an inbound voice note.
|
||||
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
|
||||
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||
- `enabled`: master toggle (default `false`; local prefs can override).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
|
||||
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
|
||||
otherwise `edge`.
|
||||
- `provider`: `"elevenlabs"` or `"openai"` (fallback is automatic).
|
||||
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
|
||||
- Accepts `provider/model` or a configured model alias.
|
||||
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||
- `timeoutMs`: request timeout (ms).
|
||||
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
|
||||
- `prefsPath`: override the local prefs JSON path.
|
||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
||||
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||
- `elevenlabs.voiceSettings`:
|
||||
@@ -220,20 +147,10 @@ Then run:
|
||||
- `elevenlabs.applyTextNormalization`: `auto|on|off`
|
||||
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
|
||||
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
|
||||
- `edge.enabled`: allow Edge TTS usage (default `true`; no API key).
|
||||
- `edge.voice`: Edge neural voice name (e.g. `en-US-MichelleNeural`).
|
||||
- `edge.lang`: language code (e.g. `en-US`).
|
||||
- `edge.outputFormat`: Edge output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- See Microsoft Speech output formats for valid values; not all formats are supported by Edge.
|
||||
- `edge.rate` / `edge.pitch` / `edge.volume`: percent strings (e.g. `+10%`, `-5%`).
|
||||
- `edge.saveSubtitles`: write JSON subtitles alongside the audio file.
|
||||
- `edge.proxy`: proxy URL for Edge TTS requests.
|
||||
- `edge.timeoutMs`: request timeout override (ms).
|
||||
|
||||
## Model-driven overrides (default on)
|
||||
|
||||
By default, the model **can** emit TTS directives for a single reply.
|
||||
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
|
||||
|
||||
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
||||
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
||||
@@ -250,7 +167,7 @@ Here you go.
|
||||
```
|
||||
|
||||
Available directive keys (when enabled):
|
||||
- `provider` (`openai` | `elevenlabs` | `edge`)
|
||||
- `provider` (`openai` | `elevenlabs`)
|
||||
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs)
|
||||
- `model` (OpenAI TTS model or ElevenLabs model id)
|
||||
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
|
||||
@@ -308,15 +225,8 @@ These override `messages.tts.*` for that host.
|
||||
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
- **Edge TTS**: uses `edge.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- `node-edge-tts` accepts an `outputFormat`, but not all formats are available
|
||||
from the Edge service. citeturn2search0
|
||||
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus). citeturn1search0
|
||||
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
|
||||
guaranteed Opus voice notes. citeturn1search1
|
||||
- If the configured Edge output format fails, Clawdbot retries with MP3.
|
||||
|
||||
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
|
||||
This is not configurable; Telegram expects Opus for voice-note UX.
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
@@ -354,10 +264,8 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
||||
`/voice` as the native command there. Text `/tts ...` still works.
|
||||
|
||||
```
|
||||
/tts on
|
||||
/tts off
|
||||
/tts always
|
||||
/tts inbound
|
||||
/tts tagged
|
||||
/tts status
|
||||
/tts provider openai
|
||||
/tts limit 2000
|
||||
@@ -368,7 +276,6 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
||||
Notes:
|
||||
- Commands require an authorized sender (allowlist/owner rules still apply).
|
||||
- `commands.text` or native command registration must be enabled.
|
||||
- `off|always|inbound|tagged` are per‑session toggles (`/tts on` is an alias for `/tts always`).
|
||||
- `limit` and `summary` are stored in local prefs, not the main config.
|
||||
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
||||
|
||||
|
||||
@@ -118,14 +118,6 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
|
||||
- `--deliver`: Deliver assistant replies to the provider (default off)
|
||||
- `--thinking <level>`: Override thinking level for sends
|
||||
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
No output after sending a message:
|
||||
- Run `/status` in the TUI to confirm the Gateway is connected and idle/busy.
|
||||
- Check the Gateway logs: `clawdbot logs --follow`.
|
||||
- Confirm the agent can run: `clawdbot status` and `clawdbot models status`.
|
||||
- If you expect messages in a chat channel, enable delivery (`/deliver on` or `--deliver`).
|
||||
- `--history-limit <n>`: History entries to load (default 200)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
36
docs/vps.md
36
docs/vps.md
@@ -1,36 +0,0 @@
|
||||
---
|
||||
summary: "VPS hosting hub for Clawdbot (Railway/Fly/Hetzner/exe.dev)"
|
||||
read_when:
|
||||
- You want to run the Gateway in the cloud
|
||||
- You need a quick map of VPS/hosting guides
|
||||
---
|
||||
# VPS hosting
|
||||
|
||||
This hub links to the supported VPS/hosting guides and explains how cloud
|
||||
deployments work at a high level.
|
||||
|
||||
## Pick a provider
|
||||
|
||||
- **Railway** (one‑click + browser setup): [Railway](/railway)
|
||||
- **Fly.io**: [Fly.io](/platforms/fly)
|
||||
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
|
||||
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
- **AWS (EC2/Lightsail/free tier)**: works well too. Video guide:
|
||||
https://x.com/techfrenAJ/status/2014934471095812547
|
||||
|
||||
## How cloud setups work
|
||||
|
||||
- The **Gateway runs on the VPS** and owns state + workspace.
|
||||
- You connect from your laptop/phone via the **Control UI** or **Tailscale/SSH**.
|
||||
- Treat the VPS as the source of truth and **back up** the state + workspace.
|
||||
|
||||
Remote access: [Gateway remote](/gateway/remote)
|
||||
Platforms hub: [Platforms](/platforms)
|
||||
|
||||
## Using nodes with a VPS
|
||||
|
||||
You can keep the Gateway in the cloud and pair **nodes** on your local devices
|
||||
(Mac/iOS/Android/headless). Nodes provide local screen/camera/canvas and `system.run`
|
||||
capabilities while the Gateway stays in the cloud.
|
||||
|
||||
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes)
|
||||
@@ -47,8 +47,7 @@ function mergeBlueBubblesAccountConfig(
|
||||
};
|
||||
const { accounts: _ignored, ...rest } = base;
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length";
|
||||
return { ...rest, ...account, chunkMode };
|
||||
return { ...rest, ...account };
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesAccount(params: {
|
||||
|
||||
@@ -38,7 +38,6 @@ const bluebubblesAccountSchema = z.object({
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
|
||||
@@ -1189,53 +1189,9 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
||||
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||
// Body uses inline [[reply_to:N]] tag format
|
||||
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
|
||||
});
|
||||
|
||||
it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: ClawdbotConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "replying now",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
replyTo: {
|
||||
guid: "p:1/msg-0",
|
||||
text: "original message",
|
||||
handle: { address: "+15550000000", displayName: "Alice" },
|
||||
},
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
|
||||
expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
|
||||
expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
|
||||
// Body uses just the ID (no sender) for token savings
|
||||
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
|
||||
expect(callArgs.ctx.Body).toContain("original message");
|
||||
});
|
||||
|
||||
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
||||
@@ -1304,8 +1260,9 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
||||
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
|
||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||
// Body uses inline [[reply_to:N]] tag format with short ID
|
||||
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
|
||||
// Body uses just the short ID (no sender) for token savings
|
||||
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
|
||||
expect(callArgs.ctx.Body).toContain("original message (cached)");
|
||||
});
|
||||
|
||||
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
||||
@@ -1348,88 +1305,6 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("tapback text parsing", () => {
|
||||
it("does not rewrite tapback-like text without metadata", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: ClawdbotConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "Loved this idea",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
|
||||
expect(callArgs.ctx.Body).toContain("Loved this idea");
|
||||
expect(callArgs.ctx.Body).not.toContain("reacted with");
|
||||
});
|
||||
|
||||
it("parses tapback text with custom emoji when metadata is present", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: ClawdbotConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: 'Reacted 😅 to "nice one"',
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-2",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
|
||||
expect(callArgs.ctx.Body).toContain("reacted with 😅");
|
||||
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ack reactions", () => {
|
||||
it("sends ack reaction when configured", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
@@ -1884,7 +1759,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reacted with ❤️ [[reply_to:"),
|
||||
expect.stringContaining("reaction added"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -1924,7 +1799,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
|
||||
expect.stringContaining("reaction removed"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -2030,7 +1905,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "p:1/msg-uuid-12345",
|
||||
guid: "msg-uuid-12345",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
@@ -2046,7 +1921,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
// MessageSid should be short ID "1" instead of full UUID
|
||||
expect(callArgs.ctx.MessageSid).toBe("1");
|
||||
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
|
||||
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
|
||||
});
|
||||
|
||||
it("resolves short ID back to UUID", async () => {
|
||||
@@ -2070,7 +1945,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "p:1/msg-uuid-12345",
|
||||
guid: "msg-uuid-12345",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
@@ -2083,7 +1958,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
// The short ID "1" should resolve back to the full UUID
|
||||
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
|
||||
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
||||
});
|
||||
|
||||
it("returns UUID unchanged when not in cache", () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ type BlueBubblesReplyCacheEntry = {
|
||||
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
||||
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
||||
|
||||
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
|
||||
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
|
||||
const blueBubblesShortIdToUuid = new Map<string, string>();
|
||||
const blueBubblesUuidToShortId = new Map<string, string>();
|
||||
let blueBubblesShortIdCounter = 0;
|
||||
@@ -78,7 +78,7 @@ function rememberBlueBubblesReplyCache(
|
||||
return { ...entry, shortId: "" };
|
||||
}
|
||||
|
||||
// Check if we already have a short ID for this GUID
|
||||
// Check if we already have a short ID for this UUID
|
||||
let shortId = blueBubblesUuidToShortId.get(messageId);
|
||||
if (!shortId) {
|
||||
shortId = generateShortId();
|
||||
@@ -86,7 +86,7 @@ function rememberBlueBubblesReplyCache(
|
||||
blueBubblesUuidToShortId.set(messageId, shortId);
|
||||
}
|
||||
|
||||
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
|
||||
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
|
||||
|
||||
// Refresh insertion order.
|
||||
blueBubblesReplyCacheByMessageId.delete(messageId);
|
||||
@@ -122,8 +122,8 @@ function rememberBlueBubblesReplyCache(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
|
||||
* Returns the input unchanged if it's already a GUID or not found in the mapping.
|
||||
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
|
||||
* Returns the input unchanged if it's already a UUID or not found in the mapping.
|
||||
*/
|
||||
export function resolveBlueBubblesMessageId(
|
||||
shortOrUuid: string,
|
||||
@@ -159,7 +159,7 @@ export function _resetBlueBubblesShortIdState(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the short ID for a message GUID, if one exists.
|
||||
* Gets the short ID for a UUID, if one exists.
|
||||
*/
|
||||
function getShortIdForUuid(uuid: string): string | undefined {
|
||||
return blueBubblesUuidToShortId.get(uuid.trim());
|
||||
@@ -390,15 +390,29 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
|
||||
function formatReplyTag(message: {
|
||||
const REPLY_BODY_TRUNCATE_LENGTH = 60;
|
||||
|
||||
function formatReplyContext(message: {
|
||||
replyToId?: string;
|
||||
replyToShortId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
}): string | null {
|
||||
// Prefer short ID
|
||||
const rawId = message.replyToShortId || message.replyToId;
|
||||
if (!rawId) return null;
|
||||
return `[[reply_to:${rawId}]]`;
|
||||
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
||||
// Prefer short ID for token savings
|
||||
const displayId = message.replyToShortId || message.replyToId;
|
||||
// Only include sender if we don't have an ID (fallback)
|
||||
const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
|
||||
const rawBody = message.replyToBody?.trim();
|
||||
if (!rawBody) {
|
||||
return `[Replying to ${label}]\n[/Replying]`;
|
||||
}
|
||||
// Truncate long reply bodies for token savings
|
||||
const body =
|
||||
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
|
||||
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…`
|
||||
: rawBody;
|
||||
return `[Replying to ${label}]\n${body}\n[/Replying]`;
|
||||
}
|
||||
|
||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||
@@ -615,10 +629,6 @@ type NormalizedWebhookMessage = {
|
||||
fromMe?: boolean;
|
||||
attachments?: BlueBubblesAttachment[];
|
||||
balloonBundleId?: string;
|
||||
associatedMessageGuid?: string;
|
||||
associatedMessageType?: number;
|
||||
associatedMessageEmoji?: string;
|
||||
isTapback?: boolean;
|
||||
participants?: BlueBubblesParticipant[];
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
@@ -655,113 +665,6 @@ const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "re
|
||||
[3005, { emoji: "❓", action: "removed" }],
|
||||
]);
|
||||
|
||||
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
|
||||
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
|
||||
["loved", { emoji: "❤️", action: "added" }],
|
||||
["liked", { emoji: "👍", action: "added" }],
|
||||
["disliked", { emoji: "👎", action: "added" }],
|
||||
["laughed at", { emoji: "😂", action: "added" }],
|
||||
["emphasized", { emoji: "‼️", action: "added" }],
|
||||
["questioned", { emoji: "❓", action: "added" }],
|
||||
// Removal patterns (e.g., "Removed a heart from")
|
||||
["removed a heart from", { emoji: "❤️", action: "removed" }],
|
||||
["removed a like from", { emoji: "👍", action: "removed" }],
|
||||
["removed a dislike from", { emoji: "👎", action: "removed" }],
|
||||
["removed a laugh from", { emoji: "😂", action: "removed" }],
|
||||
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
|
||||
["removed a question from", { emoji: "❓", action: "removed" }],
|
||||
]);
|
||||
|
||||
const TAPBACK_EMOJI_REGEX =
|
||||
/(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
|
||||
|
||||
function extractFirstEmoji(text: string): string | null {
|
||||
const match = text.match(TAPBACK_EMOJI_REGEX);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
function extractQuotedTapbackText(text: string): string | null {
|
||||
const match = text.match(/[“"]([^”"]+)[”"]/s);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function isTapbackAssociatedType(type: number | undefined): boolean {
|
||||
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
|
||||
}
|
||||
|
||||
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
|
||||
if (typeof type !== "number" || !Number.isFinite(type)) return undefined;
|
||||
if (type >= 3000 && type < 4000) return "removed";
|
||||
if (type >= 2000 && type < 3000) return "added";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveTapbackContext(message: NormalizedWebhookMessage): {
|
||||
emojiHint?: string;
|
||||
actionHint?: "added" | "removed";
|
||||
replyToId?: string;
|
||||
} | null {
|
||||
const associatedType = message.associatedMessageType;
|
||||
const hasTapbackType = isTapbackAssociatedType(associatedType);
|
||||
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
|
||||
if (!hasTapbackType && !hasTapbackMarker) return null;
|
||||
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
|
||||
const actionHint = resolveTapbackActionHint(associatedType);
|
||||
const emojiHint =
|
||||
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
|
||||
return { emojiHint, actionHint, replyToId };
|
||||
}
|
||||
|
||||
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
|
||||
function parseTapbackText(params: {
|
||||
text: string;
|
||||
emojiHint?: string;
|
||||
actionHint?: "added" | "removed";
|
||||
requireQuoted?: boolean;
|
||||
}): {
|
||||
emoji: string;
|
||||
action: "added" | "removed";
|
||||
quotedText: string;
|
||||
} | null {
|
||||
const trimmed = params.text.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
|
||||
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
|
||||
if (lower.startsWith(pattern)) {
|
||||
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
|
||||
const afterPattern = trimmed.slice(pattern.length).trim();
|
||||
if (params.requireQuoted) {
|
||||
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
|
||||
if (!strictMatch) return null;
|
||||
return { emoji, action, quotedText: strictMatch[1] };
|
||||
}
|
||||
const quotedText =
|
||||
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
|
||||
return { emoji, action, quotedText };
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.startsWith("reacted")) {
|
||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||
if (!emoji) return null;
|
||||
const quotedText = extractQuotedTapbackText(trimmed);
|
||||
if (params.requireQuoted && !quotedText) return null;
|
||||
const fallback = trimmed.slice("reacted".length).trim();
|
||||
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
|
||||
}
|
||||
|
||||
if (lower.startsWith("removed")) {
|
||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||
if (!emoji) return null;
|
||||
const quotedText = extractQuotedTapbackText(trimmed);
|
||||
if (params.requireQuoted && !quotedText) return null;
|
||||
const fallback = trimmed.slice("removed".length).trim();
|
||||
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function maskSecret(value: string): string {
|
||||
if (value.length <= 6) return "***";
|
||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||||
@@ -902,25 +805,6 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
readString(message, "messageId") ??
|
||||
undefined;
|
||||
const balloonBundleId = readString(message, "balloonBundleId");
|
||||
const associatedMessageGuid =
|
||||
readString(message, "associatedMessageGuid") ??
|
||||
readString(message, "associated_message_guid") ??
|
||||
readString(message, "associatedMessageId") ??
|
||||
undefined;
|
||||
const associatedMessageType =
|
||||
readNumberLike(message, "associatedMessageType") ??
|
||||
readNumberLike(message, "associated_message_type");
|
||||
const associatedMessageEmoji =
|
||||
readString(message, "associatedMessageEmoji") ??
|
||||
readString(message, "associated_message_emoji") ??
|
||||
readString(message, "reactionEmoji") ??
|
||||
readString(message, "reaction_emoji") ??
|
||||
undefined;
|
||||
const isTapback =
|
||||
readBoolean(message, "isTapback") ??
|
||||
readBoolean(message, "is_tapback") ??
|
||||
readBoolean(message, "tapback") ??
|
||||
undefined;
|
||||
|
||||
const timestampRaw =
|
||||
readNumber(message, "date") ??
|
||||
@@ -951,10 +835,6 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
fromMe,
|
||||
attachments: extractAttachments(message),
|
||||
balloonBundleId,
|
||||
associatedMessageGuid,
|
||||
associatedMessageType,
|
||||
associatedMessageEmoji,
|
||||
isTapback,
|
||||
participants: normalizedParticipants,
|
||||
replyToId: replyMetadata.replyToId,
|
||||
replyToBody: replyMetadata.replyToBody,
|
||||
@@ -976,13 +856,8 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
||||
if (!associatedGuid || associatedType === undefined) return null;
|
||||
|
||||
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
||||
const associatedEmoji =
|
||||
readString(message, "associatedMessageEmoji") ??
|
||||
readString(message, "associated_message_emoji") ??
|
||||
readString(message, "reactionEmoji") ??
|
||||
readString(message, "reaction_emoji");
|
||||
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
|
||||
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
|
||||
const emoji = mapping?.emoji ?? `reaction:${associatedType}`;
|
||||
const action = mapping?.action ?? "added";
|
||||
|
||||
const handleValue = message.handle ?? message.sender;
|
||||
const handle =
|
||||
@@ -1247,21 +1122,7 @@ async function processMessage(
|
||||
const text = message.text.trim();
|
||||
const attachments = message.attachments ?? [];
|
||||
const placeholder = buildMessagePlaceholder(message);
|
||||
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
|
||||
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
|
||||
const tapbackContext = resolveTapbackContext(message);
|
||||
const tapbackParsed = parseTapbackText({
|
||||
text,
|
||||
emojiHint: tapbackContext?.emojiHint,
|
||||
actionHint: tapbackContext?.actionHint,
|
||||
requireQuoted: !tapbackContext,
|
||||
});
|
||||
const isTapbackMessage = Boolean(tapbackParsed);
|
||||
const rawBody = tapbackParsed
|
||||
? tapbackParsed.action === "removed"
|
||||
? `removed ${tapbackParsed.emoji} reaction`
|
||||
: `reacted with ${tapbackParsed.emoji}`
|
||||
: text || placeholder;
|
||||
const rawBody = text || placeholder;
|
||||
|
||||
const cacheMessageId = message.messageId?.trim();
|
||||
let messageShortId: string | undefined;
|
||||
@@ -1588,11 +1449,7 @@ async function processMessage(
|
||||
let replyToSender = message.replyToSender;
|
||||
let replyToShortId: string | undefined;
|
||||
|
||||
if (isTapbackMessage && tapbackContext?.replyToId) {
|
||||
replyToId = tapbackContext.replyToId;
|
||||
}
|
||||
|
||||
if (replyToId) {
|
||||
if (replyToId && (!replyToBody || !replyToSender)) {
|
||||
const cached = resolveReplyContextFromCache({
|
||||
accountId: account.accountId,
|
||||
replyToId,
|
||||
@@ -1620,15 +1477,8 @@ async function processMessage(
|
||||
replyToShortId = getShortIdForUuid(replyToId);
|
||||
}
|
||||
|
||||
// Use inline [[reply_to:N]] tag format
|
||||
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
|
||||
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
|
||||
const replyTag = formatReplyTag({ replyToId, replyToShortId });
|
||||
const baseBody = replyTag
|
||||
? isTapbackMessage
|
||||
? `${rawBody} ${replyTag}`
|
||||
: `${replyTag} ${rawBody}`
|
||||
: rawBody;
|
||||
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
|
||||
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
||||
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||
const groupMembers = isGroup
|
||||
@@ -1851,21 +1701,16 @@ async function processMessage(
|
||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||
? account.config.textChunkLimit
|
||||
: DEFAULT_TEXT_LIMIT;
|
||||
const chunkMode = account.config.chunkMode ?? "length";
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
const chunks =
|
||||
chunkMode === "newline"
|
||||
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
|
||||
: core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
if (!chunks.length && text) chunks.push(text);
|
||||
if (!chunks.length) return;
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
for (const chunk of chunks) {
|
||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
@@ -1874,17 +1719,6 @@ async function processMessage(
|
||||
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
// In newline mode, restart typing after each chunk if more chunks remain
|
||||
// Small delay allows the Apple API to finish clearing the typing state from message send
|
||||
if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
}).catch(() => {
|
||||
// Ignore typing errors
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
@@ -2037,11 +1871,7 @@ async function processReaction(
|
||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||
// Use short ID for token savings
|
||||
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
||||
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
|
||||
const text =
|
||||
reaction.action === "removed"
|
||||
? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
|
||||
: `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
|
||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
|
||||
core.system.enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||
|
||||
@@ -38,8 +38,6 @@ export type BlueBubblesAccountConfig = {
|
||||
dms?: Record<string, unknown>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: Record<string, unknown>;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "googlechat",
|
||||
"channels": [
|
||||
"googlechat"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { googlechatDock, googlechatPlugin } from "./src/channel.js";
|
||||
import { handleGoogleChatWebhookRequest } from "./src/monitor.js";
|
||||
import { setGoogleChatRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "googlechat",
|
||||
name: "Google Chat",
|
||||
description: "Clawdbot Google Chat channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setGoogleChatRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock });
|
||||
api.registerHttpHandler(handleGoogleChatWebhookRequest);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "@clawdbot/googlechat",
|
||||
"version": "2026.1.22",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Google Chat channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "googlechat",
|
||||
"label": "Google Chat",
|
||||
"selectionLabel": "Google Chat (Chat API)",
|
||||
"detailLabel": "Google Chat",
|
||||
"docsPath": "/channels/googlechat",
|
||||
"docsLabel": "googlechat",
|
||||
"blurb": "Google Workspace Chat app via HTTP webhooks.",
|
||||
"aliases": [
|
||||
"gchat",
|
||||
"google-chat"
|
||||
],
|
||||
"order": 55
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@clawdbot/googlechat",
|
||||
"localPath": "extensions/googlechat",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"clawdbot": ">=2026.1.24-0"
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js";
|
||||
|
||||
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
|
||||
|
||||
export type ResolvedGoogleChatAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
config: GoogleChatAccountConfig;
|
||||
credentialSource: GoogleChatCredentialSource;
|
||||
credentials?: Record<string, unknown>;
|
||||
credentialsFile?: string;
|
||||
};
|
||||
|
||||
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
|
||||
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listGoogleChatAccountIds(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 resolveDefaultGoogleChatAccountId(cfg: ClawdbotConfig): string {
|
||||
const channel = cfg.channels?.["googlechat"] as GoogleChatConfig | undefined;
|
||||
if (channel?.defaultAccount?.trim()) return channel.defaultAccount.trim();
|
||||
const ids = listGoogleChatAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): GoogleChatAccountConfig | undefined {
|
||||
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as GoogleChatAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeGoogleChatAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): GoogleChatAccountConfig {
|
||||
const raw = (cfg.channels?.["googlechat"] ?? {}) as GoogleChatConfig;
|
||||
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account } as GoogleChatAccountConfig;
|
||||
}
|
||||
|
||||
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === "object") return value as Record<string, unknown>;
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCredentialsFromConfig(params: {
|
||||
accountId: string;
|
||||
account: GoogleChatAccountConfig;
|
||||
}): {
|
||||
credentials?: Record<string, unknown>;
|
||||
credentialsFile?: string;
|
||||
source: GoogleChatCredentialSource;
|
||||
} {
|
||||
const { account, accountId } = params;
|
||||
const inline = parseServiceAccount(account.serviceAccount);
|
||||
if (inline) {
|
||||
return { credentials: inline, source: "inline" };
|
||||
}
|
||||
|
||||
const file = account.serviceAccountFile?.trim();
|
||||
if (file) {
|
||||
return { credentialsFile: file, source: "file" };
|
||||
}
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const envJson = process.env[ENV_SERVICE_ACCOUNT];
|
||||
const envInline = parseServiceAccount(envJson);
|
||||
if (envInline) {
|
||||
return { credentials: envInline, source: "env" };
|
||||
}
|
||||
const envFile = process.env[ENV_SERVICE_ACCOUNT_FILE]?.trim();
|
||||
if (envFile) {
|
||||
return { credentialsFile: envFile, source: "env" };
|
||||
}
|
||||
}
|
||||
|
||||
return { source: "none" };
|
||||
}
|
||||
|
||||
export function resolveGoogleChatAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedGoogleChatAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled =
|
||||
(params.cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const credentials = resolveCredentialsFromConfig({ accountId, account: merged });
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: merged.name?.trim() || undefined,
|
||||
enabled,
|
||||
config: merged,
|
||||
credentialSource: credentials.source,
|
||||
credentials: credentials.credentials,
|
||||
credentialsFile: credentials.credentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledGoogleChatAccounts(cfg: ClawdbotConfig): ResolvedGoogleChatAccount[] {
|
||||
return listGoogleChatAccountIds(cfg)
|
||||
.map((accountId) => resolveGoogleChatAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ClawdbotConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
createGoogleChatReaction,
|
||||
deleteGoogleChatReaction,
|
||||
listGoogleChatReactions,
|
||||
sendGoogleChatMessage,
|
||||
uploadGoogleChatAttachment,
|
||||
} from "./api.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import { resolveGoogleChatOutboundSpace } from "./targets.js";
|
||||
|
||||
const providerId = "googlechat";
|
||||
|
||||
function listEnabledAccounts(cfg: ClawdbotConfig) {
|
||||
return listEnabledGoogleChatAccounts(cfg).filter(
|
||||
(account) => account.enabled && account.credentialSource !== "none",
|
||||
);
|
||||
}
|
||||
|
||||
function isReactionsEnabled(accounts: ReturnType<typeof listEnabledAccounts>, cfg: ClawdbotConfig) {
|
||||
for (const account of accounts) {
|
||||
const gate = createActionGate(
|
||||
(account.config.actions ?? (cfg.channels?.["googlechat"] as { actions?: unknown })?.actions) as Record<
|
||||
string,
|
||||
boolean | undefined
|
||||
>,
|
||||
);
|
||||
if (gate("reactions")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
|
||||
return new Set(["users/app", account.config.botUser?.trim()].filter(Boolean) as string[]);
|
||||
}
|
||||
|
||||
export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listEnabledAccounts(cfg as ClawdbotConfig);
|
||||
if (accounts.length === 0) return [];
|
||||
const actions = new Set<ChannelMessageActionName>([]);
|
||||
actions.add("send");
|
||||
if (isReactionsEnabled(accounts, cfg as ClawdbotConfig)) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
extractToolSend: ({ args }) => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action !== "sendMessage") return null;
|
||||
const to = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!to) return null;
|
||||
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||
return { to, accountId };
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId,
|
||||
});
|
||||
if (account.credentialSource === "none") {
|
||||
throw new Error("Google Chat credentials are missing.");
|
||||
}
|
||||
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "message", {
|
||||
required: true,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||
|
||||
if (mediaUrl) {
|
||||
const core = getGoogleChatRuntime();
|
||||
const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, { maxBytes });
|
||||
const upload = await uploadGoogleChatAttachment({
|
||||
account,
|
||||
space,
|
||||
filename: loaded.filename ?? "attachment",
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
});
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: content,
|
||||
thread: threadId ?? undefined,
|
||||
attachments: upload.attachmentUploadToken
|
||||
? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.filename }]
|
||||
: undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, to: space });
|
||||
}
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: content,
|
||||
thread: threadId ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, to: space });
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageName = readStringParam(params, "messageId", { required: true });
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a Google Chat reaction.",
|
||||
});
|
||||
if (remove || isEmpty) {
|
||||
const reactions = await listGoogleChatReactions({ account, messageName });
|
||||
const appUsers = resolveAppUserNames(account);
|
||||
const toRemove = reactions.filter((reaction) => {
|
||||
const userName = reaction.user?.name?.trim();
|
||||
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) return false;
|
||||
if (emoji) return reaction.emoji?.unicode === emoji;
|
||||
return true;
|
||||
});
|
||||
for (const reaction of toRemove) {
|
||||
if (!reaction.name) continue;
|
||||
await deleteGoogleChatReaction({ account, reactionName: reaction.name });
|
||||
}
|
||||
return jsonResult({ ok: true, removed: toRemove.length });
|
||||
}
|
||||
const reaction = await createGoogleChatReaction({
|
||||
account,
|
||||
messageName,
|
||||
emoji,
|
||||
});
|
||||
return jsonResult({ ok: true, reaction });
|
||||
}
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageName = readStringParam(params, "messageId", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
const reactions = await listGoogleChatReactions({
|
||||
account,
|
||||
messageName,
|
||||
limit: limit ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, reactions });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia } from "./api.js";
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
}));
|
||||
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
config: {},
|
||||
} as ResolvedGoogleChatAccount;
|
||||
|
||||
describe("downloadGoogleChatMedia", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects when content-length exceeds max bytes", async () => {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-length": "50", "content-type": "application/octet-stream" },
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
|
||||
await expect(
|
||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||
).rejects.toThrow(/max bytes/i);
|
||||
});
|
||||
|
||||
it("rejects when streamed payload exceeds max bytes", async () => {
|
||||
const chunks = [new Uint8Array(6), new Uint8Array(6)];
|
||||
let index = 0;
|
||||
const body = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index < chunks.length) {
|
||||
controller.enqueue(chunks[index++]);
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
|
||||
await expect(
|
||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||
).rejects.toThrow(/max bytes/i);
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { getGoogleChatAccessToken } from "./auth.js";
|
||||
import type { GoogleChatReaction } from "./types.js";
|
||||
|
||||
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
|
||||
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
|
||||
|
||||
async function fetchJson<T>(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<T> {
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function fetchOk(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<void> {
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBuffer(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: { maxBytes?: number },
|
||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
const maxBytes = options?.maxBytes;
|
||||
const lengthHeader = res.headers.get("content-length");
|
||||
if (maxBytes && lengthHeader) {
|
||||
const length = Number(lengthHeader);
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
}
|
||||
if (!maxBytes || !res.body) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.length;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks, total);
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
}
|
||||
|
||||
export async function sendGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
space: string;
|
||||
text?: string;
|
||||
thread?: string;
|
||||
attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>;
|
||||
}): Promise<{ messageName?: string } | null> {
|
||||
const { account, space, text, thread, attachments } = params;
|
||||
const body: Record<string, unknown> = {};
|
||||
if (text) body.text = text;
|
||||
if (thread) body.thread = { name: thread };
|
||||
if (attachments && attachments.length > 0) {
|
||||
body.attachment = attachments.map((item) => ({
|
||||
attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken },
|
||||
...(item.contentName ? { contentName: item.contentName } : {}),
|
||||
}));
|
||||
}
|
||||
const url = `${CHAT_API_BASE}/${space}/messages`;
|
||||
const result = await fetchJson<{ name?: string }>(account, url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return result ? { messageName: result.name } : null;
|
||||
}
|
||||
|
||||
export async function updateGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
text: string;
|
||||
}): Promise<{ messageName?: string }> {
|
||||
const { account, messageName, text } = params;
|
||||
const url = `${CHAT_API_BASE}/${messageName}?updateMask=text`;
|
||||
const result = await fetchJson<{ name?: string }>(account, url, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
return { messageName: result.name };
|
||||
}
|
||||
|
||||
export async function deleteGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
}): Promise<void> {
|
||||
const { account, messageName } = params;
|
||||
const url = `${CHAT_API_BASE}/${messageName}`;
|
||||
await fetchOk(account, url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function uploadGoogleChatAttachment(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
space: string;
|
||||
filename: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
}): Promise<{ attachmentUploadToken?: string }> {
|
||||
const { account, space, filename, buffer, contentType } = params;
|
||||
const boundary = `clawdbot-${crypto.randomUUID()}`;
|
||||
const metadata = JSON.stringify({ filename });
|
||||
const header = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n`;
|
||||
const mediaHeader = `--${boundary}\r\nContent-Type: ${contentType ?? "application/octet-stream"}\r\n\r\n`;
|
||||
const footer = `\r\n--${boundary}--\r\n`;
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(header, "utf8"),
|
||||
Buffer.from(mediaHeader, "utf8"),
|
||||
buffer,
|
||||
Buffer.from(footer, "utf8"),
|
||||
]);
|
||||
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": `multipart/related; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
const payload = (await res.json()) as {
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
};
|
||||
return { attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken };
|
||||
}
|
||||
|
||||
export async function downloadGoogleChatMedia(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
resourceName: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
const { account, resourceName, maxBytes } = params;
|
||||
const url = `${CHAT_API_BASE}/media/${resourceName}?alt=media`;
|
||||
return await fetchBuffer(account, url, undefined, { maxBytes });
|
||||
}
|
||||
|
||||
export async function createGoogleChatReaction(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
emoji: string;
|
||||
}): Promise<GoogleChatReaction> {
|
||||
const { account, messageName, emoji } = params;
|
||||
const url = `${CHAT_API_BASE}/${messageName}/reactions`;
|
||||
return await fetchJson<GoogleChatReaction>(account, url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ emoji: { unicode: emoji } }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listGoogleChatReactions(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
messageName: string;
|
||||
limit?: number;
|
||||
}): Promise<GoogleChatReaction[]> {
|
||||
const { account, messageName, limit } = params;
|
||||
const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`);
|
||||
if (limit && limit > 0) url.searchParams.set("pageSize", String(limit));
|
||||
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), {
|
||||
method: "GET",
|
||||
});
|
||||
return result.reactions ?? [];
|
||||
}
|
||||
|
||||
export async function deleteGoogleChatReaction(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
reactionName: string;
|
||||
}): Promise<void> {
|
||||
const { account, reactionName } = params;
|
||||
const url = `${CHAT_API_BASE}/${reactionName}`;
|
||||
await fetchOk(account, url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function findGoogleChatDirectMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
userName: string;
|
||||
}): Promise<{ name?: string; displayName?: string } | null> {
|
||||
const { account, userName } = params;
|
||||
const url = new URL(`${CHAT_API_BASE}/spaces:findDirectMessage`);
|
||||
url.searchParams.set("name", userName);
|
||||
return await fetchJson<{ name?: string; displayName?: string }>(account, url.toString(), {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeGoogleChat(account: ResolvedGoogleChatAccount): Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const url = new URL(`${CHAT_API_BASE}/spaces`);
|
||||
url.searchParams.set("pageSize", "1");
|
||||
await fetchJson<Record<string, unknown>>(account, url.toString(), { method: "GET" });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { GoogleAuth, OAuth2Client } from "google-auth-library";
|
||||
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
|
||||
const CHAT_ISSUER = "chat@system.gserviceaccount.com";
|
||||
// Google Workspace Add-ons use a different service account pattern
|
||||
const ADDON_ISSUER_PATTERN = /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$/;
|
||||
const CHAT_CERTS_URL =
|
||||
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
|
||||
|
||||
const authCache = new Map<string, { key: string; auth: GoogleAuth }>();
|
||||
const verifyClient = new OAuth2Client();
|
||||
|
||||
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
|
||||
|
||||
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
|
||||
if (account.credentialsFile) return `file:${account.credentialsFile}`;
|
||||
if (account.credentials) return `inline:${JSON.stringify(account.credentials)}`;
|
||||
return "none";
|
||||
}
|
||||
|
||||
function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
|
||||
const key = buildAuthKey(account);
|
||||
const cached = authCache.get(account.accountId);
|
||||
if (cached && cached.key === key) return cached.auth;
|
||||
|
||||
if (account.credentialsFile) {
|
||||
const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] });
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (account.credentials) {
|
||||
const auth = new GoogleAuth({ credentials: account.credentials, scopes: [CHAT_SCOPE] });
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
return auth;
|
||||
}
|
||||
|
||||
const auth = new GoogleAuth({ scopes: [CHAT_SCOPE] });
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
return auth;
|
||||
}
|
||||
|
||||
export async function getGoogleChatAccessToken(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
): Promise<string> {
|
||||
const auth = getAuthInstance(account);
|
||||
const client = await auth.getClient();
|
||||
const access = await client.getAccessToken();
|
||||
const token = typeof access === "string" ? access : access?.token;
|
||||
if (!token) {
|
||||
throw new Error("Missing Google Chat access token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function fetchChatCerts(): Promise<Record<string, string>> {
|
||||
const now = Date.now();
|
||||
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
|
||||
return cachedCerts.certs;
|
||||
}
|
||||
const res = await fetch(CHAT_CERTS_URL);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Chat certs (${res.status})`);
|
||||
}
|
||||
const certs = (await res.json()) as Record<string, string>;
|
||||
cachedCerts = { fetchedAt: now, certs };
|
||||
return certs;
|
||||
}
|
||||
|
||||
export type GoogleChatAudienceType = "app-url" | "project-number";
|
||||
|
||||
export async function verifyGoogleChatRequest(params: {
|
||||
bearer?: string | null;
|
||||
audienceType?: GoogleChatAudienceType | null;
|
||||
audience?: string | null;
|
||||
}): Promise<{ ok: boolean; reason?: string }> {
|
||||
const bearer = params.bearer?.trim();
|
||||
if (!bearer) return { ok: false, reason: "missing token" };
|
||||
const audience = params.audience?.trim();
|
||||
if (!audience) return { ok: false, reason: "missing audience" };
|
||||
const audienceType = params.audienceType ?? null;
|
||||
|
||||
if (audienceType === "app-url") {
|
||||
try {
|
||||
const ticket = await verifyClient.verifyIdToken({
|
||||
idToken: bearer,
|
||||
audience,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
const email = payload?.email ?? "";
|
||||
const ok = payload?.email_verified && (email === CHAT_ISSUER || ADDON_ISSUER_PATTERN.test(email));
|
||||
return ok ? { ok: true } : { ok: false, reason: `invalid issuer: ${email}` };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
|
||||
}
|
||||
}
|
||||
|
||||
if (audienceType === "project-number") {
|
||||
try {
|
||||
const certs = await fetchChatCerts();
|
||||
await verifyClient.verifySignedJwtWithCertsAsync(bearer, certs, audience, [CHAT_ISSUER]);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, reason: "unsupported audience type" };
|
||||
}
|
||||
|
||||
export const GOOGLE_CHAT_SCOPE = CHAT_SCOPE;
|
||||
@@ -1,580 +0,0 @@
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
missingTargetError,
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveGoogleChatGroupRequireMention,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelDock,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ClawdbotConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { GoogleChatConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listGoogleChatAccountIds,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
resolveGoogleChatAccount,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
import { googlechatMessageActions } from "./actions.js";
|
||||
import { sendGoogleChatMessage, uploadGoogleChatAttachment, probeGoogleChat } from "./api.js";
|
||||
import { googlechatOnboardingAdapter } from "./onboarding.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js";
|
||||
import {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
normalizeGoogleChatTarget,
|
||||
resolveGoogleChatOutboundSpace,
|
||||
} from "./targets.js";
|
||||
|
||||
const meta = getChatChannelMeta("googlechat");
|
||||
|
||||
const formatAllowFromEntry = (entry: string) =>
|
||||
entry
|
||||
.trim()
|
||||
.replace(/^(googlechat|google-chat|gchat):/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.replace(/^users\//i, "")
|
||||
.toLowerCase();
|
||||
|
||||
export const googlechatDock: ChannelDock = {
|
||||
id: "googlechat",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
reactions: true,
|
||||
media: true,
|
||||
threads: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveGoogleChatAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ??
|
||||
[]
|
||||
).map((entry) => String(entry)),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry))
|
||||
.filter(Boolean)
|
||||
.map(formatAllowFromEntry),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveGoogleChatGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const threadId = context.MessageThreadId ?? context.ReplyToId;
|
||||
return {
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: threadId != null ? String(threadId) : undefined,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const googlechatActions: ChannelMessageActionAdapter = {
|
||||
listActions: (ctx) => googlechatMessageActions.listActions?.(ctx) ?? [],
|
||||
extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null,
|
||||
handleAction: async (ctx) => {
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
throw new Error("Google Chat actions are not available.");
|
||||
}
|
||||
return await googlechatMessageActions.handleAction(ctx);
|
||||
},
|
||||
};
|
||||
|
||||
export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
||||
id: "googlechat",
|
||||
meta: { ...meta },
|
||||
onboarding: googlechatOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "googlechatUserId",
|
||||
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveGoogleChatAccount({ cfg: cfg as ClawdbotConfig });
|
||||
if (account.credentialSource === "none") return;
|
||||
const user = normalizeGoogleChatTarget(id) ?? id;
|
||||
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target });
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: PAIRING_APPROVED_MESSAGE,
|
||||
});
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.googlechat"] },
|
||||
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listGoogleChatAccountIds(cfg as ClawdbotConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveGoogleChatAccount({ cfg: cfg as ClawdbotConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg as ClawdbotConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
sectionKey: "googlechat",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
sectionKey: "googlechat",
|
||||
accountId,
|
||||
clearBaseFields: [
|
||||
"serviceAccount",
|
||||
"serviceAccountFile",
|
||||
"audienceType",
|
||||
"audience",
|
||||
"webhookPath",
|
||||
"webhookUrl",
|
||||
"botUser",
|
||||
"name",
|
||||
],
|
||||
}),
|
||||
isConfigured: (account) => account.credentialSource !== "none",
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
credentialSource: account.credentialSource,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveGoogleChatAccount({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId,
|
||||
}).config.dm?.allowFrom ?? []
|
||||
).map((entry) => String(entry)),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry))
|
||||
.filter(Boolean)
|
||||
.map(formatAllowFromEntry),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
(cfg as ClawdbotConfig).channels?.["googlechat"]?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const allowFromPath = useAccountPath
|
||||
? `channels.googlechat.accounts.${resolvedAccountId}.dm.`
|
||||
: "channels.googlechat.dm.";
|
||||
return {
|
||||
policy: account.config.dm?.policy ?? "pairing",
|
||||
allowFrom: account.config.dm?.allowFrom ?? [],
|
||||
allowFromPath,
|
||||
approveHint: formatPairingApproveHint("googlechat"),
|
||||
normalizeEntry: (raw) => formatAllowFromEntry(raw),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const warnings: string[] = [];
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy === "open") {
|
||||
warnings.push(
|
||||
`- Google Chat spaces: groupPolicy="open" allows any space to trigger (mention-gated). Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups.`,
|
||||
);
|
||||
}
|
||||
if (account.config.dm?.policy === "open") {
|
||||
warnings.push(
|
||||
`- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".`,
|
||||
);
|
||||
}
|
||||
return warnings;
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveGoogleChatGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off",
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeGoogleChatTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw, normalized) => {
|
||||
const value = normalized ?? raw.trim();
|
||||
return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value);
|
||||
},
|
||||
hint: "<spaces/{space}|users/{user}>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId,
|
||||
});
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const allowFrom = account.config.dm?.allowFrom ?? [];
|
||||
const peers = Array.from(
|
||||
new Set(
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry) => Boolean(entry) && entry !== "*")
|
||||
.map((entry) => normalizeGoogleChatTarget(entry) ?? entry),
|
||||
),
|
||||
)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
return peers;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId,
|
||||
});
|
||||
const groups = account.config.groups ?? {};
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const entries = Object.keys(groups)
|
||||
.filter((key) => key && key !== "*")
|
||||
.filter((key) => (q ? key.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return entries;
|
||||
},
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ inputs, kind }) => {
|
||||
const resolved = inputs.map((input) => {
|
||||
const normalized = normalizeGoogleChatTarget(input);
|
||||
if (!normalized) {
|
||||
return { input, resolved: false, note: "empty target" };
|
||||
}
|
||||
if (kind === "user" && isGoogleChatUserTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
if (kind === "group" && isGoogleChatSpaceTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "use spaces/{space} or users/{user}",
|
||||
};
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
},
|
||||
actions: googlechatActions,
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
channelKey: "googlechat",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.";
|
||||
}
|
||||
if (!input.useEnv && !input.token && !input.tokenFile) {
|
||||
return "Google Chat requires --token (service account JSON) or --token-file.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
channelKey: "googlechat",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig as ClawdbotConfig,
|
||||
channelKey: "googlechat",
|
||||
})
|
||||
: namedConfig;
|
||||
const patch = input.useEnv
|
||||
? {}
|
||||
: input.tokenFile
|
||||
? { serviceAccountFile: input.tokenFile }
|
||||
: input.token
|
||||
? { serviceAccount: input.token }
|
||||
: {};
|
||||
const audienceType = input.audienceType?.trim();
|
||||
const audience = input.audience?.trim();
|
||||
const webhookPath = input.webhookPath?.trim();
|
||||
const webhookUrl = input.webhookUrl?.trim();
|
||||
const configPatch = {
|
||||
...patch,
|
||||
...(audienceType ? { audienceType } : {}),
|
||||
...(audience ? { audience } : {}),
|
||||
...(webhookPath ? { webhookPath } : {}),
|
||||
...(webhookUrl ? { webhookUrl } : {}),
|
||||
};
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
"googlechat": {
|
||||
...(next.channels?.["googlechat"] ?? {}),
|
||||
enabled: true,
|
||||
...configPatch,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
"googlechat": {
|
||||
...(next.channels?.["googlechat"] ?? {}),
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.["googlechat"]?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(next.channels?.["googlechat"]?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
...configPatch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) =>
|
||||
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
const allowList = allowListRaw
|
||||
.filter((entry) => entry !== "*")
|
||||
.map((entry) => normalizeGoogleChatTarget(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
if (trimmed) {
|
||||
const normalized = normalizeGoogleChatTarget(trimmed);
|
||||
if (!normalized) {
|
||||
if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
|
||||
return { ok: true, to: allowList[0] };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError(
|
||||
"Google Chat",
|
||||
"<spaces/{space}|users/{user}> or channels.googlechat.dm.allowFrom[0]",
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: normalized };
|
||||
}
|
||||
|
||||
if (allowList.length > 0) {
|
||||
return { ok: true, to: allowList[0] };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError(
|
||||
"Google Chat",
|
||||
"<spaces/{space}|users/{user}> or channels.googlechat.dm.allowFrom[0]",
|
||||
),
|
||||
};
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId,
|
||||
});
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text,
|
||||
thread,
|
||||
});
|
||||
return {
|
||||
channel: "googlechat",
|
||||
messageId: result?.messageName ?? "",
|
||||
chatId: space,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
|
||||
if (!mediaUrl) {
|
||||
throw new Error("Google Chat mediaUrl is required.");
|
||||
}
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId,
|
||||
});
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
|
||||
const runtime = getGoogleChatRuntime();
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
(cfg.channels?.["googlechat"] as { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number } | undefined)
|
||||
?.accounts?.[accountId]?.mediaMaxMb ??
|
||||
(cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
|
||||
accountId,
|
||||
});
|
||||
const loaded = await runtime.channel.media.fetchRemoteMedia(mediaUrl, {
|
||||
maxBytes: maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
||||
});
|
||||
const upload = await uploadGoogleChatAttachment({
|
||||
account,
|
||||
space,
|
||||
filename: loaded.filename ?? "attachment",
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
});
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text,
|
||||
thread,
|
||||
attachments: upload.attachmentUploadToken
|
||||
? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.filename }]
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
channel: "googlechat",
|
||||
messageId: result?.messageName ?? "",
|
||||
chatId: space,
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((entry) => {
|
||||
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const enabled = entry.enabled !== false;
|
||||
const configured = entry.configured === true;
|
||||
if (!enabled || !configured) return [];
|
||||
const issues = [];
|
||||
if (!entry.audience) {
|
||||
issues.push({
|
||||
channel: "googlechat",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audience is missing (set channels.googlechat.audience).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
if (!entry.audienceType) {
|
||||
issues.push({
|
||||
channel: "googlechat",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audienceType is missing (app-url or project-number).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
credentialSource: snapshot.credentialSource ?? "none",
|
||||
audienceType: snapshot.audienceType ?? null,
|
||||
audience: snapshot.audience ?? null,
|
||||
webhookPath: snapshot.webhookPath ?? null,
|
||||
webhookUrl: snapshot.webhookUrl ?? null,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) => probeGoogleChat(account),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
credentialSource: account.credentialSource,
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
dmPolicy: account.config.dm?.policy ?? "pairing",
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
webhookPath: resolveGoogleChatWebhookPath({ account }),
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
});
|
||||
const unregister = await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg as ClawdbotConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
|
||||
});
|
||||
return () => {
|
||||
unregister?.();
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSenderAllowed } from "./monitor.js";
|
||||
|
||||
describe("isSenderAllowed", () => {
|
||||
it("matches allowlist entries with users/<email>", () => {
|
||||
expect(
|
||||
isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches allowlist entries with raw email", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("still matches user id entries", () => {
|
||||
expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-matching emails", () => {
|
||||
expect(isSenderAllowed("users/123", "jane@example.com", ["users/other@example.com"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,900 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
type ResolvedGoogleChatAccount
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
downloadGoogleChatMedia,
|
||||
deleteGoogleChatMessage,
|
||||
sendGoogleChatMessage,
|
||||
updateGoogleChatMessage,
|
||||
} from "./api.js";
|
||||
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import type {
|
||||
GoogleChatAnnotation,
|
||||
GoogleChatAttachment,
|
||||
GoogleChatEvent,
|
||||
GoogleChatSpace,
|
||||
GoogleChatMessage,
|
||||
GoogleChatUser,
|
||||
} from "./types.js";
|
||||
|
||||
export type GoogleChatRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type GoogleChatMonitorOptions = {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: ClawdbotConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
webhookPath?: string;
|
||||
webhookUrl?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
type GoogleChatCoreRuntime = ReturnType<typeof getGoogleChatRuntime>;
|
||||
|
||||
type WebhookTarget = {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: ClawdbotConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
path: string;
|
||||
audienceType?: GoogleChatAudienceType;
|
||||
audience?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
mediaMaxMb: number;
|
||||
};
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
|
||||
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
runtime.log?.(`[googlechat] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWebhookPath(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "/";
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||
return withSlash.slice(0, -1);
|
||||
}
|
||||
return withSlash;
|
||||
}
|
||||
|
||||
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
|
||||
const trimmedPath = webhookPath?.trim();
|
||||
if (trimmedPath) return normalizeWebhookPath(trimmedPath);
|
||||
if (webhookUrl?.trim()) {
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
return normalizeWebhookPath(parsed.pathname || "/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return "/googlechat";
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
||||
let resolved = false;
|
||||
const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
req.removeAllListeners();
|
||||
resolve(value);
|
||||
};
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
doResolve({ ok: false, error: "payload too large" });
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw.trim()) {
|
||||
doResolve({ ok: false, error: "empty payload" });
|
||||
return;
|
||||
}
|
||||
doResolve({ ok: true, value: JSON.parse(raw) as unknown });
|
||||
} catch (err) {
|
||||
doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
|
||||
const key = normalizeWebhookPath(target.path);
|
||||
const normalizedTarget = { ...target, path: key };
|
||||
const existing = webhookTargets.get(key) ?? [];
|
||||
const next = [...existing, normalizedTarget];
|
||||
webhookTargets.set(key, next);
|
||||
return () => {
|
||||
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
||||
if (updated.length > 0) {
|
||||
webhookTargets.set(key, updated);
|
||||
} else {
|
||||
webhookTargets.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "app-url" || normalized === "app_url" || normalized === "app") {
|
||||
return "app-url";
|
||||
}
|
||||
if (normalized === "project-number" || normalized === "project_number" || normalized === "project") {
|
||||
return "project-number";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handleGoogleChatWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const path = normalizeWebhookPath(url.pathname);
|
||||
const targets = webhookTargets.get(path);
|
||||
if (!targets || targets.length === 0) return false;
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "POST");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const authHeader = String(req.headers.authorization ?? "");
|
||||
const bearer = authHeader.toLowerCase().startsWith("bearer ")
|
||||
? authHeader.slice("bearer ".length)
|
||||
: "";
|
||||
|
||||
const body = await readJsonBody(req, 1024 * 1024);
|
||||
if (!body.ok) {
|
||||
res.statusCode = body.error === "payload too large" ? 413 : 400;
|
||||
res.end(body.error ?? "invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
let raw = body.value;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Transform Google Workspace Add-on format to standard Chat API format
|
||||
const rawObj = raw as {
|
||||
commonEventObject?: { hostApp?: string };
|
||||
chat?: {
|
||||
messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage };
|
||||
user?: GoogleChatUser;
|
||||
eventTime?: string;
|
||||
};
|
||||
authorizationEventObject?: { systemIdToken?: string };
|
||||
};
|
||||
|
||||
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
|
||||
const chat = rawObj.chat;
|
||||
const messagePayload = chat.messagePayload;
|
||||
raw = {
|
||||
type: "MESSAGE",
|
||||
space: messagePayload?.space,
|
||||
message: messagePayload?.message,
|
||||
user: chat.user,
|
||||
eventTime: chat.eventTime,
|
||||
};
|
||||
|
||||
// For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken
|
||||
const systemIdToken = rawObj.authorizationEventObject?.systemIdToken;
|
||||
if (!bearer && systemIdToken) {
|
||||
Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` });
|
||||
}
|
||||
}
|
||||
|
||||
const event = raw as GoogleChatEvent;
|
||||
const eventType = event.type ?? (raw as { eventType?: string }).eventType;
|
||||
if (typeof eventType !== "string") {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (eventType === "MESSAGE") {
|
||||
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-extract bearer in case it was updated from Add-on format
|
||||
const authHeaderNow = String(req.headers.authorization ?? "");
|
||||
const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ")
|
||||
? authHeaderNow.slice("bearer ".length)
|
||||
: bearer;
|
||||
|
||||
let selected: WebhookTarget | undefined;
|
||||
for (const target of targets) {
|
||||
const audienceType = target.audienceType;
|
||||
const audience = target.audience;
|
||||
const verification = await verifyGoogleChatRequest({
|
||||
bearer: effectiveBearer,
|
||||
audienceType,
|
||||
audience,
|
||||
});
|
||||
if (verification.ok) {
|
||||
selected = target;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
selected.statusSink?.({ lastInboundAt: Date.now() });
|
||||
processGoogleChatEvent(event, selected).catch((err) => {
|
||||
selected?.runtime.error?.(
|
||||
`[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
|
||||
const eventType = event.type ?? (event as { eventType?: string }).eventType;
|
||||
if (eventType !== "MESSAGE") return;
|
||||
if (!event.message || !event.space) return;
|
||||
|
||||
await processMessageWithPipeline({
|
||||
event,
|
||||
account: target.account,
|
||||
config: target.config,
|
||||
runtime: target.runtime,
|
||||
core: target.core,
|
||||
statusSink: target.statusSink,
|
||||
mediaMaxMb: target.mediaMaxMb,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUserId(raw?: string | null): string {
|
||||
const trimmed = raw?.trim() ?? "";
|
||||
if (!trimmed) return "";
|
||||
return trimmed.replace(/^users\//i, "").toLowerCase();
|
||||
}
|
||||
|
||||
export function isSenderAllowed(
|
||||
senderId: string,
|
||||
senderEmail: string | undefined,
|
||||
allowFrom: string[],
|
||||
) {
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedSenderId = normalizeUserId(senderId);
|
||||
const normalizedEmail = senderEmail?.trim().toLowerCase() ?? "";
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = String(entry).trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (normalized === normalizedSenderId) return true;
|
||||
if (normalizedEmail && normalized === normalizedEmail) return true;
|
||||
if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) return true;
|
||||
if (normalized.replace(/^users\//i, "") === normalizedSenderId) return true;
|
||||
if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveGroupConfig(params: {
|
||||
groupId: string;
|
||||
groupName?: string | null;
|
||||
groups?: Record<string, { requireMention?: boolean; allow?: boolean; enabled?: boolean; users?: Array<string | number>; systemPrompt?: string }>;
|
||||
}) {
|
||||
const { groupId, groupName, groups } = params;
|
||||
const entries = groups ?? {};
|
||||
const keys = Object.keys(entries);
|
||||
if (keys.length === 0) {
|
||||
return { entry: undefined, allowlistConfigured: false };
|
||||
}
|
||||
const normalizedName = groupName?.trim().toLowerCase();
|
||||
const candidates = [groupId, groupName ?? "", normalizedName ?? ""].filter(Boolean);
|
||||
let entry = candidates.map((candidate) => entries[candidate]).find(Boolean);
|
||||
if (!entry && normalizedName) {
|
||||
entry = entries[normalizedName];
|
||||
}
|
||||
const fallback = entries["*"];
|
||||
return { entry: entry ?? fallback, allowlistConfigured: true, fallback };
|
||||
}
|
||||
|
||||
function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: string | null) {
|
||||
const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION");
|
||||
const hasAnyMention = mentionAnnotations.length > 0;
|
||||
const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
|
||||
const wasMentioned = mentionAnnotations.some((entry) => {
|
||||
const userName = entry.userMention?.user?.name;
|
||||
if (!userName) return false;
|
||||
if (botTargets.has(userName)) return true;
|
||||
return normalizeUserId(userName) === "app";
|
||||
});
|
||||
return { hasAnyMention, wasMentioned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bot display name with fallback chain:
|
||||
* 1. Account config name
|
||||
* 2. Agent name from config
|
||||
* 3. "Clawdbot" as generic fallback
|
||||
*/
|
||||
function resolveBotDisplayName(params: {
|
||||
accountName?: string;
|
||||
agentId: string;
|
||||
config: ClawdbotConfig;
|
||||
}): string {
|
||||
const { accountName, agentId, config } = params;
|
||||
if (accountName?.trim()) return accountName.trim();
|
||||
const agent = config.agents?.list?.find((a) => a.id === agentId);
|
||||
if (agent?.name?.trim()) return agent.name.trim();
|
||||
return "Clawdbot";
|
||||
}
|
||||
|
||||
async function processMessageWithPipeline(params: {
|
||||
event: GoogleChatEvent;
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: ClawdbotConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
mediaMaxMb: number;
|
||||
}): Promise<void> {
|
||||
const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params;
|
||||
const space = event.space;
|
||||
const message = event.message;
|
||||
if (!space || !message) return;
|
||||
|
||||
const spaceId = space.name ?? "";
|
||||
if (!spaceId) return;
|
||||
const spaceType = (space.type ?? "").toUpperCase();
|
||||
const isGroup = spaceType !== "DM";
|
||||
const sender = message.sender ?? event.user;
|
||||
const senderId = sender?.name ?? "";
|
||||
const senderName = sender?.displayName ?? "";
|
||||
const senderEmail = sender?.email ?? undefined;
|
||||
|
||||
const allowBots = account.config.allowBots === true;
|
||||
if (!allowBots) {
|
||||
if (sender?.type?.toUpperCase() === "BOT") {
|
||||
logVerbose(core, runtime, `skip bot-authored message (${senderId || "unknown"})`);
|
||||
return;
|
||||
}
|
||||
if (senderId === "users/app") {
|
||||
logVerbose(core, runtime, "skip app-authored message");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const messageText = (message.argumentText ?? message.text ?? "").trim();
|
||||
const attachments = message.attachment ?? [];
|
||||
const hasMedia = attachments.length > 0;
|
||||
const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
|
||||
if (!rawBody) return;
|
||||
|
||||
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
const groupConfigResolved = resolveGroupConfig({
|
||||
groupId: spaceId,
|
||||
groupName: space.displayName ?? null,
|
||||
groups: account.config.groups ?? undefined,
|
||||
});
|
||||
const groupEntry = groupConfigResolved.entry;
|
||||
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
|
||||
let effectiveWasMentioned: boolean | undefined;
|
||||
|
||||
if (isGroup) {
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerbose(core, runtime, `drop group message (groupPolicy=disabled, space=${spaceId})`);
|
||||
return;
|
||||
}
|
||||
const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured;
|
||||
const groupAllowed =
|
||||
Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]);
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (!groupAllowlistConfigured) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!groupAllowed) {
|
||||
logVerbose(core, runtime, `drop group message (not allowlisted, space=${spaceId})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (groupEntry?.enabled === false || groupEntry?.allow === false) {
|
||||
logVerbose(core, runtime, `drop group message (space disabled, space=${spaceId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupUsers.length > 0) {
|
||||
const ok = isSenderAllowed(senderId, senderEmail, groupUsers.map((v) => String(v)));
|
||||
if (!ok) {
|
||||
logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
||||
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => [])
|
||||
: [];
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (isGroup) {
|
||||
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
|
||||
const annotations = message.annotations ?? [];
|
||||
const mentionInfo = extractMentionInfo(annotations, account.config.botUser);
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config,
|
||||
surface: "googlechat",
|
||||
});
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup: true,
|
||||
requireMention,
|
||||
canDetectMention: true,
|
||||
wasMentioned: mentionInfo.wasMentioned,
|
||||
implicitMention: false,
|
||||
hasAnyMention: mentionInfo.hasAnyMention,
|
||||
allowTextCommands,
|
||||
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
|
||||
commandAuthorized: commandAuthorized === true,
|
||||
});
|
||||
effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||
if (mentionGate.shouldSkip) {
|
||||
logVerbose(core, runtime, `drop group message (mention required, space=${spaceId})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled" || account.config.dm?.enabled === false) {
|
||||
logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const allowed = senderAllowedForCommands;
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "googlechat",
|
||||
id: senderId,
|
||||
meta: { name: senderName || undefined, email: senderEmail },
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`);
|
||||
try {
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: core.channel.pairing.buildPairingReply({
|
||||
channel: "googlechat",
|
||||
idLine: `Your Google Chat user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
logVerbose(core, runtime, `googlechat: drop control command from ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: config,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: spaceId,
|
||||
},
|
||||
});
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (attachments.length > 0) {
|
||||
const first = attachments[0];
|
||||
const attachmentData = await downloadAttachment(first, account, mediaMaxMb, core);
|
||||
if (attachmentData) {
|
||||
mediaPath = attachmentData.path;
|
||||
mediaType = attachmentData.contentType;
|
||||
}
|
||||
}
|
||||
|
||||
const fromLabel = isGroup
|
||||
? space.displayName || `space:${spaceId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Google Chat",
|
||||
from: fromLabel,
|
||||
timestamp: event.eventTime ? Date.parse(event.eventTime) : undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = groupConfigResolved.entry?.systemPrompt?.trim() || undefined;
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: `googlechat:${senderId}`,
|
||||
To: `googlechat:${spaceId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "channel" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
SenderUsername: senderEmail,
|
||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "googlechat",
|
||||
Surface: "googlechat",
|
||||
MessageSid: message.name,
|
||||
MessageSidFull: message.name,
|
||||
ReplyToId: message.thread?.name,
|
||||
ReplyToIdFull: message.thread?.name,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
MediaUrl: mediaPath,
|
||||
GroupSpace: isGroup ? space.displayName ?? undefined : undefined,
|
||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||
OriginatingChannel: "googlechat",
|
||||
OriginatingTo: `googlechat:${spaceId}`,
|
||||
});
|
||||
|
||||
void core.channel.session
|
||||
.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);
|
||||
});
|
||||
|
||||
// Typing indicator setup
|
||||
// Note: Reaction mode requires user OAuth, not available with service account auth.
|
||||
// If reaction is configured, we fall back to message mode with a warning.
|
||||
let typingIndicator = account.config.typingIndicator ?? "message";
|
||||
if (typingIndicator === "reaction") {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] typingIndicator="reaction" requires user OAuth (not supported with service account). Falling back to "message" mode.`,
|
||||
);
|
||||
typingIndicator = "message";
|
||||
}
|
||||
let typingMessageName: string | undefined;
|
||||
|
||||
// Start typing indicator (message mode only, reaction mode not supported with app auth)
|
||||
if (typingIndicator === "message") {
|
||||
try {
|
||||
const botName = resolveBotDisplayName({
|
||||
accountName: account.config.name,
|
||||
agentId: route.agentId,
|
||||
config,
|
||||
});
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: `_${botName} is typing..._`,
|
||||
thread: message.thread?.name,
|
||||
});
|
||||
typingMessageName = result?.messageName;
|
||||
} catch (err) {
|
||||
runtime.error?.(`Failed sending typing message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverGoogleChatReply({
|
||||
payload,
|
||||
account,
|
||||
spaceId,
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
statusSink,
|
||||
typingMessageName,
|
||||
});
|
||||
// Only use typing message for first delivery
|
||||
typingMessageName = undefined;
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadAttachment(
|
||||
attachment: GoogleChatAttachment,
|
||||
account: ResolvedGoogleChatAccount,
|
||||
mediaMaxMb: number,
|
||||
core: GoogleChatCoreRuntime,
|
||||
): Promise<{ path: string; contentType?: string } | null> {
|
||||
const resourceName = attachment.attachmentDataRef?.resourceName;
|
||||
if (!resourceName) return null;
|
||||
const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||
const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes });
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
downloaded.buffer,
|
||||
downloaded.contentType ?? attachment.contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
attachment.contentName,
|
||||
);
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
async function deliverGoogleChatReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
|
||||
account: ResolvedGoogleChatAccount;
|
||||
spaceId: string;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
config: ClawdbotConfig;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
typingMessageName?: string;
|
||||
}): Promise<void> {
|
||||
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params;
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
let suppressCaption = false;
|
||||
if (typingMessageName) {
|
||||
try {
|
||||
await deleteGoogleChatMessage({
|
||||
account,
|
||||
messageName: typingMessageName,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
|
||||
const fallbackText = payload.text?.trim()
|
||||
? payload.text
|
||||
: mediaList.length > 1
|
||||
? "Sent attachments."
|
||||
: "Sent attachment.";
|
||||
try {
|
||||
await updateGoogleChatMessage({
|
||||
account,
|
||||
messageName: typingMessageName,
|
||||
text: fallbackText,
|
||||
});
|
||||
suppressCaption = Boolean(payload.text?.trim());
|
||||
} catch (updateErr) {
|
||||
runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first && !suppressCaption ? payload.text : undefined;
|
||||
first = false;
|
||||
try {
|
||||
const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, {
|
||||
maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
||||
});
|
||||
const upload = await uploadAttachmentForReply({
|
||||
account,
|
||||
spaceId,
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
filename: loaded.filename ?? "attachment",
|
||||
});
|
||||
if (!upload.attachmentUploadToken) {
|
||||
throw new Error("missing attachment upload token");
|
||||
}
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: caption,
|
||||
thread: payload.replyToId,
|
||||
attachments: [
|
||||
{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.filename },
|
||||
],
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Google Chat attachment send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.text) {
|
||||
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
||||
const chunkMode = core.channel.text.resolveChunkMode(
|
||||
config,
|
||||
"googlechat",
|
||||
account.accountId,
|
||||
);
|
||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||
payload.text,
|
||||
chunkLimit,
|
||||
chunkMode,
|
||||
);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
try {
|
||||
// Edit typing message with first chunk if available
|
||||
if (i === 0 && typingMessageName) {
|
||||
await updateGoogleChatMessage({
|
||||
account,
|
||||
messageName: typingMessageName,
|
||||
text: chunk,
|
||||
});
|
||||
} else {
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: spaceId,
|
||||
text: chunk,
|
||||
thread: payload.replyToId,
|
||||
});
|
||||
}
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAttachmentForReply(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
spaceId: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const { account, spaceId, buffer, contentType, filename } = params;
|
||||
const { uploadGoogleChatAttachment } = await import("./api.js");
|
||||
return await uploadGoogleChatAttachment({
|
||||
account,
|
||||
space: spaceId,
|
||||
filename,
|
||||
buffer,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void {
|
||||
const core = getGoogleChatRuntime();
|
||||
const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl);
|
||||
if (!webhookPath) {
|
||||
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const audienceType = normalizeAudienceType(options.account.config.audienceType);
|
||||
const audience = options.account.config.audience?.trim();
|
||||
const mediaMaxMb = options.account.config.mediaMaxMb ?? 20;
|
||||
|
||||
const unregister = registerGoogleChatWebhookTarget({
|
||||
account: options.account,
|
||||
config: options.config,
|
||||
runtime: options.runtime,
|
||||
core,
|
||||
path: webhookPath,
|
||||
audienceType,
|
||||
audience,
|
||||
statusSink: options.statusSink,
|
||||
mediaMaxMb,
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}
|
||||
|
||||
export async function startGoogleChatMonitor(params: GoogleChatMonitorOptions): Promise<() => void> {
|
||||
return monitorGoogleChatProvider(params);
|
||||
}
|
||||
|
||||
export function resolveGoogleChatWebhookPath(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
}): string {
|
||||
return resolveWebhookPath(
|
||||
params.account.config.webhookPath,
|
||||
params.account.config.webhookUrl,
|
||||
) ?? "/googlechat";
|
||||
}
|
||||
|
||||
export function computeGoogleChatMediaMaxMb(params: { account: ResolvedGoogleChatAccount }) {
|
||||
return params.account.config.mediaMaxMb ?? 20;
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import type { ClawdbotConfig, DmPolicy } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
promptAccountId,
|
||||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
type WizardPrompter,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listGoogleChatAccountIds,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
resolveGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
const channel = "googlechat" as const;
|
||||
|
||||
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
|
||||
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||
|
||||
function setGoogleChatDmPolicy(cfg: ClawdbotConfig, policy: DmPolicy) {
|
||||
const allowFrom =
|
||||
policy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.["googlechat"]?.dm?.allowFrom)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"googlechat": {
|
||||
...(cfg.channels?.["googlechat"] ?? {}),
|
||||
dm: {
|
||||
...(cfg.channels?.["googlechat"]?.dm ?? {}),
|
||||
policy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const current = params.cfg.channels?.["googlechat"]?.dm?.allowFrom ?? [];
|
||||
const entry = await params.prompter.text({
|
||||
message: "Google Chat allowFrom (user id or email)",
|
||||
placeholder: "users/123456789, name@example.com",
|
||||
initialValue: current[0] ? String(current[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
const unique = [...new Set(parts)];
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...params.cfg.channels,
|
||||
"googlechat": {
|
||||
...(params.cfg.channels?.["googlechat"] ?? {}),
|
||||
enabled: true,
|
||||
dm: {
|
||||
...(params.cfg.channels?.["googlechat"]?.dm ?? {}),
|
||||
policy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Google Chat",
|
||||
channel,
|
||||
policyKey: "channels.googlechat.dm.policy",
|
||||
allowFromKey: "channels.googlechat.dm.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.["googlechat"]?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy),
|
||||
promptAllowFrom,
|
||||
};
|
||||
|
||||
function applyAccountConfig(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
patch: Record<string, unknown>;
|
||||
}): ClawdbotConfig {
|
||||
const { cfg, accountId, patch } = params;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"googlechat": {
|
||||
...(cfg.channels?.["googlechat"] ?? {}),
|
||||
enabled: true,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"googlechat": {
|
||||
...(cfg.channels?.["googlechat"] ?? {}),
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.["googlechat"]?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.["googlechat"]?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function promptCredentials(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const envReady =
|
||||
accountId === DEFAULT_ACCOUNT_ID &&
|
||||
(Boolean(process.env[ENV_SERVICE_ACCOUNT]) ||
|
||||
Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]));
|
||||
if (envReady) {
|
||||
const useEnv = await prompter.confirm({
|
||||
message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (useEnv) {
|
||||
return applyAccountConfig({ cfg, accountId, patch: {} });
|
||||
}
|
||||
}
|
||||
|
||||
const method = await prompter.select({
|
||||
message: "Google Chat auth method",
|
||||
options: [
|
||||
{ value: "file", label: "Service account JSON file" },
|
||||
{ value: "inline", label: "Paste service account JSON" },
|
||||
],
|
||||
initialValue: "file",
|
||||
});
|
||||
|
||||
if (method === "file") {
|
||||
const path = await prompter.text({
|
||||
message: "Service account JSON path",
|
||||
placeholder: "/path/to/service-account.json",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
return applyAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
patch: { serviceAccountFile: String(path).trim() },
|
||||
});
|
||||
}
|
||||
|
||||
const json = await prompter.text({
|
||||
message: "Service account JSON (single line)",
|
||||
placeholder: "{\"type\":\"service_account\", ... }",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
return applyAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
patch: { serviceAccount: String(json).trim() },
|
||||
});
|
||||
}
|
||||
|
||||
async function promptAudience(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const account = resolveGoogleChatAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const currentType = account.config.audienceType ?? "app-url";
|
||||
const currentAudience = account.config.audience ?? "";
|
||||
const audienceType = (await params.prompter.select({
|
||||
message: "Webhook audience type",
|
||||
options: [
|
||||
{ value: "app-url", label: "App URL (recommended)" },
|
||||
{ value: "project-number", label: "Project number" },
|
||||
],
|
||||
initialValue: currentType === "project-number" ? "project-number" : "app-url",
|
||||
})) as "app-url" | "project-number";
|
||||
const audience = await params.prompter.text({
|
||||
message: audienceType === "project-number" ? "Project number" : "App URL",
|
||||
placeholder: audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat",
|
||||
initialValue: currentAudience || undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
return applyAccountConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
patch: { audienceType, audience: String(audience).trim() },
|
||||
});
|
||||
}
|
||||
|
||||
async function noteGoogleChatSetup(prompter: WizardPrompter) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Google Chat apps use service-account auth and an HTTPS webhook.",
|
||||
"Set the Chat API scopes in your service account and configure the Chat app URL.",
|
||||
"Webhook verification requires audience type + audience value.",
|
||||
`Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`,
|
||||
].join("\n"),
|
||||
"Google Chat setup",
|
||||
);
|
||||
}
|
||||
|
||||
export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listGoogleChatAccountIds(cfg).some(
|
||||
(accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
|
||||
);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [
|
||||
`Google Chat: ${configured ? "configured" : "needs service account"}`,
|
||||
],
|
||||
selectionHint: configured ? "configured" : "needs auth",
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
}) => {
|
||||
const override = accountOverrides["googlechat"]?.trim();
|
||||
const defaultAccountId = resolveDefaultGoogleChatAccountId(cfg);
|
||||
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Google Chat",
|
||||
currentId: accountId,
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
await noteGoogleChatSetup(prompter);
|
||||
next = await promptCredentials({ cfg: next, prompter, accountId });
|
||||
next = await promptAudience({ cfg: next, prompter, accountId });
|
||||
|
||||
const namedConfig = migrateBaseNameToDefaultAccount({
|
||||
cfg: next,
|
||||
channelKey: "googlechat",
|
||||
});
|
||||
|
||||
return { cfg: namedConfig, accountId };
|
||||
},
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setGoogleChatRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getGoogleChatRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Google Chat runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
normalizeGoogleChatTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("normalizeGoogleChatTarget", () => {
|
||||
it("normalizes provider prefixes", () => {
|
||||
expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123");
|
||||
expect(normalizeGoogleChatTarget("google-chat:spaces/AAA")).toBe("spaces/AAA");
|
||||
expect(normalizeGoogleChatTarget("gchat:user:User@Example.com")).toBe(
|
||||
"users/user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes email targets to users/<email>", () => {
|
||||
expect(normalizeGoogleChatTarget("User@Example.com")).toBe("users/user@example.com");
|
||||
expect(normalizeGoogleChatTarget("users/User@Example.com")).toBe("users/user@example.com");
|
||||
});
|
||||
|
||||
it("preserves space targets", () => {
|
||||
expect(normalizeGoogleChatTarget("space:spaces/BBB")).toBe("spaces/BBB");
|
||||
expect(normalizeGoogleChatTarget("spaces/CCC")).toBe("spaces/CCC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("target helpers", () => {
|
||||
it("detects user and space targets", () => {
|
||||
expect(isGoogleChatUserTarget("users/abc")).toBe(true);
|
||||
expect(isGoogleChatSpaceTarget("spaces/abc")).toBe(true);
|
||||
expect(isGoogleChatUserTarget("spaces/abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { findGoogleChatDirectMessage } from "./api.js";
|
||||
|
||||
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
const normalized = withoutPrefix
|
||||
.replace(/^user:(users\/)?/i, "users/")
|
||||
.replace(/^space:(spaces\/)?/i, "spaces/");
|
||||
if (isGoogleChatUserTarget(normalized)) {
|
||||
const suffix = normalized.slice("users/".length);
|
||||
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
|
||||
}
|
||||
if (isGoogleChatSpaceTarget(normalized)) return normalized;
|
||||
if (normalized.includes("@")) return `users/${normalized.toLowerCase()}`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isGoogleChatUserTarget(value: string): boolean {
|
||||
return value.toLowerCase().startsWith("users/");
|
||||
}
|
||||
|
||||
export function isGoogleChatSpaceTarget(value: string): boolean {
|
||||
return value.toLowerCase().startsWith("spaces/");
|
||||
}
|
||||
|
||||
function stripMessageSuffix(target: string): string {
|
||||
const index = target.indexOf("/messages/");
|
||||
if (index === -1) return target;
|
||||
return target.slice(0, index);
|
||||
}
|
||||
|
||||
export async function resolveGoogleChatOutboundSpace(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
target: string;
|
||||
}): Promise<string> {
|
||||
const normalized = normalizeGoogleChatTarget(params.target);
|
||||
if (!normalized) {
|
||||
throw new Error("Missing Google Chat target.");
|
||||
}
|
||||
const base = stripMessageSuffix(normalized);
|
||||
if (isGoogleChatSpaceTarget(base)) return base;
|
||||
if (isGoogleChatUserTarget(base)) {
|
||||
const dm = await findGoogleChatDirectMessage({
|
||||
account: params.account,
|
||||
userName: base,
|
||||
});
|
||||
if (!dm?.name) {
|
||||
throw new Error(`No Google Chat DM found for ${base}`);
|
||||
}
|
||||
return dm.name;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user