Compare commits

...

78 Commits

Author SHA1 Message Date
Peter Steinberger
04b92925e0 fix: align tui editor init (#1298) (thanks @sibbl) 2026-01-20 14:31:28 +00:00
Peter Steinberger
73957ca92b refactor: split matrix provider modules 2026-01-20 14:24:41 +00:00
Peter Steinberger
da10ca1585 fix: drain openresponses test responses 2026-01-20 14:20:04 +00:00
Peter Steinberger
5d017dae5a feat: add update channel status
Co-authored-by: Richard Poelderl <18185649+p6l-richard@users.noreply.github.com>
2026-01-20 14:19:03 +00:00
Peter Steinberger
30fd7001f2 fix: tolerate pi-tui type exports 2026-01-20 14:17:39 +00:00
Peter Steinberger
da4b124480 fix(browser): register AI snapshot refs (#1282)
thanks @John-Rood

Co-authored-by: John Rood <62669593+John-Rood@users.noreply.github.com>
2026-01-20 14:14:36 +00:00
John Rood
710c681283 fix(browser): register refs from AI snapshot for act commands
When using the default AI snapshot format without explicit options like
interactive/compact/labels, refs were not being registered because
snapshotAiViaPlaywright returns raw text without ref registration.

This caused 'Unknown ref' errors when subsequently using act commands
with refs like e12 that appeared in the snapshot text.

The fix extracts refs from the AI snapshot using buildRoleSnapshotFromAiSnapshot
and registers them via rememberRoleRefsForTarget so act commands can resolve them.

Fixes #1268
2026-01-20 14:13:48 +00:00
Peter Steinberger
e45228ac37 fix: merge login shell PATH for gateway exec 2026-01-20 14:04:13 +00:00
Peter Steinberger
a0180f364d fix: accept pi-tui editor ctor variants 2026-01-20 14:02:36 +00:00
Peter Steinberger
d69f246ba7 chore: fix lint/format 2026-01-20 13:52:59 +00:00
Peter Steinberger
a81989048d fix: update ui ed25519 + bluebubbles actions 2026-01-20 13:43:27 +00:00
Peter Steinberger
b56e9964f5 style: format update channel logic 2026-01-20 13:41:30 +00:00
Peter Steinberger
ddd7fc1513 style: format update channel logic 2026-01-20 13:41:30 +00:00
Peter Steinberger
4ebf55f1db feat: add dev update channel 2026-01-20 13:41:30 +00:00
Peter Steinberger
cc24ede586 docs: note release channels and add contributor
Co-authored-by: Richard Poelderl <richard.poelderl@gmail.com>
2026-01-20 13:41:30 +00:00
Peter Steinberger
eb3b84f3d2 chore: record zalouser PR commits 2026-01-20 13:38:38 +00:00
Peter Steinberger
304244f2be docs: add zalouser PR thanks 2026-01-20 13:33:13 +00:00
Peter Steinberger
f067ea25b4 fix: align zalouser status + schema 2026-01-20 13:32:11 +00:00
Peter Steinberger
fa51294f65 fix: sync mobile gateway auth v3 2026-01-20 13:30:40 +00:00
Peter Steinberger
a4d1c4d522 fix: run doctor config flow once 2026-01-20 13:27:51 +00:00
Peter Steinberger
6e17c463ae fix: add /skill fallback for native limits
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-20 13:20:29 +00:00
Peter Steinberger
63797e841d Merge pull request #1247 from sebslight/fix/perplexity-web-search-provider
Config: accept Perplexity for web_search
2026-01-20 13:18:18 +00:00
Peter Steinberger
fdb171cb15 refactor: centralize channel ui metadata 2026-01-20 13:11:49 +00:00
Peter Steinberger
6f9861bb9b chore: update deps 2026-01-20 13:06:16 +00:00
Peter Steinberger
759068304e fix: tighten tls fingerprints and approval events 2026-01-20 13:04:20 +00:00
Peter Steinberger
ded578b1fa docs: finalize clawnet refactor doc 2026-01-20 13:04:20 +00:00
Peter Steinberger
dcb8d16591 fix: validate ws tls fingerprint 2026-01-20 13:04:20 +00:00
Peter Steinberger
06c17a333e docs: update protocol + security notes 2026-01-20 13:04:20 +00:00
Peter Steinberger
409a16060b feat: enrich presence with roles 2026-01-20 13:04:20 +00:00
Peter Steinberger
7720106624 feat: add discovery role hints 2026-01-20 13:04:19 +00:00
Peter Steinberger
c613769d22 feat: add remote gateway tls fingerprint 2026-01-20 13:04:19 +00:00
Peter Steinberger
87343c374e feat: route exec approvals via gateway 2026-01-20 13:04:19 +00:00
Peter Steinberger
67be9aed28 chore: make @napi-rs/canvas optional 2026-01-20 13:04:19 +00:00
Peter Steinberger
b48d5d96d3 test: cover scope upgrade flow 2026-01-20 13:04:19 +00:00
Peter Steinberger
d8cc7db5e6 feat: wire role-scoped device creds 2026-01-20 13:04:19 +00:00
Peter Steinberger
dfbf6ac263 feat: enforce device-bound connect challenge 2026-01-20 13:04:19 +00:00
Peter Steinberger
121ae6036b docs: add matrix crypto setup note 2026-01-20 12:42:41 +00:00
Peter Steinberger
6e1ad31b49 build: allow matrix crypto build scripts 2026-01-20 12:23:05 +00:00
Peter Steinberger
0330b483ad docs: add #1306 changelog entry 2026-01-20 12:08:15 +00:00
Peter Steinberger
9a2bf57e1c refactor: extend channel plugin boundary 2026-01-20 12:07:54 +00:00
Peter Steinberger
439044068a fix: drop stray Peekaboo submodule 2026-01-20 12:07:54 +00:00
Tyler Yust
4c3b4aeb76 fix: remove unused typingSignals variable in get-reply-run 2026-01-20 12:07:54 +00:00
Tyler Yust
1e8b291374 refactor: improve error handling in embedded run payloads by clarifying conditions for user-facing error messages and updating test descriptions for typing behavior 2026-01-20 12:07:54 +00:00
Tyler Yust
95f82154f7 feat: extend BlueBubbles attachment handling by adding support for reply context, allowing users to reference previous messages in media attachments 2026-01-20 12:07:54 +00:00
Tyler Yust
7bc3998451 feat: add media size validation to BlueBubbles media handling, ensuring compliance with channel limits and improving error handling for oversized media 2026-01-20 12:07:54 +00:00
Tyler Yust
d029ceab1c feat: enhance BlueBubbles media and message handling by adding reply context support and improving outbound message ID tracking 2026-01-20 12:07:54 +00:00
Tyler Yust
c331bdc27d feat: refactor BlueBubbles media handling by introducing a dedicated media send function and optimizing message processing for media attachments 2026-01-20 12:07:54 +00:00
Tyler Yust
b0b42b4e14 feat: improve BlueBubbles message processing by adding reply context formatting and enhancing message ID extraction from responses 2026-01-20 12:07:54 +00:00
Tyler Yust
e5514d4854 feat: implement reply context handling in BlueBubbles messaging, enhancing message formatting and metadata resolution 2026-01-20 12:07:54 +00:00
Tyler Yust
20bc89d96c feat: enhance BlueBubbles messaging targets by adding support for UUID and hex chat identifiers, improving normalization and parsing functions 2026-01-20 12:07:54 +00:00
Tyler Yust
199fef2a5e feat: enhance BlueBubbles group message handling by adding account-specific logging and improving typing signal conditions 2026-01-20 12:07:54 +00:00
Tyler Yust
d9a2ac7e72 feat: enhance BlueBubbles functionality by implementing macOS version checks for message editing and improving server info caching 2026-01-20 12:07:54 +00:00
Tyler Yust
a16934b2ab feat: update BlueBubbles documentation and code to clarify group icon handling and normalize chat identifiers 2026-01-20 12:07:54 +00:00
Tyler Yust
14a072f5fa feat: add support for setting group icons in BlueBubbles, enhancing group management capabilities 2026-01-20 12:07:54 +00:00
Tyler Yust
574b848863 feat: enhance BlueBubbles message actions with support for message editing, reply metadata, and improved effect handling 2026-01-20 12:07:54 +00:00
Tyler Yust
2e6c58bf75 feat: improve BlueBubbles message action error handling and enhance channel action descriptions 2026-01-20 12:07:54 +00:00
Tyler Yust
a5d89e6eb1 feat: enhance BlueBubbles channel integration with new messaging target normalization and typing indicator improvements 2026-01-20 12:07:54 +00:00
Tyler Yust
61907ddf3e feat: add new channel capabilities for editing, unsending, replying, effects, and group management 2026-01-20 12:07:54 +00:00
Tyler Yust
1eab8fa9b0 Step 5 + Review 2026-01-20 12:07:54 +00:00
Tyler Yust
2cf444be02 Step 4 (Needs Review) 2026-01-20 12:07:54 +00:00
Tyler Yust
7870ce8177 Step 3 + Review 2026-01-20 12:07:54 +00:00
Tyler Yust
e9d691d472 (Step 2) Phase 2 & 3 Complete + Reviewed 2026-01-20 12:07:54 +00:00
Tyler Yust
ac2fcfe96a Phase 0 + Review 2026-01-20 12:07:54 +00:00
Peter Steinberger
627fa3083b Merge pull request #1298 from sibbl/matrix-with-e2ee-support
rewrite(matrix): integration with end to end encryption support
2026-01-20 12:04:45 +00:00
Peter Steinberger
e4877656ca fix: add path import for shell utils (#1298) (thanks @sibbl) 2026-01-20 11:59:36 +00:00
Peter Steinberger
d91f0ceeb3 fix: polish matrix e2ee storage (#1298) (thanks @sibbl) 2026-01-20 11:59:36 +00:00
Sebastian Schubotz
9b71382efb rewrite(matrix): use matrix-bot-sdk as base to enable e2ee encryption, strictly follow location + typing + group concepts, fix room bugs 2026-01-20 11:59:11 +00:00
Peter Steinberger
dd82d32d85 Merge pull request #1292 from bradleypriest/pr/chat-thinking-tool
ui(chat): separate tool/thinking output and add toggle
2026-01-20 11:57:21 +00:00
Peter Steinberger
5a42f7cabd fix: align bird skill metadata and flags (#1302) (thanks @odysseus0) 2026-01-20 11:55:14 +00:00
Peter Steinberger
f2c25c5f40 Merge pull request #1302 from odysseus0/docs/bird-skill-update
docs(bird): update skill for v0.5-0.8 features
2026-01-20 11:53:56 +00:00
Peter Steinberger
7e08de4a5f fix: add nextcloud talk manifest (#1297) (thanks @ysqander) 2026-01-20 11:38:11 +00:00
Bradley Priest
c9d02f0132 ui(chat): separate tool/thinking output and add toggle
- Render assistant reasoning as a distinct block (not merged into message text).\n- Detect tool-like messages reliably and style them separately.\n- Add a "🧠" toggle to hide/show tool + thinking output, persisted in UI settings.
2026-01-20 21:07:29 +13:00
George Zhang
0bd99717be docs(bird): update skill for v0.5-0.8 features
- Add 18 missing commands (home, news, lists, engagement, etc.)
- Document pagination, media uploads, output options
- Add config file format and library usage
- Update posting advice (engagement actions now work)
- Add troubleshooting section
2026-01-20 03:49:40 +08:00
Sebastian
154c49511c Changelog: drop unrelated gateway fix 2026-01-19 13:19:09 -05:00
Sebastian
34462b3221 Config: allow Perplexity web_search provider 2026-01-19 13:03:59 -05:00
tsu
0372bdf6fe fix: add enabled property to groupConfigSchema for improved configuration 2026-01-19 20:25:17 +07:00
tsu
cd8309cc31 chore: simplify user parsing logic in probeZalouser function 2026-01-19 19:18:04 +07:00
tsu
5d9a5b7958 feat: implement zalouser channel plugin with configuration and status monitoring 2026-01-19 14:26:16 +07:00
306 changed files with 17833 additions and 3279 deletions

2
.npmrc
View File

@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs

View File

@@ -41,6 +41,11 @@
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
## Release Channels (Naming)
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- dev: moving head on `main` (no tag; git checkout main).
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.

View File

@@ -5,14 +5,23 @@ Docs: https://docs.clawd.bot
## 2026.1.20-1
### Changes
- Deps: update workspace + memory-lancedb dependencies.
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
### Fixes
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
## 2026.1.19-3
@@ -25,6 +34,8 @@ Docs: https://docs.clawd.bot
### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
## 2026.1.19-2

View File

@@ -71,6 +71,15 @@ clawdbot agent --message "Ship checklist" --thinking high
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
## Development channels
- **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` (macOS app may be missing).
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
@@ -492,5 +501,5 @@ Thanks to all clawtributors:
<a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a>
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a>
<a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/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/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/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>
</p>

View File

@@ -1,15 +1,33 @@
{
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
"originHash" : "2e6f580ad7d1e839d513aa883350369bf2e4193fad872030fdaea7827f34d8ef",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
}
},
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
@@ -27,6 +45,24 @@
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0"
}
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swiftui-math",
"state" : {
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
"version" : "0.1.0"
}
},
{
"identity" : "textual",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
"version" : "0.2.0"
}
}
],
"version" : 3

View File

@@ -12,6 +12,7 @@ import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatSessionEntry
import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.gateway.DeviceAuthStore
import com.clawdbot.android.gateway.DeviceIdentityStore
import com.clawdbot.android.gateway.GatewayClientInfo
import com.clawdbot.android.gateway.GatewayConnectOptions
@@ -62,6 +63,7 @@ class NodeRuntime(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
@@ -153,6 +155,7 @@ class NodeRuntime(context: Context) {
GatewaySession(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { name, remote, mainSessionKey ->
operatorConnected = true
operatorStatusText = "Connected"
@@ -188,6 +191,7 @@ class NodeRuntime(context: Context) {
GatewaySession(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
nodeConnected = true
nodeStatusText = "Connected"

View File

@@ -189,6 +189,18 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, fingerprint.trim()) }
}
fun getString(key: String): String? {
return prefs.getString(key, null)
}
fun putString(key: String, value: String) {
prefs.edit { putString(key, value) }
}
fun remove(key: String) {
prefs.edit { remove(key) }
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing

View File

@@ -0,0 +1,26 @@
package com.clawdbot.android.gateway
import com.clawdbot.android.SecurePrefs
class DeviceAuthStore(private val prefs: SecurePrefs) {
fun loadToken(deviceId: String, role: String): String? {
val key = tokenKey(deviceId, role)
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveToken(deviceId: String, role: String, token: String) {
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
}
fun clearToken(deviceId: String, role: String) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
}
private fun tokenKey(deviceId: String, role: String): String {
val normalizedDevice = deviceId.trim().lowercase()
val normalizedRole = role.trim().lowercase()
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
}

View File

@@ -55,6 +55,7 @@ data class GatewayConnectOptions(
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
@@ -177,6 +178,7 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String?>()
private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null
private val loggerTag = "ClawdbotGateway"
@@ -253,7 +255,8 @@ class GatewaySession(
override fun onOpen(webSocket: WebSocket, response: Response) {
scope.launch {
try {
sendConnect()
val nonce = awaitConnectNonce()
sendConnect(nonce)
} catch (err: Throwable) {
connectDeferred.completeExceptionally(err)
closeQuietly()
@@ -288,16 +291,30 @@ class GatewaySession(
}
}
private suspend fun sendConnect() {
val payload = buildConnectParams()
private suspend fun sendConnect(connectNonce: String?) {
val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) {
val msg = res.error?.message ?: "connect failed"
if (canFallbackToShared) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
throw IllegalStateException(msg)
}
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
if (!deviceToken.isNullOrBlank()) {
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
}
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
val sessionDefaults =
@@ -308,7 +325,12 @@ class GatewaySession(
connectDeferred.complete(Unit)
}
private fun buildConnectParams(): JsonObject {
private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String?,
authToken: String,
authPassword: String?,
): JsonObject {
val client = options.client
val locale = Locale.getDefault().toLanguageTag()
val clientObj =
@@ -323,22 +345,20 @@ class GatewaySession(
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
}
val authToken = token?.trim().orEmpty()
val authPassword = password?.trim().orEmpty()
val password = authPassword?.trim().orEmpty()
val authJson =
when {
authToken.isNotEmpty() ->
buildJsonObject {
put("token", JsonPrimitive(authToken))
}
authPassword.isNotEmpty() ->
password.isNotEmpty() ->
buildJsonObject {
put("password", JsonPrimitive(authPassword))
put("password", JsonPrimitive(password))
}
else -> null
}
val identity = identityStore.loadOrCreate()
val signedAtMs = System.currentTimeMillis()
val payload =
buildDeviceAuthPayload(
@@ -349,6 +369,7 @@ class GatewaySession(
scopes = options.scopes,
signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
)
val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -359,6 +380,9 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
}
} else {
null
@@ -416,6 +440,13 @@ class GatewaySession(
val event = frame["event"].asStringOrNull() ?: return
val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted) {
connectNonceDeferred.complete(nonce)
}
return
}
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson)
return
@@ -423,6 +454,21 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private suspend fun awaitConnectNonce(): String? {
if (isLoopbackHost(endpoint.host)) return null
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) {
null
}
}
private fun extractConnectNonce(payloadJson: String?): String? {
if (payloadJson.isNullOrBlank()) return null
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
return obj["nonce"].asStringOrNull()
}
private fun handleInvokeEvent(payloadJson: String) {
val payload =
try {
@@ -544,19 +590,26 @@ class GatewaySession(
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
return listOf(
"v1",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
).joinToString("|")
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
val parts =
mutableListOf(
version,
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {

View File

@@ -84,5 +84,7 @@ private fun sha256Hex(data: ByteArray): String {
}
private fun normalizeFingerprint(raw: String): String {
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
val stripped = raw.trim()
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
}

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string>
<string>2026.1.9</string>
<key>CFBundleVersion</key>
<string>202601113</string>
<string>20260109</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@@ -1,15 +1,13 @@
Sources/Bridge/BridgeClient.swift
Sources/Bridge/BridgeConnectionController.swift
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
Sources/Bridge/BridgeDiscoveryModel.swift
Sources/Bridge/BridgeEndpointID.swift
Sources/Bridge/BridgeSession.swift
Sources/Bridge/BridgeSettingsStore.swift
Sources/Bridge/KeychainStore.swift
Sources/Gateway/GatewayConnectionController.swift
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Gateway/GatewaySettingsStore.swift
Sources/Gateway/KeychainStore.swift
Sources/Camera/CameraController.swift
Sources/Chat/ChatSheet.swift
Sources/Chat/IOSBridgeChatTransport.swift
Sources/Chat/IOSGatewayChatTransport.swift
Sources/ClawdbotApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift
Sources/RootCanvas.swift
Sources/RootTabs.swift
@@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift
Sources/SessionKey.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string>
<string>2026.1.9</string>
<key>CFBundleVersion</key>
<string>202601113</string>
<string>20260109</string>
</dict>
</plist>

View File

@@ -78,6 +78,7 @@ let package = Package(
.executableTarget(
name: "ClawdbotWizardCLI",
dependencies: [
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
],
path: "Sources/ClawdbotWizardCLI",

View File

@@ -426,34 +426,17 @@ extension ChannelsSettings {
}
private func resolveChannelTitle(_ id: String) -> String {
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
let label = self.store.resolveChannelLabel(id)
if label != id { return label }
return id.prefix(1).uppercased() + id.dropFirst()
}
private func resolveChannelDetailTitle(_ id: String) -> String {
switch id {
case "whatsapp": "WhatsApp Web"
case "telegram": "Telegram Bot"
case "discord": "Discord Bot"
case "slack": "Slack Bot"
case "signal": "Signal REST"
case "imessage": "iMessage"
default: self.resolveChannelTitle(id)
}
return self.store.resolveChannelDetailLabel(id)
}
private func resolveChannelSystemImage(_ id: String) -> String {
switch id {
case "whatsapp": "message"
case "telegram": "paperplane"
case "discord": "bubble.left.and.bubble.right"
case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill"
default: "message"
}
return self.store.resolveChannelSystemImage(id)
}
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {

View File

@@ -153,9 +153,19 @@ struct ChannelsStatusSnapshot: Codable {
let application: AnyCodable?
}
struct ChannelUiMetaEntry: Codable {
let id: String
let label: String
let detailLabel: String
let systemImage: String?
}
let ts: Double
let channelOrder: [String]
let channelLabels: [String: String]
let channelDetailLabels: [String: String]? = nil
let channelSystemImages: [String: String]? = nil
let channelMeta: [ChannelUiMetaEntry]? = nil
let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String]
@@ -217,6 +227,47 @@ final class ChannelsStore {
var configRoot: [String: Any] = [:]
var configLoaded = false
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
self.snapshot?.channelMeta?.first(where: { $0.id == id })
}
func resolveChannelLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
return meta.label
}
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id
}
func resolveChannelDetailLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
return meta.detailLabel
}
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
return detail
}
return self.resolveChannelLabel(id)
}
func resolveChannelSystemImage(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
return symbol
}
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
return symbol
}
return "message"
}
func orderedChannelIds() -> [String] {
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
return meta.map { $0.id }
}
return self.snapshot?.channelOrder ?? []
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}

View File

@@ -42,7 +42,8 @@ extension CronJobEditor {
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.channel = GatewayAgentChannel(raw: channel)
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
@@ -210,7 +211,8 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["channel"] = self.channel.rawValue
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver

View File

@@ -14,7 +14,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.channel = .last
self.channel = "last"
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"

View File

@@ -1,10 +1,12 @@
import ClawdbotProtocol
import Observation
import SwiftUI
struct CronJobEditor: View {
let job: CronJob?
@Binding var isSaving: Bool
@Binding var error: String?
@Bindable var channelsStore: ChannelsStore
let onCancel: () -> Void
let onSave: ([String: AnyCodable]) -> Void
@@ -45,13 +47,29 @@ struct CronJobEditor: View {
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
@State var channel: GatewayAgentChannel = .last
@State var channel: String = "last"
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false
@State var postPrefix: String = "Cron"
var channelOptions: [String] {
let ordered = self.channelsStore.orderedChannelIds()
var options = ["last"] + ordered
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !options.contains(trimmed) {
options.append(trimmed)
}
var seen = Set<String>()
return options.filter { seen.insert($0).inserted }
}
func channelLabel(for id: String) -> String {
if id == "last" { return "last" }
return self.channelsStore.resolveChannelLabel(id)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
@@ -333,13 +351,9 @@ struct CronJobEditor: View {
GridRow {
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentChannel.imessage)
ForEach(self.channelOptions, id: \.self) { channel in
Text(self.channelLabel(for: channel)).tag(channel)
}
}
.labelsHidden()
.pickerStyle(.segmented)

View File

@@ -8,13 +8,20 @@ extension CronSettings {
self.content
Spacer(minLength: 0)
}
.onAppear { self.store.start() }
.onDisappear { self.store.stop() }
.onAppear {
self.store.start()
self.channelsStore.start()
}
.onDisappear {
self.store.stop()
self.channelsStore.stop()
}
.sheet(isPresented: self.$showEditor) {
CronJobEditor(
job: self.editingJob,
isSaving: self.$isSaving,
error: self.$editorError,
channelsStore: self.channelsStore,
onCancel: {
self.showEditor = false
self.editingJob = nil

View File

@@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider {
durationMs: 1234,
nextRunAtMs: nil),
]
return CronSettings(store: store)
return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
@@ -103,7 +103,7 @@ extension CronSettings {
store.selectedJobId = job.id
store.runEntries = [run]
let view = CronSettings(store: store)
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
_ = view.body
_ = view.jobRow(job)
_ = view.jobContextMenu(job)

View File

@@ -3,13 +3,15 @@ import SwiftUI
struct CronSettings: View {
@Bindable var store: CronJobsStore
@Bindable var channelsStore: ChannelsStore
@State var showEditor = false
@State var editingJob: CronJob?
@State var editorError: String?
@State var isSaving = false
@State var confirmDelete: CronJob?
init(store: CronJobsStore = .shared) {
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) {
self.store = store
self.channelsStore = channelsStore
}
}

View File

@@ -46,6 +46,7 @@ private struct ExecHostRequest: Codable {
var needsScreenRecording: Bool?
var agentId: String?
var sessionKey: String?
var approvalDecision: ExecApprovalDecision?
}
private struct ExecHostRunResult: Codable {
@@ -328,8 +329,21 @@ private enum ExecHostExecutor {
return false
}()
var approvedByAsk = false
if requiresAsk {
let approvalDecision = request.approvalDecision
if approvalDecision == .deny {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied"))
}
var approvedByAsk = approvalDecision != nil
if requiresAsk, approvalDecision == nil {
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
@@ -364,6 +378,13 @@ private enum ExecHostExecutor {
}
}
if approvalDecision == .allowAlways, security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
return ExecHostResponse(
type: "exec-res",

View File

@@ -15,6 +15,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case signal
case imessage
case msteams
case bluebubbles
case webchat
init(raw: String?) {

View File

@@ -20,7 +20,7 @@ public struct ConnectParams: Codable, Sendable {
public let permissions: [String: AnyCodable]?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]
public let device: [String: AnyCodable]?
public let auth: [String: AnyCodable]?
public let locale: String?
public let useragent: String?
@@ -34,7 +34,7 @@ public struct ConnectParams: Codable, Sendable {
permissions: [String: AnyCodable]?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable],
device: [String: AnyCodable]?,
auth: [String: AnyCodable]?,
locale: String?,
useragent: String?
@@ -205,6 +205,9 @@ public struct PresenceEntry: Codable, Sendable {
public let tags: [String]?
public let text: String?
public let ts: Int
public let deviceid: String?
public let roles: [String]?
public let scopes: [String]?
public let instanceid: String?
public init(
@@ -220,6 +223,9 @@ public struct PresenceEntry: Codable, Sendable {
tags: [String]?,
text: String?,
ts: Int,
deviceid: String?,
roles: [String]?,
scopes: [String]?,
instanceid: String?
) {
self.host = host
@@ -234,6 +240,9 @@ public struct PresenceEntry: Codable, Sendable {
self.tags = tags
self.text = text
self.ts = ts
self.deviceid = deviceid
self.roles = roles
self.scopes = scopes
self.instanceid = instanceid
}
private enum CodingKeys: String, CodingKey {
@@ -249,6 +258,9 @@ public struct PresenceEntry: Codable, Sendable {
case tags
case text
case ts
case deviceid = "deviceId"
case roles
case scopes
case instanceid = "instanceId"
}
}
@@ -1312,6 +1324,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int
public let channelorder: [String]
public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable]
@@ -1320,6 +1335,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int,
channelorder: [String],
channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable]
@@ -1327,6 +1345,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts
self.channelorder = channelorder
self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels
self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid
@@ -1335,6 +1356,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts
case channelorder = "channelOrder"
case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels
case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId"

View File

@@ -1,7 +1,10 @@
import ClawdbotKit
import ClawdbotProtocol
import Darwin
import Foundation
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
struct WizardCliOptions {
var url: String?
var token: String?
@@ -228,6 +231,10 @@ private func parseInt(_ value: Any?) -> Int? {
}
actor GatewayWizardClient {
private enum ConnectChallengeError: Error {
case timeout
}
private let url: URL
private let token: String?
private let password: String?
@@ -235,6 +242,7 @@ actor GatewayWizardClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let session = URLSession(configuration: .default)
private let connectChallengeTimeoutSeconds: Double = 0.75
private var task: URLSessionWebSocketTask?
init(url: URL, token: String?, password: String?, json: Bool) {
@@ -257,7 +265,7 @@ actor GatewayWizardClient {
self.task = nil
}
func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame {
func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame {
guard let task = self.task else {
throw WizardCliError.gatewayError("gateway not connected")
}
@@ -266,7 +274,7 @@ actor GatewayWizardClient {
type: "req",
id: id,
method: method,
params: params.map { AnyCodable($0) })
params: params.map { ProtoAnyCodable($0) })
let data = try self.encoder.encode(frame)
try await task.send(.data(data))
@@ -309,28 +317,65 @@ actor GatewayWizardClient {
}
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let client: [String: AnyCodable] = [
"id": AnyCodable("clawdbot-macos"),
"displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
"version": AnyCodable("dev"),
"platform": AnyCodable(platform),
"deviceFamily": AnyCodable("Mac"),
"mode": AnyCodable("ui"),
"instanceId": AnyCodable(UUID().uuidString),
let clientId = "clawdbot-macos"
let clientMode = "ui"
let role = "operator"
let scopes: [String] = []
let client: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(clientId),
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
"version": ProtoAnyCodable("dev"),
"platform": ProtoAnyCodable(platform),
"deviceFamily": ProtoAnyCodable("Mac"),
"mode": ProtoAnyCodable(clientMode),
"instanceId": ProtoAnyCodable(UUID().uuidString),
]
var params: [String: AnyCodable] = [
"minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": AnyCodable(client),
"caps": AnyCodable([String]()),
"locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([String]()),
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
]
if let token = self.token {
params["auth"] = AnyCodable(["token": AnyCodable(token)])
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
} else if let password = self.password {
params["auth"] = AnyCodable(["password": AnyCodable(password)])
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
clientId,
clientMode,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
let reqId = UUID().uuidString
@@ -338,31 +383,57 @@ actor GatewayWizardClient {
type: "req",
id: reqId,
method: "connect",
params: AnyCodable(params))
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await task.send(.data(data))
let message = try await task.receive()
let frameResponse = try decodeFrame(message)
guard case let .res(res) = frameResponse, res.id == reqId else {
throw WizardCliError.gatewayError("connect failed (unexpected response)")
while true {
let message = try await task.receive()
let frameResponse = try decodeFrame(message)
if case let .res(res) = frameResponse, res.id == reqId {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg)
}
_ = try self.decodePayload(res, as: HelloOk.self)
return
}
}
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg)
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String {
return nonce
}
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
}
_ = try self.decodePayload(res, as: HelloOk.self)
}
}
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
var params: [String: AnyCodable] = [:]
var params: [String: ProtoAnyCodable] = [:]
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if mode == "local" || mode == "remote" {
params["mode"] = AnyCodable(mode)
params["mode"] = ProtoAnyCodable(mode)
}
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
params["workspace"] = AnyCodable(workspace)
params["workspace"] = ProtoAnyCodable(workspace)
}
let startResponse = try await client.request(method: "wizard.start", params: params)
@@ -395,17 +466,17 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
if let step = decodeWizardStep(nextResult.step) {
let answer = try promptAnswer(for: step)
var answerPayload: [String: AnyCodable] = [
"stepId": AnyCodable(step.id),
var answerPayload: [String: ProtoAnyCodable] = [
"stepId": ProtoAnyCodable(step.id),
]
if !(answer is NSNull) {
answerPayload["value"] = AnyCodable(answer)
answerPayload["value"] = ProtoAnyCodable(answer)
}
let response = try await client.request(
method: "wizard.next",
params: [
"sessionId": AnyCodable(sessionId),
"answer": AnyCodable(answerPayload),
"sessionId": ProtoAnyCodable(sessionId),
"answer": ProtoAnyCodable(answerPayload),
])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json {
@@ -414,7 +485,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} else {
let response = try await client.request(
method: "wizard.next",
params: ["sessionId": AnyCodable(sessionId)])
params: ["sessionId": ProtoAnyCodable(sessionId)])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json {
dumpResult(response)
@@ -424,7 +495,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} catch WizardCliError.cancelled {
_ = try? await client.request(
method: "wizard.cancel",
params: ["sessionId": AnyCodable(sessionId)])
params: ["sessionId": ProtoAnyCodable(sessionId)])
throw WizardCliError.cancelled
}
}

View File

@@ -11,6 +11,7 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}
@@ -18,6 +19,7 @@ import Testing
#expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
#expect(GatewayAgentChannel(raw: "unknown") == .last)
}
}

View File

@@ -0,0 +1,107 @@
import Foundation
public struct DeviceAuthEntry: Codable, Sendable {
public let token: String
public let role: String
public let scopes: [String]
public let updatedAtMs: Int
public init(token: String, role: String, scopes: [String], updatedAtMs: Int) {
self.token = token
self.role = role
self.scopes = scopes
self.updatedAtMs = updatedAtMs
}
}
private struct DeviceAuthStoreFile: Codable {
var version: Int
var deviceId: String
var tokens: [String: DeviceAuthEntry]
}
public enum DeviceAuthStore {
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = normalizeRole(role)
return store.tokens[role]
}
public static func storeToken(
deviceId: String,
role: String,
token: String,
scopes: [String] = []
) -> DeviceAuthEntry {
let normalizedRole = normalizeRole(role)
var next = readStore()
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
let entry = DeviceAuthEntry(
token: token,
role: normalizedRole,
scopes: normalizeScopes(scopes),
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
)
if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
next?.tokens[normalizedRole] = entry
if let store = next {
writeStore(store)
}
return entry
}
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
writeStore(store)
}
private static func normalizeRole(_ role: String) -> String {
role.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func normalizeScopes(_ scopes: [String]) -> [String] {
let trimmed = scopes
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return Array(Set(trimmed)).sorted()
}
private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
private static func readStore() -> DeviceAuthStoreFile? {
let url = fileURL()
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
}
guard decoded.version == 1 else { return nil }
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = fileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try JSONEncoder().encode(store)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
// best-effort only
}
}
}

View File

@@ -1,11 +1,18 @@
import CryptoKit
import Foundation
struct DeviceIdentity: Codable, Sendable {
var deviceId: String
var publicKey: String
var privateKey: String
var createdAtMs: Int
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
public var privateKey: String
public var createdAtMs: Int
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) {
self.deviceId = deviceId
self.publicKey = publicKey
self.privateKey = privateKey
self.createdAtMs = createdAtMs
}
}
enum DeviceIdentityPaths {
@@ -27,10 +34,10 @@ enum DeviceIdentityPaths {
}
}
enum DeviceIdentityStore {
public enum DeviceIdentityStore {
private static let fileName = "device.json"
static func loadOrCreate() -> DeviceIdentity {
public static func loadOrCreate() -> DeviceIdentity {
let url = self.fileURL()
if let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
@@ -44,7 +51,7 @@ enum DeviceIdentityStore {
return identity
}
static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
do {
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
@@ -76,7 +83,7 @@ enum DeviceIdentityStore {
.replacingOccurrences(of: "=", with: "")
}
static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
public static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
guard let data = Data(base64Encoded: identity.publicKey) else { return nil }
return self.base64UrlEncode(data)
}

View File

@@ -94,6 +94,10 @@ public struct GatewayConnectOptions: Sendable {
// Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
private enum ConnectChallengeError: Error {
case timeout
}
public actor GatewayChannelActor {
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
private var task: WebSocketTaskBox?
@@ -113,6 +117,7 @@ public actor GatewayChannelActor {
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6
private let connectChallengeTimeoutSeconds: Double = 0.75
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
@@ -256,6 +261,8 @@ public actor GatewayChannelActor {
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
let clientId = options.clientId
let clientMode = options.clientMode
let role = options.role
let scopes = options.scopes
let reqId = UUID().uuidString
var client: [String: ProtoAnyCodable] = [
@@ -278,8 +285,8 @@ public actor GatewayChannelActor {
"caps": ProtoAnyCodable(options.caps),
"locale": ProtoAnyCodable(primaryLocale),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(options.role),
"scopes": ProtoAnyCodable(options.scopes),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
]
if !options.commands.isEmpty {
params["commands"] = ProtoAnyCodable(options.commands)
@@ -287,32 +294,44 @@ public actor GatewayChannelActor {
if !options.permissions.isEmpty {
params["permissions"] = ProtoAnyCodable(options.permissions)
}
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
let identity = DeviceIdentityStore.loadOrCreate()
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
let authToken = storedToken ?? self.token
let canFallbackToShared = storedToken != nil && self.token != nil
if let authToken {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopes = options.scopes.joined(separator: ",")
let payload = [
"v1",
let connectNonce = try await self.waitForConnectChallenge()
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
clientId,
clientMode,
options.role,
scopes,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
].joined(separator: "|")
authToken ?? "",
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
params["device"] = ProtoAnyCodable([
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
])
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
let frame = RequestFrame(
@@ -322,40 +341,22 @@ public actor GatewayChannelActor {
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await self.task?.send(.data(data))
guard let msg = try await task?.receive() else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response, identity: identity, role: role)
} catch {
if canFallbackToShared {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
throw error
}
try await self.handleConnectResponse(msg, reqId: reqId)
}
private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
let data: Data? = switch msg {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
}
let decoder = JSONDecoder()
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
}
guard case let .res(res) = frame, res.id == reqId else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity,
role: String
) async throws {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
@@ -373,6 +374,17 @@ public actor GatewayChannelActor {
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
if let auth = ok.auth,
let deviceToken = auth["deviceToken"]?.value as? String {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
self.lastTick = Date()
self.tickTask?.cancel()
self.tickTask = Task { [weak self] in
@@ -424,6 +436,7 @@ public actor GatewayChannelActor {
waiter.resume(returning: .res(res))
}
case let .event(evt):
if evt.event == "connect.challenge" { return }
if let seq = evt.seq {
if let last = lastSeq, seq > last + 1 {
await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
@@ -437,6 +450,63 @@ public actor GatewayChannelActor {
}
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: { [weak self] in
guard let self else { return nil }
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String {
return nonce
}
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
}
}
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {
guard let task = self.task else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
}
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
}
if case let .res(res) = frame, res.id == reqId {
return res
}
}
}
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
let data: Data? = switch msg {
case let .data(data): data
case let .string(text): text.data(using: .utf8)
@unknown default: nil
}
return data
}
private func watchTicks() async {
let tolerance = self.tickIntervalMs * 2
while self.connected {

View File

@@ -108,5 +108,9 @@ private func sha256Hex(_ data: Data) -> String {
}
private func normalizeFingerprint(_ raw: String) -> String {
raw.lowercased().filter(\.isHexDigit)
let stripped = raw.replacingOccurrences(
of: #"(?i)^sha-?256\s*:?\s*"#,
with: "",
options: .regularExpression)
return stripped.lowercased().filter(\.isHexDigit)
}

View File

@@ -5,6 +5,7 @@ public let GATEWAY_PROTOCOL_VERSION = 3
public enum ErrorCode: String, Codable, Sendable {
case notLinked = "NOT_LINKED"
case notPaired = "NOT_PAIRED"
case agentTimeout = "AGENT_TIMEOUT"
case invalidRequest = "INVALID_REQUEST"
case unavailable = "UNAVAILABLE"
@@ -15,6 +16,11 @@ public struct ConnectParams: Codable, Sendable {
public let maxprotocol: Int
public let client: [String: AnyCodable]
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
public let auth: [String: AnyCodable]?
public let locale: String?
public let useragent: String?
@@ -24,6 +30,11 @@ public struct ConnectParams: Codable, Sendable {
maxprotocol: Int,
client: [String: AnyCodable],
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
auth: [String: AnyCodable]?,
locale: String?,
useragent: String?
@@ -32,6 +43,11 @@ public struct ConnectParams: Codable, Sendable {
self.maxprotocol = maxprotocol
self.client = client
self.caps = caps
self.commands = commands
self.permissions = permissions
self.role = role
self.scopes = scopes
self.device = device
self.auth = auth
self.locale = locale
self.useragent = useragent
@@ -41,6 +57,11 @@ public struct ConnectParams: Codable, Sendable {
case maxprotocol = "maxProtocol"
case client
case caps
case commands
case permissions
case role
case scopes
case device
case auth
case locale
case useragent = "userAgent"
@@ -54,6 +75,7 @@ public struct HelloOk: Codable, Sendable {
public let features: [String: AnyCodable]
public let snapshot: Snapshot
public let canvashosturl: String?
public let auth: [String: AnyCodable]?
public let policy: [String: AnyCodable]
public init(
@@ -63,6 +85,7 @@ public struct HelloOk: Codable, Sendable {
features: [String: AnyCodable],
snapshot: Snapshot,
canvashosturl: String?,
auth: [String: AnyCodable]?,
policy: [String: AnyCodable]
) {
self.type = type
@@ -71,6 +94,7 @@ public struct HelloOk: Codable, Sendable {
self.features = features
self.snapshot = snapshot
self.canvashosturl = canvashosturl
self.auth = auth
self.policy = policy
}
private enum CodingKeys: String, CodingKey {
@@ -80,6 +104,7 @@ public struct HelloOk: Codable, Sendable {
case features
case snapshot
case canvashosturl = "canvasHostUrl"
case auth
case policy
}
}
@@ -180,6 +205,9 @@ public struct PresenceEntry: Codable, Sendable {
public let tags: [String]?
public let text: String?
public let ts: Int
public let deviceid: String?
public let roles: [String]?
public let scopes: [String]?
public let instanceid: String?
public init(
@@ -195,6 +223,9 @@ public struct PresenceEntry: Codable, Sendable {
tags: [String]?,
text: String?,
ts: Int,
deviceid: String?,
roles: [String]?,
scopes: [String]?,
instanceid: String?
) {
self.host = host
@@ -209,6 +240,9 @@ public struct PresenceEntry: Codable, Sendable {
self.tags = tags
self.text = text
self.ts = ts
self.deviceid = deviceid
self.roles = roles
self.scopes = scopes
self.instanceid = instanceid
}
private enum CodingKeys: String, CodingKey {
@@ -224,6 +258,9 @@ public struct PresenceEntry: Codable, Sendable {
case tags
case text
case ts
case deviceid = "deviceId"
case roles
case scopes
case instanceid = "instanceId"
}
}
@@ -706,6 +743,93 @@ public struct NodeInvokeParams: Codable, Sendable {
}
}
public struct NodeInvokeResultParams: Codable, Sendable {
public let id: String
public let nodeid: String
public let ok: Bool
public let payload: AnyCodable?
public let payloadjson: String?
public let error: [String: AnyCodable]?
public init(
id: String,
nodeid: String,
ok: Bool,
payload: AnyCodable?,
payloadjson: String?,
error: [String: AnyCodable]?
) {
self.id = id
self.nodeid = nodeid
self.ok = ok
self.payload = payload
self.payloadjson = payloadjson
self.error = error
}
private enum CodingKeys: String, CodingKey {
case id
case nodeid = "nodeId"
case ok
case payload
case payloadjson = "payloadJSON"
case error
}
}
public struct NodeEventParams: Codable, Sendable {
public let event: String
public let payload: AnyCodable?
public let payloadjson: String?
public init(
event: String,
payload: AnyCodable?,
payloadjson: String?
) {
self.event = event
self.payload = payload
self.payloadjson = payloadjson
}
private enum CodingKeys: String, CodingKey {
case event
case payload
case payloadjson = "payloadJSON"
}
}
public struct NodeInvokeRequestEvent: Codable, Sendable {
public let id: String
public let nodeid: String
public let command: String
public let paramsjson: String?
public let timeoutms: Int?
public let idempotencykey: String?
public init(
id: String,
nodeid: String,
command: String,
paramsjson: String?,
timeoutms: Int?,
idempotencykey: String?
) {
self.id = id
self.nodeid = nodeid
self.command = command
self.paramsjson = paramsjson
self.timeoutms = timeoutms
self.idempotencykey = idempotencykey
}
private enum CodingKeys: String, CodingKey {
case id
case nodeid = "nodeId"
case command
case paramsjson = "paramsJSON"
case timeoutms = "timeoutMs"
case idempotencykey = "idempotencyKey"
}
}
public struct SessionsListParams: Codable, Sendable {
public let limit: Int?
public let activeminutes: Int?
@@ -1381,6 +1505,22 @@ public struct ModelsListResult: Codable, Sendable {
public struct SkillsStatusParams: Codable, Sendable {
}
public struct SkillsBinsParams: Codable, Sendable {
}
public struct SkillsBinsResult: Codable, Sendable {
public let bins: [String]
public init(
bins: [String]
) {
self.bins = bins
}
private enum CodingKeys: String, CodingKey {
case bins
}
}
public struct SkillsInstallParams: Codable, Sendable {
public let name: String
public let installid: String
@@ -1735,6 +1875,225 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
}
}
public struct ExecApprovalRequestParams: Codable, Sendable {
public let command: String
public let cwd: String?
public let host: String?
public let security: String?
public let ask: String?
public let agentid: String?
public let resolvedpath: String?
public let sessionkey: String?
public let timeoutms: Int?
public init(
command: String,
cwd: String?,
host: String?,
security: String?,
ask: String?,
agentid: String?,
resolvedpath: String?,
sessionkey: String?,
timeoutms: Int?
) {
self.command = command
self.cwd = cwd
self.host = host
self.security = security
self.ask = ask
self.agentid = agentid
self.resolvedpath = resolvedpath
self.sessionkey = sessionkey
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case command
case cwd
case host
case security
case ask
case agentid = "agentId"
case resolvedpath = "resolvedPath"
case sessionkey = "sessionKey"
case timeoutms = "timeoutMs"
}
}
public struct ExecApprovalResolveParams: Codable, Sendable {
public let id: String
public let decision: String
public init(
id: String,
decision: String
) {
self.id = id
self.decision = decision
}
private enum CodingKeys: String, CodingKey {
case id
case decision
}
}
public struct DevicePairListParams: Codable, Sendable {
}
public struct DevicePairApproveParams: Codable, Sendable {
public let requestid: String
public init(
requestid: String
) {
self.requestid = requestid
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
}
}
public struct DevicePairRejectParams: Codable, Sendable {
public let requestid: String
public init(
requestid: String
) {
self.requestid = requestid
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
}
}
public struct DeviceTokenRotateParams: Codable, Sendable {
public let deviceid: String
public let role: String
public let scopes: [String]?
public init(
deviceid: String,
role: String,
scopes: [String]?
) {
self.deviceid = deviceid
self.role = role
self.scopes = scopes
}
private enum CodingKeys: String, CodingKey {
case deviceid = "deviceId"
case role
case scopes
}
}
public struct DeviceTokenRevokeParams: Codable, Sendable {
public let deviceid: String
public let role: String
public init(
deviceid: String,
role: String
) {
self.deviceid = deviceid
self.role = role
}
private enum CodingKeys: String, CodingKey {
case deviceid = "deviceId"
case role
}
}
public struct DevicePairRequestedEvent: Codable, Sendable {
public let requestid: String
public let deviceid: String
public let publickey: String
public let displayname: String?
public let platform: String?
public let clientid: String?
public let clientmode: String?
public let role: String?
public let roles: [String]?
public let scopes: [String]?
public let remoteip: String?
public let silent: Bool?
public let isrepair: Bool?
public let ts: Int
public init(
requestid: String,
deviceid: String,
publickey: String,
displayname: String?,
platform: String?,
clientid: String?,
clientmode: String?,
role: String?,
roles: [String]?,
scopes: [String]?,
remoteip: String?,
silent: Bool?,
isrepair: Bool?,
ts: Int
) {
self.requestid = requestid
self.deviceid = deviceid
self.publickey = publickey
self.displayname = displayname
self.platform = platform
self.clientid = clientid
self.clientmode = clientmode
self.role = role
self.roles = roles
self.scopes = scopes
self.remoteip = remoteip
self.silent = silent
self.isrepair = isrepair
self.ts = ts
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
case deviceid = "deviceId"
case publickey = "publicKey"
case displayname = "displayName"
case platform
case clientid = "clientId"
case clientmode = "clientMode"
case role
case roles
case scopes
case remoteip = "remoteIp"
case silent
case isrepair = "isRepair"
case ts
}
}
public struct DevicePairResolvedEvent: Codable, Sendable {
public let requestid: String
public let deviceid: String
public let decision: String
public let ts: Int
public init(
requestid: String,
deviceid: String,
decision: String,
ts: Int
) {
self.requestid = requestid
self.deviceid = deviceid
self.decision = decision
self.ts = ts
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
case deviceid = "deviceId"
case decision
case ts
}
}
public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String
public let limit: Int?

View File

@@ -1,55 +1,203 @@
---
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing, advanced actions)."
read_when:
- Setting up BlueBubbles channel
- Troubleshooting webhook pairing
- Configuring iMessage on macOS
---
# BlueBubbles (macOS REST)
Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **Recommended for iMessage integration** due to its richer API and easier setup compared to the legacy imsg channel.
## Overview
- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
- Runs on macOS via the BlueBubbles helper app ([bluebubbles.app](https://bluebubbles.app)).
- Recommended/tested: macOS Sequoia (15). macOS Tahoe (26) works; edit is currently broken on Tahoe, and group icon updates may report success but not sync.
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
- Reactions are surfaced as system events just like Slack/Telegram so agents can mention them before replying.
- Reactions are surfaced as system events just like Slack/Telegram so agents can "mention" them before replying.
- Advanced features: edit, unsend, reply threading, message effects, group management.
## Quick start
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
3. Configure Clawdbot:
1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)).
2. In the BlueBubbles config, enable the web API and set a password.
3. Run `clawdbot onboard` and select BlueBubbles, or configure manually:
```json5
{
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://bluebubbles-host:1234",
serverUrl: "http://192.168.1.100:1234",
password: "example-password",
webhookPath: "/bluebubbles-webhook",
actions: { reactions: true }
webhookPath: "/bluebubbles-webhook"
}
}
}
```
4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`).
5. Start the gateway; it will register the webhook handler and start pairing.
## Configuration notes
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
## Onboarding
BlueBubbles is available in the interactive setup wizard:
```
clawdbot onboard
```
## How it works
- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
The wizard prompts for:
- **Server URL** (required): BlueBubbles server address (e.g., `http://192.168.1.100:1234`)
- **Password** (required): API password from BlueBubbles Server settings
- **Webhook path** (optional): Defaults to `/bluebubbles-webhook`
- **DM policy**: pairing, allowlist, open, or disabled
- **Allow list**: Phone numbers, emails, or chat targets
You can also add BlueBubbles via CLI:
```
clawdbot channels add bluebubbles --http-url http://192.168.1.100:1234 --password <password>
```
## Access control (DMs + groups)
DMs:
- Default: `channels.bluebubbles.dmPolicy = "pairing"`.
- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via:
- `clawdbot pairing list bluebubbles`
- `clawdbot pairing approve bluebubbles <CODE>`
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
Groups:
- `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`).
- `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
### Mention gating (groups)
BlueBubbles supports mention gating for group chats, matching iMessage/WhatsApp behavior:
- Uses `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) to detect mentions.
- When `requireMention` is enabled for a group, the agent only responds when mentioned.
- Control commands from authorized senders bypass mention gating.
Per-group configuration:
```json5
{
channels: {
bluebubbles: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: {
"*": { requireMention: true }, // default for all groups
"iMessage;-;chat123": { requireMention: false } // override for specific group
}
}
}
}
```
### Command gating
- Control commands (e.g., `/config`, `/model`) require authorization.
- Uses `allowFrom` and `groupAllowFrom` to determine command authorization.
- Authorized senders can run control commands even without mentioning in groups.
## Typing + read receipts
- **Typing indicators**: Sent automatically before and during response generation.
- **Read receipts**: Controlled by `channels.bluebubbles.sendReadReceipts` (default: `true`).
- **Typing indicators**: Clawdbot sends typing start events; BlueBubbles clears typing automatically on send or timeout (manual stop via DELETE is unreliable).
```json5
{
channels: {
bluebubbles: {
sendReadReceipts: false // disable read receipts
}
}
}
```
## Advanced actions
BlueBubbles supports advanced message actions when enabled in config:
```json5
{
channels: {
bluebubbles: {
actions: {
reactions: true, // tapbacks (default: true)
edit: true, // edit sent messages (macOS 13+, broken on macOS 26 Tahoe)
unsend: true, // unsend messages (macOS 13+)
reply: true, // reply threading by message GUID
sendWithEffect: true, // message effects (slam, loud, etc.)
renameGroup: true, // rename group chats
setGroupIcon: true, // set group chat icon/photo (flaky on macOS 26 Tahoe)
addParticipant: true, // add participants to groups
removeParticipant: true, // remove participants from groups
leaveGroup: true, // leave group chats
sendAttachment: true // send attachments/media
}
}
}
}
```
Available actions:
- **react**: Add/remove tapback reactions (`messageId`, `emoji`, `remove`)
- **edit**: Edit a sent message (`messageId`, `text`)
- **unsend**: Unsend a message (`messageId`)
- **reply**: Reply to a specific message (`messageId`, `text`, `to`)
- **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`)
- **renameGroup**: Rename a group chat (`chatGuid`, `displayName`)
- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) — flaky on macOS 26 Tahoe (API may return success but the icon does not sync).
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
- **leaveGroup**: Leave a group chat (`chatGuid`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
## Block streaming
Control whether responses are sent as a single message or streamed in blocks:
```json5
{
channels: {
bluebubbles: {
blockStreaming: true // enable block streaming (default behavior)
}
}
}
```
## Media + limits
- Inbound attachments are downloaded and stored in the media cache.
- Media cap via `channels.bluebubbles.mediaMaxMb` (default: 8 MB).
- Outbound text is chunked to `channels.bluebubbles.textChunkLimit` (default: 4000 chars).
## Configuration reference
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.bluebubbles.enabled`: Enable/disable the channel.
- `channels.bluebubbles.serverUrl`: BlueBubbles REST API base URL.
- `channels.bluebubbles.password`: API password.
- `channels.bluebubbles.webhookPath`: Webhook endpoint path (default: `/bluebubbles-webhook`).
- `channels.bluebubbles.dmPolicy`: `pairing | allowlist | open | disabled` (default: `pairing`).
- `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`).
- `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`).
- `channels.bluebubbles.groupAllowFrom`: Group sender allowlist.
- `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.).
- `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.mediaMaxMb`: Inbound media cap in MB (default: 8).
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
- `channels.bluebubbles.actions`: Enable/disable specific actions.
- `channels.bluebubbles.accounts`: Multi-account configuration.
Related global options:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
- `messages.responsePrefix`.
## Addressing / delivery targets
Prefer `chat_guid` for stable routing:
- `chat_guid:iMessage;-;+15555550123` (preferred for groups)
- `chat_id:123`
- `chat_identifier:...`
- Direct handles: `+15555550123`, `user@example.com`
## Security
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
@@ -57,8 +205,12 @@ Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
## Troubleshooting
- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
- If typing/read events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes.
- Group icon updates can be flaky on macOS 26 (Tahoe): the API may return success but the new icon does not sync.
- Clawdbot auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`.
- For status/health info: `clawdbot status --all` or `clawdbot status --deep`.
For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugins) guide.

