Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
383bdcd778 | ||
|
|
18cb665b66 | ||
|
|
22060137e0 | ||
|
|
390ba5f42a | ||
|
|
b8593fd4fb | ||
|
|
68a467dd66 | ||
|
|
d18319a57d | ||
|
|
15e5bb3459 | ||
|
|
41f6d06967 | ||
|
|
e3a99aa2ce | ||
|
|
c1d8456860 | ||
|
|
da10ca1585 | ||
|
|
5d017dae5a | ||
|
|
30fd7001f2 | ||
|
|
da4b124480 | ||
|
|
710c681283 | ||
|
|
e45228ac37 | ||
|
|
a0180f364d | ||
|
|
d69f246ba7 | ||
|
|
a81989048d | ||
|
|
b56e9964f5 | ||
|
|
ddd7fc1513 | ||
|
|
4ebf55f1db | ||
|
|
cc24ede586 | ||
|
|
eb3b84f3d2 | ||
|
|
304244f2be | ||
|
|
f067ea25b4 | ||
|
|
fa51294f65 | ||
|
|
a4d1c4d522 | ||
|
|
6e17c463ae | ||
|
|
63797e841d | ||
|
|
fdb171cb15 | ||
|
|
6f9861bb9b | ||
|
|
759068304e | ||
|
|
ded578b1fa | ||
|
|
dcb8d16591 | ||
|
|
06c17a333e | ||
|
|
409a16060b | ||
|
|
7720106624 | ||
|
|
c613769d22 | ||
|
|
87343c374e | ||
|
|
67be9aed28 | ||
|
|
b48d5d96d3 | ||
|
|
d8cc7db5e6 | ||
|
|
dfbf6ac263 | ||
|
|
121ae6036b | ||
|
|
6e1ad31b49 | ||
|
|
0330b483ad | ||
|
|
9a2bf57e1c | ||
|
|
439044068a | ||
|
|
4c3b4aeb76 | ||
|
|
1e8b291374 | ||
|
|
95f82154f7 | ||
|
|
7bc3998451 | ||
|
|
d029ceab1c | ||
|
|
c331bdc27d | ||
|
|
b0b42b4e14 | ||
|
|
e5514d4854 | ||
|
|
20bc89d96c | ||
|
|
199fef2a5e | ||
|
|
d9a2ac7e72 | ||
|
|
a16934b2ab | ||
|
|
14a072f5fa | ||
|
|
574b848863 | ||
|
|
2e6c58bf75 | ||
|
|
a5d89e6eb1 | ||
|
|
61907ddf3e | ||
|
|
1eab8fa9b0 | ||
|
|
2cf444be02 | ||
|
|
7870ce8177 | ||
|
|
e9d691d472 | ||
|
|
ac2fcfe96a | ||
|
|
627fa3083b | ||
|
|
e4877656ca | ||
|
|
d91f0ceeb3 | ||
|
|
9b71382efb | ||
|
|
dd82d32d85 | ||
|
|
5a42f7cabd | ||
|
|
f2c25c5f40 | ||
|
|
7e08de4a5f | ||
|
|
c9e3c14f9c | ||
|
|
636a8e3181 | ||
|
|
660f87278c | ||
|
|
9ec1fb4a80 | ||
|
|
74757cd5af | ||
|
|
9f75550702 | ||
|
|
8214ab507c | ||
|
|
2f6d5805de | ||
|
|
48f733e4b3 | ||
|
|
e5f7435d9f | ||
|
|
322c8dc4fc | ||
|
|
cf04b0e3bf | ||
|
|
0f9f510dd9 | ||
|
|
e110cf4fb1 | ||
|
|
6942ceb7a9 | ||
|
|
47cf28f6b6 | ||
|
|
115b4379bf | ||
|
|
a6db1edee3 | ||
|
|
4ed1b7c7ed | ||
|
|
7ef7b94bc0 | ||
|
|
da7da30b22 | ||
|
|
213d9b47b0 | ||
|
|
d88b239d3c | ||
|
|
1c02de1309 | ||
|
|
d3c2b83f88 | ||
|
|
93f80894a3 | ||
|
|
c440cc2f84 | ||
|
|
8ddedc3fc5 | ||
|
|
2439c31844 | ||
|
|
292f21ae78 | ||
|
|
9c2c4b1138 | ||
|
|
e0e33e12d1 | ||
|
|
eb5145c5d1 | ||
|
|
aedf4ce328 | ||
|
|
94af5a72fc | ||
|
|
9dbc1435a6 | ||
|
|
32a668e4d9 | ||
|
|
c17c7b4e24 | ||
|
|
c5e732951b | ||
|
|
069b50635b | ||
|
|
51f1f23235 | ||
|
|
c9d02f0132 | ||
|
|
0bd99717be | ||
|
|
154c49511c | ||
|
|
34462b3221 | ||
|
|
0372bdf6fe | ||
|
|
cd8309cc31 | ||
|
|
c3a34408f3 | ||
|
|
9f280454ba | ||
|
|
5d9a5b7958 |
No files matched your search
@@ -66,3 +66,6 @@ apps/ios/*.mobileprovision
|
|||||||
IDENTITY.md
|
IDENTITY.md
|
||||||
USER.md
|
USER.md
|
||||||
.tgz
|
.tgz
|
||||||
|
|
||||||
|
# local tooling
|
||||||
|
.serena/
|
||||||
@@ -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
|
||||||
@@ -1 +0,0 @@
|
|||||||
/cache
|
|
||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -1,87 +0,0 @@
|
|||||||
# list of languages for which language servers are started; choose from:
|
|
||||||
# al bash clojure cpp csharp csharp_omnisharp
|
|
||||||
# dart elixir elm erlang fortran fsharp
|
|
||||||
# go groovy haskell java julia kotlin
|
|
||||||
# lua markdown nix pascal perl php
|
|
||||||
# powershell python python_jedi r rego ruby
|
|
||||||
# ruby_solargraph rust scala swift terraform toml
|
|
||||||
# typescript typescript_vts yaml zig
|
|
||||||
# Note:
|
|
||||||
# - For C, use cpp
|
|
||||||
# - For JavaScript, use typescript
|
|
||||||
# - For Free Pascal / Lazarus, use pascal
|
|
||||||
# Special requirements:
|
|
||||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
|
||||||
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
|
|
||||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
|
||||||
# The first language is the default language and the respective language server will be used as a fallback.
|
|
||||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
|
||||||
languages:
|
|
||||||
- typescript
|
|
||||||
|
|
||||||
# the encoding used by text files in the project
|
|
||||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
|
||||||
encoding: "utf-8"
|
|
||||||
|
|
||||||
# whether to use the project's gitignore file to ignore files
|
|
||||||
# Added on 2025-04-07
|
|
||||||
ignore_all_files_in_gitignore: true
|
|
||||||
|
|
||||||
# list of additional paths to ignore
|
|
||||||
# same syntax as gitignore, so you can use * and **
|
|
||||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
|
||||||
# Added (renamed) on 2025-04-07
|
|
||||||
ignored_paths: []
|
|
||||||
|
|
||||||
# whether the project is in read-only mode
|
|
||||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
|
||||||
# Added on 2025-04-18
|
|
||||||
read_only: false
|
|
||||||
|
|
||||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
|
||||||
# Below is the complete list of tools for convenience.
|
|
||||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
|
||||||
# execute `uv run scripts/print_tool_overview.py`.
|
|
||||||
#
|
|
||||||
# * `activate_project`: Activates a project by name.
|
|
||||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
|
||||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
|
||||||
# * `delete_lines`: Deletes a range of lines within a file.
|
|
||||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
|
||||||
# * `execute_shell_command`: Executes a shell command.
|
|
||||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
|
||||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
|
||||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
|
||||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
|
||||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
|
||||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
|
||||||
# Should only be used in settings where the system prompt cannot be set,
|
|
||||||
# e.g. in clients you have no control over, like Claude Desktop.
|
|
||||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
|
||||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
|
||||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
|
||||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
|
||||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
|
||||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
|
||||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
|
||||||
# * `read_file`: Reads a file within the project directory.
|
|
||||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
|
||||||
# * `remove_project`: Removes a project from the Serena configuration.
|
|
||||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
|
||||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
|
||||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
|
||||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
|
||||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
|
||||||
# * `switch_modes`: Activates modes by providing a list of their names
|
|
||||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
|
||||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
|
||||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
|
||||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
|
||||||
excluded_tools: []
|
|
||||||
|
|
||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
|
||||||
# (contrary to the memories, which are loaded on demand).
|
|
||||||
initial_prompt: ""
|
|
||||||
|
|
||||||
project_name: "clawdbot"
|
|
||||||
included_optional_tools: []
|
|
||||||
@@ -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.
|
- 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.
|
- 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
|
## Testing Guidelines
|
||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||||
|
|||||||
+15
-2
@@ -5,13 +5,24 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.20-1
|
## 2026.1.20-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Deps: update workspace + memory-lancedb dependencies.
|
||||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
- 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
|
### Fixes
|
||||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||||
|
- Session: preserve command argument casing while matching reset triggers. (#1308) — thanks @dougvk.
|
||||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
- 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.
|
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||||
- Agents: enforce tool policy/sandbox rules for tool-dispatched skill commands and forward tool media outputs. (#1235) — thanks @dougvk.
|
- 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
|
## 2026.1.19-3
|
||||||
|
|
||||||
@@ -24,6 +35,8 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
- 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.
|
- 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
|
## 2026.1.19-2
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ clawdbot agent --message "Ship checklist" --thinking high
|
|||||||
|
|
||||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
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)
|
## From source (development)
|
||||||
|
|
||||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||||
@@ -471,24 +480,26 @@ Thanks to all clawtributors:
|
|||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
||||||
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
||||||
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a>
|
||||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
<a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
|
||||||
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a>
|
||||||
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
|
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a>
|
||||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
<a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a>
|
||||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
<a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
|
||||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
|
||||||
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
|
||||||
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
<a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
|
||||||
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a>
|
||||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a>
|
||||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
<a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
|
||||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
<a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a>
|
||||||
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
<a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a>
|
||||||
<a href="https://github.com/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a>
|
||||||
<a href="https://github.com/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/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=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/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=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/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>
|
</p>
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
"originHash" : "2e6f580ad7d1e839d513aa883350369bf2e4193fad872030fdaea7827f34d8ef",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "commander",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/steipete/Commander.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||||
|
"version" : "0.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "elevenlabskit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||||
"version" : "0.1.0"
|
"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",
|
"identity" : "swift-syntax",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -27,6 +45,24 @@
|
|||||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||||
"version" : "0.99.0"
|
"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
|
"version" : 3
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.clawdbot.android.chat.ChatMessage
|
|||||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
import com.clawdbot.android.chat.OutgoingAttachment
|
import com.clawdbot.android.chat.OutgoingAttachment
|
||||||
|
import com.clawdbot.android.gateway.DeviceAuthStore
|
||||||
import com.clawdbot.android.gateway.DeviceIdentityStore
|
import com.clawdbot.android.gateway.DeviceIdentityStore
|
||||||
import com.clawdbot.android.gateway.GatewayClientInfo
|
import com.clawdbot.android.gateway.GatewayClientInfo
|
||||||
import com.clawdbot.android.gateway.GatewayConnectOptions
|
import com.clawdbot.android.gateway.GatewayConnectOptions
|
||||||
@@ -62,6 +63,7 @@ class NodeRuntime(context: Context) {
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
val prefs = SecurePrefs(appContext)
|
val prefs = SecurePrefs(appContext)
|
||||||
|
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||||
val canvas = CanvasController()
|
val canvas = CanvasController()
|
||||||
val camera = CameraCaptureManager(appContext)
|
val camera = CameraCaptureManager(appContext)
|
||||||
val location = LocationCaptureManager(appContext)
|
val location = LocationCaptureManager(appContext)
|
||||||
@@ -153,6 +155,7 @@ class NodeRuntime(context: Context) {
|
|||||||
GatewaySession(
|
GatewaySession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
identityStore = identityStore,
|
identityStore = identityStore,
|
||||||
|
deviceAuthStore = deviceAuthStore,
|
||||||
onConnected = { name, remote, mainSessionKey ->
|
onConnected = { name, remote, mainSessionKey ->
|
||||||
operatorConnected = true
|
operatorConnected = true
|
||||||
operatorStatusText = "Connected"
|
operatorStatusText = "Connected"
|
||||||
@@ -188,6 +191,7 @@ class NodeRuntime(context: Context) {
|
|||||||
GatewaySession(
|
GatewaySession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
identityStore = identityStore,
|
identityStore = identityStore,
|
||||||
|
deviceAuthStore = deviceAuthStore,
|
||||||
onConnected = { _, _, _ ->
|
onConnected = { _, _, _ ->
|
||||||
nodeConnected = true
|
nodeConnected = true
|
||||||
nodeStatusText = "Connected"
|
nodeStatusText = "Connected"
|
||||||
|
|||||||
@@ -189,6 +189,18 @@ class SecurePrefs(context: Context) {
|
|||||||
prefs.edit { putString(key, fingerprint.trim()) }
|
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 {
|
private fun loadOrCreateInstanceId(): String {
|
||||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||||
if (!existing.isNullOrBlank()) return existing
|
if (!existing.isNullOrBlank()) return existing
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ data class GatewayConnectOptions(
|
|||||||
class GatewaySession(
|
class GatewaySession(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val identityStore: DeviceIdentityStore,
|
private val identityStore: DeviceIdentityStore,
|
||||||
|
private val deviceAuthStore: DeviceAuthStore,
|
||||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||||
private val onDisconnected: (message: String) -> Unit,
|
private val onDisconnected: (message: String) -> Unit,
|
||||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||||
@@ -177,6 +178,7 @@ class GatewaySession(
|
|||||||
private val connectDeferred = CompletableDeferred<Unit>()
|
private val connectDeferred = CompletableDeferred<Unit>()
|
||||||
private val closedDeferred = CompletableDeferred<Unit>()
|
private val closedDeferred = CompletableDeferred<Unit>()
|
||||||
private val isClosed = AtomicBoolean(false)
|
private val isClosed = AtomicBoolean(false)
|
||||||
|
private val connectNonceDeferred = CompletableDeferred<String?>()
|
||||||
private val client: OkHttpClient = buildClient()
|
private val client: OkHttpClient = buildClient()
|
||||||
private var socket: WebSocket? = null
|
private var socket: WebSocket? = null
|
||||||
private val loggerTag = "ClawdbotGateway"
|
private val loggerTag = "ClawdbotGateway"
|
||||||
@@ -253,7 +255,8 @@ class GatewaySession(
|
|||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
sendConnect()
|
val nonce = awaitConnectNonce()
|
||||||
|
sendConnect(nonce)
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
connectDeferred.completeExceptionally(err)
|
connectDeferred.completeExceptionally(err)
|
||||||
closeQuietly()
|
closeQuietly()
|
||||||
@@ -288,16 +291,30 @@ class GatewaySession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sendConnect() {
|
private suspend fun sendConnect(connectNonce: String?) {
|
||||||
val payload = buildConnectParams()
|
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)
|
val res = request("connect", payload, timeoutMs = 8_000)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
val msg = res.error?.message ?: "connect failed"
|
val msg = res.error?.message ?: "connect failed"
|
||||||
|
if (canFallbackToShared) {
|
||||||
|
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
||||||
|
}
|
||||||
throw IllegalStateException(msg)
|
throw IllegalStateException(msg)
|
||||||
}
|
}
|
||||||
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
||||||
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
||||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
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()
|
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
||||||
val sessionDefaults =
|
val sessionDefaults =
|
||||||
@@ -308,7 +325,12 @@ class GatewaySession(
|
|||||||
connectDeferred.complete(Unit)
|
connectDeferred.complete(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildConnectParams(): JsonObject {
|
private fun buildConnectParams(
|
||||||
|
identity: DeviceIdentity,
|
||||||
|
connectNonce: String?,
|
||||||
|
authToken: String,
|
||||||
|
authPassword: String?,
|
||||||
|
): JsonObject {
|
||||||
val client = options.client
|
val client = options.client
|
||||||
val locale = Locale.getDefault().toLanguageTag()
|
val locale = Locale.getDefault().toLanguageTag()
|
||||||
val clientObj =
|
val clientObj =
|
||||||
@@ -323,22 +345,20 @@ class GatewaySession(
|
|||||||
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val authToken = token?.trim().orEmpty()
|
val password = authPassword?.trim().orEmpty()
|
||||||
val authPassword = password?.trim().orEmpty()
|
|
||||||
val authJson =
|
val authJson =
|
||||||
when {
|
when {
|
||||||
authToken.isNotEmpty() ->
|
authToken.isNotEmpty() ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("token", JsonPrimitive(authToken))
|
put("token", JsonPrimitive(authToken))
|
||||||
}
|
}
|
||||||
authPassword.isNotEmpty() ->
|
password.isNotEmpty() ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("password", JsonPrimitive(authPassword))
|
put("password", JsonPrimitive(password))
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
val identity = identityStore.loadOrCreate()
|
|
||||||
val signedAtMs = System.currentTimeMillis()
|
val signedAtMs = System.currentTimeMillis()
|
||||||
val payload =
|
val payload =
|
||||||
buildDeviceAuthPayload(
|
buildDeviceAuthPayload(
|
||||||
@@ -349,6 +369,7 @@ class GatewaySession(
|
|||||||
scopes = options.scopes,
|
scopes = options.scopes,
|
||||||
signedAtMs = signedAtMs,
|
signedAtMs = signedAtMs,
|
||||||
token = if (authToken.isNotEmpty()) authToken else null,
|
token = if (authToken.isNotEmpty()) authToken else null,
|
||||||
|
nonce = connectNonce,
|
||||||
)
|
)
|
||||||
val signature = identityStore.signPayload(payload, identity)
|
val signature = identityStore.signPayload(payload, identity)
|
||||||
val publicKey = identityStore.publicKeyBase64Url(identity)
|
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||||
@@ -359,6 +380,9 @@ class GatewaySession(
|
|||||||
put("publicKey", JsonPrimitive(publicKey))
|
put("publicKey", JsonPrimitive(publicKey))
|
||||||
put("signature", JsonPrimitive(signature))
|
put("signature", JsonPrimitive(signature))
|
||||||
put("signedAt", JsonPrimitive(signedAtMs))
|
put("signedAt", JsonPrimitive(signedAtMs))
|
||||||
|
if (!connectNonce.isNullOrBlank()) {
|
||||||
|
put("nonce", JsonPrimitive(connectNonce))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -416,6 +440,13 @@ class GatewaySession(
|
|||||||
val event = frame["event"].asStringOrNull() ?: return
|
val event = frame["event"].asStringOrNull() ?: return
|
||||||
val payloadJson =
|
val payloadJson =
|
||||||
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
|
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) {
|
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
|
||||||
handleInvokeEvent(payloadJson)
|
handleInvokeEvent(payloadJson)
|
||||||
return
|
return
|
||||||
@@ -423,6 +454,21 @@ class GatewaySession(
|
|||||||
onEvent(event, payloadJson)
|
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) {
|
private fun handleInvokeEvent(payloadJson: String) {
|
||||||
val payload =
|
val payload =
|
||||||
try {
|
try {
|
||||||
@@ -544,19 +590,26 @@ class GatewaySession(
|
|||||||
scopes: List<String>,
|
scopes: List<String>,
|
||||||
signedAtMs: Long,
|
signedAtMs: Long,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
nonce: String?,
|
||||||
): String {
|
): String {
|
||||||
val scopeString = scopes.joinToString(",")
|
val scopeString = scopes.joinToString(",")
|
||||||
val authToken = token.orEmpty()
|
val authToken = token.orEmpty()
|
||||||
return listOf(
|
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
|
||||||
"v1",
|
val parts =
|
||||||
deviceId,
|
mutableListOf(
|
||||||
clientId,
|
version,
|
||||||
clientMode,
|
deviceId,
|
||||||
role,
|
clientId,
|
||||||
scopeString,
|
clientMode,
|
||||||
signedAtMs.toString(),
|
role,
|
||||||
authToken,
|
scopeString,
|
||||||
).joinToString("|")
|
signedAtMs.toString(),
|
||||||
|
authToken,
|
||||||
|
)
|
||||||
|
if (!nonce.isNullOrBlank()) {
|
||||||
|
parts.add(nonce)
|
||||||
|
}
|
||||||
|
return parts.joinToString("|")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
|
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
|
||||||
|
|||||||
@@ -84,5 +84,7 @@ private fun sha256Hex(data: ByteArray): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeFingerprint(raw: String): 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' }
|
||||||
}
|
}
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.11-4</string>
|
<string>2026.1.9</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>20260109</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
Sources/Bridge/BridgeClient.swift
|
Sources/Gateway/GatewayConnectionController.swift
|
||||||
Sources/Bridge/BridgeConnectionController.swift
|
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
|
||||||
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
|
Sources/Gateway/GatewayDiscoveryModel.swift
|
||||||
Sources/Bridge/BridgeDiscoveryModel.swift
|
Sources/Gateway/GatewaySettingsStore.swift
|
||||||
Sources/Bridge/BridgeEndpointID.swift
|
Sources/Gateway/KeychainStore.swift
|
||||||
Sources/Bridge/BridgeSession.swift
|
|
||||||
Sources/Bridge/BridgeSettingsStore.swift
|
|
||||||
Sources/Bridge/KeychainStore.swift
|
|
||||||
Sources/Camera/CameraController.swift
|
Sources/Camera/CameraController.swift
|
||||||
Sources/Chat/ChatSheet.swift
|
Sources/Chat/ChatSheet.swift
|
||||||
Sources/Chat/IOSBridgeChatTransport.swift
|
Sources/Chat/IOSGatewayChatTransport.swift
|
||||||
Sources/ClawdbotApp.swift
|
Sources/ClawdbotApp.swift
|
||||||
|
Sources/Location/LocationService.swift
|
||||||
Sources/Model/NodeAppModel.swift
|
Sources/Model/NodeAppModel.swift
|
||||||
Sources/RootCanvas.swift
|
Sources/RootCanvas.swift
|
||||||
Sources/RootTabs.swift
|
Sources/RootTabs.swift
|
||||||
@@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift
|
|||||||
Sources/Screen/ScreenRecordService.swift
|
Sources/Screen/ScreenRecordService.swift
|
||||||
Sources/Screen/ScreenTab.swift
|
Sources/Screen/ScreenTab.swift
|
||||||
Sources/Screen/ScreenWebView.swift
|
Sources/Screen/ScreenWebView.swift
|
||||||
|
Sources/SessionKey.swift
|
||||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||||
Sources/Settings/SettingsTab.swift
|
Sources/Settings/SettingsTab.swift
|
||||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.11-4</string>
|
<string>2026.1.9</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>20260109</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -78,6 +78,7 @@ let package = Package(
|
|||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "ClawdbotWizardCLI",
|
name: "ClawdbotWizardCLI",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||||
],
|
],
|
||||||
path: "Sources/ClawdbotWizardCLI",
|
path: "Sources/ClawdbotWizardCLI",
|
||||||
|
|||||||
@@ -426,34 +426,17 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelTitle(_ id: String) -> String {
|
private func resolveChannelTitle(_ id: String) -> String {
|
||||||
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
|
let label = self.store.resolveChannelLabel(id)
|
||||||
return label
|
if label != id { return label }
|
||||||
}
|
|
||||||
return id.prefix(1).uppercased() + id.dropFirst()
|
return id.prefix(1).uppercased() + id.dropFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||||
switch id {
|
return self.store.resolveChannelDetailLabel(id)
|
||||||
case "whatsapp": "WhatsApp Web"
|
|
||||||
case "telegram": "Telegram Bot"
|
|
||||||
case "discord": "Discord Bot"
|
|
||||||
case "slack": "Slack Bot"
|
|
||||||
case "signal": "Signal REST"
|
|
||||||
case "imessage": "iMessage"
|
|
||||||
default: self.resolveChannelTitle(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||||
switch id {
|
return self.store.resolveChannelSystemImage(id)
|
||||||
case "whatsapp": "message"
|
|
||||||
case "telegram": "paperplane"
|
|
||||||
case "discord": "bubble.left.and.bubble.right"
|
|
||||||
case "slack": "number"
|
|
||||||
case "signal": "antenna.radiowaves.left.and.right"
|
|
||||||
case "imessage": "message.fill"
|
|
||||||
default: "message"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||||
|
|||||||
@@ -153,9 +153,19 @@ struct ChannelsStatusSnapshot: Codable {
|
|||||||
let application: AnyCodable?
|
let application: AnyCodable?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ChannelUiMetaEntry: Codable {
|
||||||
|
let id: String
|
||||||
|
let label: String
|
||||||
|
let detailLabel: String
|
||||||
|
let systemImage: String?
|
||||||
|
}
|
||||||
|
|
||||||
let ts: Double
|
let ts: Double
|
||||||
let channelOrder: [String]
|
let channelOrder: [String]
|
||||||
let channelLabels: [String: String]
|
let channelLabels: [String: String]
|
||||||
|
let channelDetailLabels: [String: String]? = nil
|
||||||
|
let channelSystemImages: [String: String]? = nil
|
||||||
|
let channelMeta: [ChannelUiMetaEntry]? = nil
|
||||||
let channels: [String: AnyCodable]
|
let channels: [String: AnyCodable]
|
||||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
||||||
let channelDefaultAccountId: [String: String]
|
let channelDefaultAccountId: [String: String]
|
||||||
@@ -217,6 +227,47 @@ final class ChannelsStore {
|
|||||||
var configRoot: [String: Any] = [:]
|
var configRoot: [String: Any] = [:]
|
||||||
var configLoaded = false
|
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) {
|
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||||
self.isPreview = isPreview
|
self.isPreview = isPreview
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ extension CronJobEditor {
|
|||||||
self.thinking = thinking ?? ""
|
self.thinking = thinking ?? ""
|
||||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||||
self.deliver = deliver ?? false
|
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.to = to ?? ""
|
||||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||||
}
|
}
|
||||||
@@ -210,7 +211,8 @@ extension CronJobEditor {
|
|||||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||||
payload["deliver"] = self.deliver
|
payload["deliver"] = self.deliver
|
||||||
if 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)
|
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !to.isEmpty { payload["to"] = to }
|
if !to.isEmpty { payload["to"] = to }
|
||||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ extension CronJobEditor {
|
|||||||
self.payloadKind = .agentTurn
|
self.payloadKind = .agentTurn
|
||||||
self.agentMessage = "Run diagnostic"
|
self.agentMessage = "Run diagnostic"
|
||||||
self.deliver = true
|
self.deliver = true
|
||||||
self.channel = .last
|
self.channel = "last"
|
||||||
self.to = "+15551230000"
|
self.to = "+15551230000"
|
||||||
self.thinking = "low"
|
self.thinking = "low"
|
||||||
self.timeoutSeconds = "90"
|
self.timeoutSeconds = "90"
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CronJobEditor: View {
|
struct CronJobEditor: View {
|
||||||
let job: CronJob?
|
let job: CronJob?
|
||||||
@Binding var isSaving: Bool
|
@Binding var isSaving: Bool
|
||||||
@Binding var error: String?
|
@Binding var error: String?
|
||||||
|
@Bindable var channelsStore: ChannelsStore
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
let onSave: ([String: AnyCodable]) -> Void
|
let onSave: ([String: AnyCodable]) -> Void
|
||||||
|
|
||||||
@@ -45,13 +47,29 @@ struct CronJobEditor: View {
|
|||||||
@State var systemEventText: String = ""
|
@State var systemEventText: String = ""
|
||||||
@State var agentMessage: String = ""
|
@State var agentMessage: String = ""
|
||||||
@State var deliver: Bool = false
|
@State var deliver: Bool = false
|
||||||
@State var channel: GatewayAgentChannel = .last
|
@State var channel: String = "last"
|
||||||
@State var to: String = ""
|
@State var to: String = ""
|
||||||
@State var thinking: String = ""
|
@State var thinking: String = ""
|
||||||
@State var timeoutSeconds: String = ""
|
@State var timeoutSeconds: String = ""
|
||||||
@State var bestEffortDeliver: Bool = false
|
@State var bestEffortDeliver: Bool = false
|
||||||
@State var postPrefix: String = "Cron"
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -333,13 +351,9 @@ struct CronJobEditor: View {
|
|||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Channel")
|
self.gridLabel("Channel")
|
||||||
Picker("", selection: self.$channel) {
|
Picker("", selection: self.$channel) {
|
||||||
Text("last").tag(GatewayAgentChannel.last)
|
ForEach(self.channelOptions, id: \.self) { channel in
|
||||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
Text(self.channelLabel(for: channel)).tag(channel)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ extension CronSettings {
|
|||||||
self.content
|
self.content
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.onAppear { self.store.start() }
|
.onAppear {
|
||||||
.onDisappear { self.store.stop() }
|
self.store.start()
|
||||||
|
self.channelsStore.start()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
self.store.stop()
|
||||||
|
self.channelsStore.stop()
|
||||||
|
}
|
||||||
.sheet(isPresented: self.$showEditor) {
|
.sheet(isPresented: self.$showEditor) {
|
||||||
CronJobEditor(
|
CronJobEditor(
|
||||||
job: self.editingJob,
|
job: self.editingJob,
|
||||||
isSaving: self.$isSaving,
|
isSaving: self.$isSaving,
|
||||||
error: self.$editorError,
|
error: self.$editorError,
|
||||||
|
channelsStore: self.channelsStore,
|
||||||
onCancel: {
|
onCancel: {
|
||||||
self.showEditor = false
|
self.showEditor = false
|
||||||
self.editingJob = nil
|
self.editingJob = nil
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider {
|
|||||||
durationMs: 1234,
|
durationMs: 1234,
|
||||||
nextRunAtMs: nil),
|
nextRunAtMs: nil),
|
||||||
]
|
]
|
||||||
return CronSettings(store: store)
|
return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ extension CronSettings {
|
|||||||
store.selectedJobId = job.id
|
store.selectedJobId = job.id
|
||||||
store.runEntries = [run]
|
store.runEntries = [run]
|
||||||
|
|
||||||
let view = CronSettings(store: store)
|
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||||
_ = view.body
|
_ = view.body
|
||||||
_ = view.jobRow(job)
|
_ = view.jobRow(job)
|
||||||
_ = view.jobContextMenu(job)
|
_ = view.jobContextMenu(job)
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CronSettings: View {
|
struct CronSettings: View {
|
||||||
@Bindable var store: CronJobsStore
|
@Bindable var store: CronJobsStore
|
||||||
|
@Bindable var channelsStore: ChannelsStore
|
||||||
@State var showEditor = false
|
@State var showEditor = false
|
||||||
@State var editingJob: CronJob?
|
@State var editingJob: CronJob?
|
||||||
@State var editorError: String?
|
@State var editorError: String?
|
||||||
@State var isSaving = false
|
@State var isSaving = false
|
||||||
@State var confirmDelete: CronJob?
|
@State var confirmDelete: CronJob?
|
||||||
|
|
||||||
init(store: CronJobsStore = .shared) {
|
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
|
self.channelsStore = channelsStore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,7 @@ private struct ExecHostRequest: Codable {
|
|||||||
var needsScreenRecording: Bool?
|
var needsScreenRecording: Bool?
|
||||||
var agentId: String?
|
var agentId: String?
|
||||||
var sessionKey: String?
|
var sessionKey: String?
|
||||||
|
var approvalDecision: ExecApprovalDecision?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ExecHostRunResult: Codable {
|
private struct ExecHostRunResult: Codable {
|
||||||
@@ -328,8 +329,21 @@ private enum ExecHostExecutor {
|
|||||||
return false
|
return false
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var approvedByAsk = false
|
let approvalDecision = request.approvalDecision
|
||||||
if requiresAsk {
|
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(
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
ExecApprovalPromptRequest(
|
ExecApprovalPromptRequest(
|
||||||
command: displayCommand,
|
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 {
|
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||||
return ExecHostResponse(
|
return ExecHostResponse(
|
||||||
type: "exec-res",
|
type: "exec-res",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
|||||||
case signal
|
case signal
|
||||||
case imessage
|
case imessage
|
||||||
case msteams
|
case msteams
|
||||||
|
case bluebubbles
|
||||||
case webchat
|
case webchat
|
||||||
|
|
||||||
init(raw: String?) {
|
init(raw: String?) {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,10 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
struct WizardCliOptions {
|
struct WizardCliOptions {
|
||||||
var url: String?
|
var url: String?
|
||||||
var token: String?
|
var token: String?
|
||||||
@@ -228,6 +231,10 @@ private func parseInt(_ value: Any?) -> Int? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actor GatewayWizardClient {
|
actor GatewayWizardClient {
|
||||||
|
private enum ConnectChallengeError: Error {
|
||||||
|
case timeout
|
||||||
|
}
|
||||||
|
|
||||||
private let url: URL
|
private let url: URL
|
||||||
private let token: String?
|
private let token: String?
|
||||||
private let password: String?
|
private let password: String?
|
||||||
@@ -235,6 +242,7 @@ actor GatewayWizardClient {
|
|||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let session = URLSession(configuration: .default)
|
private let session = URLSession(configuration: .default)
|
||||||
|
private let connectChallengeTimeoutSeconds: Double = 0.75
|
||||||
private var task: URLSessionWebSocketTask?
|
private var task: URLSessionWebSocketTask?
|
||||||
|
|
||||||
init(url: URL, token: String?, password: String?, json: Bool) {
|
init(url: URL, token: String?, password: String?, json: Bool) {
|
||||||
@@ -257,7 +265,7 @@ actor GatewayWizardClient {
|
|||||||
self.task = nil
|
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 {
|
guard let task = self.task else {
|
||||||
throw WizardCliError.gatewayError("gateway not connected")
|
throw WizardCliError.gatewayError("gateway not connected")
|
||||||
}
|
}
|
||||||
@@ -266,7 +274,7 @@ actor GatewayWizardClient {
|
|||||||
type: "req",
|
type: "req",
|
||||||
id: id,
|
id: id,
|
||||||
method: method,
|
method: method,
|
||||||
params: params.map { AnyCodable($0) })
|
params: params.map { ProtoAnyCodable($0) })
|
||||||
let data = try self.encoder.encode(frame)
|
let data = try self.encoder.encode(frame)
|
||||||
try await task.send(.data(data))
|
try await task.send(.data(data))
|
||||||
|
|
||||||
@@ -309,28 +317,65 @@ actor GatewayWizardClient {
|
|||||||
}
|
}
|
||||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||||
let client: [String: AnyCodable] = [
|
let clientId = "clawdbot-macos"
|
||||||
"id": AnyCodable("clawdbot-macos"),
|
let clientMode = "ui"
|
||||||
"displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
|
let role = "operator"
|
||||||
"version": AnyCodable("dev"),
|
let scopes: [String] = []
|
||||||
"platform": AnyCodable(platform),
|
let client: [String: ProtoAnyCodable] = [
|
||||||
"deviceFamily": AnyCodable("Mac"),
|
"id": ProtoAnyCodable(clientId),
|
||||||
"mode": AnyCodable("ui"),
|
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
|
||||||
"instanceId": AnyCodable(UUID().uuidString),
|
"version": ProtoAnyCodable("dev"),
|
||||||
|
"platform": ProtoAnyCodable(platform),
|
||||||
|
"deviceFamily": ProtoAnyCodable("Mac"),
|
||||||
|
"mode": ProtoAnyCodable(clientMode),
|
||||||
|
"instanceId": ProtoAnyCodable(UUID().uuidString),
|
||||||
]
|
]
|
||||||
|
|
||||||
var params: [String: AnyCodable] = [
|
var params: [String: ProtoAnyCodable] = [
|
||||||
"minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
"maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
"client": AnyCodable(client),
|
"client": ProtoAnyCodable(client),
|
||||||
"caps": AnyCodable([String]()),
|
"caps": ProtoAnyCodable([String]()),
|
||||||
"locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
|
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
|
||||||
"userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||||
|
"role": ProtoAnyCodable(role),
|
||||||
|
"scopes": ProtoAnyCodable(scopes),
|
||||||
]
|
]
|
||||||
if let token = self.token {
|
if let token = self.token {
|
||||||
params["auth"] = AnyCodable(["token": AnyCodable(token)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||||
} else if let password = self.password {
|
} 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
|
let reqId = UUID().uuidString
|
||||||
@@ -338,31 +383,57 @@ actor GatewayWizardClient {
|
|||||||
type: "req",
|
type: "req",
|
||||||
id: reqId,
|
id: reqId,
|
||||||
method: "connect",
|
method: "connect",
|
||||||
params: AnyCodable(params))
|
params: ProtoAnyCodable(params))
|
||||||
let data = try self.encoder.encode(frame)
|
let data = try self.encoder.encode(frame)
|
||||||
try await task.send(.data(data))
|
try await task.send(.data(data))
|
||||||
|
|
||||||
let message = try await task.receive()
|
while true {
|
||||||
let frameResponse = try decodeFrame(message)
|
let message = try await task.receive()
|
||||||
guard case let .res(res) = frameResponse, res.id == reqId else {
|
let frameResponse = try decodeFrame(message)
|
||||||
throw WizardCliError.gatewayError("connect failed (unexpected response)")
|
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 {
|
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()
|
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
if mode == "local" || mode == "remote" {
|
if mode == "local" || mode == "remote" {
|
||||||
params["mode"] = AnyCodable(mode)
|
params["mode"] = ProtoAnyCodable(mode)
|
||||||
}
|
}
|
||||||
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
|
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)
|
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) {
|
if let step = decodeWizardStep(nextResult.step) {
|
||||||
let answer = try promptAnswer(for: step)
|
let answer = try promptAnswer(for: step)
|
||||||
var answerPayload: [String: AnyCodable] = [
|
var answerPayload: [String: ProtoAnyCodable] = [
|
||||||
"stepId": AnyCodable(step.id),
|
"stepId": ProtoAnyCodable(step.id),
|
||||||
]
|
]
|
||||||
if !(answer is NSNull) {
|
if !(answer is NSNull) {
|
||||||
answerPayload["value"] = AnyCodable(answer)
|
answerPayload["value"] = ProtoAnyCodable(answer)
|
||||||
}
|
}
|
||||||
let response = try await client.request(
|
let response = try await client.request(
|
||||||
method: "wizard.next",
|
method: "wizard.next",
|
||||||
params: [
|
params: [
|
||||||
"sessionId": AnyCodable(sessionId),
|
"sessionId": ProtoAnyCodable(sessionId),
|
||||||
"answer": AnyCodable(answerPayload),
|
"answer": ProtoAnyCodable(answerPayload),
|
||||||
])
|
])
|
||||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||||
if opts.json {
|
if opts.json {
|
||||||
@@ -414,7 +485,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
|||||||
} else {
|
} else {
|
||||||
let response = try await client.request(
|
let response = try await client.request(
|
||||||
method: "wizard.next",
|
method: "wizard.next",
|
||||||
params: ["sessionId": AnyCodable(sessionId)])
|
params: ["sessionId": ProtoAnyCodable(sessionId)])
|
||||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||||
if opts.json {
|
if opts.json {
|
||||||
dumpResult(response)
|
dumpResult(response)
|
||||||
@@ -424,7 +495,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
|||||||
} catch WizardCliError.cancelled {
|
} catch WizardCliError.cancelled {
|
||||||
_ = try? await client.request(
|
_ = try? await client.request(
|
||||||
method: "wizard.cancel",
|
method: "wizard.cancel",
|
||||||
params: ["sessionId": AnyCodable(sessionId)])
|
params: ["sessionId": ProtoAnyCodable(sessionId)])
|
||||||
throw WizardCliError.cancelled
|
throw WizardCliError.cancelled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||||
|
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel(raw: nil) == .last)
|
#expect(GatewayAgentChannel(raw: nil) == .last)
|
||||||
#expect(GatewayAgentChannel(raw: " ") == .last)
|
#expect(GatewayAgentChannel(raw: " ") == .last)
|
||||||
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
||||||
|
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
||||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct DeviceIdentity: Codable, Sendable {
|
public struct DeviceIdentity: Codable, Sendable {
|
||||||
var deviceId: String
|
public var deviceId: String
|
||||||
var publicKey: String
|
public var publicKey: String
|
||||||
var privateKey: String
|
public var privateKey: String
|
||||||
var createdAtMs: Int
|
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 {
|
enum DeviceIdentityPaths {
|
||||||
@@ -27,10 +34,10 @@ enum DeviceIdentityPaths {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DeviceIdentityStore {
|
public enum DeviceIdentityStore {
|
||||||
private static let fileName = "device.json"
|
private static let fileName = "device.json"
|
||||||
|
|
||||||
static func loadOrCreate() -> DeviceIdentity {
|
public static func loadOrCreate() -> DeviceIdentity {
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
if let data = try? Data(contentsOf: url),
|
if let data = try? Data(contentsOf: url),
|
||||||
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
||||||
@@ -44,7 +51,7 @@ enum DeviceIdentityStore {
|
|||||||
return identity
|
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 }
|
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
|
||||||
do {
|
do {
|
||||||
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
|
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
|
||||||
@@ -76,7 +83,7 @@ enum DeviceIdentityStore {
|
|||||||
.replacingOccurrences(of: "=", with: "")
|
.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 }
|
guard let data = Data(base64Encoded: identity.publicKey) else { return nil }
|
||||||
return self.base64UrlEncode(data)
|
return self.base64UrlEncode(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
// Avoid ambiguity with the app's own AnyCodable type.
|
// Avoid ambiguity with the app's own AnyCodable type.
|
||||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
|
private enum ConnectChallengeError: Error {
|
||||||
|
case timeout
|
||||||
|
}
|
||||||
|
|
||||||
public actor GatewayChannelActor {
|
public actor GatewayChannelActor {
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
|
||||||
private var task: WebSocketTaskBox?
|
private var task: WebSocketTaskBox?
|
||||||
@@ -113,6 +117,7 @@ public actor GatewayChannelActor {
|
|||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let connectTimeoutSeconds: Double = 6
|
private let connectTimeoutSeconds: Double = 6
|
||||||
|
private let connectChallengeTimeoutSeconds: Double = 0.75
|
||||||
private var watchdogTask: Task<Void, Never>?
|
private var watchdogTask: Task<Void, Never>?
|
||||||
private var tickTask: Task<Void, Never>?
|
private var tickTask: Task<Void, Never>?
|
||||||
private let defaultRequestTimeoutMs: Double = 15000
|
private let defaultRequestTimeoutMs: Double = 15000
|
||||||
@@ -256,6 +261,8 @@ public actor GatewayChannelActor {
|
|||||||
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
|
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
|
||||||
let clientId = options.clientId
|
let clientId = options.clientId
|
||||||
let clientMode = options.clientMode
|
let clientMode = options.clientMode
|
||||||
|
let role = options.role
|
||||||
|
let scopes = options.scopes
|
||||||
|
|
||||||
let reqId = UUID().uuidString
|
let reqId = UUID().uuidString
|
||||||
var client: [String: ProtoAnyCodable] = [
|
var client: [String: ProtoAnyCodable] = [
|
||||||
@@ -278,8 +285,8 @@ public actor GatewayChannelActor {
|
|||||||
"caps": ProtoAnyCodable(options.caps),
|
"caps": ProtoAnyCodable(options.caps),
|
||||||
"locale": ProtoAnyCodable(primaryLocale),
|
"locale": ProtoAnyCodable(primaryLocale),
|
||||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||||
"role": ProtoAnyCodable(options.role),
|
"role": ProtoAnyCodable(role),
|
||||||
"scopes": ProtoAnyCodable(options.scopes),
|
"scopes": ProtoAnyCodable(scopes),
|
||||||
]
|
]
|
||||||
if !options.commands.isEmpty {
|
if !options.commands.isEmpty {
|
||||||
params["commands"] = ProtoAnyCodable(options.commands)
|
params["commands"] = ProtoAnyCodable(options.commands)
|
||||||
@@ -287,32 +294,44 @@ public actor GatewayChannelActor {
|
|||||||
if !options.permissions.isEmpty {
|
if !options.permissions.isEmpty {
|
||||||
params["permissions"] = ProtoAnyCodable(options.permissions)
|
params["permissions"] = ProtoAnyCodable(options.permissions)
|
||||||
}
|
}
|
||||||
if let token = self.token {
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
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 {
|
} else if let password = self.password {
|
||||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||||
}
|
}
|
||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
|
||||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
let scopes = options.scopes.joined(separator: ",")
|
let connectNonce = try await self.waitForConnectChallenge()
|
||||||
let payload = [
|
let scopesValue = scopes.joined(separator: ",")
|
||||||
"v1",
|
var payloadParts = [
|
||||||
|
connectNonce == nil ? "v1" : "v2",
|
||||||
identity.deviceId,
|
identity.deviceId,
|
||||||
clientId,
|
clientId,
|
||||||
clientMode,
|
clientMode,
|
||||||
options.role,
|
role,
|
||||||
scopes,
|
scopesValue,
|
||||||
String(signedAtMs),
|
String(signedAtMs),
|
||||||
self.token ?? "",
|
authToken ?? "",
|
||||||
].joined(separator: "|")
|
]
|
||||||
|
if let connectNonce {
|
||||||
|
payloadParts.append(connectNonce)
|
||||||
|
}
|
||||||
|
let payload = payloadParts.joined(separator: "|")
|
||||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||||
params["device"] = ProtoAnyCodable([
|
var device: [String: ProtoAnyCodable] = [
|
||||||
"id": ProtoAnyCodable(identity.deviceId),
|
"id": ProtoAnyCodable(identity.deviceId),
|
||||||
"publicKey": ProtoAnyCodable(publicKey),
|
"publicKey": ProtoAnyCodable(publicKey),
|
||||||
"signature": ProtoAnyCodable(signature),
|
"signature": ProtoAnyCodable(signature),
|
||||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||||
])
|
]
|
||||||
|
if let connectNonce {
|
||||||
|
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||||
|
}
|
||||||
|
params["device"] = ProtoAnyCodable(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
let frame = RequestFrame(
|
let frame = RequestFrame(
|
||||||
@@ -322,40 +341,22 @@ public actor GatewayChannelActor {
|
|||||||
params: ProtoAnyCodable(params))
|
params: ProtoAnyCodable(params))
|
||||||
let data = try self.encoder.encode(frame)
|
let data = try self.encoder.encode(frame)
|
||||||
try await self.task?.send(.data(data))
|
try await self.task?.send(.data(data))
|
||||||
guard let msg = try await task?.receive() else {
|
do {
|
||||||
throw NSError(
|
let response = try await self.waitForConnectResponse(reqId: reqId)
|
||||||
domain: "Gateway",
|
try await self.handleConnectResponse(response, identity: identity, role: role)
|
||||||
code: 1,
|
} catch {
|
||||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
|
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 {
|
private func handleConnectResponse(
|
||||||
let data: Data? = switch msg {
|
_ res: ResponseFrame,
|
||||||
case let .data(d): d
|
identity: DeviceIdentity,
|
||||||
case let .string(s): s.data(using: .utf8)
|
role: String
|
||||||
@unknown default: nil
|
) async throws {
|
||||||
}
|
|
||||||
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)"])
|
|
||||||
}
|
|
||||||
if res.ok == false {
|
if res.ok == false {
|
||||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||||
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
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 {
|
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
||||||
self.tickIntervalMs = Double(tick)
|
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.lastTick = Date()
|
||||||
self.tickTask?.cancel()
|
self.tickTask?.cancel()
|
||||||
self.tickTask = Task { [weak self] in
|
self.tickTask = Task { [weak self] in
|
||||||
@@ -424,6 +436,7 @@ public actor GatewayChannelActor {
|
|||||||
waiter.resume(returning: .res(res))
|
waiter.resume(returning: .res(res))
|
||||||
}
|
}
|
||||||
case let .event(evt):
|
case let .event(evt):
|
||||||
|
if evt.event == "connect.challenge" { return }
|
||||||
if let seq = evt.seq {
|
if let seq = evt.seq {
|
||||||
if let last = lastSeq, seq > last + 1 {
|
if let last = lastSeq, seq > last + 1 {
|
||||||
await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
|
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 {
|
private func watchTicks() async {
|
||||||
let tolerance = self.tickIntervalMs * 2
|
let tolerance = self.tickIntervalMs * 2
|
||||||
while self.connected {
|
while self.connected {
|
||||||
|
|||||||
@@ -108,5 +108,9 @@ private func sha256Hex(_ data: Data) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func normalizeFingerprint(_ raw: String) -> 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)
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ public let GATEWAY_PROTOCOL_VERSION = 3
|
|||||||
|
|
||||||
public enum ErrorCode: String, Codable, Sendable {
|
public enum ErrorCode: String, Codable, Sendable {
|
||||||
case notLinked = "NOT_LINKED"
|
case notLinked = "NOT_LINKED"
|
||||||
|
case notPaired = "NOT_PAIRED"
|
||||||
case agentTimeout = "AGENT_TIMEOUT"
|
case agentTimeout = "AGENT_TIMEOUT"
|
||||||
case invalidRequest = "INVALID_REQUEST"
|
case invalidRequest = "INVALID_REQUEST"
|
||||||
case unavailable = "UNAVAILABLE"
|
case unavailable = "UNAVAILABLE"
|
||||||
@@ -15,6 +16,11 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
public let maxprotocol: Int
|
public let maxprotocol: Int
|
||||||
public let client: [String: AnyCodable]
|
public let client: [String: AnyCodable]
|
||||||
public let caps: [String]?
|
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 auth: [String: AnyCodable]?
|
||||||
public let locale: String?
|
public let locale: String?
|
||||||
public let useragent: String?
|
public let useragent: String?
|
||||||
@@ -24,6 +30,11 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
maxprotocol: Int,
|
maxprotocol: Int,
|
||||||
client: [String: AnyCodable],
|
client: [String: AnyCodable],
|
||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
|
commands: [String]?,
|
||||||
|
permissions: [String: AnyCodable]?,
|
||||||
|
role: String?,
|
||||||
|
scopes: [String]?,
|
||||||
|
device: [String: AnyCodable]?,
|
||||||
auth: [String: AnyCodable]?,
|
auth: [String: AnyCodable]?,
|
||||||
locale: String?,
|
locale: String?,
|
||||||
useragent: String?
|
useragent: String?
|
||||||
@@ -32,6 +43,11 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
self.maxprotocol = maxprotocol
|
self.maxprotocol = maxprotocol
|
||||||
self.client = client
|
self.client = client
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
|
self.commands = commands
|
||||||
|
self.permissions = permissions
|
||||||
|
self.role = role
|
||||||
|
self.scopes = scopes
|
||||||
|
self.device = device
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
self.locale = locale
|
self.locale = locale
|
||||||
self.useragent = useragent
|
self.useragent = useragent
|
||||||
@@ -41,6 +57,11 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
case maxprotocol = "maxProtocol"
|
case maxprotocol = "maxProtocol"
|
||||||
case client
|
case client
|
||||||
case caps
|
case caps
|
||||||
|
case commands
|
||||||
|
case permissions
|
||||||
|
case role
|
||||||
|
case scopes
|
||||||
|
case device
|
||||||
case auth
|
case auth
|
||||||
case locale
|
case locale
|
||||||
case useragent = "userAgent"
|
case useragent = "userAgent"
|
||||||
@@ -54,6 +75,7 @@ public struct HelloOk: Codable, Sendable {
|
|||||||
public let features: [String: AnyCodable]
|
public let features: [String: AnyCodable]
|
||||||
public let snapshot: Snapshot
|
public let snapshot: Snapshot
|
||||||
public let canvashosturl: String?
|
public let canvashosturl: String?
|
||||||
|
public let auth: [String: AnyCodable]?
|
||||||
public let policy: [String: AnyCodable]
|
public let policy: [String: AnyCodable]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -63,6 +85,7 @@ public struct HelloOk: Codable, Sendable {
|
|||||||
features: [String: AnyCodable],
|
features: [String: AnyCodable],
|
||||||
snapshot: Snapshot,
|
snapshot: Snapshot,
|
||||||
canvashosturl: String?,
|
canvashosturl: String?,
|
||||||
|
auth: [String: AnyCodable]?,
|
||||||
policy: [String: AnyCodable]
|
policy: [String: AnyCodable]
|
||||||
) {
|
) {
|
||||||
self.type = type
|
self.type = type
|
||||||
@@ -71,6 +94,7 @@ public struct HelloOk: Codable, Sendable {
|
|||||||
self.features = features
|
self.features = features
|
||||||
self.snapshot = snapshot
|
self.snapshot = snapshot
|
||||||
self.canvashosturl = canvashosturl
|
self.canvashosturl = canvashosturl
|
||||||
|
self.auth = auth
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
@@ -80,6 +104,7 @@ public struct HelloOk: Codable, Sendable {
|
|||||||
case features
|
case features
|
||||||
case snapshot
|
case snapshot
|
||||||
case canvashosturl = "canvasHostUrl"
|
case canvashosturl = "canvasHostUrl"
|
||||||
|
case auth
|
||||||
case policy
|
case policy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +205,9 @@ public struct PresenceEntry: Codable, Sendable {
|
|||||||
public let tags: [String]?
|
public let tags: [String]?
|
||||||
public let text: String?
|
public let text: String?
|
||||||
public let ts: Int
|
public let ts: Int
|
||||||
|
public let deviceid: String?
|
||||||
|
public let roles: [String]?
|
||||||
|
public let scopes: [String]?
|
||||||
public let instanceid: String?
|
public let instanceid: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -195,6 +223,9 @@ public struct PresenceEntry: Codable, Sendable {
|
|||||||
tags: [String]?,
|
tags: [String]?,
|
||||||
text: String?,
|
text: String?,
|
||||||
ts: Int,
|
ts: Int,
|
||||||
|
deviceid: String?,
|
||||||
|
roles: [String]?,
|
||||||
|
scopes: [String]?,
|
||||||
instanceid: String?
|
instanceid: String?
|
||||||
) {
|
) {
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -209,6 +240,9 @@ public struct PresenceEntry: Codable, Sendable {
|
|||||||
self.tags = tags
|
self.tags = tags
|
||||||
self.text = text
|
self.text = text
|
||||||
self.ts = ts
|
self.ts = ts
|
||||||
|
self.deviceid = deviceid
|
||||||
|
self.roles = roles
|
||||||
|
self.scopes = scopes
|
||||||
self.instanceid = instanceid
|
self.instanceid = instanceid
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
@@ -224,6 +258,9 @@ public struct PresenceEntry: Codable, Sendable {
|
|||||||
case tags
|
case tags
|
||||||
case text
|
case text
|
||||||
case ts
|
case ts
|
||||||
|
case deviceid = "deviceId"
|
||||||
|
case roles
|
||||||
|
case scopes
|
||||||
case instanceid = "instanceId"
|
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 struct SessionsListParams: Codable, Sendable {
|
||||||
public let limit: Int?
|
public let limit: Int?
|
||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
@@ -1381,6 +1505,22 @@ public struct ModelsListResult: Codable, Sendable {
|
|||||||
public struct SkillsStatusParams: 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 struct SkillsInstallParams: Codable, Sendable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let installid: 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 struct ChatHistoryParams: Codable, Sendable {
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let limit: Int?
|
public let limit: Int?
|
||||||
|
|||||||
+178
-26
@@ -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:
|
read_when:
|
||||||
- Setting up BlueBubbles channel
|
- Setting up BlueBubbles channel
|
||||||
- Troubleshooting webhook pairing
|
- Troubleshooting webhook pairing
|
||||||
|
- Configuring iMessage on macOS
|
||||||
---
|
---
|
||||||
# BlueBubbles (macOS REST)
|
# 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
|
## 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/*`).
|
- 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.
|
- 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).
|
- 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.
|
- 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
|
## Quick start
|
||||||
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
|
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 for `guid`/`password`.
|
2. In the BlueBubbles config, enable the web API and set a password.
|
||||||
3. Configure Clawdbot:
|
3. Run `clawdbot onboard` and select BlueBubbles, or configure manually:
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
bluebubbles: {
|
bluebubbles: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
serverUrl: "http://bluebubbles-host:1234",
|
serverUrl: "http://192.168.1.100:1234",
|
||||||
password: "example-password",
|
password: "example-password",
|
||||||
webhookPath: "/bluebubbles-webhook",
|
webhookPath: "/bluebubbles-webhook"
|
||||||
actions: { reactions: true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
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.
|
5. Start the gateway; it will register the webhook handler and start pairing.
|
||||||
|
|
||||||
## Configuration notes
|
## Onboarding
|
||||||
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
|
BlueBubbles is available in the interactive setup wizard:
|
||||||
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
|
```
|
||||||
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
|
clawdbot onboard
|
||||||
- `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).
|
|
||||||
|
|
||||||
## How it works
|
The wizard prompts for:
|
||||||
- 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.
|
- **Server URL** (required): BlueBubbles server address (e.g., `http://192.168.1.100:1234`)
|
||||||
- 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`.
|
- **Password** (required): API password from BlueBubbles Server settings
|
||||||
- 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`.
|
- **Webhook path** (optional): Defaults to `/bluebubbles-webhook`
|
||||||
- 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.
|
- **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
|
## Security
|
||||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
- 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.
|
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
||||||
|
|
||||||
## Troubleshooting
|
## 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>`.
|
- 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.
|
- 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.
|
||||||
@@ -16,9 +16,10 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
- [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).
|
||||||
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
|
- [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).
|
- [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).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Clawdbot normalizes shared locations from chat channels into:
|
|||||||
Currently supported:
|
Currently supported:
|
||||||
- **Telegram** (location pins + venues + live locations)
|
- **Telegram** (location pins + venues + live locations)
|
||||||
- **WhatsApp** (locationMessage + liveLocationMessage)
|
- **WhatsApp** (locationMessage + liveLocationMessage)
|
||||||
|
- **Matrix** (`m.location` with `geo_uri`)
|
||||||
|
|
||||||
## Text formatting
|
## Text formatting
|
||||||
Locations are rendered as friendly lines without brackets:
|
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
|
## Channel notes
|
||||||
- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
|
- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
|
||||||
- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line.
|
- **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.
|
||||||
+123
-26
@@ -5,17 +5,26 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Matrix (plugin)
|
# 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
|
## Plugin required
|
||||||
|
|
||||||
Matrix ships as a plugin and is not bundled with the core install.
|
Matrix ships as a plugin and is not bundled with the core install.
|
||||||
|
|
||||||
Install via CLI (npm registry):
|
Install via CLI (npm registry):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot plugins install @clawdbot/matrix
|
clawdbot plugins install @clawdbot/matrix
|
||||||
```
|
```
|
||||||
|
|
||||||
Local checkout (when running from a git repo):
|
Local checkout (when running from a git repo):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot plugins install ./extensions/matrix
|
clawdbot plugins install ./extensions/matrix
|
||||||
```
|
```
|
||||||
@@ -25,27 +34,54 @@ Clawdbot will offer the local install path automatically.
|
|||||||
|
|
||||||
Details: [Plugins](/plugin)
|
Details: [Plugins](/plugin)
|
||||||
|
|
||||||
## Quick setup (beginner)
|
## Setup
|
||||||
|
|
||||||
1) Install the Matrix plugin:
|
1) Install the Matrix plugin:
|
||||||
- From npm: `clawdbot plugins install @clawdbot/matrix`
|
- From npm: `clawdbot plugins install @clawdbot/matrix`
|
||||||
- From a local checkout: `clawdbot plugins install ./extensions/matrix`
|
- From a local checkout: `clawdbot plugins install ./extensions/matrix`
|
||||||
2) Configure credentials:
|
2) Create a Matrix account on a homeserver:
|
||||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
|
- 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.*`
|
- Or config: `channels.matrix.*`
|
||||||
- If both are set, config takes precedence.
|
- If both are set, config takes precedence.
|
||||||
3) Restart the gateway (or finish onboarding).
|
- With access token: user ID is fetched automatically via `/whoami`.
|
||||||
4) DM access defaults to pairing; approve the pairing code on first contact.
|
- 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
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
homeserver: "https://matrix.example.org",
|
homeserver: "https://matrix.example.org",
|
||||||
userId: "@clawdbot:example.org",
|
|
||||||
accessToken: "syt_***",
|
accessToken: "syt_***",
|
||||||
dm: { policy: "pairing" }
|
dm: { policy: "pairing" }
|
||||||
}
|
}
|
||||||
@@ -53,18 +89,57 @@ Minimal config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Encryption (E2EE)
|
E2EE config (end to end encryption enabled):
|
||||||
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 won’t reply.
|
|
||||||
|
|
||||||
## What it is
|
```json5
|
||||||
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.
|
channels: {
|
||||||
- Deterministic routing: replies go back to Matrix.
|
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.
|
- DMs share the agent's main session; rooms map to group sessions.
|
||||||
|
|
||||||
## Access control (DMs)
|
## Access control (DMs)
|
||||||
|
|
||||||
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
|
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
|
||||||
- Approve via:
|
- Approve via:
|
||||||
- `clawdbot pairing list matrix`
|
- `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.
|
- `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)
|
## Rooms (groups)
|
||||||
|
|
||||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
- 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
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
rooms: {
|
groupPolicy: "allowlist",
|
||||||
"!roomId:example.org": { requireMention: true }
|
groups: {
|
||||||
}
|
"!roomId:example.org": { allow: true },
|
||||||
|
"#alias:example.org": { allow: true }
|
||||||
|
},
|
||||||
|
groupAllowFrom: ["@owner:example.org"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `requireMention: false` enables auto-reply in that room.
|
- `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.
|
- 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.
|
- 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).
|
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||||
|
- Legacy key: `channels.matrix.rooms` (same shape as `groups`).
|
||||||
|
|
||||||
## Threads
|
## Threads
|
||||||
|
|
||||||
- Reply threading is supported.
|
- 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`
|
- `off` (default), `first`, `all`
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Direct messages | ✅ Supported |
|
| Direct messages | ✅ Supported |
|
||||||
| Rooms | ✅ Supported |
|
| Rooms | ✅ Supported |
|
||||||
| Threads | ✅ Supported |
|
| Threads | ✅ Supported |
|
||||||
| Media | ✅ Supported |
|
| Media | ✅ Supported |
|
||||||
| Reactions | ✅ Supported |
|
| E2EE | ✅ Supported (crypto module required) |
|
||||||
| Polls | ✅ Supported |
|
| 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 |
|
| Native commands | ✅ Supported |
|
||||||
|
|
||||||
## Configuration reference (Matrix)
|
## Configuration reference (Matrix)
|
||||||
|
|
||||||
Full configuration: [Configuration](/gateway/configuration)
|
Full configuration: [Configuration](/gateway/configuration)
|
||||||
|
|
||||||
Provider options:
|
Provider options:
|
||||||
|
|
||||||
- `channels.matrix.enabled`: enable/disable channel startup.
|
- `channels.matrix.enabled`: enable/disable channel startup.
|
||||||
- `channels.matrix.homeserver`: homeserver URL.
|
- `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.accessToken`: access token.
|
||||||
- `channels.matrix.password`: password for login (token stored).
|
- `channels.matrix.password`: password for login (token stored).
|
||||||
- `channels.matrix.deviceName`: device display name.
|
- `channels.matrix.deviceName`: device display name.
|
||||||
|
- `channels.matrix.encryption`: enable E2EE (default: false).
|
||||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `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.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.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.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.replyToMode`: reply-to mode for threads/tags.
|
||||||
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
summary: "Nextcloud Talk support status, capabilities, and configuration"
|
||||||
|
read_when:
|
||||||
|
- Working on Nextcloud Talk channel features
|
||||||
|
---
|
||||||
|
# Nextcloud Talk (plugin)
|
||||||
|
|
||||||
|
Status: supported via plugin (webhook bot). Direct messages, rooms, reactions, and markdown messages are supported.
|
||||||
|
|
||||||
|
## Plugin required
|
||||||
|
Nextcloud Talk ships as a plugin and is not bundled with the core install.
|
||||||
|
|
||||||
|
Install via CLI (npm registry):
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/nextcloud-talk
|
||||||
|
```
|
||||||
|
|
||||||
|
Local checkout (when running from a git repo):
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install ./extensions/nextcloud-talk
|
||||||
|
```
|
||||||
|
|
||||||
|
If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected,
|
||||||
|
Clawdbot will offer the local install path automatically.
|
||||||
|
|
||||||
|
Details: [Plugins](/plugin)
|
||||||
|
|
||||||
|
## Quick setup (beginner)
|
||||||
|
1) Install the Nextcloud Talk plugin.
|
||||||
|
2) On your Nextcloud server, create a bot:
|
||||||
|
```bash
|
||||||
|
./occ talk:bot:install "Clawdbot" "<shared-secret>" "<webhook-url>" --feature reaction
|
||||||
|
```
|
||||||
|
3) Enable the bot in the target room settings.
|
||||||
|
4) Configure Clawdbot:
|
||||||
|
- Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret`
|
||||||
|
- Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only)
|
||||||
|
5) Restart the gateway (or finish onboarding).
|
||||||
|
|
||||||
|
Minimal config:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
"nextcloud-talk": {
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: "https://cloud.example.com",
|
||||||
|
botSecret: "shared-secret",
|
||||||
|
dmPolicy: "pairing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Bots cannot initiate DMs. The user must message the bot first.
|
||||||
|
- Webhook URL must be reachable by the Gateway; set `webhookPublicUrl` if behind a proxy.
|
||||||
|
- Media uploads are not supported by the bot API; media is sent as URLs.
|
||||||
|
- The webhook payload does not distinguish DMs vs rooms; set `apiUser` + `apiPassword` to enable room-type lookups (otherwise DMs are treated as rooms).
|
||||||
|
|
||||||
|
## Access control (DMs)
|
||||||
|
- Default: `channels.nextcloud-talk.dmPolicy = "pairing"`. Unknown senders get a pairing code.
|
||||||
|
- Approve via:
|
||||||
|
- `clawdbot pairing list nextcloud-talk`
|
||||||
|
- `clawdbot pairing approve nextcloud-talk <CODE>`
|
||||||
|
- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`.
|
||||||
|
|
||||||
|
## Rooms (groups)
|
||||||
|
- Default: `channels.nextcloud-talk.groupPolicy = "allowlist"` (mention-gated).
|
||||||
|
- Allowlist rooms with `channels.nextcloud-talk.rooms`:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
"nextcloud-talk": {
|
||||||
|
rooms: {
|
||||||
|
"room-token": { requireMention: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- To allow no rooms, keep the allowlist empty or set `channels.nextcloud-talk.groupPolicy="disabled"`.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
| Feature | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| Direct messages | Supported |
|
||||||
|
| Rooms | Supported |
|
||||||
|
| Threads | Not supported |
|
||||||
|
| Media | URL-only |
|
||||||
|
| Reactions | Supported |
|
||||||
|
| Native commands | Not supported |
|
||||||
|
|
||||||
|
## Configuration reference (Nextcloud Talk)
|
||||||
|
Full configuration: [Configuration](/gateway/configuration)
|
||||||
|
|
||||||
|
Provider options:
|
||||||
|
- `channels.nextcloud-talk.enabled`: enable/disable channel startup.
|
||||||
|
- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL.
|
||||||
|
- `channels.nextcloud-talk.botSecret`: bot shared secret.
|
||||||
|
- `channels.nextcloud-talk.botSecretFile`: secret file path.
|
||||||
|
- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection).
|
||||||
|
- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups.
|
||||||
|
- `channels.nextcloud-talk.apiPasswordFile`: API password file path.
|
||||||
|
- `channels.nextcloud-talk.webhookPort`: webhook listener port (default: 8788).
|
||||||
|
- `channels.nextcloud-talk.webhookHost`: webhook host (default: 0.0.0.0).
|
||||||
|
- `channels.nextcloud-talk.webhookPath`: webhook path (default: /nextcloud-talk-webhook).
|
||||||
|
- `channels.nextcloud-talk.webhookPublicUrl`: externally reachable webhook URL.
|
||||||
|
- `channels.nextcloud-talk.dmPolicy`: `pairing | allowlist | open | disabled`.
|
||||||
|
- `channels.nextcloud-talk.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
|
||||||
|
- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`.
|
||||||
|
- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs).
|
||||||
|
- `channels.nextcloud-talk.rooms`: per-room settings and allowlist.
|
||||||
|
- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables).
|
||||||
|
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||||
|
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
||||||
|
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
||||||
|
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
||||||
|
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
||||||
|
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot devices` (device pairing + token rotation/revocation)"
|
||||||
|
read_when:
|
||||||
|
- You are approving device pairing requests
|
||||||
|
- You need to rotate or revoke device tokens
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot devices`
|
||||||
|
|
||||||
|
Manage device pairing requests and device-scoped tokens.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `clawdbot devices list`
|
||||||
|
|
||||||
|
List pending pairing requests and paired devices.
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices list
|
||||||
|
clawdbot devices list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### `clawdbot devices approve <requestId>`
|
||||||
|
|
||||||
|
Approve a pending device pairing request.
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices approve <requestId>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `clawdbot devices reject <requestId>`
|
||||||
|
|
||||||
|
Reject a pending device pairing request.
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices reject <requestId>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `clawdbot devices rotate --device <id> --role <role> [--scope <scope...>]`
|
||||||
|
|
||||||
|
Rotate a device token for a specific role (optionally updating scopes).
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
|
||||||
|
```
|
||||||
|
|
||||||
|
### `clawdbot devices revoke --device <id> --role <role>`
|
||||||
|
|
||||||
|
Revoke a device token for a specific role.
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices revoke --device <deviceId> --role node
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common options
|
||||||
|
|
||||||
|
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
|
||||||
|
- `--token <token>`: Gateway token (if required).
|
||||||
|
- `--password <password>`: Gateway password (password auth).
|
||||||
|
- `--timeout <ms>`: RPC timeout.
|
||||||
|
- `--json`: JSON output (recommended for scripting).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Token rotation returns a new token (sensitive). Treat it like a secret.
|
||||||
|
- These commands require `operator.pairing` (or `operator.admin`) scope.
|
||||||
+5
-2
@@ -116,17 +116,20 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
|
|||||||
|
|
||||||
## Discover gateways (Bonjour)
|
## Discover gateways (Bonjour)
|
||||||
|
|
||||||
`gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`).
|
`gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`).
|
||||||
|
|
||||||
- Multicast DNS-SD: `local.`
|
- Multicast DNS-SD: `local.`
|
||||||
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
|
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
|
||||||
|
|
||||||
Only gateways with the **bridge enabled** will advertise the discovery beacon.
|
Only gateways with Bonjour discovery enabled (default) advertise the beacon.
|
||||||
|
|
||||||
Wide-Area discovery records include (TXT):
|
Wide-Area discovery records include (TXT):
|
||||||
|
- `role` (gateway role hint)
|
||||||
|
- `transport` (transport hint, e.g. `gateway`)
|
||||||
- `gatewayPort` (WebSocket port, usually `18789`)
|
- `gatewayPort` (WebSocket port, usually `18789`)
|
||||||
- `sshPort` (SSH port; defaults to `22` if not present)
|
- `sshPort` (SSH port; defaults to `22` if not present)
|
||||||
- `tailnetDns` (MagicDNS hostname, when available)
|
- `tailnetDns` (MagicDNS hostname, when available)
|
||||||
|
- `gatewayTls` / `gatewayTlsSha256` (TLS enabled + cert fingerprint)
|
||||||
- `cliPath` (optional hint for remote installs)
|
- `cliPath` (optional hint for remote installs)
|
||||||
|
|
||||||
### `gateway discover`
|
### `gateway discover`
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
|
- [`devices`](/cli/devices)
|
||||||
- [`node`](/cli/node)
|
- [`node`](/cli/node)
|
||||||
- [`approvals`](/cli/approvals)
|
- [`approvals`](/cli/approvals)
|
||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
@@ -188,6 +189,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
runs
|
runs
|
||||||
run
|
run
|
||||||
nodes
|
nodes
|
||||||
|
devices
|
||||||
node
|
node
|
||||||
start
|
start
|
||||||
daemon
|
daemon
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Manage Gateway plugins/extensions (loaded in-process).
|
|||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Plugin system: [Plugins](/plugin)
|
- Plugin system: [Plugins](/plugin)
|
||||||
|
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
|
||||||
- Security hardening: [Security](/gateway/security)
|
- Security hardening: [Security](/gateway/security)
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -28,6 +29,10 @@ clawdbot plugins update --all
|
|||||||
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
|
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
|
||||||
activate them.
|
activate them.
|
||||||
|
|
||||||
|
All plugins must ship a `clawdbot.plugin.json` file with an inline JSON Schema
|
||||||
|
(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent
|
||||||
|
the plugin from loading and fail config validation.
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ Notes:
|
|||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
- Output includes per-agent session stores when multiple agents are configured.
|
||||||
- Overview includes Gateway + Node service install/runtime status when available.
|
- 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)).
|
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||||
+29
-5
@@ -15,7 +15,9 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot update
|
clawdbot update
|
||||||
|
clawdbot update status
|
||||||
clawdbot update --channel beta
|
clawdbot update --channel beta
|
||||||
|
clawdbot update --channel dev
|
||||||
clawdbot update --tag beta
|
clawdbot update --tag beta
|
||||||
clawdbot update --restart
|
clawdbot update --restart
|
||||||
clawdbot update --json
|
clawdbot update --json
|
||||||
@@ -25,22 +27,43 @@ clawdbot --update
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--restart`: restart the Gateway daemon after a successful update.
|
- `--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.
|
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||||
|
|
||||||
Note: downgrades require confirmation because older versions can break configuration.
|
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)
|
## 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:
|
High-level:
|
||||||
|
|
||||||
1. Requires a clean worktree (no uncommitted changes).
|
1. Requires a clean worktree (no uncommitted changes).
|
||||||
2. Fetches and rebases against `@{upstream}`.
|
2. Switches to the selected channel (tag or branch).
|
||||||
3. Installs deps (pnpm preferred; npm fallback).
|
3. Fetches and rebases against `@{upstream}` (dev only).
|
||||||
4. Builds + builds the Control UI.
|
4. Installs deps (pnpm preferred; npm fallback).
|
||||||
5. Runs `clawdbot doctor` as the final “safe update” check.
|
5. Builds + builds the Control UI.
|
||||||
|
6. Runs `clawdbot doctor` as the final “safe update” check.
|
||||||
|
|
||||||
## `--update` shorthand
|
## `--update` shorthand
|
||||||
|
|
||||||
@@ -49,5 +72,6 @@ High-level:
|
|||||||
## See also
|
## See also
|
||||||
|
|
||||||
- `clawdbot doctor` (offers to run update first on git checkouts)
|
- `clawdbot doctor` (offers to run update first on git checkouts)
|
||||||
|
- [Development channels](/install/development-channels)
|
||||||
- [Updating](/install/updating)
|
- [Updating](/install/updating)
|
||||||
- [CLI reference](/cli)
|
- [CLI reference](/cli)
|
||||||
@@ -149,6 +149,14 @@ Control how group/room messages are handled per channel:
|
|||||||
slack: {
|
slack: {
|
||||||
groupPolicy: "allowlist",
|
groupPolicy: "allowlist",
|
||||||
channels: { "#general": { allow: true } }
|
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`).
|
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||||
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
||||||
- Slack: allowlist uses `channels.slack.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.*`).
|
- 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.
|
- 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.
|
- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ We serialize inbound auto-reply runs (all channels) through a tiny in-process qu
|
|||||||
- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
|
- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1).
|
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1 for unconfigured lanes; main defaults to 4, subagent to 8).
|
||||||
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
|
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
|
||||||
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
|
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
|
||||||
- When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
|
- When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
|||||||
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
|
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
|
||||||
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
|
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
|
||||||
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
|
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
|
||||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new <model>` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
||||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||||
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
||||||
|
|
||||||
|
|||||||
@@ -793,6 +793,7 @@
|
|||||||
"install/index",
|
"install/index",
|
||||||
"install/installer",
|
"install/installer",
|
||||||
"install/updating",
|
"install/updating",
|
||||||
|
"install/development-channels",
|
||||||
"install/uninstall",
|
"install/uninstall",
|
||||||
"install/ansible",
|
"install/ansible",
|
||||||
"install/nix",
|
"install/nix",
|
||||||
|
|||||||
@@ -1840,7 +1840,7 @@ Example:
|
|||||||
|
|
||||||
`agents.defaults.subagents` configures sub-agent defaults:
|
`agents.defaults.subagents` configures sub-agent defaults:
|
||||||
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
||||||
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
- `maxConcurrent`: max concurrent sub-agent runs (default 8)
|
||||||
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
||||||
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
|
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
|
||||||
|
|
||||||
@@ -1974,7 +1974,7 @@ Notes:
|
|||||||
|
|
||||||
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
|
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
|
||||||
execute in parallel across sessions. Each session is still serialized (one run
|
execute in parallel across sessions. Each session is still serialized (one run
|
||||||
per session key at a time). Default: 1.
|
per session key at a time). Default: 4.
|
||||||
|
|
||||||
### `agents.defaults.sandbox`
|
### `agents.defaults.sandbox`
|
||||||
|
|
||||||
@@ -2677,7 +2677,7 @@ Notes:
|
|||||||
|
|
||||||
Auth and Tailscale:
|
Auth and Tailscale:
|
||||||
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
||||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing).
|
||||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||||
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
||||||
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
||||||
@@ -2686,6 +2686,9 @@ Auth and Tailscale:
|
|||||||
`true`, Serve requests do not need a token/password; set `false` to require
|
`true`, Serve requests do not need a token/password; set `false` to require
|
||||||
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
||||||
auth mode is not `password`.
|
auth mode is not `password`.
|
||||||
|
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
|
||||||
|
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
|
||||||
|
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
|
||||||
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
||||||
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||||
@@ -2694,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.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.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.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:
|
macOS app behavior:
|
||||||
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
||||||
@@ -2707,12 +2711,36 @@ macOS app behavior:
|
|||||||
remote: {
|
remote: {
|
||||||
url: "ws://gateway.tailnet:18789",
|
url: "ws://gateway.tailnet:18789",
|
||||||
token: "your-token",
|
token: "your-token",
|
||||||
password: "your-password"
|
password: "your-password",
|
||||||
|
tlsFingerprint: "sha256:ab12cd34..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `gateway.nodes` (Node command allowlist)
|
||||||
|
|
||||||
|
The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both
|
||||||
|
**declare** a command and have it **allowed** by the Gateway to run it.
|
||||||
|
|
||||||
|
Use this section to extend or deny commands:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
nodes: {
|
||||||
|
allowCommands: ["custom.vendor.command"], // extra commands beyond defaults
|
||||||
|
denyCommands: ["sms.send"] // block a command even if declared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `allowCommands` extends the built-in per-platform defaults.
|
||||||
|
- `denyCommands` always wins (even if the node claims the command).
|
||||||
|
- `node.invoke` rejects commands that are not declared by the node.
|
||||||
|
|
||||||
### `gateway.reload` (Config hot reload)
|
### `gateway.reload` (Config hot reload)
|
||||||
|
|
||||||
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
|
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ handshake time.
|
|||||||
|
|
||||||
## Handshake (connect)
|
## Handshake (connect)
|
||||||
|
|
||||||
|
Gateway → Client (pre-connect challenge):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "event",
|
||||||
|
"event": "connect.challenge",
|
||||||
|
"payload": { "nonce": "…", "ts": 1737264000000 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Client → Gateway:
|
Client → Gateway:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -43,7 +53,14 @@ Client → Gateway:
|
|||||||
"permissions": {},
|
"permissions": {},
|
||||||
"auth": { "token": "…" },
|
"auth": { "token": "…" },
|
||||||
"locale": "en-US",
|
"locale": "en-US",
|
||||||
"userAgent": "clawdbot-cli/1.2.3"
|
"userAgent": "clawdbot-cli/1.2.3",
|
||||||
|
"device": {
|
||||||
|
"id": "device_fingerprint",
|
||||||
|
"publicKey": "…",
|
||||||
|
"signature": "…",
|
||||||
|
"signedAt": 1737264000000,
|
||||||
|
"nonce": "…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -59,6 +76,18 @@ Gateway → Client:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When a device token is issued, `hello-ok` also includes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"deviceToken": "…",
|
||||||
|
"role": "operator",
|
||||||
|
"scopes": ["operator.read", "operator.write"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Node example
|
### Node example
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -87,7 +116,8 @@ Gateway → Client:
|
|||||||
"id": "device_fingerprint",
|
"id": "device_fingerprint",
|
||||||
"publicKey": "…",
|
"publicKey": "…",
|
||||||
"signature": "…",
|
"signature": "…",
|
||||||
"signedAt": 1737264000000
|
"signedAt": 1737264000000,
|
||||||
|
"nonce": "…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +153,22 @@ Nodes declare capability claims at connect time:
|
|||||||
|
|
||||||
The Gateway treats these as **claims** and enforces server-side allowlists.
|
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
|
## Versioning
|
||||||
|
|
||||||
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`.
|
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`.
|
||||||
@@ -136,6 +182,11 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||||||
|
|
||||||
- If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token`
|
- If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token`
|
||||||
must match or the socket is closed.
|
must match or the socket is closed.
|
||||||
|
- After pairing, the Gateway issues a **device token** scoped to the connection
|
||||||
|
role + scopes. It is returned in `hello-ok.auth.deviceToken` and should be
|
||||||
|
persisted by the client for future connects.
|
||||||
|
- Device tokens can be rotated/revoked via `device.token.rotate` and
|
||||||
|
`device.token.revoke` (requires `operator.pairing` scope).
|
||||||
|
|
||||||
## Device identity + pairing
|
## Device identity + pairing
|
||||||
|
|
||||||
@@ -144,12 +195,14 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||||||
- Gateways issue tokens per device + role.
|
- Gateways issue tokens per device + role.
|
||||||
- Pairing approvals are required for new device IDs unless local auto-approval
|
- Pairing approvals are required for new device IDs unless local auto-approval
|
||||||
is enabled.
|
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 + pinning
|
||||||
|
|
||||||
- TLS is supported for WS connections.
|
- TLS is supported for WS connections.
|
||||||
- Clients may optionally pin the gateway cert fingerprint (see `gateway.tls`
|
- 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
|
## Scope
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need
|
|||||||
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
|
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
|
||||||
- **Non-loopback binds** (`lan`/`tailnet`/`auto`) must use auth tokens/passwords.
|
- **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.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`.
|
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
|
||||||
Set it to `false` if you want tokens/passwords instead.
|
Set it to `false` if you want tokens/passwords instead.
|
||||||
- Treat `browser.controlUrl` like an admin API: tailnet-only + token auth.
|
- Treat `browser.controlUrl` like an admin API: tailnet-only + token auth.
|
||||||
|
|||||||
@@ -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
|
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
||||||
protect local WS access.
|
protect local WS access.
|
||||||
|
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
||||||
|
|
||||||
Auth modes:
|
Auth modes:
|
||||||
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
||||||
|
|||||||
@@ -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
|
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
|
variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the
|
||||||
gateway).
|
gateway).
|
||||||
|
Exec runs on `host=gateway` merge your login-shell `PATH` into the exec environment,
|
||||||
|
so missing tools usually mean your shell init isn’t exporting them (or set
|
||||||
|
`tools.exec.pathPrepend`). See [/tools/exec](/tools/exec).
|
||||||
|
|
||||||
WhatsApp + Telegram channels require **Node**; Bun is unsupported. If your
|
WhatsApp + Telegram channels require **Node**; Bun is unsupported. If your
|
||||||
service was installed with Bun or a version-managed Node path, run `clawdbot doctor`
|
service was installed with Bun or a version-managed Node path, run `clawdbot doctor`
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ Triggered when the gateway starts:
|
|||||||
|
|
||||||
- **`gateway:startup`**: After channels start and hooks are loaded
|
- **`gateway:startup`**: After channels start and hooks are loaded
|
||||||
|
|
||||||
|
### Tool Result Hooks (Plugin API)
|
||||||
|
|
||||||
|
These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before Clawdbot persists them.
|
||||||
|
|
||||||
|
- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop).
|
||||||
|
|
||||||
### Future Events
|
### Future Events
|
||||||
|
|
||||||
Planned event types:
|
Planned event types:
|
||||||
|
|||||||
@@ -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. That’s 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.
|
||||||
@@ -50,20 +50,18 @@ pnpm add -g clawdbot@latest
|
|||||||
```
|
```
|
||||||
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
|
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
|
```bash
|
||||||
clawdbot update --channel beta
|
clawdbot update --channel beta
|
||||||
```
|
clawdbot update --channel dev
|
||||||
|
|
||||||
Switch back to stable later:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot update --channel stable
|
clawdbot update --channel stable
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
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`.
|
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
@@ -88,7 +86,8 @@ clawdbot update --restart
|
|||||||
|
|
||||||
It runs a safe-ish update flow:
|
It runs a safe-ish update flow:
|
||||||
- Requires a clean worktree.
|
- 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`.
|
- 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 can’t detect the install, use “Update (global install)” instead.
|
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||||
|
|||||||
+68
-14
@@ -48,8 +48,11 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||||
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
||||||
|
|
||||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. **Config
|
||||||
register:
|
validation does not execute plugin code**; it uses the plugin manifest and JSON
|
||||||
|
Schema instead. See [Plugin manifest](/plugins/manifest).
|
||||||
|
|
||||||
|
Plugins can register:
|
||||||
|
|
||||||
- Gateway RPC methods
|
- Gateway RPC methods
|
||||||
- Gateway HTTP handlers
|
- Gateway HTTP handlers
|
||||||
@@ -83,6 +86,10 @@ Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
|
|||||||
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
|
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
|
||||||
but can be disabled the same way.
|
but can be disabled the same way.
|
||||||
|
|
||||||
|
Each plugin must include a `clawdbot.plugin.json` file in its root. If a path
|
||||||
|
points at a file, the plugin root is the file's directory and must contain the
|
||||||
|
manifest.
|
||||||
|
|
||||||
If multiple plugins resolve to the same id, the first match in the order above
|
If multiple plugins resolve to the same id, the first match in the order above
|
||||||
wins and lower-precedence copies are ignored.
|
wins and lower-precedence copies are ignored.
|
||||||
|
|
||||||
@@ -105,6 +112,37 @@ becomes `name/<fileBase>`.
|
|||||||
If your plugin imports npm deps, install them in that directory so
|
If your plugin imports npm deps, install them in that directory so
|
||||||
`node_modules` is available (`npm install` / `pnpm install`).
|
`node_modules` is available (`npm install` / `pnpm install`).
|
||||||
|
|
||||||
|
### Channel catalog metadata
|
||||||
|
|
||||||
|
Channel plugins can advertise onboarding metadata via `clawdbot.channel` and
|
||||||
|
install hints via `clawdbot.install`. This keeps the core catalog data-free.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@clawdbot/nextcloud-talk",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./index.ts"],
|
||||||
|
"channel": {
|
||||||
|
"id": "nextcloud-talk",
|
||||||
|
"label": "Nextcloud Talk",
|
||||||
|
"selectionLabel": "Nextcloud Talk (self-hosted)",
|
||||||
|
"docsPath": "/channels/nextcloud-talk",
|
||||||
|
"docsLabel": "nextcloud-talk",
|
||||||
|
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
|
||||||
|
"order": 65,
|
||||||
|
"aliases": ["nc-talk", "nc"]
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@clawdbot/nextcloud-talk",
|
||||||
|
"localPath": "extensions/nextcloud-talk",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Plugin IDs
|
## Plugin IDs
|
||||||
|
|
||||||
Default plugin ids:
|
Default plugin ids:
|
||||||
@@ -140,6 +178,14 @@ Fields:
|
|||||||
|
|
||||||
Config changes **require a gateway restart**.
|
Config changes **require a gateway restart**.
|
||||||
|
|
||||||
|
Validation rules (strict):
|
||||||
|
- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
|
||||||
|
- Unknown `channels.<id>` keys are **errors** unless a plugin manifest declares
|
||||||
|
the channel id.
|
||||||
|
- Plugin config is validated using the JSON Schema embedded in
|
||||||
|
`clawdbot.plugin.json` (`configSchema`).
|
||||||
|
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
|
||||||
|
|
||||||
## Plugin slots (exclusive categories)
|
## Plugin slots (exclusive categories)
|
||||||
|
|
||||||
Some plugin categories are **exclusive** (only one active at a time). Use
|
Some plugin categories are **exclusive** (only one active at a time). Use
|
||||||
@@ -169,22 +215,26 @@ Clawdbot augments `uiHints` at runtime based on discovered plugins:
|
|||||||
`plugins.entries.<id>.config.<field>`
|
`plugins.entries.<id>.config.<field>`
|
||||||
|
|
||||||
If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive),
|
If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive),
|
||||||
provide `configSchema.uiHints`.
|
provide `uiHints` alongside your JSON Schema in the plugin manifest.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```ts
|
```json
|
||||||
export default {
|
{
|
||||||
id: "my-plugin",
|
"id": "my-plugin",
|
||||||
configSchema: {
|
"configSchema": {
|
||||||
parse: (v) => v,
|
"type": "object",
|
||||||
uiHints: {
|
"additionalProperties": false,
|
||||||
"apiKey": { label: "API Key", sensitive: true },
|
"properties": {
|
||||||
"region": { label: "Region", placeholder: "us-east-1" },
|
"apiKey": { "type": "string" },
|
||||||
},
|
"region": { "type": "string" }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
register(api) {},
|
"uiHints": {
|
||||||
};
|
"apiKey": { "label": "API Key", "sensitive": true },
|
||||||
|
"region": { "label": "Region", "placeholder": "us-east-1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
@@ -325,6 +375,8 @@ Notes:
|
|||||||
- Put config under `channels.<id>` (not `plugins.entries`).
|
- Put config under `channels.<id>` (not `plugins.entries`).
|
||||||
- `meta.label` is used for labels in CLI/UI lists.
|
- `meta.label` is used for labels in CLI/UI lists.
|
||||||
- `meta.aliases` adds alternate ids for normalization and CLI inputs.
|
- `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 (step‑by‑step)
|
### Write a new messaging channel (step‑by‑step)
|
||||||
|
|
||||||
@@ -338,6 +390,8 @@ Model provider docs live under `/providers/*`.
|
|||||||
2) Define the channel metadata
|
2) Define the channel metadata
|
||||||
- `meta.label`, `meta.selectionLabel`, `meta.docsPath`, `meta.blurb` control CLI/UI lists.
|
- `meta.label`, `meta.selectionLabel`, `meta.docsPath`, `meta.blurb` control CLI/UI lists.
|
||||||
- `meta.docsPath` should point at a docs page like `/channels/<id>`.
|
- `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
|
3) Implement the required adapters
|
||||||
- `config.listAccountIds` + `config.resolveAccount`
|
- `config.listAccountIds` + `config.resolveAccount`
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
summary: "Plugin manifest + JSON schema requirements (strict config validation)"
|
||||||
|
read_when:
|
||||||
|
- You are building a Clawdbot plugin
|
||||||
|
- You need to ship a plugin config schema or debug plugin validation errors
|
||||||
|
---
|
||||||
|
# Plugin manifest (clawdbot.plugin.json)
|
||||||
|
|
||||||
|
Every plugin **must** ship a `clawdbot.plugin.json` file in the **plugin root**.
|
||||||
|
Clawdbot uses this manifest to validate configuration **without executing plugin
|
||||||
|
code**. Missing or invalid manifests are treated as plugin errors and block
|
||||||
|
config validation.
|
||||||
|
|
||||||
|
See the full plugin system guide: [Plugins](/plugin).
|
||||||
|
|
||||||
|
## Required fields
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "voice-call",
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required keys:
|
||||||
|
- `id` (string): canonical plugin id.
|
||||||
|
- `configSchema` (object): JSON Schema for plugin config (inline).
|
||||||
|
|
||||||
|
Optional keys:
|
||||||
|
- `kind` (string): plugin kind (example: `"memory"`).
|
||||||
|
- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`).
|
||||||
|
- `providers` (array): provider ids registered by this plugin.
|
||||||
|
- `name` (string): display name for the plugin.
|
||||||
|
- `description` (string): short plugin summary.
|
||||||
|
- `uiHints` (object): config field labels/placeholders/sensitive flags for UI rendering.
|
||||||
|
- `version` (string): plugin version (informational).
|
||||||
|
|
||||||
|
## JSON Schema requirements
|
||||||
|
|
||||||
|
- **Every plugin must ship a JSON Schema**, even if it accepts no config.
|
||||||
|
- An empty schema is acceptable (for example, `{ "type": "object", "additionalProperties": false }`).
|
||||||
|
- Schemas are validated at config read/write time, not at runtime.
|
||||||
|
|
||||||
|
## Validation behavior
|
||||||
|
|
||||||
|
- Unknown `channels.*` keys are **errors**, unless the channel id is declared by
|
||||||
|
a plugin manifest.
|
||||||
|
- `plugins.entries.<id>`, `plugins.allow`, `plugins.deny`, and `plugins.slots.*`
|
||||||
|
must reference **discoverable** plugin ids. Unknown ids are **errors**.
|
||||||
|
- If a plugin is installed but has a broken or missing manifest or schema,
|
||||||
|
validation fails and Doctor reports the plugin error.
|
||||||
|
- If plugin config exists but the plugin is **disabled**, the config is kept and
|
||||||
|
a **warning** is surfaced in Doctor + logs.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 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>`).
|
||||||
@@ -22,17 +22,20 @@ read_when:
|
|||||||
- Unknown keys are validation errors (no passthrough at root or nested).
|
- Unknown keys are validation errors (no passthrough at root or nested).
|
||||||
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
|
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
|
||||||
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
|
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
|
||||||
|
- Unknown `channels.<id>` keys are errors unless a plugin manifest declares the channel id.
|
||||||
|
- Plugin manifests (`clawdbot.plugin.json`) are required for all plugins.
|
||||||
|
|
||||||
## Plugin schema enforcement
|
## Plugin schema enforcement
|
||||||
- Each plugin provides a strict schema for its config (no passthrough).
|
- Each plugin provides a strict JSON Schema for its config (inline in the manifest).
|
||||||
- Plugin load flow:
|
- Plugin load flow:
|
||||||
1) Resolve plugin schema by plugin id.
|
1) Resolve plugin manifest + schema (`clawdbot.plugin.json`).
|
||||||
2) Validate config against the schema.
|
2) Validate config against the schema.
|
||||||
3) If missing schema or invalid config: block plugin load, record error.
|
3) If missing schema or invalid config: block plugin load, record error.
|
||||||
- Error message includes:
|
- Error message includes:
|
||||||
- Plugin id
|
- Plugin id
|
||||||
- Reason (missing schema / invalid config)
|
- Reason (missing schema / invalid config)
|
||||||
- Path(s) that failed validation
|
- Path(s) that failed validation
|
||||||
|
- Disabled plugins keep their config, but Doctor + logs surface a warning.
|
||||||
|
|
||||||
## Doctor flow
|
## Doctor flow
|
||||||
- Doctor runs **every time** config is loaded (dry-run by default).
|
- Doctor runs **every time** config is loaded (dry-run by default).
|
||||||
|
|||||||
@@ -323,6 +323,22 @@ These are “real pipeline” regressions without real providers:
|
|||||||
- Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts`
|
- Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts`
|
||||||
- Gateway wizard (WS `wizard.start`/`wizard.next`, writes config + auth enforced): `src/gateway/gateway.wizard.e2e.test.ts`
|
- Gateway wizard (WS `wizard.start`/`wizard.next`, writes config + auth enforced): `src/gateway/gateway.wizard.e2e.test.ts`
|
||||||
|
|
||||||
|
## Agent reliability evals (skills)
|
||||||
|
|
||||||
|
We already have a few CI-safe tests that behave like “agent reliability evals”:
|
||||||
|
- Mock tool-calling through the real gateway + agent loop (`src/gateway/gateway.tool-calling.mock-openai.test.ts`).
|
||||||
|
- End-to-end wizard flows that validate session wiring and config effects (`src/gateway/gateway.wizard.e2e.test.ts`).
|
||||||
|
|
||||||
|
What’s still missing for skills (see [Skills](/tools/skills)):
|
||||||
|
- **Decisioning:** when skills are listed in the prompt, does the agent pick the right skill (or avoid irrelevant ones)?
|
||||||
|
- **Compliance:** does the agent read `SKILL.md` before use and follow required steps/args?
|
||||||
|
- **Workflow contracts:** multi-turn scenarios that assert tool order, session history carryover, and sandbox boundaries.
|
||||||
|
|
||||||
|
Future evals should stay deterministic first:
|
||||||
|
- A scenario runner using mock providers to assert tool calls + order, skill file reads, and session wiring.
|
||||||
|
- A small suite of skill-focused scenarios (use vs avoid, gating, prompt injection).
|
||||||
|
- Optional live evals (opt-in, env-gated) only after the CI-safe suite is in place.
|
||||||
|
|
||||||
## Adding regressions (guidance)
|
## Adding regressions (guidance)
|
||||||
|
|
||||||
When you fix a provider/model issue discovered in live:
|
When you fix a provider/model issue discovered in live:
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](
|
|||||||
|
|
||||||
## Approval flow
|
## 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
|
- command + args
|
||||||
- cwd
|
- cwd
|
||||||
- agent id
|
- agent id
|
||||||
|
|||||||
+4
-1
@@ -32,6 +32,8 @@ Notes:
|
|||||||
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
||||||
- `node` requires a paired node (companion app or headless node host).
|
- `node` requires a paired node (companion app or headless node host).
|
||||||
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
||||||
|
- On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`)
|
||||||
|
from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
@@ -55,7 +57,8 @@ Example:
|
|||||||
|
|
||||||
### PATH handling
|
### 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`
|
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||||
- Linux: `/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`.
|
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ Notes:
|
|||||||
|
|
||||||
The tool is invoked with params:
|
The tool is invoked with params:
|
||||||
`{ command: "<raw args>", commandName: "<slash command>", skillName: "<skill name>" }`.
|
`{ command: "<raw args>", commandName: "<slash command>", skillName: "<skill name>" }`.
|
||||||
Tool-dispatch commands still respect tool policies/sandbox rules (same as normal model tools).
|
|
||||||
Replies are derived from tool results; include `MEDIA:` tokens or media URLs in tool output to send attachments.
|
|
||||||
|
|
||||||
## Gating (load-time filters)
|
## Gating (load-time filters)
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
|||||||
Text + native (when enabled):
|
Text + native (when enabled):
|
||||||
- `/help`
|
- `/help`
|
||||||
- `/commands`
|
- `/commands`
|
||||||
|
- `/skill <name> [input]` (run a skill by name)
|
||||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
- `/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)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/whoami` (show your sender id; alias: `/id`)
|
||||||
@@ -72,7 +73,7 @@ Text + native (when enabled):
|
|||||||
- `/dock-slack` (alias: `/dock_slack`) (switch replies to Slack)
|
- `/dock-slack` (alias: `/dock_slack`) (switch replies to Slack)
|
||||||
- `/activation mention|always` (groups only)
|
- `/activation mention|always` (groups only)
|
||||||
- `/send on|off|inherit` (owner-only)
|
- `/send on|off|inherit` (owner-only)
|
||||||
- `/reset` or `/new`
|
- `/reset` or `/new [model]` (optional model hint; remainder is passed through)
|
||||||
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
|
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
|
||||||
- `/verbose on|full|off` (alias: `/v`)
|
- `/verbose on|full|off` (alias: `/v`)
|
||||||
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
||||||
@@ -90,6 +91,7 @@ Text-only:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
||||||
|
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
||||||
- For full provider usage breakdown, use `clawdbot status --usage`.
|
- For full provider usage breakdown, use `clawdbot status --usage`.
|
||||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
||||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||||
@@ -102,9 +104,9 @@ Notes:
|
|||||||
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
|
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
|
||||||
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
- 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 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.
|
- 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).
|
- Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model).
|
||||||
- Tool-dispatch still respects tool policies/sandbox rules; replies use the tool result text/media (including `MEDIA:` tokens).
|
|
||||||
- **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.
|
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
|
||||||
|
|
||||||
## Usage surfaces (what shows where)
|
## Usage surfaces (what shows where)
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ Override via config:
|
|||||||
|
|
||||||
Sub-agents use a dedicated in-process queue lane:
|
Sub-agents use a dedicated in-process queue lane:
|
||||||
- Lane name: `subagent`
|
- Lane name: `subagent`
|
||||||
- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `1`)
|
- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `8`)
|
||||||
|
|
||||||
## Stopping
|
## Stopping
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "bluebubbles",
|
||||||
|
"channels": [
|
||||||
|
"bluebubbles"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,28 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot BlueBubbles channel plugin",
|
"description": "Clawdbot BlueBubbles channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": ["./index.ts"],
|
||||||
|
"channel": {
|
||||||
|
"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": {
|
||||||
|
"npmSpec": "@clawdbot/bluebubbles",
|
||||||
|
"localPath": "extensions/bluebubbles",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
BLUEBUBBLES_ACTION_NAMES,
|
||||||
|
BLUEBUBBLES_ACTIONS,
|
||||||
createActionGate,
|
createActionGate,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
|
readBooleanParam,
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readReactionParams,
|
readReactionParams,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
@@ -11,8 +14,19 @@ import {
|
|||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import { isMacOS26OrHigher } from "./probe.js";
|
||||||
import { sendBlueBubblesReaction } from "./reactions.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 { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||||
import type { BlueBubblesSendTarget } from "./types.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 = {
|
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig });
|
const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig });
|
||||||
if (!account.enabled || !account.configured) return [];
|
if (!account.enabled || !account.configured) return [];
|
||||||
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
|
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
|
||||||
const actions = new Set<ChannelMessageActionName>();
|
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);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsAction: ({ action }) => action === "react",
|
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||||
extractToolSend: ({ args }): ChannelToolSend | null => {
|
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||||
if (action !== "sendMessage") return null;
|
if (action !== "sendMessage") return null;
|
||||||
@@ -51,71 +78,301 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
return { to, accountId };
|
return { to, accountId };
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, 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({
|
const account = resolveBlueBubblesAccount({
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
const baseUrl = account.config.serverUrl?.trim();
|
const baseUrl = account.config.serverUrl?.trim();
|
||||||
const password = account.config.password?.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) {
|
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) {
|
if (!baseUrl || !password) {
|
||||||
throw new Error("BlueBubbles reaction requires serverUrl and password.");
|
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
|
||||||
}
|
}
|
||||||
resolvedChatGuid =
|
|
||||||
(await resolveChatGuidForTarget({
|
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
|
||||||
baseUrl,
|
if (!resolved) {
|
||||||
password,
|
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
|
||||||
target,
|
}
|
||||||
})) ?? "";
|
return resolved;
|
||||||
}
|
};
|
||||||
if (!resolvedChatGuid) {
|
|
||||||
throw new Error("BlueBubbles reaction failed: chatGuid not found for target.");
|
// 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({
|
// Handle edit action
|
||||||
chatGuid: resolvedChatGuid,
|
if (action === "edit") {
|
||||||
messageGuid: messageId,
|
// Edit is not supported on macOS 26+
|
||||||
emoji,
|
if (isMacOS26OrHigher(accountId ?? undefined)) {
|
||||||
remove: remove || undefined,
|
throw new Error(
|
||||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
"BlueBubbles edit is not supported on macOS 26 or higher. " +
|
||||||
opts: {
|
"Apple removed the ability to edit iMessages in this version.",
|
||||||
cfg: cfg as ClawdbotConfig,
|
);
|
||||||
accountId: accountId ?? undefined,
|
}
|
||||||
},
|
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) {
|
await editBlueBubblesMessage(messageId, newText, {
|
||||||
return jsonResult({ ok: true, added: emoji });
|
...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}.`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import { resolveChatGuidForTarget } from "./send.js";
|
||||||
|
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
||||||
import {
|
import {
|
||||||
blueBubblesFetchWithTimeout,
|
blueBubblesFetchWithTimeout,
|
||||||
buildBlueBubblesApiUrl,
|
buildBlueBubblesApiUrl,
|
||||||
type BlueBubblesAttachment,
|
type BlueBubblesAttachment,
|
||||||
|
type BlueBubblesSendTarget,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
export type BlueBubblesAttachmentOpts = {
|
export type BlueBubblesAttachmentOpts = {
|
||||||
@@ -55,3 +59,168 @@ export async function downloadBlueBubblesAttachment(
|
|||||||
}
|
}
|
||||||
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "claw
|
|||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
|
collectBlueBubblesStatusIssues,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
PAIRING_APPROVED_MESSAGE,
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
resolveBlueBubblesGroupRequireMention,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
@@ -18,20 +20,30 @@ import {
|
|||||||
resolveDefaultBlueBubblesAccountId,
|
resolveDefaultBlueBubblesAccountId,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { BlueBubblesConfigSchema } from "./config-schema.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 { sendMessageBlueBubbles } from "./send.js";
|
||||||
import { normalizeBlueBubblesHandle } from "./targets.js";
|
import {
|
||||||
|
looksLikeBlueBubblesTargetId,
|
||||||
|
normalizeBlueBubblesHandle,
|
||||||
|
normalizeBlueBubblesMessagingTarget,
|
||||||
|
} from "./targets.js";
|
||||||
import { bluebubblesMessageActions } from "./actions.js";
|
import { bluebubblesMessageActions } from "./actions.js";
|
||||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||||
|
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
|
||||||
|
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
id: "bluebubbles",
|
id: "bluebubbles",
|
||||||
label: "BlueBubbles",
|
label: "BlueBubbles",
|
||||||
selectionLabel: "BlueBubbles (macOS app)",
|
selectionLabel: "BlueBubbles (macOS app)",
|
||||||
|
detailLabel: "BlueBubbles",
|
||||||
docsPath: "/channels/bluebubbles",
|
docsPath: "/channels/bluebubbles",
|
||||||
docsLabel: "bluebubbles",
|
docsLabel: "bluebubbles",
|
||||||
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
||||||
|
systemImage: "bubble.left.and.text.bubble.right",
|
||||||
|
aliases: ["bb"],
|
||||||
order: 75,
|
order: 75,
|
||||||
|
preferOver: ["imessage"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||||
@@ -39,11 +51,27 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
meta,
|
meta,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
chatTypes: ["direct", "group"],
|
chatTypes: ["direct", "group"],
|
||||||
media: false,
|
media: true,
|
||||||
reactions: 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"] },
|
reload: { configPrefixes: ["channels.bluebubbles"] },
|
||||||
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||||
|
onboarding: blueBubblesOnboardingAdapter,
|
||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig),
|
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig),
|
||||||
resolveAccount: (cfg, accountId) =>
|
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: {
|
setup: {
|
||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
applyAccountName: ({ cfg, accountId, name }) =>
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
@@ -152,6 +187,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
||||||
...(input.password ? { password: input.password } : {}),
|
...(input.password ? { password: input.password } : {}),
|
||||||
|
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
@@ -170,6 +206,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
||||||
...(input.password ? { password: input.password } : {}),
|
...(input.password ? { password: input.password } : {}),
|
||||||
|
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -199,15 +236,39 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
}
|
}
|
||||||
return { ok: true, to: trimmed };
|
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, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||||
});
|
});
|
||||||
return { channel: "bluebubbles", ...result };
|
return { channel: "bluebubbles", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async () => {
|
sendMedia: async (ctx) => {
|
||||||
throw new Error("BlueBubbles media delivery is not supported yet.");
|
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: {
|
status: {
|
||||||
@@ -218,19 +279,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
lastStopAt: null,
|
lastStopAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
collectStatusIssues: (accounts) =>
|
collectStatusIssues: collectBlueBubblesStatusIssues,
|
||||||
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}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
buildChannelSummary: ({ snapshot }) => ({
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
configured: snapshot.configured ?? false,
|
configured: snapshot.configured ?? false,
|
||||||
baseUrl: snapshot.baseUrl ?? null,
|
baseUrl: snapshot.baseUrl ?? null,
|
||||||
@@ -247,20 +296,25 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
password: account.config.password ?? null,
|
password: account.config.password ?? null,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}),
|
}),
|
||||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||||
accountId: account.accountId,
|
const running = runtime?.running ?? false;
|
||||||
name: account.name,
|
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
|
||||||
enabled: account.enabled,
|
return {
|
||||||
configured: account.configured,
|
accountId: account.accountId,
|
||||||
baseUrl: account.baseUrl,
|
name: account.name,
|
||||||
running: runtime?.running ?? false,
|
enabled: account.enabled,
|
||||||
lastStartAt: runtime?.lastStartAt ?? null,
|
configured: account.configured,
|
||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
baseUrl: account.baseUrl,
|
||||||
lastError: runtime?.lastError ?? null,
|
running,
|
||||||
probe,
|
connected: probeOk ?? running,
|
||||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
}),
|
lastError: runtime?.lastError ?? null,
|
||||||
|
probe,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
gateway: {
|
gateway: {
|
||||||
startAccount: async (ctx) => {
|
startAccount: async (ctx) => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||||
@@ -64,3 +65,290 @@ export async function sendBlueBubblesTyping(
|
|||||||
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
|
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"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,24 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
|||||||
|
|
||||||
const bluebubblesActionSchema = z
|
const bluebubblesActionSchema = z
|
||||||
.object({
|
.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();
|
.optional();
|
||||||
|
|
||||||
|
const bluebubblesGroupConfigSchema = z.object({
|
||||||
|
requireMention: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const bluebubblesAccountSchema = z.object({
|
const bluebubblesAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
@@ -22,6 +36,9 @@ const bluebubblesAccountSchema = z.object({
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: 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({
|
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
||||||
|
|||||||
@@ -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
@@ -5,9 +5,13 @@ import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
|||||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.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 { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
||||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||||
|
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
||||||
|
import { fetchBlueBubblesServerInfo } from "./probe.js";
|
||||||
|
|
||||||
export type BlueBubblesRuntimeEnv = {
|
export type BlueBubblesRuntimeEnv = {
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
@@ -25,6 +29,7 @@ export type BlueBubblesMonitorOptions = {
|
|||||||
|
|
||||||
const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
||||||
const DEFAULT_TEXT_LIMIT = 4000;
|
const DEFAULT_TEXT_LIMIT = 4000;
|
||||||
|
const invalidAckReactions = new Set<string>();
|
||||||
|
|
||||||
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
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 = {
|
type WebhookTarget = {
|
||||||
account: ResolvedBlueBubblesAccount;
|
account: ResolvedBlueBubblesAccount;
|
||||||
config: ClawdbotConfig;
|
config: ClawdbotConfig;
|
||||||
@@ -183,6 +217,21 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
|||||||
return "";
|
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 {
|
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||||
if (!record) return undefined;
|
if (!record) return undefined;
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
@@ -194,6 +243,77 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
|
|||||||
return undefined;
|
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 {
|
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
const chats = message["chats"];
|
const chats = message["chats"];
|
||||||
if (!Array.isArray(chats) || chats.length === 0) return null;
|
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);
|
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 = {
|
type NormalizedWebhookMessage = {
|
||||||
text: string;
|
text: string;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
@@ -215,6 +437,10 @@ type NormalizedWebhookMessage = {
|
|||||||
fromMe?: boolean;
|
fromMe?: boolean;
|
||||||
attachments?: BlueBubblesAttachment[];
|
attachments?: BlueBubblesAttachment[];
|
||||||
balloonBundleId?: string;
|
balloonBundleId?: string;
|
||||||
|
participants?: BlueBubblesParticipant[];
|
||||||
|
replyToId?: string;
|
||||||
|
replyToBody?: string;
|
||||||
|
replyToSender?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NormalizedWebhookReaction = {
|
type NormalizedWebhookReaction = {
|
||||||
@@ -252,6 +478,31 @@ function maskSecret(value: string): string {
|
|||||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
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 {
|
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
||||||
const data =
|
const data =
|
||||||
@@ -331,13 +582,18 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
: Array.isArray(chatsParticipants)
|
: Array.isArray(chatsParticipants)
|
||||||
? chatsParticipants
|
? chatsParticipants
|
||||||
: [];
|
: [];
|
||||||
|
const normalizedParticipants = normalizeParticipantList(participants);
|
||||||
const participantsCount = participants.length;
|
const participantsCount = participants.length;
|
||||||
const isGroup =
|
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
||||||
|
const explicitIsGroup =
|
||||||
readBoolean(message, "isGroup") ??
|
readBoolean(message, "isGroup") ??
|
||||||
readBoolean(message, "is_group") ??
|
readBoolean(message, "is_group") ??
|
||||||
readBoolean(chat, "isGroup") ??
|
readBoolean(chat, "isGroup") ??
|
||||||
readBoolean(message, "group") ??
|
readBoolean(message, "group");
|
||||||
(participantsCount > 2 ? true : false);
|
const isGroup =
|
||||||
|
typeof groupFromChatGuid === "boolean"
|
||||||
|
? groupFromChatGuid
|
||||||
|
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
|
||||||
|
|
||||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||||
const messageId =
|
const messageId =
|
||||||
@@ -360,6 +616,7 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
|
|
||||||
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
||||||
if (!normalizedSender) return null;
|
if (!normalizedSender) return null;
|
||||||
|
const replyMetadata = extractReplyMetadata(message);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
@@ -375,6 +632,10 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
fromMe,
|
fromMe,
|
||||||
attachments: extractAttachments(message),
|
attachments: extractAttachments(message),
|
||||||
balloonBundleId,
|
balloonBundleId,
|
||||||
|
participants: normalizedParticipants,
|
||||||
|
replyToId: replyMetadata.replyToId,
|
||||||
|
replyToBody: replyMetadata.replyToBody,
|
||||||
|
replyToSender: replyMetadata.replyToSender,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,12 +712,16 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
|||||||
? chatsParticipants
|
? chatsParticipants
|
||||||
: [];
|
: [];
|
||||||
const participantsCount = participants.length;
|
const participantsCount = participants.length;
|
||||||
const isGroup =
|
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
||||||
|
const explicitIsGroup =
|
||||||
readBoolean(message, "isGroup") ??
|
readBoolean(message, "isGroup") ??
|
||||||
readBoolean(message, "is_group") ??
|
readBoolean(message, "is_group") ??
|
||||||
readBoolean(chat, "isGroup") ??
|
readBoolean(chat, "isGroup") ??
|
||||||
readBoolean(message, "group") ??
|
readBoolean(message, "group");
|
||||||
(participantsCount > 2 ? true : false);
|
const isGroup =
|
||||||
|
typeof groupFromChatGuid === "boolean"
|
||||||
|
? groupFromChatGuid
|
||||||
|
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
|
||||||
|
|
||||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||||
const timestampRaw =
|
const timestampRaw =
|
||||||
@@ -637,6 +902,8 @@ async function processMessage(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { account, config, runtime, core, statusSink } = target;
|
const { account, config, runtime, core, statusSink } = target;
|
||||||
if (message.fromMe) return;
|
if (message.fromMe) return;
|
||||||
|
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
||||||
|
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
||||||
|
|
||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
const attachments = message.attachments ?? [];
|
const attachments = message.attachments ?? [];
|
||||||
@@ -648,7 +915,7 @@ async function processMessage(
|
|||||||
logVerbose(
|
logVerbose(
|
||||||
core,
|
core,
|
||||||
runtime,
|
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";
|
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||||
@@ -667,15 +934,35 @@ async function processMessage(
|
|||||||
]
|
]
|
||||||
.map((entry) => String(entry).trim())
|
.map((entry) => String(entry).trim())
|
||||||
.filter(Boolean);
|
.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") {
|
if (groupPolicy === "disabled") {
|
||||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
|
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
|
||||||
|
logGroupAllowlistHint({
|
||||||
|
runtime,
|
||||||
|
reason: "groupPolicy=disabled",
|
||||||
|
entry: groupAllowEntry,
|
||||||
|
chatName: groupName,
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (groupPolicy === "allowlist") {
|
if (groupPolicy === "allowlist") {
|
||||||
if (effectiveGroupAllowFrom.length === 0) {
|
if (effectiveGroupAllowFrom.length === 0) {
|
||||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
|
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
|
||||||
|
logGroupAllowlistHint({
|
||||||
|
runtime,
|
||||||
|
reason: "groupPolicy=allowlist (empty allowlist)",
|
||||||
|
entry: groupAllowEntry,
|
||||||
|
chatName: groupName,
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const allowed = isAllowedBlueBubblesSender({
|
const allowed = isAllowedBlueBubblesSender({
|
||||||
@@ -696,6 +983,13 @@ async function processMessage(
|
|||||||
runtime,
|
runtime,
|
||||||
`drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
|
`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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -767,7 +1061,7 @@ async function processMessage(
|
|||||||
const chatId = message.chatId ?? undefined;
|
const chatId = message.chatId ?? undefined;
|
||||||
const chatGuid = message.chatGuid ?? undefined;
|
const chatGuid = message.chatGuid ?? undefined;
|
||||||
const chatIdentifier = message.chatIdentifier ?? undefined;
|
const chatIdentifier = message.chatIdentifier ?? undefined;
|
||||||
const peerId = message.isGroup
|
const peerId = isGroup
|
||||||
? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
|
? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
|
||||||
: message.senderId;
|
: message.senderId;
|
||||||
|
|
||||||
@@ -776,11 +1070,84 @@ async function processMessage(
|
|||||||
channel: "bluebubbles",
|
channel: "bluebubbles",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
peer: {
|
peer: {
|
||||||
kind: message.isGroup ? "group" : "dm",
|
kind: isGroup ? "group" : "dm",
|
||||||
id: peerId,
|
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 baseUrl = account.config.serverUrl?.trim();
|
||||||
const password = account.config.password?.trim();
|
const password = account.config.password?.trim();
|
||||||
const maxBytes =
|
const maxBytes =
|
||||||
@@ -833,9 +1200,18 @@ async function processMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rawBody = text.trim() || placeholder;
|
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}`
|
? `group:${peerId}`
|
||||||
: message.senderName || `user:${message.senderId}`;
|
: 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, {
|
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
@@ -850,12 +1226,12 @@ async function processMessage(
|
|||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
previousTimestamp,
|
previousTimestamp,
|
||||||
envelope: envelopeOptions,
|
envelope: envelopeOptions,
|
||||||
body: rawBody,
|
body: baseBody,
|
||||||
});
|
});
|
||||||
let chatGuidForActions = chatGuid;
|
let chatGuidForActions = chatGuid;
|
||||||
if (!chatGuidForActions && baseUrl && password) {
|
if (!chatGuidForActions && baseUrl && password) {
|
||||||
const target =
|
const target =
|
||||||
message.isGroup && (chatId || chatIdentifier)
|
isGroup && (chatId || chatIdentifier)
|
||||||
? chatId
|
? chatId
|
||||||
? { kind: "chat_id", chatId }
|
? { kind: "chat_id", chatId }
|
||||||
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
|
: { 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 {
|
try {
|
||||||
await markBlueBubblesChatRead(chatGuidForActions, {
|
await markBlueBubblesChatRead(chatGuidForActions, {
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -880,11 +1300,13 @@ async function processMessage(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
|
runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
} else if (!sendReadReceipts) {
|
||||||
|
logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
|
||||||
} else {
|
} else {
|
||||||
logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
|
logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const outboundTarget = message.isGroup
|
const outboundTarget = isGroup
|
||||||
? formatBlueBubblesChatTarget({
|
? formatBlueBubblesChatTarget({
|
||||||
chatId,
|
chatId,
|
||||||
chatGuid: chatGuidForActions ?? chatGuid,
|
chatGuid: chatGuidForActions ?? chatGuid,
|
||||||
@@ -894,6 +1316,15 @@ async function processMessage(
|
|||||||
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
||||||
: message.senderId;
|
: 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 = {
|
const ctxPayload = {
|
||||||
Body: body,
|
Body: body,
|
||||||
BodyForAgent: body,
|
BodyForAgent: body,
|
||||||
@@ -906,12 +1337,17 @@ async function processMessage(
|
|||||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||||
MediaType: mediaTypes[0],
|
MediaType: mediaTypes[0],
|
||||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||||
From: message.isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
|
From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
|
||||||
To: `bluebubbles:${outboundTarget}`,
|
To: `bluebubbles:${outboundTarget}`,
|
||||||
SessionKey: route.sessionKey,
|
SessionKey: route.sessionKey,
|
||||||
AccountId: route.accountId,
|
AccountId: route.accountId,
|
||||||
ChatType: message.isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
ConversationLabel: fromLabel,
|
ConversationLabel: fromLabel,
|
||||||
|
ReplyToId: message.replyToId,
|
||||||
|
ReplyToBody: message.replyToBody,
|
||||||
|
ReplyToSender: message.replyToSender,
|
||||||
|
GroupSubject: groupSubject,
|
||||||
|
GroupMembers: groupMembers,
|
||||||
SenderName: message.senderName || undefined,
|
SenderName: message.senderName || undefined,
|
||||||
SenderId: message.senderId,
|
SenderId: message.senderId,
|
||||||
Provider: "bluebubbles",
|
Provider: "bluebubbles",
|
||||||
@@ -920,26 +1356,42 @@ async function processMessage(
|
|||||||
Timestamp: message.timestamp,
|
Timestamp: message.timestamp,
|
||||||
OriginatingChannel: "bluebubbles",
|
OriginatingChannel: "bluebubbles",
|
||||||
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
||||||
|
WasMentioned: effectiveWasMentioned,
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (chatGuidForActions && baseUrl && password) {
|
let sentMessage = false;
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg: config,
|
cfg: config,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
deliver: async (payload) => {
|
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 =
|
const textLimit =
|
||||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||||
? account.config.textChunkLimit
|
? account.config.textChunkLimit
|
||||||
@@ -948,10 +1400,15 @@ async function processMessage(
|
|||||||
if (!chunks.length && payload.text) chunks.push(payload.text);
|
if (!chunks.length && payload.text) chunks.push(payload.text);
|
||||||
if (!chunks.length) return;
|
if (!chunks.length) return;
|
||||||
for (const chunk of chunks) {
|
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,
|
cfg: config,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||||
});
|
});
|
||||||
|
maybeEnqueueOutboundMessageId(result.messageId);
|
||||||
|
sentMessage = true;
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -969,31 +1426,48 @@ async function processMessage(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onIdle: () => {
|
onIdle: () => {
|
||||||
if (!chatGuidForActions) return;
|
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
|
||||||
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)}`);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
replyOptions: {
|
||||||
|
disableBlockStreaming:
|
||||||
|
typeof account.config.blockStreaming === "boolean"
|
||||||
|
? !account.config.blockStreaming
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (chatGuidForActions && baseUrl && password) {
|
if (
|
||||||
logVerbose(core, runtime, `typing stop (finalize) chatGuid=${chatGuidForActions}`);
|
removeAckAfterReply &&
|
||||||
void sendBlueBubblesTyping(chatGuidForActions, false, {
|
sentMessage &&
|
||||||
cfg: config,
|
ackReactionPromise &&
|
||||||
accountId: account.accountId,
|
ackReactionValue &&
|
||||||
}).catch((err) => {
|
chatGuidForActions &&
|
||||||
runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`);
|
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(
|
export async function monitorBlueBubblesProvider(
|
||||||
options: BlueBubblesMonitorOptions,
|
options: BlueBubblesMonitorOptions,
|
||||||
): Promise<{ stop: () => void }> {
|
): Promise<void> {
|
||||||
const { account, config, runtime, abortSignal, statusSink } = options;
|
const { account, config, runtime, abortSignal, statusSink } = options;
|
||||||
const core = getBlueBubblesRuntime();
|
const core = getBlueBubblesRuntime();
|
||||||
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
|
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({
|
const unregister = registerBlueBubblesWebhookTarget({
|
||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
@@ -1091,21 +1576,22 @@ export async function monitorBlueBubblesProvider(
|
|||||||
statusSink,
|
statusSink,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stop = () => {
|
return await new Promise((resolve) => {
|
||||||
unregister();
|
const stop = () => {
|
||||||
};
|
unregister();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (abortSignal?.aborted) {
|
|
||||||
stop();
|
|
||||||
} else {
|
|
||||||
abortSignal?.addEventListener("abort", stop, { once: true });
|
abortSignal?.addEventListener("abort", stop, { once: true });
|
||||||
}
|
runtime.log?.(
|
||||||
|
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
|
||||||
runtime.log?.(
|
);
|
||||||
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return { stop };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
|
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -6,6 +6,97 @@ export type BlueBubblesProbe = {
|
|||||||
error?: string | null;
|
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: {
|
export async function probeBlueBubbles(params: {
|
||||||
baseUrl?: string | null;
|
baseUrl?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,7 +60,7 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
|
|||||||
return { baseUrl, password };
|
return { baseUrl, password };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeReactionInput(emoji: string, remove?: boolean): string {
|
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
||||||
const trimmed = emoji.trim();
|
const trimmed = emoji.trim();
|
||||||
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
|
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
|
||||||
let raw = trimmed.toLowerCase();
|
let raw = trimmed.toLowerCase();
|
||||||
@@ -85,7 +85,7 @@ export async function sendBlueBubblesReaction(params: {
|
|||||||
const messageGuid = params.messageGuid.trim();
|
const messageGuid = params.messageGuid.trim();
|
||||||
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
|
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
|
||||||
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
|
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 { baseUrl, password } = resolveAccount(params.opts ?? {});
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
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 type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
blueBubblesFetchWithTimeout,
|
blueBubblesFetchWithTimeout,
|
||||||
@@ -15,12 +19,52 @@ export type BlueBubblesSendOpts = {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
cfg?: ClawdbotConfig;
|
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 = {
|
export type BlueBubblesSendResult = {
|
||||||
messageId: string;
|
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 {
|
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
||||||
const parsed = parseBlueBubblesTarget(raw);
|
const parsed = parseBlueBubblesTarget(raw);
|
||||||
if (parsed.kind === "handle") {
|
if (parsed.kind === "handle") {
|
||||||
@@ -42,12 +86,18 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
|||||||
function extractMessageId(payload: unknown): string {
|
function extractMessageId(payload: unknown): string {
|
||||||
if (!payload || typeof payload !== "object") return "unknown";
|
if (!payload || typeof payload !== "object") return "unknown";
|
||||||
const record = payload as Record<string, 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 = [
|
const candidates = [
|
||||||
record.messageId,
|
record.messageId,
|
||||||
|
record.messageGuid,
|
||||||
|
record.message_guid,
|
||||||
record.guid,
|
record.guid,
|
||||||
record.id,
|
record.id,
|
||||||
data?.messageId,
|
data?.messageId,
|
||||||
|
data?.messageGuid,
|
||||||
|
data?.message_guid,
|
||||||
|
data?.message_id,
|
||||||
data?.guid,
|
data?.guid,
|
||||||
data?.id,
|
data?.id,
|
||||||
];
|
];
|
||||||
@@ -154,6 +204,7 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
|
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
|
||||||
|
|
||||||
const limit = 500;
|
const limit = 500;
|
||||||
|
let participantMatch: string | null = null;
|
||||||
for (let offset = 0; offset < 5000; offset += limit) {
|
for (let offset = 0; offset < 5000; offset += limit) {
|
||||||
const chats = await queryChats({
|
const chats = await queryChats({
|
||||||
baseUrl: params.baseUrl,
|
baseUrl: params.baseUrl,
|
||||||
@@ -184,16 +235,23 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
|
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
|
||||||
}
|
}
|
||||||
if (normalizedHandle) {
|
if (normalizedHandle) {
|
||||||
const participants = extractParticipantAddresses(chat).map((entry) =>
|
const guid = extractChatGuid(chat);
|
||||||
normalizeBlueBubblesHandle(entry),
|
const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
|
||||||
);
|
if (directHandle && directHandle === normalizedHandle) {
|
||||||
if (participants.includes(normalizedHandle)) {
|
return guid;
|
||||||
return extractChatGuid(chat);
|
}
|
||||||
|
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(
|
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.",
|
"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> = {
|
const payload: Record<string, unknown> = {
|
||||||
chatGuid,
|
chatGuid,
|
||||||
tempGuid: crypto.randomUUID(),
|
tempGuid: crypto.randomUUID(),
|
||||||
message: trimmedText,
|
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({
|
const url = buildBlueBubblesApiUrl({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,11 +20,41 @@ const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> =
|
|||||||
{ prefix: "sms:", service: "sms" },
|
{ prefix: "sms:", service: "sms" },
|
||||||
{ prefix: "auto:", service: "auto" },
|
{ 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 {
|
function stripPrefix(value: string, prefix: string): string {
|
||||||
return value.slice(prefix.length).trim();
|
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 {
|
export function normalizeBlueBubblesHandle(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) return "";
|
||||||
@@ -36,8 +66,83 @@ export function normalizeBlueBubblesHandle(raw: string): string {
|
|||||||
return trimmed.replace(/\s+/g, "");
|
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();
|
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");
|
if (!trimmed) throw new Error("BlueBubbles target is required");
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
@@ -95,6 +200,22 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
|||||||
return { kind: "chat_guid", chatGuid: value };
|
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" };
|
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 };
|
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) };
|
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||||
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||||
|
|
||||||
|
export type BlueBubblesGroupConfig = {
|
||||||
|
/** If true, only respond in this group when mentioned. */
|
||||||
|
requireMention?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type BlueBubblesAccountConfig = {
|
export type BlueBubblesAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -36,10 +41,23 @@ export type BlueBubblesAccountConfig = {
|
|||||||
blockStreamingCoalesce?: Record<string, unknown>;
|
blockStreamingCoalesce?: Record<string, unknown>;
|
||||||
/** Max outbound media size in MB. */
|
/** Max outbound media size in MB. */
|
||||||
mediaMaxMb?: number;
|
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 = {
|
export type BlueBubblesActionConfig = {
|
||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
|
edit?: boolean;
|
||||||
|
unsend?: boolean;
|
||||||
|
reply?: boolean;
|
||||||
|
sendWithEffect?: boolean;
|
||||||
|
renameGroup?: boolean;
|
||||||
|
addParticipant?: boolean;
|
||||||
|
removeParticipant?: boolean;
|
||||||
|
leaveGroup?: boolean;
|
||||||
|
sendAttachment?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BlueBubblesConfig = {
|
export type BlueBubblesConfig = {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "copilot-proxy",
|
||||||
|
"providers": [
|
||||||
|
"copilot-proxy"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "discord",
|
||||||
|
"channels": [
|
||||||
|
"discord"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "google-antigravity-auth",
|
||||||
|
"providers": [
|
||||||
|
"google-antigravity"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "google-gemini-cli-auth",
|
||||||
|
"providers": [
|
||||||
|
"google-gemini-cli"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "imessage",
|
||||||
|
"channels": [
|
||||||
|
"imessage"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "matrix",
|
||||||
|
"channels": [
|
||||||
|
"matrix"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,28 @@
|
|||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
]
|
],
|
||||||
|
"channel": {
|
||||||
|
"id": "matrix",
|
||||||
|
"label": "Matrix",
|
||||||
|
"selectionLabel": "Matrix (plugin)",
|
||||||
|
"docsPath": "/channels/matrix",
|
||||||
|
"docsLabel": "matrix",
|
||||||
|
"blurb": "open protocol; install the plugin to enable.",
|
||||||
|
"order": 70,
|
||||||
|
"quickstartAllowFrom": true
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@clawdbot/matrix",
|
||||||
|
"localPath": "extensions/matrix",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
||||||
"clawdbot": "workspace:*",
|
"clawdbot": "workspace:*",
|
||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"matrix-js-sdk": "40.0.0"
|
"matrix-bot-sdk": "0.8.0",
|
||||||
|
"music-metadata": "^11.10.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import os from "node:os";
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
@@ -11,7 +10,7 @@ describe("matrix directory", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMatrixRuntime({
|
setMatrixRuntime({
|
||||||
state: {
|
state: {
|
||||||
resolveStateDir: () => os.tmpdir(),
|
resolveStateDir: (_env, homeDir) => homeDir(),
|
||||||
},
|
},
|
||||||
} as PluginRuntime);
|
} as PluginRuntime);
|
||||||
});
|
});
|
||||||
@@ -21,7 +20,8 @@ describe("matrix directory", () => {
|
|||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
|
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
|
||||||
rooms: {
|
groupAllowFrom: ["@dana:example.org"],
|
||||||
|
groups: {
|
||||||
"!room1:example.org": { users: ["@carol:example.org"] },
|
"!room1:example.org": { users: ["@carol:example.org"] },
|
||||||
"#alias:example.org": { users: [] },
|
"#alias:example.org": { users: [] },
|
||||||
},
|
},
|
||||||
@@ -40,6 +40,7 @@ describe("matrix directory", () => {
|
|||||||
{ kind: "user", id: "user:@alice:example.org" },
|
{ kind: "user", id: "user:@alice:example.org" },
|
||||||
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
|
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
|
||||||
{ kind: "user", id: "user:@carol:example.org" },
|
{ kind: "user", id: "user:@carol:example.org" },
|
||||||
|
{ kind: "user", id: "user:@dana:example.org" },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ const meta = {
|
|||||||
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
||||||
let normalized = raw.trim();
|
let normalized = raw.trim();
|
||||||
if (!normalized) return undefined;
|
if (!normalized) return undefined;
|
||||||
if (normalized.toLowerCase().startsWith("matrix:")) {
|
const lowered = normalized.toLowerCase();
|
||||||
|
if (lowered.startsWith("matrix:")) {
|
||||||
normalized = normalized.slice("matrix:".length).trim();
|
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(
|
function buildMatrixConfigUpdate(
|
||||||
@@ -155,10 +157,11 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
}),
|
}),
|
||||||
collectWarnings: ({ account, cfg }) => {
|
collectWarnings: ({ account, cfg }) => {
|
||||||
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
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 [];
|
if (groupPolicy !== "open") return [];
|
||||||
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: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg }) =>
|
resolveReplyToMode: ({ cfg }) =>
|
||||||
(cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
|
(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: {
|
messaging: {
|
||||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||||
@@ -194,7 +208,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
ids.add(raw.replace(/^matrix:/i, ""));
|
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 ?? []) {
|
for (const entry of room.users ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") continue;
|
if (!raw || raw === "*") continue;
|
||||||
@@ -226,7 +247,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||||
const q = query?.trim().toLowerCase() || "";
|
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())
|
.map((raw) => raw.trim())
|
||||||
.filter((raw) => Boolean(raw) && raw !== "*")
|
.filter((raw) => Boolean(raw) && raw !== "*")
|
||||||
.map((raw) => raw.replace(/^matrix:/i, ""))
|
.map((raw) => raw.replace(/^matrix:/i, ""))
|
||||||
@@ -263,10 +285,16 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
validateInput: ({ input }) => {
|
validateInput: ({ input }) => {
|
||||||
if (input.useEnv) return null;
|
if (input.useEnv) return null;
|
||||||
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
|
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
|
||||||
if (!input.userId?.trim()) return "Matrix requires --user-id";
|
const accessToken = input.accessToken?.trim();
|
||||||
if (!input.accessToken?.trim() && !input.password?.trim()) {
|
const password = input.password?.trim();
|
||||||
|
const userId = input.userId?.trim();
|
||||||
|
if (!accessToken && !password) {
|
||||||
return "Matrix requires --access-token or --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;
|
return null;
|
||||||
},
|
},
|
||||||
applyAccountConfig: ({ cfg, input }) => {
|
applyAccountConfig: ({ cfg, input }) => {
|
||||||
@@ -381,6 +409,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
mediaMaxMb: account.config.mediaMaxMb,
|
mediaMaxMb: account.config.mediaMaxMb,
|
||||||
initialSyncLimit: account.config.initialSyncLimit,
|
initialSyncLimit: account.config.initialSyncLimit,
|
||||||
replyToMode: account.config.replyToMode,
|
replyToMode: account.config.replyToMode,
|
||||||
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loaded 100 of 458 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user