View File

@@ -16,8 +16,8 @@ Text is supported everywhere; media and reactions vary by channel.
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [iMessage](/channels/imessage) — macOS only; native integration.
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).

View File

@@ -14,6 +14,7 @@ Clawdbot normalizes shared locations from chat channels into:
Currently supported:
- **Telegram** (location pins + venues + live locations)
- **WhatsApp** (locationMessage + liveLocationMessage)
- **Matrix** (`m.location` with `geo_uri`)
## Text formatting
Locations are rendered as friendly lines without brackets:
@@ -44,3 +45,4 @@ When a location is present, these fields are added to `ctx`:
## Channel notes
- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line.
- **Matrix**: `geo_uri` is parsed as a pin location; altitude is ignored and `LocationIsLive` is always false.

View File

@@ -5,17 +5,26 @@ read_when:
---
# Matrix (plugin)
Status: supported via plugin (matrix-js-sdk). Direct messages, rooms, threads, media, reactions, and polls.
Matrix is an open, decentralized messaging protocol. Clawdbot connects as a Matrix **user**
on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
but it requires E2EE to be enabled.
Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
polls (send + poll-start as text), location, and E2EE (with crypto support).
## Plugin required
Matrix ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/matrix
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/matrix
```
@@ -25,27 +34,54 @@ Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner)
## Setup
1) Install the Matrix plugin:
- From npm: `clawdbot plugins install @clawdbot/matrix`
- From a local checkout: `clawdbot plugins install ./extensions/matrix`
2) Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
2) Create a Matrix account on a homeserver:
- Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/)
- Or host it yourself.
3) Get an access token for the bot account:
- Use the Matrix login API with `curl` at your home server:
```bash
curl --request POST \
--url https://matrix.example.org/_matrix/client/v3/login \
--header 'Content-Type: application/json' \
--data '{
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": "your-user-name"
},
"password": "your-password"
}'
```
- Replace `matrix.example.org` with your homeserver URL.
- Or set `channels.matrix.userId` + `channels.matrix.password`: Clawdbot calls the same
login endpoint, stores the access token in `~/.clawdbot/credentials/matrix/credentials.json`,
and reuses it on next start.
4) Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`)
- Or config: `channels.matrix.*`
- If both are set, config takes precedence.
3) Restart the gateway (or finish onboarding).
4) DM access defaults to pairing; approve the pairing code on first contact.
- With access token: user ID is fetched automatically via `/whoami`.
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`).
5) Restart the gateway (or finish onboarding).
6) Start a DM with the bot or invite it to a room from any Matrix client
(Element, Beeper, etc.; see https://matrix.org/ecosystem/clients/). Beeper requires E2EE,
so set `channels.matrix.encryption: true` and verify the device.
Runtime note: Matrix requires Node.js (Bun is not supported).
Minimal config (access token, user ID auto-fetched):
Minimal config:
```json5
{
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@clawdbot:example.org",
accessToken: "syt_***",
dm: { policy: "pairing" }
}
@@ -53,18 +89,57 @@ Minimal config:
}
```
## Encryption (E2EE)
End-to-end encrypted rooms are **not** supported.
- Use unencrypted rooms or disable encryption when creating the room.
- If a room is E2EE, the bot will receive encrypted events and wont reply.
E2EE config (end to end encryption enabled):
## What it is
Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and listens to DMs and rooms.
- A Matrix user account owned by the Gateway.
- Deterministic routing: replies go back to Matrix.
```json5
{
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_***",
encryption: true,
dm: { policy: "pairing" }
}
}
}
```
## Encryption (E2EE)
End-to-end encryption is **supported** via the Rust crypto SDK.
Enable with `channels.matrix.encryption: true`:
- If the crypto module loads, encrypted rooms are decrypted automatically.
- Outbound media is encrypted when sending to encrypted rooms.
- On first connection, Clawdbot requests device verification from your other sessions.
- Verify the device in another Matrix client (Element, etc.) to enable key sharing.
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
Clawdbot logs a warning.
- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`),
allow build scripts for `@matrix-org/matrix-sdk-crypto-nodejs` and run
`pnpm rebuild @matrix-org/matrix-sdk-crypto-nodejs` or fetch the binary with
`node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
Crypto state is stored per account + access token in
`~/.clawdbot/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/crypto/`
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
If the access token (device) changes, a new store is created and the bot must be
re-verified for encrypted rooms.
**Device verification:**
When E2EE is enabled, the bot will request verification from your other sessions on startup.
Open Element (or another client) and approve the verification request to establish trust.
Once verified, the bot can decrypt messages in encrypted rooms.
## Routing model
- Replies always go back to Matrix.
- DMs share the agent's main session; rooms map to group sessions.
## Access control (DMs)
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
- Approve via:
- `clawdbot pairing list matrix`
@@ -73,58 +148,80 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
## Rooms (groups)
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
- Allowlist rooms with `channels.matrix.rooms`:
- Allowlist rooms with `channels.matrix.groups` (room IDs, aliases, or names):
```json5
{
channels: {
matrix: {
rooms: {
"!roomId:example.org": { requireMention: true }
}
groupPolicy: "allowlist",
groups: {
"!roomId:example.org": { allow: true },
"#alias:example.org": { allow: true }
},
groupAllowFrom: ["@owner:example.org"]
}
}
}
```
- `requireMention: false` enables auto-reply in that room.
- `groups."*"` can set defaults for mention gating across rooms.
- `groupAllowFrom` restricts which senders can trigger the bot in rooms (optional).
- Per-room `users` allowlists can further restrict senders inside a specific room.
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`.
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
- Legacy key: `channels.matrix.rooms` (same shape as `groups`).
## Threads
- Reply threading is supported.
- `channels.matrix.replyToMode` controls replies when tagged:
- `channels.matrix.threadReplies` controls whether replies stay in threads:
- `off`, `inbound` (default), `always`
- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread:
- `off` (default), `first`, `all`
## Capabilities
| Feature | Status |
|---------|--------|
| Direct messages | ✅ Supported |
| Rooms | ✅ Supported |
| Threads | ✅ Supported |
| Media | ✅ Supported |
| Reactions | ✅ Supported |
| Polls | ✅ Supported |
| E2EE | ✅ Supported (crypto module required) |
| Reactions | ✅ Supported (send/read via tools) |
| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) |
| Location | ✅ Supported (geo URI; altitude ignored) |
| Native commands | ✅ Supported |
## Configuration reference (Matrix)
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.matrix.enabled`: enable/disable channel startup.
- `channels.matrix.homeserver`: homeserver URL.
- `channels.matrix.userId`: Matrix user ID.
- `channels.matrix.userId`: Matrix user ID (optional with access token).
- `channels.matrix.accessToken`: access token.
- `channels.matrix.password`: password for login (token stored).
- `channels.matrix.deviceName`: device display name.
- `channels.matrix.encryption`: enable E2EE (default: false).
- `channels.matrix.initialSyncLimit`: initial sync limit.
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
- `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).
- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages.
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
- `channels.matrix.rooms`: per-room settings and allowlist.
- `channels.matrix.groups`: group allowlist + per-room settings map.
- `channels.matrix.rooms`: legacy group allowlist/config.
- `channels.matrix.replyToMode`: reply-to mode for threads/tags.
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).

View File

@@ -124,6 +124,8 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
Only gateways with Bonjour discovery enabled (default) advertise the beacon.
Wide-Area discovery records include (TXT):
- `role` (gateway role hint)
- `transport` (transport hint, e.g. `gateway`)
- `gatewayPort` (WebSocket port, usually `18789`)
- `sshPort` (SSH port; defaults to `22` if not present)
- `tailnetDns` (MagicDNS hostname, when available)

View File

@@ -20,4 +20,5 @@ Notes:
- `--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 service install/runtime status when available.
- Overview includes update channel + git SHA (for source checkouts).
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).

View File

@@ -15,7 +15,9 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
```bash
clawdbot update
clawdbot update status
clawdbot update --channel beta
clawdbot update --channel dev
clawdbot update --tag beta
clawdbot update --restart
clawdbot update --json
@@ -25,22 +27,43 @@ clawdbot --update
## Options
- `--restart`: restart the Gateway daemon after a successful update.
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s).
Note: downgrades require confirmation because older versions can break configuration.
## `update status`
Show the active update channel + git tag/branch/SHA (for source checkouts), plus update availability.
```bash
clawdbot update status
clawdbot update status --json
clawdbot update status --timeout 10
```
Options:
- `--json`: print machine-readable status JSON.
- `--timeout <seconds>`: timeout for checks (default is 3s).
## What it does (git checkout)
Channels:
- `stable`: checkout the latest non-beta tag, then build + doctor.
- `beta`: checkout the latest `-beta` tag, then build + doctor.
- `dev`: checkout `main`, then fetch + rebase.
High-level:
1. Requires a clean worktree (no uncommitted changes).
2. Fetches and rebases against `@{upstream}`.
3. Installs deps (pnpm preferred; npm fallback).
4. Builds + builds the Control UI.
5. Runs `clawdbot doctor` as the final “safe update” check.
2. Switches to the selected channel (tag or branch).
3. Fetches and rebases against `@{upstream}` (dev only).
4. Installs deps (pnpm preferred; npm fallback).
5. Builds + builds the Control UI.
6. Runs `clawdbot doctor` as the final “safe update” check.
## `--update` shorthand
@@ -49,5 +72,6 @@ High-level:
## See also
- `clawdbot doctor` (offers to run update first on git checkouts)
- [Development channels](/install/development-channels)
- [Updating](/install/updating)
- [CLI reference](/cli)

View File

@@ -149,6 +149,14 @@ Control how group/room messages are handled per channel:
slack: {
groupPolicy: "allowlist",
channels: { "#general": { allow: true } }
},
matrix: {
groupPolicy: "allowlist",
groupAllowFrom: ["@owner:example.org"],
groups: {
"!roomId:example.org": { allow: true },
"#alias:example.org": { allow: true }
}
}
}
}
@@ -165,6 +173,7 @@ Notes:
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`).
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
- Slack: allowlist uses `channels.slack.channels`.
- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`).
- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked.

View File

@@ -793,6 +793,7 @@
"install/index",
"install/installer",
"install/updating",
"install/development-channels",
"install/uninstall",
"install/ansible",
"install/nix",

View File

@@ -2697,6 +2697,7 @@ Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `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).
- `gateway.remote.tlsFingerprint` pins the gateway TLS cert fingerprint (sha256).
macOS app behavior:
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
@@ -2710,7 +2711,8 @@ macOS app behavior:
remote: {
url: "ws://gateway.tailnet:18789",
token: "your-token",
password: "your-password"
password: "your-password",
tlsFingerprint: "sha256:ab12cd34..."
}
}
}

View File

@@ -20,6 +20,16 @@ handshake time.
## Handshake (connect)
Gateway → Client (pre-connect challenge):
```json
{
"type": "event",
"event": "connect.challenge",
"payload": { "nonce": "…", "ts": 1737264000000 }
}
```
Client → Gateway:
```json
@@ -43,7 +53,14 @@ Client → Gateway:
"permissions": {},
"auth": { "token": "…" },
"locale": "en-US",
"userAgent": "clawdbot-cli/1.2.3"
"userAgent": "clawdbot-cli/1.2.3",
"device": {
"id": "device_fingerprint",
"publicKey": "…",
"signature": "…",
"signedAt": 1737264000000,
"nonce": "…"
}
}
}
```
@@ -99,7 +116,8 @@ When a device token is issued, `hello-ok` also includes:
"id": "device_fingerprint",
"publicKey": "…",
"signature": "…",
"signedAt": 1737264000000
"signedAt": 1737264000000,
"nonce": "…"
}
}
}
@@ -135,11 +153,22 @@ Nodes declare capability claims at connect time:
The Gateway treats these as **claims** and enforces server-side allowlists.
## Presence
- `system-presence` returns entries keyed by device identity.
- Presence entries include `deviceId`, `roles`, and `scopes` so UIs can show a single row per device
even when it connects as both **operator** and **node**.
### Node helper methods
- Nodes may call `skills.bins` to fetch the current list of skill executables
for auto-allow checks.
## Exec approvals
- When an exec request needs approval, the gateway broadcasts `exec.approval.requested`.
- Operator clients resolve by calling `exec.approval.resolve` (requires `operator.approvals` scope).
## Versioning
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`.
@@ -167,12 +196,13 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Pairing approvals are required for new device IDs unless local auto-approval
is enabled.
- All WS clients must include `device` identity during `connect` (operator + node).
- Non-local connections must sign the server-provided `connect.challenge` nonce.
## TLS + pinning
- TLS is supported for WS connections.
- Clients may optionally pin the gateway cert fingerprint (see `gateway.tls`
config and client TLS settings).
config plus `gateway.remote.tlsFingerprint` or CLI `--tls-fingerprint`).
## Scope

View File

@@ -114,6 +114,7 @@ Short version: **keep the Gateway loopback-only** unless youre sure you need
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
- **Non-loopback binds** (`lan`/`tailnet`/`auto`) must use auth tokens/passwords.
- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
Set it to `false` if you want tokens/passwords instead.
- Treat `browser.controlUrl` like an admin API: tailnet-only + token auth.

View File

@@ -267,6 +267,7 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`.
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access.
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Auth modes:
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).

View File

@@ -79,6 +79,9 @@ This intentionally excludes version managers (nvm/fnm/volta/asdf) and package
managers (pnpm/npm) because the daemon does not load your shell init. Runtime
variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the
gateway).
Exec runs on `host=gateway` merge your login-shell `PATH` into the exec environment,
so missing tools usually mean your shell init isnt exporting them (or set
`tools.exec.pathPrepend`). See [/tools/exec](/tools/exec).
WhatsApp + Telegram channels require **Node**; Bun is unsupported. If your
service was installed with Bun or a version-managed Node path, run `clawdbot doctor`

View File

@@ -0,0 +1,56 @@
---
summary: "Stable, beta, and dev channels: semantics, switching, and tagging"
read_when:
- You want to switch between stable/beta/dev
- You are tagging or publishing prereleases
---
# Development channels
Clawdbot ships three update channels:
- **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).
## Switching channels
Git checkout:
```bash
clawdbot update --channel stable
clawdbot update --channel beta
clawdbot update --channel dev
```
- `stable`/`beta` check out the latest matching tag.
- `dev` switches to `main` and rebases on the upstream.
npm/pnpm global install:
```bash
clawdbot update --channel stable
clawdbot update --channel beta
clawdbot update --channel dev
```
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
## Tagging best practices
- 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.
- Publish dist-tags alongside git tags:
- `latest` → stable
- `beta` → prerelease
- `dev` → main snapshot (optional)
## macOS app availability
Beta and dev builds may **not** include a macOS app release. Thats OK:
- The git tag and npm dist-tag can still be published.
- Call out “no macOS build for this beta” in release notes or changelog.

View File

@@ -50,20 +50,18 @@ pnpm add -g clawdbot@latest
```
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
To stay on the beta channel for CLI updates:
To switch update channels (git + npm installs):
```bash
clawdbot update --channel beta
```
Switch back to stable later:
```bash
clawdbot update --channel dev
clawdbot update --channel stable
```
Use `--tag <dist-tag|version>` for a one-off install tag/version.
See [Development channels](/install/development-channels) for channel semantics and release notes.
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
Then:
@@ -88,7 +86,8 @@ clawdbot update --restart
It runs a safe-ish update flow:
- Requires a clean worktree.
- Fetches + rebases against the configured upstream.
- Switches to the selected channel (tag or branch).
- Fetches + rebases against the configured upstream (dev channel).
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it cant detect the install, use “Update (global install)” instead.

View File

@@ -375,6 +375,8 @@ Notes:
- Put config under `channels.<id>` (not `plugins.entries`).
- `meta.label` is used for labels in CLI/UI lists.
- `meta.aliases` adds alternate ids for normalization and CLI inputs.
- `meta.preferOver` lists channel ids to skip auto-enable when both are configured.
- `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons.
### Write a new messaging channel (stepbystep)
@@ -388,6 +390,8 @@ Model provider docs live under `/providers/*`.
2) Define the channel metadata
- `meta.label`, `meta.selectionLabel`, `meta.docsPath`, `meta.blurb` control CLI/UI lists.
- `meta.docsPath` should point at a docs page like `/channels/<id>`.
- `meta.preferOver` lets a plugin replace another channel (auto-enable prefers it).
- `meta.detailLabel` and `meta.systemImage` are used by UIs for detail text/icons.
3) Implement the required adapters
- `config.listAccountIds` + `config.resolveAccount`

View File

@@ -61,3 +61,6 @@ Optional keys:
- The manifest is **required for all plugins**, including local filesystem loads.
- Runtime still loads the plugin module separately; the manifest is only for
discovery + validation.
- If your plugin depends on native modules, document the build steps and any
package-manager allowlist requirements (for example, pnpm `allow-build-scripts`
+ `pnpm rebuild <package>`).

View File

@@ -120,7 +120,11 @@ CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](
## Approval flow
When a prompt is required, the companion app displays a confirmation dialog with:
When a prompt is required, the gateway broadcasts `exec.approval.requested` to operator clients.
The Control UI and macOS app resolve it via `exec.approval.resolve`, then the gateway forwards the
approved request to the node host.
The confirmation dialog includes:
- command + args
- cwd
- agent id

View File

@@ -57,7 +57,8 @@ Example:
### PATH handling
- `host=gateway`: uses the Gateway process `PATH`. Daemons install a minimal `PATH`:
- `host=gateway`: merges your login-shell `PATH` into the exec environment (unless the exec call
already sets `env.PATH`). The daemon itself still runs with a minimal `PATH`:
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.

View File

@@ -58,6 +58,7 @@ They run immediately, are stripped before the model sees the message, and the re
Text + native (when enabled):
- `/help`
- `/commands`
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`)
@@ -102,6 +103,7 @@ Notes:
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
- `/skill <name> [input]` runs a skill by name (useful when native command limits prevent per-skill commands).
- By default, skill commands are forwarded to the model as a normal request.
- Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.

View File

@@ -9,9 +9,17 @@
"id": "bluebubbles",
"label": "BlueBubbles",
"selectionLabel": "BlueBubbles (macOS app)",
"detailLabel": "BlueBubbles",
"docsPath": "/channels/bluebubbles",
"docsLabel": "bluebubbles",
"blurb": "iMessage via the BlueBubbles mac app + REST API.",
"aliases": [
"bb"
],
"preferOver": [
"imessage"
],
"systemImage": "bubble.left.and.text.bubble.right",
"order": 75
},
"install": {

View File

@@ -0,0 +1,511 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { bluebubblesMessageActions } from "./actions.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./reactions.js", () => ({
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./send.js", () => ({
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
}));
vi.mock("./chat.js", () => ({
editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined),
addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./attachments.js", () => ({
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
}));
describe("bluebubblesMessageActions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("listActions", () => {
it("returns empty array when account is not enabled", () => {
const cfg: ClawdbotConfig = {
channels: { bluebubbles: { enabled: false } },
};
const actions = bluebubblesMessageActions.listActions({ cfg });
expect(actions).toEqual([]);
});
it("returns empty array when account is not configured", () => {
const cfg: ClawdbotConfig = {
channels: { bluebubbles: { enabled: true } },
};
const actions = bluebubblesMessageActions.listActions({ cfg });
expect(actions).toEqual([]);
});
it("returns react action when enabled and configured", () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const actions = bluebubblesMessageActions.listActions({ cfg });
expect(actions).toContain("react");
});
it("excludes react action when reactions are gated off", () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: "test-password",
actions: { reactions: false },
},
},
};
const actions = bluebubblesMessageActions.listActions({ cfg });
expect(actions).not.toContain("react");
// Other actions should still be present
expect(actions).toContain("edit");
expect(actions).toContain("unsend");
});
});
describe("supportsAction", () => {
it("returns true for react action", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true);
});
it("returns true for all supported actions", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true);
});
it("returns false for unsupported actions", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false);
expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false);
});
});
describe("extractToolSend", () => {
it("extracts send params from sendMessage action", () => {
const result = bluebubblesMessageActions.extractToolSend({
args: {
action: "sendMessage",
to: "+15551234567",
accountId: "test-account",
},
});
expect(result).toEqual({
to: "+15551234567",
accountId: "test-account",
});
});
it("returns null for non-sendMessage action", () => {
const result = bluebubblesMessageActions.extractToolSend({
args: { action: "react", to: "+15551234567" },
});
expect(result).toBeNull();
});
it("returns null when to is missing", () => {
const result = bluebubblesMessageActions.extractToolSend({
args: { action: "sendMessage" },
});
expect(result).toBeNull();
});
});
describe("handleAction", () => {
it("throws for unsupported actions", async () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "unknownAction",
params: {},
cfg,
accountId: null,
}),
).rejects.toThrow("is not supported");
});
it("throws when emoji is missing for react action", async () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "react",
params: { messageId: "msg-123" },
cfg,
accountId: null,
}),
).rejects.toThrow(/emoji/i);
});
it("throws when messageId is missing", async () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "react",
params: { emoji: "❤️" },
cfg,
accountId: null,
}),
).rejects.toThrow("messageId");
});
it("throws when chatGuid cannot be resolved", async () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null);
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "react",
params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" },
cfg,
accountId: null,
}),
).rejects.toThrow("chatGuid not found");
});
it("sends reaction successfully with chatGuid", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15551234567",
messageGuid: "msg-123",
emoji: "❤️",
}),
);
// jsonResult returns { content: [...], details: payload }
expect(result).toMatchObject({
details: { ok: true, added: "❤️" },
});
});
it("sends reaction removal successfully", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
remove: true,
},
cfg,
accountId: null,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
remove: true,
}),
);
// jsonResult returns { content: [...], details: payload }
expect(result).toMatchObject({
details: { ok: true, removed: true },
});
});
it("resolves chatGuid from to parameter", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "👍",
messageId: "msg-456",
to: "+15559876543",
},
cfg,
accountId: null,
});
expect(resolveChatGuidForTarget).toHaveBeenCalled();
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15559876543",
}),
);
});
it("passes partIndex when provided", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "😂",
messageId: "msg-789",
chatGuid: "iMessage;-;chat-guid",
partIndex: 2,
},
cfg,
accountId: null,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
partIndex: 2,
}),
);
});
it("accepts message param for edit action", async () => {
const { editBlueBubblesMessage } = await import("./chat.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "edit",
params: { messageId: "msg-123", message: "updated" },
cfg,
accountId: null,
});
expect(editBlueBubblesMessage).toHaveBeenCalledWith(
"msg-123",
"updated",
expect.objectContaining({ cfg, accountId: undefined }),
);
});
it("accepts message/target aliases for sendWithEffect", async () => {
const { sendMessageBlueBubbles } = await import("./send.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await bluebubblesMessageActions.handleAction({
action: "sendWithEffect",
params: {
message: "peekaboo",
target: "+15551234567",
effect: "invisible ink",
},
cfg,
accountId: null,
});
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
"+15551234567",
"peekaboo",
expect.objectContaining({ effectId: "invisible ink" }),
);
expect(result).toMatchObject({
details: { ok: true, messageId: "msg-123", effect: "invisible ink" },
});
});
it("throws when buffer is missing for setGroupIcon", async () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "setGroupIcon",
params: { chatGuid: "iMessage;-;chat-guid" },
cfg,
accountId: null,
}),
).rejects.toThrow(/requires an image/i);
});
it("sets group icon successfully with chatGuid and buffer", async () => {
const { setGroupIconBlueBubbles } = await import("./chat.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
// Base64 encode a simple test buffer
const testBuffer = Buffer.from("fake-image-data");
const base64Buffer = testBuffer.toString("base64");
const result = await bluebubblesMessageActions.handleAction({
action: "setGroupIcon",
params: {
chatGuid: "iMessage;-;chat-guid",
buffer: base64Buffer,
filename: "group-icon.png",
contentType: "image/png",
},
cfg,
accountId: null,
});
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
"iMessage;-;chat-guid",
expect.any(Uint8Array),
"group-icon.png",
expect.objectContaining({ contentType: "image/png" }),
);
expect(result).toMatchObject({
details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true },
});
});
it("uses default filename when not provided for setGroupIcon", async () => {
const { setGroupIconBlueBubbles } = await import("./chat.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const base64Buffer = Buffer.from("test").toString("base64");
await bluebubblesMessageActions.handleAction({
action: "setGroupIcon",
params: {
chatGuid: "iMessage;-;chat-guid",
buffer: base64Buffer,
},
cfg,
accountId: null,
});
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
"iMessage;-;chat-guid",
expect.any(Uint8Array),
"icon.png",
expect.anything(),
);
});
});
});

View File

@@ -1,6 +1,9 @@
import {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
createActionGate,
jsonResult,
readBooleanParam,
readNumberParam,
readReactionParams,
readStringParam,
@@ -11,8 +14,19 @@ import {
} from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { isMacOS26OrHigher } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget } from "./send.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import {
editBlueBubblesMessage,
unsendBlueBubblesMessage,
renameBlueBubblesChat,
setGroupIconBlueBubbles,
addBlueBubblesParticipant,
removeBlueBubblesParticipant,
leaveBlueBubblesChat,
} from "./chat.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
@@ -32,16 +46,29 @@ function mapTarget(raw: string): BlueBubblesSendTarget {
};
}
function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig });
if (!account.enabled || !account.configured) return [];
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>();
if (gate("reactions")) actions.add("react");
const macOS26 = isMacOS26OrHigher(account.accountId);
for (const action of BLUEBUBBLES_ACTION_NAMES) {
const spec = BLUEBUBBLES_ACTIONS[action];
if (!spec?.gate) continue;
if (spec.unsupportedOnMacOS26 && macOS26) continue;
if (gate(spec.gate)) actions.add(action);
}
return Array.from(actions);
},
supportsAction: ({ action }) => action === "react",
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
extractToolSend: ({ args }): ChannelToolSend | null => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null;
@@ -51,71 +78,301 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId }) => {
if (action !== "react") {
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
}
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
});
if (isEmpty && !remove) {
throw new Error("Emoji is required to send a BlueBubbles reaction.");
}
const messageId = readStringParam(params, "messageId", { required: true });
const chatGuid = readStringParam(params, "chatGuid");
const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true });
const to = readStringParam(params, "to");
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const account = resolveBlueBubblesAccount({
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
});
const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim();
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
// Helper to resolve chatGuid from various params
const resolveChatGuid = async (): Promise<string> => {
const chatGuid = readStringParam(params, "chatGuid");
if (chatGuid?.trim()) return chatGuid.trim();
const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true });
const to = readStringParam(params, "to");
const target = chatIdentifier?.trim()
? ({
kind: "chat_identifier",
chatIdentifier: chatIdentifier.trim(),
} as BlueBubblesSendTarget)
: typeof chatId === "number"
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
: to
? mapTarget(to)
: null;
let resolvedChatGuid = chatGuid?.trim() || "";
if (!resolvedChatGuid) {
const target =
chatIdentifier?.trim()
? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget)
: typeof chatId === "number"
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
: to
? mapTarget(to)
: null;
if (!target) {
throw new Error("BlueBubbles reaction requires chatGuid, chatIdentifier, chatId, or to.");
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
}
if (!baseUrl || !password) {
throw new Error("BlueBubbles reaction requires serverUrl and password.");
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
}
resolvedChatGuid =
(await resolveChatGuidForTarget({
baseUrl,
password,
target,
})) ?? "";
}
if (!resolvedChatGuid) {
throw new Error("BlueBubbles reaction failed: chatGuid not found for target.");
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
if (!resolved) {
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
}
return resolved;
};
// Handle react action
if (action === "react") {
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
});
if (isEmpty && !remove) {
throw new Error(
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_guid>.",
);
}
const messageId = readStringParam(params, "messageId");
if (!messageId) {
throw new Error(
"BlueBubbles react requires messageId parameter (the message GUID to react to). " +
"Use action=react with messageId=<message_guid>, emoji=<emoji>, and to/chatGuid to identify the chat.",
);
}
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const resolvedChatGuid = await resolveChatGuid();
await sendBlueBubblesReaction({
chatGuid: resolvedChatGuid,
messageGuid: messageId,
emoji,
remove: remove || undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
opts,
});
return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: emoji }) });
}
await sendBlueBubblesReaction({
chatGuid: resolvedChatGuid,
messageGuid: messageId,
emoji,
remove: remove || undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
opts: {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
},
});
// Handle edit action
if (action === "edit") {
// Edit is not supported on macOS 26+
if (isMacOS26OrHigher(accountId ?? undefined)) {
throw new Error(
"BlueBubbles edit is not supported on macOS 26 or higher. " +
"Apple removed the ability to edit iMessages in this version.",
);
}
const messageId = readStringParam(params, "messageId");
const newText =
readStringParam(params, "text") ??
readStringParam(params, "newText") ??
readStringParam(params, "message");
if (!messageId || !newText) {
const missing: string[] = [];
if (!messageId) missing.push("messageId (the message GUID to edit)");
if (!newText) missing.push("text (the new message content)");
throw new Error(
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
`Use action=edit with messageId=<message_guid>, text=<new_content>.`,
);
}
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
if (!remove) {
return jsonResult({ ok: true, added: emoji });
await editBlueBubblesMessage(messageId, newText, {
...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
});
return jsonResult({ ok: true, edited: messageId });
}
return jsonResult({ ok: true, removed: true });
// Handle unsend action
if (action === "unsend") {
const messageId = readStringParam(params, "messageId");
if (!messageId) {
throw new Error(
"BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " +
"Use action=unsend with messageId=<message_guid>.",
);
}
const partIndex = readNumberParam(params, "partIndex", { integer: true });
await unsendBlueBubblesMessage(messageId, {
...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true, unsent: messageId });
}
// Handle reply action
if (action === "reply") {
const messageId = readStringParam(params, "messageId");
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
if (!messageId || !text || !to) {
const missing: string[] = [];
if (!messageId) missing.push("messageId (the message GUID to reply to)");
if (!text) missing.push("text or message (the reply message content)");
if (!to) missing.push("to or target (the chat target)");
throw new Error(
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
`Use action=reply with messageId=<message_guid>, message=<your reply>, target=<chat_target>.`,
);
}
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const result = await sendMessageBlueBubbles(to, text, {
...opts,
replyToMessageGuid: messageId,
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: messageId });
}
// Handle sendWithEffect action
if (action === "sendWithEffect") {
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
if (!text || !to || !effectId) {
const missing: string[] = [];
if (!text) missing.push("text or message (the message content)");
if (!to) missing.push("to or target (the chat target)");
if (!effectId)
missing.push(
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
);
throw new Error(
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,
);
}
const result = await sendMessageBlueBubbles(to, text, {
...opts,
effectId,
});
return jsonResult({ ok: true, messageId: result.messageId, effect: effectId });
}
// Handle renameGroup action
if (action === "renameGroup") {
const resolvedChatGuid = await resolveChatGuid();
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
if (!displayName) {
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
}
await renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
}
// Handle setGroupIcon action
if (action === "setGroupIcon") {
const resolvedChatGuid = await resolveChatGuid();
const base64Buffer = readStringParam(params, "buffer");
const filename =
readStringParam(params, "filename") ??
readStringParam(params, "name") ??
"icon.png";
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
if (!base64Buffer) {
throw new Error(
"BlueBubbles setGroupIcon requires an image. " +
"Use action=setGroupIcon with media=<image_url> or path=<local_file_path> to set the group icon.",
);
}
// Decode base64 to buffer
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
...opts,
contentType: contentType ?? undefined,
});
return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true });
}
// Handle addParticipant action
if (action === "addParticipant") {
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) {
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
}
await addBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
}
// Handle removeParticipant action
if (action === "removeParticipant") {
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) {
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
}
await removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
}
// Handle leaveGroup action
if (action === "leaveGroup") {
const resolvedChatGuid = await resolveChatGuid();
await leaveBlueBubblesChat(resolvedChatGuid, opts);
return jsonResult({ ok: true, left: resolvedChatGuid });
}
// Handle sendAttachment action
if (action === "sendAttachment") {
const to = readStringParam(params, "to", { required: true });
const filename = readStringParam(params, "filename", { required: true });
const caption = readStringParam(params, "caption");
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
// Buffer can come from params.buffer (base64) or params.path (file path)
const base64Buffer = readStringParam(params, "buffer");
const filePath = readStringParam(params, "path") ?? readStringParam(params, "filePath");
let buffer: Uint8Array;
if (base64Buffer) {
// Decode base64 to buffer
buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
} else if (filePath) {
// Read file from path (will be handled by caller providing buffer)
throw new Error(
"BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.",
);
} else {
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
}
const result = await sendBlueBubblesAttachment({
to,
buffer,
filename,
contentType: contentType ?? undefined,
caption: caption ?? undefined,
opts,
});
return jsonResult({ ok: true, messageId: result.messageId });
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};

View File

@@ -0,0 +1,240 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import type { BlueBubblesAttachment } from "./types.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
const mockFetch = vi.fn();
describe("downloadBlueBubblesAttachment", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test-password",
}),
).rejects.toThrow("guid is required");
});
it("throws when guid is empty string", async () => {
const attachment: BlueBubblesAttachment = { guid: " " };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test-password",
}),
).rejects.toThrow("guid is required");
});
it("throws when serverUrl is missing", async () => {
const attachment: BlueBubblesAttachment = { guid: "att-123" };
await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
const attachment: BlueBubblesAttachment = { guid: "att-123" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("downloads attachment successfully", async () => {
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/png" }),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-123" };
const result = await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(result.buffer).toEqual(mockBuffer);
expect(result.contentType).toBe("image/png");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/attachment/att-123/download"),
expect.objectContaining({ method: "GET" }),
);
});
it("includes password in URL query", async () => {
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/jpeg" }),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-456" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "my-secret-password",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret-password");
});
it("encodes guid in URL", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
text: () => Promise.resolve("Attachment not found"),
});
const attachment: BlueBubblesAttachment = { guid: "att-missing" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("download failed (404): Attachment not found");
});
it("throws when attachment exceeds max bytes", async () => {
const largeBuffer = new Uint8Array(10 * 1024 * 1024);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
maxBytes: 5 * 1024 * 1024,
}),
).rejects.toThrow("too large");
});
it("uses default max bytes when not specified", async () => {
const largeBuffer = new Uint8Array(9 * 1024 * 1024);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("too large");
});
it("uses attachment mimeType as fallback when response has no content-type", async () => {
const mockBuffer = new Uint8Array([1, 2, 3]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = {
guid: "att-789",
mimeType: "video/mp4",
};
const result = await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.contentType).toBe("video/mp4");
});
it("prefers response content-type over attachment mimeType", async () => {
const mockBuffer = new Uint8Array([1, 2, 3]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/webp" }),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = {
guid: "att-xyz",
mimeType: "image/png",
};
const result = await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.contentType).toBe("image/webp");
});
it("resolves credentials from config when opts not provided", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-config" };
const result = await downloadBlueBubblesAttachment(attachment, {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:5678",
password: "config-password",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:5678");
expect(calledUrl).toContain("password=config-password");
expect(result.buffer).toEqual(new Uint8Array([1]));
});
});

View File

@@ -1,9 +1,13 @@
import crypto from "node:crypto";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveChatGuidForTarget } from "./send.js";
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
type BlueBubblesAttachment,
type BlueBubblesSendTarget,
} from "./types.js";
export type BlueBubblesAttachmentOpts = {
@@ -55,3 +59,168 @@ export async function downloadBlueBubblesAttachment(
}
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
}
export type SendBlueBubblesAttachmentResult = {
messageId: string;
};
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
return {
kind: "handle",
address: normalizeBlueBubblesHandle(parsed.to),
service: parsed.service,
};
}
if (parsed.kind === "chat_id") {
return { kind: "chat_id", chatId: parsed.chatId };
}
if (parsed.kind === "chat_guid") {
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
}
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
}
function extractMessageId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "unknown";
const record = payload as Record<string, unknown>;
const data = record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
const candidates = [
record.messageId,
record.guid,
record.id,
data?.messageId,
data?.guid,
data?.id,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
}
return "unknown";
}
/**
* Send an attachment via BlueBubbles API.
* Supports sending media files (images, videos, audio, documents) to a chat.
*/
export async function sendBlueBubblesAttachment(params: {
to: string;
buffer: Uint8Array;
filename: string;
contentType?: string;
caption?: string;
replyToMessageGuid?: string;
replyToPartIndex?: number;
opts?: BlueBubblesAttachmentOpts;
}): Promise<SendBlueBubblesAttachmentResult> {
const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
params;
const { baseUrl, password } = resolveAccount(opts);
const target = resolveSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
});
if (!chatGuid) {
throw new Error(
"BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/message/attachment",
password,
});
// Build FormData with the attachment
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();
// Helper to add a form field
const addField = (name: string, value: string) => {
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
parts.push(encoder.encode(`${value}\r\n`));
};
// Helper to add a file field
const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => {
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(
`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`,
),
);
parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`));
parts.push(fileBuffer);
parts.push(encoder.encode("\r\n"));
};
// Add required fields
addFile("attachment", buffer, filename, contentType);
addField("chatGuid", chatGuid);
addField("name", filename);
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
addField("method", "private-api");
const trimmedReplyTo = replyToMessageGuid?.trim();
if (trimmedReplyTo) {
addField("selectedMessageGuid", trimmedReplyTo);
addField(
"partIndex",
typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0",
);
}
// Add optional caption
if (caption) {
addField("message", caption);
addField("text", caption);
addField("caption", caption);
}
// Close the multipart body
parts.push(encoder.encode(`--${boundary}--\r\n`));
// Combine all parts into a single buffer
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
const body = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
body.set(part, offset);
offset += part.length;
}
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body,
},
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`);
}
const responseBody = await res.text();
if (!responseBody) return { messageId: "ok" };
try {
const parsed = JSON.parse(responseBody) as unknown;
return { messageId: extractMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}

View File

@@ -2,12 +2,14 @@ import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "claw
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveBlueBubblesGroupRequireMention,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
@@ -18,20 +20,30 @@ import {
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { probeBlueBubbles } from "./probe.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
import {
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
} from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
import { sendBlueBubblesMedia } from "./media-send.js";
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
detailLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
systemImage: "bubble.left.and.text.bubble.right",
aliases: ["bb"],
order: 75,
preferOver: ["imessage"],
};
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
@@ -39,11 +51,27 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
meta,
capabilities: {
chatTypes: ["direct", "group"],
media: false,
media: true,
reactions: true,
edit: true,
unsend: true,
reply: true,
effects: true,
groupManagement: true,
},
groups: {
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
reload: { configPrefixes: ["channels.bluebubbles"] },
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
onboarding: blueBubblesOnboardingAdapter,
config: {
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) =>
@@ -111,6 +139,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
];
},
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
targetResolver: {
looksLikeId: looksLikeBlueBubblesTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
@@ -152,6 +187,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
},
},
} as ClawdbotConfig;
@@ -170,6 +206,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
},
},
},
@@ -199,15 +236,39 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId }) => {
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const replyToMessageGuid = typeof replyToId === "string" ? replyToId.trim() : "";
const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
return { channel: "bluebubbles", ...result };
},
sendMedia: async () => {
throw new Error("BlueBubbles media delivery is not supported yet.");
sendMedia: async (ctx) => {
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
const resolvedCaption = caption ?? text;
const result = await sendBlueBubblesMedia({
cfg: cfg as ClawdbotConfig,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: resolvedCaption ?? undefined,
replyToId: replyToId ?? null,
accountId: accountId ?? undefined,
});
return { channel: "bluebubbles", ...result };
},
},
status: {
@@ -218,19 +279,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return [];
return [
{
channel: "bluebubbles",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
collectStatusIssues: collectBlueBubblesStatusIssues,
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
baseUrl: snapshot.baseUrl ?? null,
@@ -247,20 +296,25 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
password: account.config.password ?? null,
timeoutMs,
}),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const running = runtime?.running ?? false;
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running,
connected: probeOk ?? running,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {

View File

@@ -0,0 +1,462 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
const mockFetch = vi.fn();
describe("chat", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("markBlueBubblesChatRead", () => {
it("does nothing when chatGuid is empty", async () => {
await markBlueBubblesChatRead("", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when chatGuid is whitespace", async () => {
await markBlueBubblesChatRead(" ", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("throws when serverUrl is missing", async () => {
await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
await expect(
markBlueBubblesChatRead("chat-guid", {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("marks chat as read successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead("iMessage;-;+15551234567", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"),
expect.objectContaining({ method: "POST" }),
);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead("chat-123", {
serverUrl: "http://localhost:1234",
password: "my-secret",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
text: () => Promise.resolve("Chat not found"),
});
await expect(
markBlueBubblesChatRead("missing-chat", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("read failed (404): Chat not found");
});
it("trims chatGuid before using", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead(" chat-with-spaces ", {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read");
expect(calledUrl).not.toContain("%20chat");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead("chat-123", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:9999",
password: "config-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:9999");
expect(calledUrl).toContain("password=config-pass");
});
});
describe("sendBlueBubblesTyping", () => {
it("does nothing when chatGuid is empty", async () => {
await sendBlueBubblesTyping("", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when chatGuid is whitespace", async () => {
await sendBlueBubblesTyping(" ", false, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("throws when serverUrl is missing", async () => {
await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
await expect(
sendBlueBubblesTyping("chat-guid", true, {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("sends typing start with POST method", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
expect.objectContaining({ method: "POST" }),
);
});
it("sends typing stop with DELETE method", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("iMessage;-;+15551234567", false, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
expect.objectContaining({ method: "DELETE" }),
);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("chat-123", true, {
serverUrl: "http://localhost:1234",
password: "typing-secret",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=typing-secret");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal error"),
});
await expect(
sendBlueBubblesTyping("chat-123", true, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("typing failed (500): Internal error");
});
it("trims chatGuid before using", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping(" trimmed-chat ", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing");
});
it("encodes special characters in chatGuid", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("chat-123", true, {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://typing-server:8888",
password: "typing-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("typing-server:8888");
expect(calledUrl).toContain("password=typing-pass");
});
it("can start and stop typing in sequence", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("chat-123", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
await sendBlueBubblesTyping("chat-123", false, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0][1].method).toBe("POST");
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
});
});
describe("setGroupIconBlueBubbles", () => {
it("throws when chatGuid is empty", async () => {
await expect(
setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("chatGuid");
});
it("throws when buffer is empty", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("image buffer");
});
it("throws when serverUrl is missing", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}),
).rejects.toThrow("serverUrl is required");
});
it("throws when password is missing", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("sets group icon successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", {
serverUrl: "http://localhost:1234",
password: "test-password",
contentType: "image/png",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"),
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": expect.stringContaining("multipart/form-data"),
}),
}),
);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "my-secret",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal error"),
});
await expect(
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("setGroupIcon failed (500): Internal error");
});
it("trims chatGuid before using", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon");
expect(calledUrl).not.toContain("%20chat");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:9999",
password: "config-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:9999");
expect(calledUrl).toContain("password=config-pass");
});
it("includes filename in multipart body", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", {
serverUrl: "http://localhost:1234",
password: "test",
contentType: "image/jpeg",
});
const body = mockFetch.mock.calls[0][1].body as Uint8Array;
const bodyString = new TextDecoder().decode(body);
expect(bodyString).toContain('filename="custom-icon.jpg"');
expect(bodyString).toContain("image/jpeg");
});
});
});

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import { resolveBlueBubblesAccount } from "./accounts.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
@@ -64,3 +65,290 @@ export async function sendBlueBubblesTyping(
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Edit a message via BlueBubbles API.
* Requires macOS 13 (Ventura) or higher with Private API enabled.
*/
export async function editBlueBubblesMessage(
messageGuid: string,
newText: string,
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
): Promise<void> {
const trimmedGuid = messageGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles edit requires messageGuid");
const trimmedText = newText.trim();
if (!trimmedText) throw new Error("BlueBubbles edit requires newText");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
password,
});
const payload = {
editedMessage: trimmedText,
backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Unsend (retract) a message via BlueBubbles API.
* Requires macOS 13 (Ventura) or higher with Private API enabled.
*/
export async function unsendBlueBubblesMessage(
messageGuid: string,
opts: BlueBubblesChatOpts & { partIndex?: number } = {},
): Promise<void> {
const trimmedGuid = messageGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles unsend requires messageGuid");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
password,
});
const payload = {
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Rename a group chat via BlueBubbles API.
*/
export async function renameBlueBubblesChat(
chatGuid: string,
displayName: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles rename requires chatGuid");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName }),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Add a participant to a group chat via BlueBubbles API.
*/
export async function addBlueBubblesParticipant(
chatGuid: string,
address: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles addParticipant requires chatGuid");
const trimmedAddress = address.trim();
if (!trimmedAddress) throw new Error("BlueBubbles addParticipant requires address");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: trimmedAddress }),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Remove a participant from a group chat via BlueBubbles API.
*/
export async function removeBlueBubblesParticipant(
chatGuid: string,
address: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles removeParticipant requires chatGuid");
const trimmedAddress = address.trim();
if (!trimmedAddress) throw new Error("BlueBubbles removeParticipant requires address");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: trimmedAddress }),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Leave a group chat via BlueBubbles API.
*/
export async function leaveBlueBubblesChat(
chatGuid: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles leaveChat requires chatGuid");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{ method: "POST" },
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Set a group chat's icon/photo via BlueBubbles API.
* Requires Private API to be enabled.
*/
export async function setGroupIconBlueBubbles(
chatGuid: string,
buffer: Uint8Array,
filename: string,
opts: BlueBubblesChatOpts & { contentType?: string } = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles setGroupIcon requires chatGuid");
if (!buffer || buffer.length === 0) {
throw new Error("BlueBubbles setGroupIcon requires image buffer");
}
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
password,
});
// Build multipart form-data
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();
// Add file field named "icon" as per API spec
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(
`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`,
),
);
parts.push(
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
);
parts.push(buffer);
parts.push(encoder.encode("\r\n"));
// Close multipart body
parts.push(encoder.encode(`--${boundary}--\r\n`));
// Combine into single buffer
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
const body = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
body.set(part, offset);
offset += part.length;
}
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body,
},
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
}
}

View File

@@ -4,10 +4,24 @@ const allowFromEntry = z.union([z.string(), z.number()]);
const bluebubblesActionSchema = z
.object({
reactions: z.boolean().optional(),
reactions: z.boolean().default(true),
edit: z.boolean().default(true),
unsend: z.boolean().default(true),
reply: z.boolean().default(true),
sendWithEffect: z.boolean().default(true),
renameGroup: z.boolean().default(true),
setGroupIcon: z.boolean().default(true),
addParticipant: z.boolean().default(true),
removeParticipant: z.boolean().default(true),
leaveGroup: z.boolean().default(true),
sendAttachment: z.boolean().default(true),
})
.optional();
const bluebubblesGroupConfigSchema = z.object({
requireMention: z.boolean().optional(),
});
const bluebubblesAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
@@ -22,6 +36,9 @@ const bluebubblesAccountSchema = z.object({
dmHistoryLimit: z.number().int().min(0).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
sendReadReceipts: z.boolean().optional(),
blockStreaming: z.boolean().optional(),
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
});
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({

View File

@@ -0,0 +1,159 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { sendMessageBlueBubbles } from "./send.js";
import { getBlueBubblesRuntime } from "./runtime.js";
const HTTP_URL_RE = /^https?:\/\//i;
const MB = 1024 * 1024;
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
if (typeof maxBytes !== "number" || maxBytes <= 0) return;
if (sizeBytes <= maxBytes) return;
const maxLabel = (maxBytes / MB).toFixed(0);
const sizeLabel = (sizeBytes / MB).toFixed(2);
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
}
function resolveLocalMediaPath(source: string): string {
if (!source.startsWith("file://")) return source;
try {
return fileURLToPath(source);
} catch {
throw new Error(`Invalid file:// URL: ${source}`);
}
}
function resolveFilenameFromSource(source?: string): string | undefined {
if (!source) return undefined;
if (source.startsWith("file://")) {
try {
return path.basename(fileURLToPath(source)) || undefined;
} catch {
return undefined;
}
}
if (HTTP_URL_RE.test(source)) {
try {
return path.basename(new URL(source).pathname) || undefined;
} catch {
return undefined;
}
}
const base = path.basename(source);
return base || undefined;
}
export async function sendBlueBubblesMedia(params: {
cfg: ClawdbotConfig;
to: string;
mediaUrl?: string;
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
replyToId?: string | null;
accountId?: string;
}) {
const {
cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption,
replyToId,
accountId,
} = params;
const core = getBlueBubblesRuntime();
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.bluebubbles?.mediaMaxMb,
accountId,
});
let buffer: Uint8Array;
let resolvedContentType = contentType ?? undefined;
let resolvedFilename = filename ?? undefined;
if (mediaBuffer) {
assertMediaWithinLimit(mediaBuffer.byteLength, maxBytes);
buffer = mediaBuffer;
if (!resolvedContentType) {
const hint = mediaPath ?? mediaUrl;
const detected = await core.media.detectMime({
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
filePath: hint,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
}
} else {
const source = mediaPath ?? mediaUrl;
if (!source) {
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
}
if (HTTP_URL_RE.test(source)) {
const fetched = await core.channel.media.fetchRemoteMedia({
url: source,
maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined,
});
buffer = fetched.buffer;
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
resolvedFilename = resolvedFilename ?? fetched.fileName;
} else {
const localPath = resolveLocalMediaPath(source);
const fs = await import("node:fs/promises");
if (typeof maxBytes === "number" && maxBytes > 0) {
const stats = await fs.stat(localPath);
assertMediaWithinLimit(stats.size, maxBytes);
}
const data = await fs.readFile(localPath);
assertMediaWithinLimit(data.byteLength, maxBytes);
buffer = new Uint8Array(data);
if (!resolvedContentType) {
const detected = await core.media.detectMime({
buffer: data,
filePath: localPath,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(localPath);
}
}
}
const attachmentResult = await sendBlueBubblesAttachment({
to,
buffer,
filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined,
replyToMessageGuid: replyToId?.trim() || undefined,
opts: {
cfg,
accountId,
},
});
const trimmedCaption = caption?.trim();
if (trimmedCaption) {
await sendMessageBlueBubbles(to, trimmedCaption, {
cfg,
accountId,
replyToMessageGuid: replyToId?.trim() || undefined,
});
}
return attachmentResult;
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,13 @@ import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
import { resolveAckReaction } from "../../../src/agents/identity.js";
import { sendBlueBubblesMedia } from "./media-send.js";
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import { fetchBlueBubblesServerInfo } from "./probe.js";
export type BlueBubblesRuntimeEnv = {
log?: (message: string) => void;
@@ -25,6 +29,7 @@ export type BlueBubblesMonitorOptions = {
const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
const DEFAULT_TEXT_LIMIT = 4000;
const invalidAckReactions = new Set<string>();
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
@@ -34,6 +39,35 @@ function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv
}
}
function logGroupAllowlistHint(params: {
runtime: BlueBubblesRuntimeEnv;
reason: string;
entry: string | null;
chatName?: string;
accountId?: string;
}): void {
const log = params.runtime.log ?? console.log;
const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
const accountHint = params.accountId
? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
: "";
if (params.entry) {
log(
`[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
`"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
);
log(
`[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
);
return;
}
log(
`[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
`channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
`channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
);
}
type WebhookTarget = {
account: ResolvedBlueBubblesAccount;
config: ClawdbotConfig;
@@ -183,6 +217,21 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
return "";
}
function formatReplyContext(message: {
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
}): string | null {
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
const sender = message.replyToSender?.trim() || "unknown sender";
const idPart = message.replyToId ? ` id:${message.replyToId}` : "";
const body = message.replyToBody?.trim();
if (!body) {
return `[Replying to ${sender}${idPart}]\n[/Replying]`;
}
return `[Replying to ${sender}${idPart}]\n${body}\n[/Replying]`;
}
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
if (!record) return undefined;
const value = record[key];
@@ -194,6 +243,77 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
return undefined;
}
function extractReplyMetadata(message: Record<string, unknown>): {
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
} {
const replyRaw =
message["replyTo"] ??
message["reply_to"] ??
message["replyToMessage"] ??
message["reply_to_message"] ??
message["repliedMessage"] ??
message["quotedMessage"] ??
message["associatedMessage"] ??
message["reply"];
const replyRecord = asRecord(replyRaw);
const replyHandle = asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
const replySenderRaw =
readString(replyHandle, "address") ??
readString(replyHandle, "handle") ??
readString(replyHandle, "id") ??
readString(replyRecord, "senderId") ??
readString(replyRecord, "sender") ??
readString(replyRecord, "from");
const normalizedSender = replySenderRaw
? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
: undefined;
const replyToBody =
readString(replyRecord, "text") ??
readString(replyRecord, "body") ??
readString(replyRecord, "message") ??
readString(replyRecord, "subject") ??
undefined;
const directReplyId =
readString(message, "replyToMessageGuid") ??
readString(message, "replyToGuid") ??
readString(message, "replyGuid") ??
readString(message, "selectedMessageGuid") ??
readString(message, "selectedMessageId") ??
readString(message, "replyToMessageId") ??
readString(message, "replyId") ??
readString(replyRecord, "guid") ??
readString(replyRecord, "id") ??
readString(replyRecord, "messageId");
const associatedType =
readNumberLike(message, "associatedMessageType") ??
readNumberLike(message, "associated_message_type");
const associatedGuid =
readString(message, "associatedMessageGuid") ??
readString(message, "associated_message_guid") ??
readString(message, "associatedMessageId");
const isReactionAssociation =
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
const messageGuid = readString(message, "guid");
const fallbackReplyId =
!replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
? threadOriginatorGuid
: undefined;
return {
replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
replyToBody: replyToBody?.trim() || undefined,
replyToSender: normalizedSender || undefined,
};
}
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
const chats = message["chats"];
if (!Array.isArray(chats) || chats.length === 0) return null;
@@ -201,6 +321,108 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
return asRecord(first);
}
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
if (typeof entry === "string" || typeof entry === "number") {
const raw = String(entry).trim();
if (!raw) return null;
const normalized = normalizeBlueBubblesHandle(raw) || raw;
return normalized ? { id: normalized } : null;
}
const record = asRecord(entry);
if (!record) return null;
const nestedHandle =
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
const idRaw =
readString(record, "address") ??
readString(record, "handle") ??
readString(record, "id") ??
readString(record, "phoneNumber") ??
readString(record, "phone_number") ??
readString(record, "email") ??
readString(nestedHandle, "address") ??
readString(nestedHandle, "handle") ??
readString(nestedHandle, "id");
const nameRaw =
readString(record, "displayName") ??
readString(record, "name") ??
readString(record, "title") ??
readString(nestedHandle, "displayName") ??
readString(nestedHandle, "name");
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
if (!normalizedId) return null;
const name = nameRaw?.trim() || undefined;
return { id: normalizedId, name };
}
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
if (!Array.isArray(raw) || raw.length === 0) return [];
const seen = new Set<string>();
const output: BlueBubblesParticipant[] = [];
for (const entry of raw) {
const normalized = normalizeParticipantEntry(entry);
if (!normalized?.id) continue;
const key = normalized.id.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
output.push(normalized);
}
return output;
}
function formatGroupMembers(params: {
participants?: BlueBubblesParticipant[];
fallback?: BlueBubblesParticipant;
}): string | undefined {
const seen = new Set<string>();
const ordered: BlueBubblesParticipant[] = [];
for (const entry of params.participants ?? []) {
if (!entry?.id) continue;
const key = entry.id.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
ordered.push(entry);
}
if (ordered.length === 0 && params.fallback?.id) {
ordered.push(params.fallback);
}
if (ordered.length === 0) return undefined;
return ordered
.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id))
.join(", ");
}
function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
const guid = chatGuid?.trim();
if (!guid) return undefined;
const parts = guid.split(";");
if (parts.length >= 3) {
if (parts[1] === "+") return true;
if (parts[1] === "-") return false;
}
if (guid.includes(";+;")) return true;
if (guid.includes(";-;")) return false;
return undefined;
}
function formatGroupAllowlistEntry(params: {
chatGuid?: string;
chatId?: number;
chatIdentifier?: string;
}): string | null {
const guid = params.chatGuid?.trim();
if (guid) return `chat_guid:${guid}`;
const chatId = params.chatId;
if (typeof chatId === "number" && Number.isFinite(chatId)) return `chat_id:${chatId}`;
const identifier = params.chatIdentifier?.trim();
if (identifier) return `chat_identifier:${identifier}`;
return null;
}
type BlueBubblesParticipant = {
id: string;
name?: string;
};
type NormalizedWebhookMessage = {
text: string;
senderId: string;
@@ -215,6 +437,10 @@ type NormalizedWebhookMessage = {
fromMe?: boolean;
attachments?: BlueBubblesAttachment[];
balloonBundleId?: string;
participants?: BlueBubblesParticipant[];
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
};
type NormalizedWebhookReaction = {
@@ -252,6 +478,31 @@ function maskSecret(value: string): string {
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
function resolveBlueBubblesAckReaction(params: {
cfg: ClawdbotConfig;
agentId: string;
core: BlueBubblesCoreRuntime;
runtime: BlueBubblesRuntimeEnv;
}): string | null {
const raw = resolveAckReaction(params.cfg, params.agentId).trim();
if (!raw) return null;
try {
normalizeBlueBubblesReactionInput(raw);
return raw;
} catch {
const key = raw.toLowerCase();
if (!invalidAckReactions.has(key)) {
invalidAckReactions.add(key);
logVerbose(
params.core,
params.runtime,
`ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
);
}
return null;
}
}
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
const dataRaw = payload.data ?? payload.payload ?? payload.event;
const data =
@@ -331,13 +582,18 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
: Array.isArray(chatsParticipants)
? chatsParticipants
: [];
const normalizedParticipants = normalizeParticipantList(participants);
const participantsCount = participants.length;
const isGroup =
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
const explicitIsGroup =
readBoolean(message, "isGroup") ??
readBoolean(message, "is_group") ??
readBoolean(chat, "isGroup") ??
readBoolean(message, "group") ??
(participantsCount > 2 ? true : false);
readBoolean(message, "group");
const isGroup =
typeof groupFromChatGuid === "boolean"
? groupFromChatGuid
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
const messageId =
@@ -360,6 +616,7 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
const normalizedSender = normalizeBlueBubblesHandle(senderId);
if (!normalizedSender) return null;
const replyMetadata = extractReplyMetadata(message);
return {
text,
@@ -375,6 +632,10 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
fromMe,
attachments: extractAttachments(message),
balloonBundleId,
participants: normalizedParticipants,
replyToId: replyMetadata.replyToId,
replyToBody: replyMetadata.replyToBody,
replyToSender: replyMetadata.replyToSender,
};
}
@@ -451,12 +712,16 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
? chatsParticipants
: [];
const participantsCount = participants.length;
const isGroup =
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
const explicitIsGroup =
readBoolean(message, "isGroup") ??
readBoolean(message, "is_group") ??
readBoolean(chat, "isGroup") ??
readBoolean(message, "group") ??
(participantsCount > 2 ? true : false);
readBoolean(message, "group");
const isGroup =
typeof groupFromChatGuid === "boolean"
? groupFromChatGuid
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
const timestampRaw =
@@ -637,6 +902,8 @@ async function processMessage(
): Promise<void> {
const { account, config, runtime, core, statusSink } = target;
if (message.fromMe) return;
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
const text = message.text.trim();
const attachments = message.attachments ?? [];
@@ -648,7 +915,7 @@ async function processMessage(
logVerbose(
core,
runtime,
`msg sender=${message.senderId} group=${message.isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
`msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
);
const dmPolicy = account.config.dmPolicy ?? "pairing";
@@ -667,15 +934,35 @@ async function processMessage(
]
.map((entry) => String(entry).trim())
.filter(Boolean);
const groupAllowEntry = formatGroupAllowlistEntry({
chatGuid: message.chatGuid,
chatId: message.chatId ?? undefined,
chatIdentifier: message.chatIdentifier ?? undefined,
});
const groupName = message.chatName?.trim() || undefined;
if (message.isGroup) {
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
logGroupAllowlistHint({
runtime,
reason: "groupPolicy=disabled",
entry: groupAllowEntry,
chatName: groupName,
accountId: account.accountId,
});
return;
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
logGroupAllowlistHint({
runtime,
reason: "groupPolicy=allowlist (empty allowlist)",
entry: groupAllowEntry,
chatName: groupName,
accountId: account.accountId,
});
return;
}
const allowed = isAllowedBlueBubblesSender({
@@ -696,6 +983,13 @@ async function processMessage(
runtime,
`drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
);
logGroupAllowlistHint({
runtime,
reason: "groupPolicy=allowlist (not allowlisted)",
entry: groupAllowEntry,
chatName: groupName,
accountId: account.accountId,
});
return;
}
}
@@ -767,7 +1061,7 @@ async function processMessage(
const chatId = message.chatId ?? undefined;
const chatGuid = message.chatGuid ?? undefined;
const chatIdentifier = message.chatIdentifier ?? undefined;
const peerId = message.isGroup
const peerId = isGroup
? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
: message.senderId;
@@ -776,11 +1070,84 @@ async function processMessage(
channel: "bluebubbles",
accountId: account.accountId,
peer: {
kind: message.isGroup ? "group" : "dm",
kind: isGroup ? "group" : "dm",
id: peerId,
},
});
// Mention gating for group chats (parity with iMessage/WhatsApp)
const messageText = text;
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
const wasMentioned = isGroup
? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
: true;
const canDetectMention = mentionRegexes.length > 0;
const requireMention = core.channel.groups.resolveRequireMention({
cfg: config,
channel: "bluebubbles",
groupId: peerId,
accountId: account.accountId,
});
// Command gating (parity with iMessage/WhatsApp)
const useAccessGroups = config.commands?.useAccessGroups !== false;
const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
const ownerAllowedForCommands =
effectiveAllowFrom.length > 0
? isAllowedBlueBubblesSender({
allowFrom: effectiveAllowFrom,
sender: message.senderId,
chatId: message.chatId ?? undefined,
chatGuid: message.chatGuid ?? undefined,
chatIdentifier: message.chatIdentifier ?? undefined,
})
: false;
const groupAllowedForCommands =
effectiveGroupAllowFrom.length > 0
? isAllowedBlueBubblesSender({
allowFrom: effectiveGroupAllowFrom,
sender: message.senderId,
chatId: message.chatId ?? undefined,
chatGuid: message.chatGuid ?? undefined,
chatIdentifier: message.chatIdentifier ?? undefined,
})
: false;
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
const commandAuthorized = isGroup
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
})
: dmAuthorized;
// Block control commands from unauthorized senders in groups
if (isGroup && hasControlCmd && !commandAuthorized) {
logVerbose(
core,
runtime,
`bluebubbles: drop control command from unauthorized sender ${message.senderId}`,
);
return;
}
// Allow control commands to bypass mention gating when authorized (parity with iMessage)
const shouldBypassMention =
isGroup &&
requireMention &&
!wasMentioned &&
commandAuthorized &&
hasControlCmd;
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
// Skip group messages that require mention but weren't mentioned
if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
return;
}
const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim();
const maxBytes =
@@ -833,9 +1200,18 @@ async function processMessage(
}
}
const rawBody = text.trim() || placeholder;
const fromLabel = message.isGroup
const replyContext = formatReplyContext(message);
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
const fromLabel = isGroup
? `group:${peerId}`
: message.senderName || `user:${message.senderId}`;
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
const groupMembers = isGroup
? formatGroupMembers({
participants: message.participants,
fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
})
: undefined;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
@@ -850,12 +1226,12 @@ async function processMessage(
timestamp: message.timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
body: baseBody,
});
let chatGuidForActions = chatGuid;
if (!chatGuidForActions && baseUrl && password) {
const target =
message.isGroup && (chatId || chatIdentifier)
const target =
isGroup && (chatId || chatIdentifier)
? chatId
? { kind: "chat_id", chatId }
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
@@ -870,7 +1246,51 @@ async function processMessage(
}
}
if (chatGuidForActions && baseUrl && password) {
const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
const ackReactionValue = resolveBlueBubblesAckReaction({
cfg: config,
agentId: route.agentId,
core,
runtime,
});
const shouldAckReaction = () => {
if (!ackReactionValue) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return !isGroup;
if (ackReactionScope === "group-all") return isGroup;
if (ackReactionScope === "group-mentions") {
if (!isGroup) return false;
if (!requireMention) return false;
if (!canDetectMention) return false;
return effectiveWasMentioned;
}
return false;
};
const ackMessageId = message.messageId?.trim() || "";
const ackReactionPromise =
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
? sendBlueBubblesReaction({
chatGuid: chatGuidForActions,
messageGuid: ackMessageId,
emoji: ackReactionValue,
opts: { cfg: config, accountId: account.accountId },
}).then(
() => true,
(err) => {
logVerbose(
core,
runtime,
`ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
);
return false;
},
)
: null;
// Respect sendReadReceipts config (parity with WhatsApp)
const sendReadReceipts = account.config.sendReadReceipts !== false;
if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
try {
await markBlueBubblesChatRead(chatGuidForActions, {
cfg: config,
@@ -880,11 +1300,13 @@ async function processMessage(
} catch (err) {
runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
}
} else if (!sendReadReceipts) {
logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
} else {
logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
}
const outboundTarget = message.isGroup
const outboundTarget = isGroup
? formatBlueBubblesChatTarget({
chatId,
chatGuid: chatGuidForActions ?? chatGuid,
@@ -894,6 +1316,15 @@ async function processMessage(
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
: message.senderId;
const maybeEnqueueOutboundMessageId = (messageId?: string) => {
const trimmed = messageId?.trim();
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
core.system.enqueueSystemEvent(`BlueBubbles sent message id: ${trimmed}`, {
sessionKey: route.sessionKey,
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
});
};
const ctxPayload = {
Body: body,
BodyForAgent: body,
@@ -906,12 +1337,17 @@ async function processMessage(
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaType: mediaTypes[0],
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
From: message.isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
To: `bluebubbles:${outboundTarget}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: message.isGroup ? "group" : "direct",
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
ReplyToId: message.replyToId,
ReplyToBody: message.replyToBody,
ReplyToSender: message.replyToSender,
GroupSubject: groupSubject,
GroupMembers: groupMembers,
SenderName: message.senderName || undefined,
SenderId: message.senderId,
Provider: "bluebubbles",
@@ -920,26 +1356,42 @@ async function processMessage(
Timestamp: message.timestamp,
OriginatingChannel: "bluebubbles",
OriginatingTo: `bluebubbles:${outboundTarget}`,
WasMentioned: effectiveWasMentioned,
CommandAuthorized: commandAuthorized,
};
if (chatGuidForActions && baseUrl && password) {
logVerbose(core, runtime, `typing start (pre-dispatch) chatGuid=${chatGuidForActions}`);
try {
await sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config,
accountId: account.accountId,
});
} catch (err) {
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
}
}
let sentMessage = false;
try {
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined;
first = false;
const result = await sendBlueBubblesMedia({
cfg: config,
to: outboundTarget,
mediaUrl,
caption: caption ?? undefined,
replyToId: payload.replyToId ?? null,
accountId: account.accountId,
});
maybeEnqueueOutboundMessageId(result.messageId);
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
}
return;
}
const textLimit =
account.config.textChunkLimit && account.config.textChunkLimit > 0
? account.config.textChunkLimit
@@ -948,10 +1400,15 @@ async function processMessage(
if (!chunks.length && payload.text) chunks.push(payload.text);
if (!chunks.length) return;
for (const chunk of chunks) {
await sendMessageBlueBubbles(outboundTarget, chunk, {
const replyToMessageGuid =
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
cfg: config,
accountId: account.accountId,
replyToMessageGuid: replyToMessageGuid || undefined,
});
maybeEnqueueOutboundMessageId(result.messageId);
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
}
},
@@ -969,31 +1426,48 @@ async function processMessage(
}
},
onIdle: () => {
if (!chatGuidForActions) return;
if (!baseUrl || !password) return;
logVerbose(core, runtime, `typing stop chatGuid=${chatGuidForActions}`);
void sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,
}).catch((err) => {
runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`);
});
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
},
onError: (err, info) => {
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
},
},
replyOptions: {
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,
},
});
} finally {
if (chatGuidForActions && baseUrl && password) {
logVerbose(core, runtime, `typing stop (finalize) chatGuid=${chatGuidForActions}`);
void sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,
}).catch((err) => {
runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`);
if (
removeAckAfterReply &&
sentMessage &&
ackReactionPromise &&
ackReactionValue &&
chatGuidForActions &&
ackMessageId
) {
void ackReactionPromise.then((didAck) => {
if (!didAck) return;
sendBlueBubblesReaction({
chatGuid: chatGuidForActions,
messageGuid: ackMessageId,
emoji: ackReactionValue,
remove: true,
opts: { cfg: config, accountId: account.accountId },
}).catch((err) => {
logVerbose(
core,
runtime,
`ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
);
});
});
}
if (chatGuidForActions && baseUrl && password && !sentMessage) {
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
}
}
}
@@ -1077,11 +1551,22 @@ async function processReaction(
export async function monitorBlueBubblesProvider(
options: BlueBubblesMonitorOptions,
): Promise<{ stop: () => void }> {
): Promise<void> {
const { account, config, runtime, abortSignal, statusSink } = options;
const core = getBlueBubblesRuntime();
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
// Fetch and cache server info (for macOS version detection in action gating)
const serverInfo = await fetchBlueBubblesServerInfo({
baseUrl: account.baseUrl,
password: account.config.password,
accountId: account.accountId,
timeoutMs: 5000,
}).catch(() => null);
if (serverInfo?.os_version) {
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
}
const unregister = registerBlueBubblesWebhookTarget({
account,
config,
@@ -1091,21 +1576,22 @@ export async function monitorBlueBubblesProvider(
statusSink,
});
const stop = () => {
unregister();
};
return await new Promise((resolve) => {
const stop = () => {
unregister();
resolve();
};
if (abortSignal?.aborted) {
stop();
return;
}
if (abortSignal?.aborted) {
stop();
} else {
abortSignal?.addEventListener("abort", stop, { once: true });
}
runtime.log?.(
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
);
return { stop };
runtime.log?.(
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
);
});
}
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {

View File

@@ -0,0 +1,334 @@
import type { ClawdbotConfig, DmPolicy, WizardPrompter } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../../../src/channels/plugins/onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "../../../src/channels/plugins/onboarding/helpers.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import {
listBlueBubblesAccountIds,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { normalizeBlueBubblesServerUrl } from "./types.js";
import { parseBlueBubblesAllowTarget, normalizeBlueBubblesHandle } from "./targets.js";
const channel = "bluebubbles" as const;
function setBlueBubblesDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.bluebubbles?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
bluebubbles: {
...cfg.channels?.bluebubbles,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setBlueBubblesAllowFrom(
cfg: ClawdbotConfig,
accountId: string,
allowFrom: string[],
): ClawdbotConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
bluebubbles: {
...cfg.channels?.bluebubbles,
allowFrom,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
bluebubbles: {
...cfg.channels?.bluebubbles,
accounts: {
...cfg.channels?.bluebubbles?.accounts,
[accountId]: {
...cfg.channels?.bluebubbles?.accounts?.[accountId],
allowFrom,
},
},
},
},
};
}
function parseBlueBubblesAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
async function promptBlueBubblesAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultBlueBubblesAccountId(params.cfg);
const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist BlueBubbles DMs by handle or chat target.",
"Examples:",
"- +15555550123",
"- user@example.com",
"- chat_id:123",
"- chat_guid:iMessage;-;+15555550123",
"Multiple entries: comma- or newline-separated.",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
].join("\n"),
"BlueBubbles allowlist",
);
const entry = await params.prompter.text({
message: "BlueBubbles allowFrom (handle or chat_id)",
placeholder: "+15555550123, user@example.com, chat_id:123",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const parts = parseBlueBubblesAllowFromInput(raw);
for (const part of parts) {
if (part === "*") continue;
const parsed = parseBlueBubblesAllowTarget(part);
if (parsed.kind === "handle" && !parsed.handle) {
return `Invalid entry: ${part}`;
}
}
return undefined;
},
});
const parts = parseBlueBubblesAllowFromInput(String(entry));
const unique = [...new Set(parts)];
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "BlueBubbles",
channel,
policyKey: "channels.bluebubbles.dmPolicy",
allowFromKey: "channels.bluebubbles.allowFrom",
getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy),
promptAllowFrom: promptBlueBubblesAllowFrom,
};
export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listBlueBubblesAccountIds(cfg).some((accountId) => {
const account = resolveBlueBubblesAccount({ cfg, accountId });
return account.configured;
});
return {
channel,
configured,
statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`],
selectionHint: configured ? "configured" : "iMessage via BlueBubbles app",
quickstartScore: configured ? 1 : 0,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
const blueBubblesOverride = accountOverrides.bluebubbles?.trim();
const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg);
let accountId = blueBubblesOverride
? normalizeAccountId(blueBubblesOverride)
: defaultAccountId;
if (shouldPromptAccountIds && !blueBubblesOverride) {
accountId = await promptAccountId({
cfg,
prompter,
label: "BlueBubbles",
currentId: accountId,
listAccountIds: listBlueBubblesAccountIds,
defaultAccountId,
});
}
let next = cfg;
const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
// Prompt for server URL
let serverUrl = resolvedAccount.config.serverUrl?.trim();
if (!serverUrl) {
await prompter.note(
[
"Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).",
"Find this in the BlueBubbles Server app under Connection.",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
].join("\n"),
"BlueBubbles server URL",
);
const entered = await prompter.text({
message: "BlueBubbles server URL",
placeholder: "http://192.168.1.100:1234",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "Required";
try {
const normalized = normalizeBlueBubblesServerUrl(trimmed);
new URL(normalized);
return undefined;
} catch {
return "Invalid URL format";
}
},
});
serverUrl = String(entered).trim();
} else {
const keepUrl = await prompter.confirm({
message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
initialValue: true,
});
if (!keepUrl) {
const entered = await prompter.text({
message: "BlueBubbles server URL",
placeholder: "http://192.168.1.100:1234",
initialValue: serverUrl,
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "Required";
try {
const normalized = normalizeBlueBubblesServerUrl(trimmed);
new URL(normalized);
return undefined;
} catch {
return "Invalid URL format";
}
},
});
serverUrl = String(entered).trim();
}
}
// Prompt for password
let password = resolvedAccount.config.password?.trim();
if (!password) {
await prompter.note(
[
"Enter the BlueBubbles server password.",
"Find this in the BlueBubbles Server app under Settings.",
].join("\n"),
"BlueBubbles password",
);
const entered = await prompter.text({
message: "BlueBubbles password",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
password = String(entered).trim();
} else {
const keepPassword = await prompter.confirm({
message: "BlueBubbles password already set. Keep it?",
initialValue: true,
});
if (!keepPassword) {
const entered = await prompter.text({
message: "BlueBubbles password",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
password = String(entered).trim();
}
}
// Prompt for webhook path (optional)
const existingWebhookPath = resolvedAccount.config.webhookPath?.trim();
const wantsWebhook = await prompter.confirm({
message: "Configure a custom webhook path? (default: /bluebubbles-webhook)",
initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"),
});
let webhookPath = "/bluebubbles-webhook";
if (wantsWebhook) {
const entered = await prompter.text({
message: "Webhook path",
placeholder: "/bluebubbles-webhook",
initialValue: existingWebhookPath || "/bluebubbles-webhook",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "Required";
if (!trimmed.startsWith("/")) return "Path must start with /";
return undefined;
},
});
webhookPath = String(entered).trim();
}
// Apply config
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
serverUrl,
password,
webhookPath,
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...next.channels?.bluebubbles?.accounts,
[accountId]: {
...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true,
serverUrl,
password,
webhookPath,
},
},
},
},
};
}
await prompter.note(
[
"Configure the webhook URL in BlueBubbles Server:",
"1. Open BlueBubbles Server → Settings → Webhooks",
"2. Add your Clawdbot gateway URL + webhook path",
" Example: https://your-gateway-host:3000/bluebubbles-webhook",
"3. Enable the webhook and save",
"",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
].join("\n"),
"BlueBubbles next steps",
);
return { cfg: next, accountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false },
},
}),
};

View File

@@ -6,6 +6,97 @@ export type BlueBubblesProbe = {
error?: string | null;
};
export type BlueBubblesServerInfo = {
os_version?: string;
server_version?: string;
private_api?: boolean;
helper_connected?: boolean;
proxy_service?: string;
detected_icloud?: string;
computer_id?: string;
};
/** Cache server info by account ID to avoid repeated API calls */
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function buildCacheKey(accountId?: string): string {
return accountId?.trim() || "default";
}
/**
* Fetch server info from BlueBubbles API and cache it.
* Returns cached result if available and not expired.
*/
export async function fetchBlueBubblesServerInfo(params: {
baseUrl?: string | null;
password?: string | null;
accountId?: string;
timeoutMs?: number;
}): Promise<BlueBubblesServerInfo | null> {
const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim();
if (!baseUrl || !password) return null;
const cacheKey = buildCacheKey(params.accountId);
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
}
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
try {
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
if (!res.ok) return null;
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload?.data as BlueBubblesServerInfo | undefined;
if (data) {
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
}
return data ?? null;
} catch {
return null;
}
}
/**
* Get cached server info synchronously (for use in listActions).
* Returns null if not cached or expired.
*/
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
const cacheKey = buildCacheKey(accountId);
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
}
return null;
}
/**
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
*/
export function parseMacOSMajorVersion(version?: string | null): number | null {
if (!version) return null;
const match = /^(\d+)/.exec(version.trim());
return match ? Number.parseInt(match[1], 10) : null;
}
/**
* Check if the cached server info indicates macOS 26 or higher.
* Returns false if no cached info is available (fail open for action listing).
*/
export function isMacOS26OrHigher(accountId?: string): boolean {
const info = getCachedBlueBubblesServerInfo(accountId);
if (!info?.os_version) return false;
const major = parseMacOSMajorVersion(info.os_version);
return major !== null && major >= 26;
}
/** Clear the server info cache (for testing) */
export function clearServerInfoCache(): void {
serverInfoCache.clear();
}
export async function probeBlueBubbles(params: {
baseUrl?: string | null;
password?: string | null;

View File

@@ -0,0 +1,393 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { sendBlueBubblesReaction } from "./reactions.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
const mockFetch = vi.fn();
describe("reactions", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("sendBlueBubblesReaction", () => {
it("throws when chatGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "",
messageGuid: "msg-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("chatGuid");
});
it("throws when messageGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("messageGuid");
});
it("throws when emoji is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("emoji or name");
});
it("throws when serverUrl is missing", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
opts: {},
}),
).rejects.toThrow("serverUrl is required");
});
it("throws when password is missing", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
},
}),
).rejects.toThrow("password is required");
});
it("throws for unsupported reaction type", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "unsupported",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("Unsupported BlueBubbles reaction");
});
describe("reaction type normalization", () => {
const testCases = [
{ input: "love", expected: "love" },
{ input: "like", expected: "like" },
{ input: "dislike", expected: "dislike" },
{ input: "laugh", expected: "laugh" },
{ input: "emphasize", expected: "emphasize" },
{ input: "question", expected: "question" },
{ input: "heart", expected: "love" },
{ input: "thumbs_up", expected: "like" },
{ input: "thumbs-down", expected: "dislike" },
{ input: "thumbs_down", expected: "dislike" },
{ input: "haha", expected: "laugh" },
{ input: "lol", expected: "laugh" },
{ input: "emphasis", expected: "emphasize" },
{ input: "exclaim", expected: "emphasize" },
{ input: "❤️", expected: "love" },
{ input: "❤", expected: "love" },
{ input: "♥️", expected: "love" },
{ input: "😍", expected: "love" },
{ input: "👍", expected: "like" },
{ input: "👎", expected: "dislike" },
{ input: "😂", expected: "laugh" },
{ input: "🤣", expected: "laugh" },
{ input: "😆", expected: "laugh" },
{ input: "‼️", expected: "emphasize" },
{ input: "‼", expected: "emphasize" },
{ input: "❗", expected: "emphasize" },
{ input: "❓", expected: "question" },
{ input: "❔", expected: "question" },
{ input: "LOVE", expected: "love" },
{ input: "Like", expected: "like" },
];
for (const { input, expected } of testCases) {
it(`normalizes "${input}" to "${expected}"`, async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: input,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe(expected);
});
}
});
it("sends reaction successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "iMessage;-;+15551234567",
messageGuid: "msg-uuid-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/message/react"),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
expect(body.selectedMessageGuid).toBe("msg-uuid-123");
expect(body.reaction).toBe("love");
expect(body.partIndex).toBe(0);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "like",
opts: {
serverUrl: "http://localhost:1234",
password: "my-react-password",
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-react-password");
});
it("sends reaction removal with dash prefix", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
});
it("strips leading dash from emoji when remove flag is set", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "-love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
});
it("uses custom partIndex when provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "laugh",
partIndex: 3,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(3);
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
text: () => Promise.resolve("Invalid reaction type"),
});
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "like",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("reaction failed (400): Invalid reaction type");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "emphasize",
opts: {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://react-server:7777",
password: "react-pass",
},
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("react-server:7777");
expect(calledUrl).toContain("password=react-pass");
});
it("trims chatGuid and messageGuid", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: " chat-with-spaces ",
messageGuid: " msg-with-spaces ",
emoji: "question",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.chatGuid).toBe("chat-with-spaces");
expect(body.selectedMessageGuid).toBe("msg-with-spaces");
});
describe("reaction removal aliases", () => {
it("handles emoji-based removal", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "👍",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-like");
});
it("handles text alias removal", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "haha",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-laugh");
});
});
});
});

View File

@@ -60,7 +60,7 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
return { baseUrl, password };
}
function normalizeReactionInput(emoji: string, remove?: boolean): string {
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
const trimmed = emoji.trim();
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
let raw = trimmed.toLowerCase();
@@ -85,7 +85,7 @@ export async function sendBlueBubblesReaction(params: {
const messageGuid = params.messageGuid.trim();
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
const reaction = normalizeReactionInput(params.emoji, params.remove);
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
const { baseUrl, password } = resolveAccount(params.opts ?? {});
const url = buildBlueBubblesApiUrl({
baseUrl,

View File

@@ -0,0 +1,690 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
import type { BlueBubblesSendTarget } from "./types.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
const mockFetch = vi.fn();
describe("send", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("resolveChatGuidForTarget", () => {
it("returns chatGuid directly for chat_guid target", async () => {
const target: BlueBubblesSendTarget = {
kind: "chat_guid",
chatGuid: "iMessage;-;+15551234567",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;+15551234567");
expect(mockFetch).not.toHaveBeenCalled();
});
it("queries chats to resolve chat_id target", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{ id: 123, guid: "iMessage;-;chat123", participants: [] },
{ id: 456, guid: "iMessage;-;chat456", participants: [] },
],
}),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;chat456");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/query"),
expect.objectContaining({ method: "POST" }),
);
});
it("queries chats to resolve chat_identifier target", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
identifier: "chat123@group.imessage",
guid: "iMessage;-;chat123",
participants: [],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "chat_identifier",
chatIdentifier: "chat123@group.imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;chat123");
});
it("resolves handle target by matching participant", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15559999999",
participants: [{ address: "+15559999999" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;+15551234567");
});
it("prefers direct chat guid when handle also appears in a group chat", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;+15551234567");
});
it("returns null when chat not found", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBeNull();
});
it("handles API error gracefully", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBeNull();
});
it("paginates through chats to find match", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: Array(500)
.fill(null)
.map((_, i) => ({
id: i,
guid: `chat-${i}`,
participants: [],
})),
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [{ id: 555, guid: "found-chat", participants: [] }],
}),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("found-chat");
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("normalizes handle addresses for matching", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;test@example.com",
participants: [{ address: "Test@Example.COM" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "test@example.com",
service: "auto",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;test@example.com");
});
it("extracts guid from various response formats", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
chatGuid: "format1-guid",
id: 100,
participants: [],
},
],
}),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("format1-guid");
});
});
describe("sendMessageBlueBubbles", () => {
beforeEach(() => {
mockFetch.mockReset();
});
it("throws when text is empty", async () => {
await expect(
sendMessageBlueBubbles("+15551234567", "", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("requires text");
});
it("throws when text is whitespace only", async () => {
await expect(
sendMessageBlueBubbles("+15551234567", " ", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("requires text");
});
it("throws when serverUrl is missing", async () => {
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("throws when chatGuid cannot be resolved", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
await expect(
sendMessageBlueBubbles("+15559999999", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("chatGuid not found");
});
it("sends message successfully", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-123" },
}),
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("msg-uuid-123");
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
expect(sendCall[0]).toContain("/api/v1/message/text");
const body = JSON.parse(sendCall[1].body);
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
expect(body.message).toBe("Hello world!");
expect(body.method).toBeUndefined();
});
it("uses private-api when reply metadata is present", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-124" },
}),
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-123",
replyToPartIndex: 1,
});
expect(result.messageId).toBe("msg-uuid-124");
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.selectedMessageGuid).toBe("reply-guid-123");
expect(body.partIndex).toBe(1);
});
it("normalizes effect names and uses private-api for effects", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-125" },
}),
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
effectId: "invisible ink",
});
expect(result.messageId).toBe("msg-uuid-125");
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
});
it("sends message with chat_guid target directly", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { messageId: "direct-msg-123" },
}),
),
});
const result = await sendMessageBlueBubbles(
"chat_guid:iMessage;-;direct-chat",
"Direct message",
{
serverUrl: "http://localhost:1234",
password: "test",
},
);
expect(result.messageId).toBe("direct-msg-123");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("handles send failure", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal server error"),
});
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("send failed (500)");
});
it("handles empty response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("ok");
});
it("handles invalid JSON response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json"),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("ok");
});
it("extracts messageId from various response formats", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
id: "numeric-id-456",
}),
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("numeric-id-456");
});
it("extracts messageGuid from response payload", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { messageGuid: "msg-guid-789" },
}),
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("msg-guid-789");
});
it("resolves credentials from config", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:5678",
password: "config-pass",
},
},
},
});
expect(result.messageId).toBe("msg-123");
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:5678");
});
it("includes tempGuid in request payload", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
});
await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.tempGuid).toBeDefined();
expect(typeof body.tempGuid).toBe("string");
expect(body.tempGuid.length).toBeGreaterThan(0);
});
});
});

View File

@@ -1,7 +1,11 @@
import crypto from "node:crypto";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
import {
extractHandleFromChatGuid,
normalizeBlueBubblesHandle,
parseBlueBubblesTarget,
} from "./targets.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import {
blueBubblesFetchWithTimeout,
@@ -15,12 +19,52 @@ export type BlueBubblesSendOpts = {
accountId?: string;
timeoutMs?: number;
cfg?: ClawdbotConfig;
/** Message GUID to reply to (reply threading) */
replyToMessageGuid?: string;
/** Part index for reply (default: 0) */
replyToPartIndex?: number;
/** Effect ID or short name for message effects (e.g., "slam", "balloons") */
effectId?: string;
};
export type BlueBubblesSendResult = {
messageId: string;
};
/** Maps short effect names to full Apple effect IDs */
const EFFECT_MAP: Record<string, string> = {
// Bubble effects
slam: "com.apple.MobileSMS.expressivesend.impact",
loud: "com.apple.MobileSMS.expressivesend.loud",
gentle: "com.apple.MobileSMS.expressivesend.gentle",
invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink",
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
// Screen effects
echo: "com.apple.messages.effect.CKEchoEffect",
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
confetti: "com.apple.messages.effect.CKConfettiEffect",
love: "com.apple.messages.effect.CKHeartEffect",
heart: "com.apple.messages.effect.CKHeartEffect",
hearts: "com.apple.messages.effect.CKHeartEffect",
lasers: "com.apple.messages.effect.CKLasersEffect",
fireworks: "com.apple.messages.effect.CKFireworksEffect",
celebration: "com.apple.messages.effect.CKSparklesEffect",
};
function resolveEffectId(raw?: string): string | undefined {
if (!raw) return undefined;
const trimmed = raw.trim().toLowerCase();
if (EFFECT_MAP[trimmed]) return EFFECT_MAP[trimmed];
const normalized = trimmed.replace(/[\s_]+/g, "-");
if (EFFECT_MAP[normalized]) return EFFECT_MAP[normalized];
const compact = trimmed.replace(/[\s_-]+/g, "");
if (EFFECT_MAP[compact]) return EFFECT_MAP[compact];
return raw;
}
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
@@ -42,12 +86,18 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
function extractMessageId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "unknown";
const record = payload as Record<string, unknown>;
const data = record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
const data =
record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
const candidates = [
record.messageId,
record.messageGuid,
record.message_guid,
record.guid,
record.id,
data?.messageId,
data?.messageGuid,
data?.message_guid,
data?.message_id,
data?.guid,
data?.id,
];
@@ -154,6 +204,7 @@ export async function resolveChatGuidForTarget(params: {
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
const limit = 500;
let participantMatch: string | null = null;
for (let offset = 0; offset < 5000; offset += limit) {
const chats = await queryChats({
baseUrl: params.baseUrl,
@@ -184,16 +235,23 @@ export async function resolveChatGuidForTarget(params: {
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
}
if (normalizedHandle) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
return extractChatGuid(chat);
const guid = extractChatGuid(chat);
const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
if (directHandle && directHandle === normalizedHandle) {
return guid;
}
if (!participantMatch && guid) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
}
}
}
}
}
return null;
return participantMatch;
}
export async function sendMessageBlueBubbles(
@@ -227,12 +285,27 @@ export async function sendMessageBlueBubbles(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
const effectId = resolveEffectId(opts.effectId);
const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId);
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: trimmedText,
method: "apple-script",
};
if (needsPrivateApi) {
payload.method = "private-api";
}
// Add reply threading support
if (opts.replyToMessageGuid) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
// Add message effects support
if (effectId) {
payload.effectId = effectId;
}
const url = buildBlueBubblesApiUrl({
baseUrl,

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import {
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
parseBlueBubblesAllowTarget,
} from "./targets.js";
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
});
it("normalizes group numeric targets to chat_id", () => {
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
});
it("strips provider prefix and normalizes handles", () => {
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
"imessage:user@example.com",
);
});
it("extracts handle from DM chat_guid for cross-context matching", () => {
// DM format: service;-;handle
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
"+19257864429",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
"+15551234567",
);
// Email handles
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
"user@example.com",
);
});
it("preserves group chat_guid format", () => {
// Group format: service;+;groupId
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
"chat_guid:iMessage;+;chat123456789",
);
});
it("normalizes raw chat_guid values", () => {
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
"chat_guid:iMessage;+;chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
});
it("normalizes chat<digits> pattern to chat_identifier format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
"chat_identifier:chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
});
it("normalizes UUID/hex chat identifiers", () => {
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
);
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
);
});
});
describe("looksLikeBlueBubblesTargetId", () => {
it("accepts chat targets", () => {
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
});
it("accepts email handles", () => {
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
});
it("accepts phone numbers with punctuation", () => {
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
});
it("accepts raw chat_guid values", () => {
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
});
it("accepts chat<digits> pattern as chat_id", () => {
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
});
it("accepts UUID/hex chat identifiers", () => {
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
});
it("rejects display names", () => {
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
kind: "chat_identifier",
chatIdentifier: "Chat456789",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
kind: "handle",
to: "+19257864429",
service: "auto",
});
});
it("parses raw chat_guid format", () => {
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
});
});
});
describe("parseBlueBubblesAllowTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
kind: "handle",
handle: "+19257864429",
});
});
});

View File

@@ -20,11 +20,41 @@ const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> =
{ prefix: "sms:", service: "sms" },
{ prefix: "auto:", service: "auto" },
];
const CHAT_IDENTIFIER_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
function parseRawChatGuid(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parts = trimmed.split(";");
if (parts.length !== 3) return null;
const service = parts[0]?.trim();
const separator = parts[1]?.trim();
const identifier = parts[2]?.trim();
if (!service || !identifier) return null;
if (separator !== "+" && separator !== "-") return null;
return `${service};${separator};${identifier}`;
}
function stripPrefix(value: string, prefix: string): string {
return value.slice(prefix.length).trim();
}
function stripBlueBubblesPrefix(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) return trimmed;
return trimmed.slice("bluebubbles:".length).trim();
}
function looksLikeRawChatIdentifier(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (/^chat\d+$/i.test(trimmed)) return true;
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
}
export function normalizeBlueBubblesHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
@@ -36,8 +66,83 @@ export function normalizeBlueBubblesHandle(raw: string): string {
return trimmed.replace(/\s+/g, "");
}
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
/**
* Extracts the handle from a chat_guid if it's a DM (1:1 chat).
* BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429")
* Group chat format: "service;+;groupId" (has "+" instead of "-")
*/
export function extractHandleFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
// DM format: service;-;handle (3 parts, middle is "-")
if (parts.length === 3 && parts[1] === "-") {
const handle = parts[2]?.trim();
if (handle) return normalizeBlueBubblesHandle(handle);
}
return null;
}
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim();
if (!trimmed) return undefined;
trimmed = stripBlueBubblesPrefix(trimmed);
if (!trimmed) return undefined;
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_id") return `chat_id:${parsed.chatId}`;
if (parsed.kind === "chat_guid") {
// For DM chat_guids, normalize to just the handle for easier comparison.
// This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890".
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) return handle;
// For group chats or unrecognized formats, keep the full chat_guid
return `chat_guid:${parsed.chatGuid}`;
}
if (parsed.kind === "chat_identifier") return `chat_identifier:${parsed.chatIdentifier}`;
const handle = normalizeBlueBubblesHandle(parsed.to);
if (!handle) return undefined;
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
} catch {
return trimmed;
}
}
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) return false;
if (parseRawChatGuid(candidate)) return true;
const lowered = candidate.toLowerCase();
if (/^(imessage|sms|auto):/.test(lowered)) return true;
if (
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
lowered,
)
) {
return true;
}
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
if (/^chat\d+$/i.test(candidate)) return true;
if (looksLikeRawChatIdentifier(candidate)) return true;
if (candidate.includes("@")) return true;
const digitsOnly = candidate.replace(/[\s().-]/g, "");
if (/^\+?\d{3,}$/.test(digitsOnly)) return true;
if (normalized) {
const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) return false;
const normalizedLower = normalizedTrimmed.toLowerCase();
if (
/^(imessage|sms|auto):/.test(normalizedLower) ||
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
) {
return true;
}
}
return false;
}
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = stripBlueBubblesPrefix(raw);
if (!trimmed) throw new Error("BlueBubbles target is required");
const lower = trimmed.toLowerCase();
@@ -95,6 +200,22 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
return { kind: "chat_guid", chatGuid: value };
}
const rawChatGuid = parseRawChatGuid(trimmed);
if (rawChatGuid) {
return { kind: "chat_guid", chatGuid: rawChatGuid };
}
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
if (/^chat\d+$/i.test(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
// Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
if (looksLikeRawChatIdentifier(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
return { kind: "handle", to: trimmed, service: "auto" };
}
@@ -140,6 +261,17 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
if (value) return { kind: "chat_guid", chatGuid: value };
}
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
if (/^chat\d+$/i.test(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
// Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
if (looksLikeRawChatIdentifier(trimmed)) {
return { kind: "chat_identifier", chatIdentifier: trimmed };
}
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
}

View File

@@ -1,6 +1,11 @@
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type BlueBubblesGroupConfig = {
/** If true, only respond in this group when mentioned. */
requireMention?: boolean;
};
export type BlueBubblesAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -36,10 +41,23 @@ export type BlueBubblesAccountConfig = {
blockStreamingCoalesce?: Record<string, unknown>;
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/** Send read receipts for incoming messages (default: true). */
sendReadReceipts?: boolean;
/** Per-group configuration keyed by chat GUID or identifier. */
groups?: Record<string, BlueBubblesGroupConfig>;
};
export type BlueBubblesActionConfig = {
reactions?: boolean;
edit?: boolean;
unsend?: boolean;
reply?: boolean;
sendWithEffect?: boolean;
renameGroup?: boolean;
addParticipant?: boolean;
removeParticipant?: boolean;
leaveGroup?: boolean;
sendAttachment?: boolean;
};
export type BlueBubblesConfig = {

View File

@@ -24,8 +24,10 @@
}
},
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"clawdbot": "workspace:*",
"markdown-it": "14.1.0",
"matrix-js-sdk": "40.0.0"
"matrix-bot-sdk": "0.8.0",
"music-metadata": "^11.10.6"
}
}

View File

@@ -1,4 +1,3 @@
import os from "node:os";
import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
@@ -11,7 +10,7 @@ describe("matrix directory", () => {
beforeEach(() => {
setMatrixRuntime({
state: {
resolveStateDir: () => os.tmpdir(),
resolveStateDir: (_env, homeDir) => homeDir(),
},
} as PluginRuntime);
});
@@ -21,7 +20,8 @@ describe("matrix directory", () => {
channels: {
matrix: {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
rooms: {
groupAllowFrom: ["@dana:example.org"],
groups: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
@@ -40,6 +40,7 @@ describe("matrix directory", () => {
{ kind: "user", id: "user:@alice:example.org" },
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
{ kind: "user", id: "user:@carol:example.org" },
{ kind: "user", id: "user:@dana:example.org" },
]),
);

View File

@@ -46,10 +46,12 @@ const meta = {
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
let normalized = raw.trim();
if (!normalized) return undefined;
if (normalized.toLowerCase().startsWith("matrix:")) {
const lowered = normalized.toLowerCase();
if (lowered.startsWith("matrix:")) {
normalized = normalized.slice("matrix:".length).trim();
}
return normalized ? normalized.toLowerCase() : undefined;
const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
return stripped || undefined;
}
function buildMatrixConfigUpdate(
@@ -155,10 +157,11 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
}),
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupPolicy =
account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.",
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.",
];
},
},
@@ -168,6 +171,17 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
threading: {
resolveReplyToMode: ({ cfg }) =>
(cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To;
return {
currentChannelId: currentTarget?.trim() || undefined,
currentThreadTs:
context.MessageThreadId != null
? String(context.MessageThreadId)
: context.ReplyToId,
hasRepliedRef,
};
},
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
@@ -194,7 +208,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
ids.add(raw.replace(/^matrix:/i, ""));
}
for (const room of Object.values(account.config.rooms ?? {})) {
for (const entry of account.config.groupAllowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
ids.add(raw.replace(/^matrix:/i, ""));
}
const groups = account.config.groups ?? account.config.rooms ?? {};
for (const room of Object.values(groups)) {
for (const entry of room.users ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
@@ -226,7 +247,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = Object.keys(account.config.rooms ?? {})
const groups = account.config.groups ?? account.config.rooms ?? {};
const ids = Object.keys(groups)
.map((raw) => raw.trim())
.filter((raw) => Boolean(raw) && raw !== "*")
.map((raw) => raw.replace(/^matrix:/i, ""))
@@ -263,10 +285,16 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
validateInput: ({ input }) => {
if (input.useEnv) return null;
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
if (!input.userId?.trim()) return "Matrix requires --user-id";
if (!input.accessToken?.trim() && !input.password?.trim()) {
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const userId = input.userId?.trim();
if (!accessToken && !password) {
return "Matrix requires --access-token or --password";
}
if (!accessToken) {
if (!userId) return "Matrix requires --user-id when using --password";
if (!password) return "Matrix requires --password when using --user-id";
}
return null;
},
applyAccountConfig: ({ cfg, input }) => {
@@ -381,6 +409,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,
replyToMode: account.config.replyToMode,
accountId: account.accountId,
});
},
},

View File

@@ -41,6 +41,7 @@ export const MatrixConfigSchema = z.object({
password: z.string().optional(),
deviceName: z.string().optional(),
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
@@ -49,7 +50,9 @@ export const MatrixConfigSchema = z.object({
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
dm: matrixDmSchema,
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
});

View File

@@ -20,7 +20,7 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.rooms,
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
roomId,
aliases,
name: groupChannel || undefined,

View File

@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAccount } from "./accounts.js";
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: () => null,
credentialsMatchConfig: () => false,
}));
const envKeys = [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
"MATRIX_ACCESS_TOKEN",
"MATRIX_PASSWORD",
"MATRIX_DEVICE_NAME",
];
describe("resolveMatrixAccount", () => {
let prevEnv: Record<string, string | undefined> = {};
beforeEach(() => {
prevEnv = {};
for (const key of envKeys) {
prevEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of envKeys) {
const value = prevEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("treats access-token-only config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-access",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("requires userId + password when no access token is set", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(false);
});
it("marks password auth as configured when userId is present", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
});

View File

@@ -31,18 +31,20 @@ export function resolveMatrixAccount(params: {
const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig;
const enabled = base.enabled !== false;
const resolved = resolveMatrixConfig(params.cfg, process.env);
const hasCore = Boolean(resolved.homeserver && resolved.userId);
const hasToken = Boolean(resolved.accessToken || resolved.password);
const hasHomeserver = Boolean(resolved.homeserver);
const hasUserId = Boolean(resolved.userId);
const hasAccessToken = Boolean(resolved.accessToken);
const hasPassword = Boolean(resolved.password);
const hasPasswordAuth = hasUserId && hasPassword;
const stored = loadMatrixCredentials(process.env);
const hasStored =
stored &&
resolved.homeserver &&
resolved.userId &&
credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
});
const configured = hasCore && (hasToken || Boolean(hasStored));
stored && resolved.homeserver
? credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
return {
accountId,
enabled,

View File

@@ -1,447 +1,15 @@
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
import {
Direction,
EventType,
MatrixError,
MsgType,
RelationType,
} from "matrix-js-sdk";
import type {
ReactionEventContent,
RoomMessageEventContent,
} from "matrix-js-sdk/lib/@types/events.js";
import type {
RoomPinnedEventsEventContent,
RoomTopicEventContent,
} from "matrix-js-sdk/lib/@types/state_events.js";
import { getMatrixRuntime } from "../runtime.js";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
waitForMatrixSync,
} from "./client.js";
import {
reactMatrixMessage,
resolveMatrixRoomId,
sendMessageMatrix,
} from "./send.js";
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
};
export type MatrixMessageSummary = {
eventId?: string;
sender?: string;
body?: string;
msgtype?: string;
timestamp?: number;
relatesTo?: {
relType?: string;
eventId?: string;
key?: string;
};
};
export type MatrixReactionSummary = {
key: string;
count: number;
users: string[];
};
type MatrixActionClient = {
client: MatrixClient;
stopOnDone: boolean;
};
function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) return { client: opts.client, stopOnDone: false };
const active = getActiveMatrixClient();
if (active) return { client: active, stopOnDone: false };
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
localTimeoutMs: opts.timeoutMs,
});
await client.startClient({
initialSyncLimit: 0,
lazyLoadMembers: true,
threadSupport: true,
});
await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs });
return { client, stopOnDone: true };
}
function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary {
const content = event.getContent<RoomMessageEventContent>();
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
if (relates) {
if ("rel_type" in relates) {
relType = relates.rel_type;
eventId = relates.event_id;
} else if ("m.in_reply_to" in relates) {
eventId = relates["m.in_reply_to"]?.event_id;
}
}
const relatesTo =
relType || eventId
? {
relType,
eventId,
}
: undefined;
return {
eventId: event.getId() ?? undefined,
sender: event.getSender() ?? undefined,
body: content.body,
msgtype: content.msgtype,
timestamp: event.getTs() ?? undefined,
relatesTo,
};
}
async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
try {
const content = (await client.getStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err) {
const httpStatus = err instanceof MatrixError ? err.httpStatus : undefined;
const errcode = err instanceof MatrixError ? err.errcode : undefined;
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
return [];
}
throw err;
}
}
async function fetchEventSummary(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
const raw = await client.fetchRoomEvent(roomId, eventId);
const mapper = client.getEventMapper();
const event = mapper(raw);
if (event.isRedacted()) return null;
return summarizeMatrixEvent(event);
}
export async function sendMatrixMessage(
to: string,
content: string,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
} = {},
) {
return await sendMessageMatrix(to, content, {
mediaUrl: opts.mediaUrl,
replyToId: opts.replyToId,
threadId: opts.threadId,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
}
export async function editMatrixMessage(
roomId: string,
messageId: string,
content: string,
opts: MatrixActionClientOpts = {},
) {
const trimmed = content.trim();
if (!trimmed) throw new Error("Matrix edit requires content");
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const newContent = {
msgtype: MsgType.Text,
body: trimmed,
} satisfies RoomMessageEventContent;
const payload: RoomMessageEventContent = {
msgtype: MsgType.Text,
body: `* ${trimmed}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: messageId,
},
};
const response = await client.sendMessage(resolvedRoom, payload);
return { eventId: response.event_id ?? null };
} finally {
if (stopOnDone) client.stopClient();
}
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, undefined, {
reason: opts.reason,
});
} finally {
if (stopOnDone) client.stopClient();
}
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 20;
const token = opts.before?.trim() || opts.after?.trim() || null;
const dir = opts.after ? Direction.Forward : Direction.Backward;
const res = await client.createMessagesRequest(resolvedRoom, token, limit, dir);
const mapper = client.getEventMapper();
const events = res.chunk.map(mapper);
const messages = events
.filter((event) => event.getType() === EventType.RoomMessage)
.filter((event) => !event.isRedacted())
.map(summarizeMatrixEvent);
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
} finally {
if (stopOnDone) client.stopClient();
}
}
export async function listMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
const res = await client.relations(
resolvedRoom,
messageId,
RelationType.Annotation,
EventType.Reaction,
{ dir: Direction.Backward, limit },
);
const summaries = new Map<string, MatrixReactionSummary>();
for (const event of res.events) {
const content = event.getContent<ReactionEventContent>();
const key = content["m.relates_to"].key;
if (!key) continue;
const sender = event.getSender() ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? {
key,
count: 0,
users: [],
};
entry.count += 1;
if (sender && !entry.users.includes(sender)) {
entry.users.push(sender);
}
summaries.set(key, entry);
}
return Array.from(summaries.values());
} finally {
if (stopOnDone) client.stopClient();
}
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const res = await client.relations(
resolvedRoom,
messageId,
RelationType.Annotation,
EventType.Reaction,
{ dir: Direction.Backward, limit: 200 },
);
const userId = client.getUserId();
if (!userId) return { removed: 0 };
const targetEmoji = opts.emoji?.trim();
const toRemove = res.events
.filter((event) => event.getSender() === userId)
.filter((event) => {
if (!targetEmoji) return true;
const content = event.getContent<ReactionEventContent>();
return content["m.relates_to"].key === targetEmoji;
})
.map((event) => event.getId())
.filter((id): id is string => Boolean(id));
if (toRemove.length === 0) return { removed: 0 };
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
} finally {
if (stopOnDone) client.stopClient();
}
}
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.includes(messageId) ? current : [...current, messageId];
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stopClient();
}
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.filter((id) => id !== messageId);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stopClient();
}
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const pinned = await readPinnedEvents(client, resolvedRoom);
const events = (
await Promise.all(
pinned.map(async (eventId) => {
try {
return await fetchEventSummary(client, resolvedRoom, eventId);
} catch {
return null;
}
}),
)
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
} finally {
if (stopOnDone) client.stopClient();
}
}
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
const profile = await client.getProfileInfo(userId);
const member = roomId ? client.getRoom(roomId)?.getMember(userId) : undefined;
return {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: member?.membership ?? null,
powerLevel: member?.powerLevel ?? null,
displayName: member?.name ?? null,
};
} finally {
if (stopOnDone) client.stopClient();
}
}
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const room = client.getRoom(resolvedRoom);
const topicEvent = room?.currentState.getStateEvents(EventType.RoomTopic, "");
const topicContent = topicEvent?.getContent<RoomTopicEventContent>();
const topic = typeof topicContent?.topic === "string" ? topicContent.topic : undefined;
return {
roomId: resolvedRoom,
name: room?.name ?? null,
topic: topic ?? null,
canonicalAlias: room?.getCanonicalAlias?.() ?? null,
altAliases: room?.getAltAliases?.() ?? [],
memberCount: room?.getJoinedMemberCount?.() ?? null,
};
} finally {
if (stopOnDone) client.stopClient();
}
}
export { reactMatrixMessage };
export type {
MatrixActionClientOpts,
MatrixMessageSummary,
MatrixReactionSummary,
} from "./actions/types.js";
export {
sendMatrixMessage,
editMatrixMessage,
deleteMatrixMessage,
readMatrixMessages,
} from "./actions/messages.js";
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
export { reactMatrixMessage } from "./send.js";

View File

@@ -0,0 +1,53 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
export function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
export async function resolveActionClient(
opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) return { client: opts.client, stopOnDone: false };
const active = getActiveMatrixClient();
if (active) return { client: active, stopOnDone: false };
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off actions.
}
}
await client.start();
return { client, stopOnDone: true };
}

View File

@@ -0,0 +1,120 @@
import {
EventType,
MsgType,
RelationType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
} from "./types.js";
import { resolveActionClient } from "./client.js";
import { summarizeMatrixRawEvent } from "./summary.js";
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
export async function sendMatrixMessage(
to: string,
content: string,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
} = {},
) {
return await sendMessageMatrix(to, content, {
mediaUrl: opts.mediaUrl,
replyToId: opts.replyToId,
threadId: opts.threadId,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
}
export async function editMatrixMessage(
roomId: string,
messageId: string,
content: string,
opts: MatrixActionClientOpts = {},
) {
const trimmed = content.trim();
if (!trimmed) throw new Error("Matrix edit requires content");
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const newContent = {
msgtype: MsgType.Text,
body: trimmed,
} satisfies RoomMessageEventContent;
const payload: RoomMessageEventContent = {
msgtype: MsgType.Text,
body: `* ${trimmed}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: messageId,
},
};
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
} finally {
if (stopOnDone) client.stop();
}
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason);
} finally {
if (stopOnDone) client.stop();
}
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// matrix-bot-sdk uses doRequest for room messages
const res = await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
{
dir,
limit,
from: token,
},
) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
const messages = res.chunk
.filter((event) => event.type === EventType.RoomMessage)
.filter((event) => !event.unsigned?.redacted_because)
.map(summarizeMatrixRawEvent);
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
} finally {
if (stopOnDone) client.stop();
}
}

View File

@@ -0,0 +1,70 @@
import {
EventType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type RoomPinnedEventsEventContent,
} from "./types.js";
import { resolveActionClient } from "./client.js";
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
import { resolveMatrixRoomId } from "../send.js";
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.includes(messageId) ? current : [...current, messageId];
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
}
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.filter((id) => id !== messageId);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
}
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const pinned = await readPinnedEvents(client, resolvedRoom);
const events = (
await Promise.all(
pinned.map(async (eventId) => {
try {
return await fetchEventSummary(client, resolvedRoom, eventId);
} catch {
return null;
}
}),
)
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
} finally {
if (stopOnDone) client.stop();
}
}

View File

@@ -0,0 +1,84 @@
import {
EventType,
RelationType,
type MatrixActionClientOpts,
type MatrixRawEvent,
type MatrixReactionSummary,
type ReactionEventContent,
} from "./types.js";
import { resolveActionClient } from "./client.js";
import { resolveMatrixRoomId } from "../send.js";
export async function listMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
// matrix-bot-sdk uses doRequest for relations
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit },
) as { chunk: MatrixRawEvent[] };
const summaries = new Map<string, MatrixReactionSummary>();
for (const event of res.chunk) {
const content = event.content as ReactionEventContent;
const key = content["m.relates_to"]?.key;
if (!key) continue;
const sender = event.sender ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? {
key,
count: 0,
users: [],
};
entry.count += 1;
if (sender && !entry.users.includes(sender)) {
entry.users.push(sender);
}
summaries.set(key, entry);
}
return Array.from(summaries.values());
} finally {
if (stopOnDone) client.stop();
}
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit: 200 },
) as { chunk: MatrixRawEvent[] };
const userId = await client.getUserId();
if (!userId) return { removed: 0 };
const targetEmoji = opts.emoji?.trim();
const toRemove = res.chunk
.filter((event) => event.sender === userId)
.filter((event) => {
if (!targetEmoji) return true;
const content = event.content as ReactionEventContent;
return content["m.relates_to"]?.key === targetEmoji;
})
.map((event) => event.event_id)
.filter((id): id is string => Boolean(id));
if (toRemove.length === 0) return { removed: 0 };
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
} finally {
if (stopOnDone) client.stop();
}
}

View File

@@ -0,0 +1,88 @@
import { EventType, type MatrixActionClientOpts } from "./types.js";
import { resolveActionClient } from "./client.js";
import { resolveMatrixRoomId } from "../send.js";
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
// matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed
return {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
};
} finally {
if (stopOnDone) client.stop();
}
}
export async function getMatrixRoomInfo(
roomId: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
// matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
let memberCount: number | null = null;
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = nameState?.name ?? null;
} catch {
// ignore
}
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = topicState?.topic ?? null;
} catch {
// ignore
}
try {
const aliasState = await client.getRoomStateEvent(
resolvedRoom,
"m.room.canonical_alias",
"",
);
canonicalAlias = aliasState?.alias ?? null;
} catch {
// ignore
}
try {
const members = await client.getJoinedRoomMembers(resolvedRoom);
memberCount = members.length;
} catch {
// ignore
}
return {
roomId: resolvedRoom,
name,
topic,
canonicalAlias,
altAliases: [], // Would need separate query
memberCount,
};
} finally {
if (stopOnDone) client.stop();
}
}

View File

@@ -0,0 +1,77 @@
import type { MatrixClient } from "matrix-bot-sdk";
import {
EventType,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
type RoomPinnedEventsEventContent,
} from "./types.js";
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
if (relates) {
if ("rel_type" in relates) {
relType = relates.rel_type;
eventId = relates.event_id;
} else if ("m.in_reply_to" in relates) {
eventId = relates["m.in_reply_to"]?.event_id;
}
}
const relatesTo =
relType || eventId
? {
relType,
eventId,
}
: undefined;
return {
eventId: event.event_id,
sender: event.sender,
body: content.body,
msgtype: content.msgtype,
timestamp: event.origin_server_ts,
relatesTo,
};
}
export async function readPinnedEvents(
client: MatrixClient,
roomId: string,
): Promise<string[]> {
try {
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err: unknown) {
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
const httpStatus = errObj.statusCode;
const errcode = errObj.body?.errcode;
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
return [];
}
throw err;
}
}
export async function fetchEventSummary(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
try {
const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent;
if (raw.unsigned?.redacted_because) return null;
return summarizeMatrixRawEvent(raw);
} catch {
// Event not found, redacted, or inaccessible - return null
return null;
}
}

View File

@@ -0,0 +1,84 @@
import type { MatrixClient } from "matrix-bot-sdk";
export const MsgType = {
Text: "m.text",
} as const;
export const RelationType = {
Replace: "m.replace",
Annotation: "m.annotation",
} as const;
export const EventType = {
RoomMessage: "m.room.message",
RoomPinnedEvents: "m.room.pinned_events",
RoomTopic: "m.room.topic",
Reaction: "m.reaction",
} as const;
export type RoomMessageEventContent = {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
export type ReactionEventContent = {
"m.relates_to": {
rel_type: string;
event_id: string;
key: string;
};
};
export type RoomPinnedEventsEventContent = {
pinned: string[];
};
export type RoomTopicEventContent = {
topic?: string;
};
export type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
redacted_because?: unknown;
};
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
};
export type MatrixMessageSummary = {
eventId?: string;
sender?: string;
body?: string;
msgtype?: string;
timestamp?: number;
relatesTo?: {
relType?: string;
eventId?: string;
key?: string;
};
};
export type MatrixReactionSummary = {
key: string;
count: number;
users: string[];
};
export type MatrixActionClient = {
client: MatrixClient;
stopOnDone: boolean;
};

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-js-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
let activeClient: MatrixClient | null = null;

View File

@@ -32,6 +32,7 @@ describe("resolveMatrixConfig", () => {
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
});
});
@@ -51,5 +52,6 @@ describe("resolveMatrixConfig", () => {
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
});

View File

@@ -1,338 +1,9 @@
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
};
export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
deviceName?: string;
initialSyncLimit?: number;
};
type MatrixSdk = typeof import("matrix-js-sdk");
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
};
let sharedClientState: SharedMatrixClientState | null = null;
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
let sharedClientStartPromise: Promise<void> | null = null;
export function isBunRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun === "string";
}
async function loadMatrixSdk(): Promise<MatrixSdk> {
return (await import("matrix-js-sdk")) as MatrixSdk;
}
function clean(value?: string): string {
return value?.trim() ?? "";
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken =
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const deviceName =
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
return {
homeserver,
userId,
accessToken,
password,
deviceName,
initialSyncLimit,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
if (!resolved.userId) {
throw new Error("Matrix userId is required (matrix.userId)");
}
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("./credentials.js");
const cached = loadMatrixCredentials(env);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId,
})
? cached
: null;
if (resolved.accessToken) {
if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
}
return {
homeserver: resolved.homeserver,
userId: resolved.userId,
accessToken: resolved.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
};
}
if (cachedCredentials) {
touchMatrixCredentials(env);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
};
}
if (!resolved.password) {
throw new Error(
"Matrix access token or password is required (matrix.accessToken or matrix.password)",
);
}
const sdk = await loadMatrixSdk();
const loginClient = sdk.createClient({
baseUrl: resolved.homeserver,
});
const login = await loginClient.loginRequest({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
});
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
});
return auth;
}
export async function createMatrixClient(params: {
homeserver: string;
userId: string;
accessToken: string;
localTimeoutMs?: number;
}): Promise<MatrixClient> {
const sdk = await loadMatrixSdk();
const store = new sdk.MemoryStore();
return sdk.createClient({
baseUrl: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
localTimeoutMs: params.localTimeoutMs,
store,
});
}
function buildSharedClientKey(auth: MatrixAuth): string {
return [auth.homeserver, auth.userId, auth.accessToken].join("|");
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
localTimeoutMs: params.timeoutMs,
});
return { client, key: buildSharedClientKey(params.auth), started: false };
}
async function ensureSharedClientStarted(params: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
}): Promise<void> {
if (params.state.started) return;
if (sharedClientStartPromise) {
await sharedClientStartPromise;
return;
}
sharedClientStartPromise = (async () => {
const startOpts: Parameters<MatrixClient["startClient"]>[0] = {
lazyLoadMembers: true,
threadSupport: true,
};
if (typeof params.initialSyncLimit === "number") {
startOpts.initialSyncLimit = params.initialSyncLimit;
}
await params.state.client.startClient(startOpts);
await waitForMatrixSync({
client: params.state.client,
timeoutMs: params.timeoutMs,
});
params.state.started = true;
})();
try {
await sharedClientStartPromise;
} finally {
sharedClientStartPromise = null;
}
}
export async function resolveSharedMatrixClient(
params: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
auth?: MatrixAuth;
startClient?: boolean;
} = {},
): Promise<MatrixClient> {
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
const key = buildSharedClientKey(auth);
const shouldStart = params.startClient !== false;
if (sharedClientState?.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: sharedClientState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
});
}
return sharedClientState.client;
}
if (sharedClientPromise) {
const pending = await sharedClientPromise;
if (pending.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
});
}
return pending.client;
}
pending.client.stopClient();
sharedClientState = null;
sharedClientPromise = null;
}
sharedClientPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
});
try {
const created = await sharedClientPromise;
sharedClientState = created;
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
});
}
return created.client;
} finally {
sharedClientPromise = null;
}
}
export async function waitForMatrixSync(params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
const timeoutMs = Math.max(1000, params.timeoutMs ?? 15_000);
if (params.client.getSyncState() === SyncState.Syncing) return;
await new Promise<void>((resolve, reject) => {
let done = false;
let timer: NodeJS.Timeout | undefined;
const cleanup = () => {
if (done) return;
done = true;
params.client.removeListener(ClientEvent.Sync, onSync);
if (params.abortSignal) {
params.abortSignal.removeEventListener("abort", onAbort);
}
if (timer) {
clearTimeout(timer);
timer = undefined;
}
};
const onSync = (state: SyncState) => {
if (done) return;
if (state === SyncState.Prepared || state === SyncState.Syncing) {
cleanup();
resolve();
}
if (state === SyncState.Error) {
cleanup();
reject(new Error("Matrix sync failed"));
}
};
const onAbort = () => {
cleanup();
reject(new Error("Matrix sync aborted"));
};
params.client.on(ClientEvent.Sync, onSync);
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(() => {
cleanup();
reject(new Error("Matrix sync timed out"));
}, timeoutMs);
});
}
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
export { isBunRuntime } from "./client/runtime.js";
export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js";
export { createMatrixClient } from "./client/create-client.js";
export {
resolveSharedMatrixClient,
waitForMatrixSync,
stopSharedClient,
} from "./client/shared.js";

View File

@@ -0,0 +1,165 @@
import { MatrixClient } from "matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
function clean(value?: string): string {
return value?.trim() ?? "";
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken =
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const deviceName =
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
const encryption = matrix.encryption ?? false;
return {
homeserver,
userId,
accessToken,
password,
deviceName,
initialSyncLimit,
encryption,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("./credentials.js");
const cached = loadMatrixCredentials(env);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
let userId = resolved.userId;
if (!userId) {
// Fetch userId from access token via whoami
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = await tempClient.getUserId();
userId = whoami;
// Save the credentials with the fetched userId
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
});
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
}
return {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (cachedCredentials) {
touchMatrixCredentials(env);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (!resolved.userId) {
throw new Error(
"Matrix userId is required when no access token is configured (matrix.userId)",
);
}
if (!resolved.password) {
throw new Error(
"Matrix password is required when no access token is configured (matrix.password)",
);
}
// Login with password using HTTP API
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
}),
});
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed: ${errorText}`);
}
const login = (await loginResponse.json()) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
});
return auth;
}

View File

@@ -0,0 +1,127 @@
import fs from "node:fs";
import {
LogService,
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
} from "matrix-bot-sdk";
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
resolveMatrixStoragePaths,
writeStorageMeta,
} from "./storage.js";
function sanitizeUserIdList(input: unknown, label: string): string[] {
if (input == null) return [];
if (!Array.isArray(input)) {
LogService.warn(
"MatrixClientLite",
`Expected ${label} list to be an array, got ${typeof input}`,
);
return [];
}
const filtered = input.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
if (filtered.length !== input.length) {
LogService.warn(
"MatrixClientLite",
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
);
}
return filtered;
}
export async function createMatrixClient(params: {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
localTimeoutMs?: number;
accountId?: string | null;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
// Create storage provider
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({ storagePaths, env });
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
// Create crypto storage if encryption is enabled
let cryptoStorage: ICryptoStorageProvider | undefined;
if (params.encryption) {
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
try {
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
cryptoStorage = new RustSdkCryptoStorageProvider(
storagePaths.cryptoPath,
StoreType.Sqlite,
);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
}
}
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId,
});
const client = new MatrixClient(
params.homeserver,
params.accessToken,
storage,
cryptoStorage,
);
if (client.crypto) {
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
client.crypto.updateSyncData = async (
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
changedDeviceLists,
leftDeviceLists,
) => {
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
try {
return await originalUpdateSyncData(
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
safeChanged,
safeLeft,
);
} catch (err) {
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
if (message.includes("Expect value to be String")) {
LogService.warn(
"MatrixClientLite",
"Ignoring malformed device list entries during crypto sync",
message,
);
return;
}
throw err;
}
};
}
return client;
}

View File

@@ -0,0 +1,35 @@
import { ConsoleLogger, LogService } from "matrix-bot-sdk";
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();
function shouldSuppressMatrixHttpNotFound(
module: string,
messageOrObject: unknown[],
): boolean {
if (module !== "MatrixHttpClient") return false;
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") return false;
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
export function ensureMatrixSdkLoggingConfigured(): void {
if (matrixSdkLoggingConfigured) return;
matrixSdkLoggingConfigured = true;
LogService.setLogger({
trace: (module, ...messageOrObject) =>
matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) =>
matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) =>
matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) =>
matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
matrixSdkBaseLogger.error(module, ...messageOrObject);
},
});
}

View File

@@ -0,0 +1,4 @@
export function isBunRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun === "string";
}

View File

@@ -0,0 +1,169 @@
import { LogService } from "matrix-bot-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { createMatrixClient } from "./create-client.js";
import { resolveMatrixAuth } from "./config.js";
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
import type { MatrixAuth } from "./types.js";
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
cryptoReady: boolean;
};
let sharedClientState: SharedMatrixClientState | null = null;
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
let sharedClientStartPromise: Promise<void> | null = null;
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
return [
auth.homeserver,
auth.userId,
auth.accessToken,
auth.encryption ? "e2ee" : "plain",
accountId ?? DEFAULT_ACCOUNT_KEY,
].join("|");
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
accountId?: string | null;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
accountId: params.accountId,
});
return {
client,
key: buildSharedClientKey(params.auth, params.accountId),
started: false,
cryptoReady: false,
};
}
async function ensureSharedClientStarted(params: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
encryption?: boolean;
}): Promise<void> {
if (params.state.started) return;
if (sharedClientStartPromise) {
await sharedClientStartPromise;
return;
}
sharedClientStartPromise = (async () => {
const client = params.state.client;
// Initialize crypto if enabled
if (params.encryption && !params.state.cryptoReady) {
try {
const joinedRooms = await client.getJoinedRooms();
if (client.crypto) {
await client.crypto.prepare(joinedRooms);
params.state.cryptoReady = true;
}
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
}
}
await client.start();
params.state.started = true;
})();
try {
await sharedClientStartPromise;
} finally {
sharedClientStartPromise = null;
}
}
export async function resolveSharedMatrixClient(
params: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
auth?: MatrixAuth;
startClient?: boolean;
accountId?: string | null;
} = {},
): Promise<MatrixClient> {
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
const key = buildSharedClientKey(auth, params.accountId);
const shouldStart = params.startClient !== false;
if (sharedClientState?.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: sharedClientState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return sharedClientState.client;
}
if (sharedClientPromise) {
const pending = await sharedClientPromise;
if (pending.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return pending.client;
}
pending.client.stop();
sharedClientState = null;
sharedClientPromise = null;
}
sharedClientPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId,
});
try {
const created = await sharedClientPromise;
sharedClientState = created;
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return created.client;
} finally {
sharedClientPromise = null;
}
}
export async function waitForMatrixSync(_params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// matrix-bot-sdk handles sync internally in start()
// This is kept for API compatibility but is essentially a no-op now
}
export function stopSharedClient(): void {
if (sharedClientState) {
sharedClientState.client.stop();
sharedClientState = null;
}
}

View File

@@ -0,0 +1,131 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixStoragePaths } from "./types.js";
export const DEFAULT_ACCOUNT_KEY = "default";
const STORAGE_META_FILENAME = "storage-meta.json";
function sanitizePathSegment(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || "unknown";
}
function resolveHomeserverKey(homeserver: string): string {
try {
const url = new URL(homeserver);
if (url.host) return sanitizePathSegment(url.host);
} catch {
// fall through
}
return sanitizePathSegment(homeserver);
}
function hashAccessToken(accessToken: string): string {
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
}
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
storagePath: string;
cryptoPath: string;
} {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return {
storagePath: path.join(stateDir, "matrix", "bot-storage.json"),
cryptoPath: path.join(stateDir, "matrix", "crypto"),
};
}
export function resolveMatrixStoragePaths(params: {
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): MatrixStoragePaths {
const env = params.env ?? process.env;
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
const userKey = sanitizePathSegment(params.userId);
const serverKey = resolveHomeserverKey(params.homeserver);
const tokenHash = hashAccessToken(params.accessToken);
const rootDir = path.join(
stateDir,
"matrix",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
);
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
accountKey,
tokenHash,
};
}
export function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
env?: NodeJS.ProcessEnv;
}): void {
const legacy = resolveLegacyStoragePaths(params.env);
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
const hasNewStorage =
fs.existsSync(params.storagePaths.storagePath) ||
fs.existsSync(params.storagePaths.cryptoPath);
if (!hasLegacyStorage && !hasLegacyCrypto) return;
if (hasNewStorage) return;
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
if (hasLegacyStorage) {
try {
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
} catch {
// Ignore migration failures; new store will be created.
}
}
if (hasLegacyCrypto) {
try {
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
} catch {
// Ignore migration failures; new store will be created.
}
}
}
export function writeStorageMeta(params: {
storagePaths: MatrixStoragePaths;
homeserver: string;
userId: string;
accountId?: string | null;
}): void {
try {
const payload = {
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
accessTokenHash: params.storagePaths.tokenHash,
createdAt: new Date().toISOString(),
};
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
fs.writeFileSync(
params.storagePaths.metaPath,
JSON.stringify(payload, null, 2),
"utf-8",
);
} catch {
// ignore meta write failures
}
}

View File

@@ -0,0 +1,34 @@
export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
/**
* Authenticated Matrix configuration.
* Note: deviceId is NOT included here because it's implicit in the accessToken.
* The crypto storage assumes the device ID (and thus access token) does not change
* between restarts. If the access token becomes invalid or crypto storage is lost,
* both will need to be recreated together.
*/
export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
export type MatrixStoragePaths = {
rootDir: string;
storagePath: string;
cryptoPath: string;
metaPath: string;
accountKey: string;
tokenHash: string;
};

View File

@@ -8,6 +8,7 @@ export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
createdAt: string;
lastUsedAt?: string;
};
@@ -94,5 +95,9 @@ export function credentialsMatchConfig(
stored: MatrixStoredCredentials,
config: { homeserver: string; userId: string },
): boolean {
// If userId is empty (token-based auth), only match homeserver
if (!config.userId) {
return stored.homeserver === config.homeserver;
}
return stored.homeserver === config.homeserver && stored.userId === config.userId;
}

Some files were not shown because too many files have changed in this diff Show More