Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146ceccdf4 | ||
|
|
b605e8aee4 | ||
|
|
18f0051d26 | ||
|
|
b012b1105e | ||
|
|
21370fc09b | ||
|
|
4999f15688 | ||
|
|
243a8b019e | ||
|
|
5c4079f66c | ||
|
|
b1f086b536 | ||
|
|
d298b8c16b | ||
|
|
40968bd5e0 | ||
|
|
80e6c070bf | ||
|
|
26fcca087b | ||
|
|
02ca148583 | ||
|
|
ae1c6f4313 | ||
|
|
9faed2226a | ||
|
|
cf04b24632 | ||
|
|
9f856abfe7 | ||
|
|
40e928a4c4 | ||
|
|
faa5838147 | ||
|
|
5c5745dee5 | ||
|
|
15c735de4d | ||
|
|
8bf484bdad | ||
|
|
36719690a2 | ||
|
|
f2666d2092 | ||
|
|
a28c271488 | ||
|
|
1d9d5b30ce | ||
|
|
14f56a4e18 | ||
|
|
687c41e838 | ||
|
|
ddb7b5c6a4 | ||
|
|
262e35c219 | ||
|
|
95f0befd65 | ||
|
|
83d5e30027 | ||
|
|
842be7b864 | ||
|
|
cb5d76ed3d | ||
|
|
3d5ffee07f | ||
|
|
4fda10c508 | ||
|
|
0b0d8b2406 | ||
|
|
844ff2ee8f | ||
|
|
8c666666ef | ||
|
|
2394703593 | ||
|
|
404470853a | ||
|
|
99fc0fbac1 | ||
|
|
91ed00f800 | ||
|
|
76698ed296 | ||
|
|
74f382f732 | ||
|
|
a76aea1bc0 | ||
|
|
533766207f | ||
|
|
59fa002561 | ||
|
|
48ab168df2 | ||
|
|
bef9d5bdc8 | ||
|
|
c6812c6af4 | ||
|
|
1f7cb4b853 | ||
|
|
d161f3ab0f | ||
|
|
c65b91c841 | ||
|
|
760b1e8fc6 | ||
|
|
188893f319 | ||
|
|
04ee9e7765 | ||
|
|
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 | ||
|
|
b8b0b3f0e7 | ||
|
|
528524e4c7 | ||
|
|
154c49511c | ||
|
|
34462b3221 | ||
|
|
0372bdf6fe | ||
|
|
cd8309cc31 | ||
|
|
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 +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`.
|
||||||
|
|||||||
+23
-1
@@ -2,21 +2,39 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.20-1
|
## 2026.1.20
|
||||||
|
|
||||||
### 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.
|
||||||
|
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||||
|
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
||||||
|
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||||
|
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
||||||
|
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
||||||
|
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1326) — thanks @dougvk.
|
||||||
|
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||||
|
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||||
- 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).
|
||||||
- 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.
|
||||||
|
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
||||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
- 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.
|
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
||||||
|
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
|
||||||
|
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
|
||||||
|
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
|
||||||
|
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
||||||
|
|
||||||
## 2026.1.19-3
|
## 2026.1.19-3
|
||||||
|
|
||||||
@@ -29,6 +47,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
|
||||||
|
|
||||||
@@ -51,6 +71,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||||
|
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||||
@@ -63,6 +84,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||||
|
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
|
||||||
|
|
||||||
## 2026.1.18-5
|
## 2026.1.18-5
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -473,24 +482,24 @@ Thanks to all clawtributors:
|
|||||||
<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/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/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/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/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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
||||||
<a href="https://github.com/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/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
|
||||||
<a href="https://github.com/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/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
|
||||||
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/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/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
||||||
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/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/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/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/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/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/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/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/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/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/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/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/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/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/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=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/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/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/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/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
|
||||||
<a href="https://github.com/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/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/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/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/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/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/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/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
|
||||||
<a href="https://github.com/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/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/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/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/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=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/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=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
|
||||||
<a href="https://github.com/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=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/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=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=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/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=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=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=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=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "commander",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/Commander.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||||
"version" : "0.1.0"
|
"version" : "0.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
## Clawdbot Node (Android) (internal)
|
## Clawdbot Node (Android) (internal)
|
||||||
|
|
||||||
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
|
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601114
|
versionCode = 202601200
|
||||||
versionName = "2026.1.11-4"
|
versionName = "2026.1.20"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ class GatewayDiscovery(
|
|||||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
private val dns = DnsResolver.getInstance()
|
private val dns = DnsResolver.getInstance()
|
||||||
private val serviceType = "_clawdbot-gateway._tcp."
|
private val serviceType = "_clawdbot-gw._tcp."
|
||||||
private val wideAreaDomain = "clawdbot.internal."
|
private val wideAreaDomain = "clawdbot.internal."
|
||||||
private val logTag = "Clawdbot/GatewayDiscovery"
|
private val logTag = "Clawdbot/GatewayDiscovery"
|
||||||
|
|
||||||
|
|||||||
@@ -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.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>20260120</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_clawdbot-gateway._tcp</string>
|
<string>_clawdbot-gw._tcp</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
|
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import Testing
|
|||||||
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
||||||
let endpoint = NWEndpoint.service(
|
let endpoint = NWEndpoint.service(
|
||||||
name: "Clawdbot\\032Gateway \\032 Node\n",
|
name: "Clawdbot\\032Gateway \\032 Node\n",
|
||||||
type: "_clawdbot-gateway._tcp",
|
type: "_clawdbot-gw._tcp",
|
||||||
domain: "local.",
|
domain: "local.",
|
||||||
interface: nil)
|
interface: nil)
|
||||||
|
|
||||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
|
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
||||||
@@ -22,7 +22,7 @@ import Testing
|
|||||||
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
||||||
let endpoint = NWEndpoint.service(
|
let endpoint = NWEndpoint.service(
|
||||||
name: "Clawdbot\\032Gateway",
|
name: "Clawdbot\\032Gateway",
|
||||||
type: "_clawdbot-gateway._tcp",
|
type: "_clawdbot-gw._tcp",
|
||||||
domain: "local.",
|
domain: "local.",
|
||||||
interface: nil)
|
interface: nil)
|
||||||
|
|
||||||
|
|||||||
@@ -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.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>20260120</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -81,8 +81,8 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdbot
|
CFBundleDisplayName: Clawdbot
|
||||||
CFBundleIconName: AppIcon
|
CFBundleIconName: AppIcon
|
||||||
CFBundleShortVersionString: "2026.1.9"
|
CFBundleShortVersionString: "2026.1.20"
|
||||||
CFBundleVersion: "20260109"
|
CFBundleVersion: "20260120"
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
@@ -92,7 +92,7 @@ targets:
|
|||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsArbitraryLoadsInWebContent: true
|
NSAllowsArbitraryLoadsInWebContent: true
|
||||||
NSBonjourServices:
|
NSBonjourServices:
|
||||||
- _clawdbot-gateway._tcp
|
- _clawdbot-gw._tcp
|
||||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
|
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
|
||||||
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
||||||
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
||||||
@@ -130,5 +130,5 @@ targets:
|
|||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: ClawdbotTests
|
CFBundleDisplayName: ClawdbotTests
|
||||||
CFBundleShortVersionString: "2026.1.9"
|
CFBundleShortVersionString: "2026.1.20"
|
||||||
CFBundleVersion: "20260109"
|
CFBundleVersion: "20260120"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "axorcist",
|
"identity" : "axorcist",
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ let package = Package(
|
|||||||
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
||||||
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
||||||
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
||||||
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
|
.executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]),
|
||||||
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
|
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||||
@@ -67,21 +66,13 @@ let package = Package(
|
|||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "ClawdbotDiscoveryCLI",
|
name: "ClawdbotMacCLI",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"ClawdbotDiscovery",
|
"ClawdbotDiscovery",
|
||||||
],
|
|
||||||
path: "Sources/ClawdbotDiscoveryCLI",
|
|
||||||
swiftSettings: [
|
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
|
||||||
]),
|
|
||||||
.executableTarget(
|
|
||||||
name: "ClawdbotWizardCLI",
|
|
||||||
dependencies: [
|
|
||||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||||
],
|
],
|
||||||
path: "Sources/ClawdbotWizardCLI",
|
path: "Sources/ClawdbotMacCLI",
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ private struct EventRow: View {
|
|||||||
return f.string(from: date)
|
return f.string(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
|
private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? {
|
||||||
let normalized = dict.mapValues { $0.value }
|
let normalized = dict.mapValues { $0.value }
|
||||||
guard JSONSerialization.isValidJSONObject(normalized),
|
guard JSONSerialization.isValidJSONObject(normalized),
|
||||||
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
|
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
|
||||||
@@ -98,7 +98,10 @@ struct AgentEventsWindow_Previews: PreviewProvider {
|
|||||||
seq: 1,
|
seq: 1,
|
||||||
stream: "tool",
|
stream: "tool",
|
||||||
ts: Date().timeIntervalSince1970 * 1000,
|
ts: Date().timeIntervalSince1970 * 1000,
|
||||||
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
|
data: [
|
||||||
|
"phase": ClawdbotProtocol.AnyCodable("start"),
|
||||||
|
"name": ClawdbotProtocol.AnyCodable("bash"),
|
||||||
|
],
|
||||||
summary: nil)
|
summary: nil)
|
||||||
AgentEventStore.shared.append(sample)
|
AgentEventStore.shared.append(sample)
|
||||||
return AgentEventsWindow()
|
return AgentEventsWindow()
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
|
||||||
|
typealias AnyCodable = ClawdbotKit.AnyCodable
|
||||||
|
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
|
||||||
|
|
||||||
extension AnyCodable {
|
extension AnyCodable {
|
||||||
var stringValue: String? { self.value as? String }
|
var stringValue: String? { self.value as? String }
|
||||||
var boolValue: Bool? { self.value as? Bool }
|
var boolValue: Bool? { self.value as? Bool }
|
||||||
@@ -20,3 +25,23 @@ extension AnyCodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ClawdbotProtocol.AnyCodable {
|
||||||
|
var stringValue: String? { self.value as? String }
|
||||||
|
var boolValue: Bool? { self.value as? Bool }
|
||||||
|
var intValue: Int? { self.value as? Int }
|
||||||
|
var doubleValue: Double? { self.value as? Double }
|
||||||
|
var dictionaryValue: [String: ClawdbotProtocol.AnyCodable]? { self.value as? [String: ClawdbotProtocol.AnyCodable] }
|
||||||
|
var arrayValue: [ClawdbotProtocol.AnyCodable]? { self.value as? [ClawdbotProtocol.AnyCodable] }
|
||||||
|
|
||||||
|
var foundationValue: Any {
|
||||||
|
switch self.value {
|
||||||
|
case let dict as [String: ClawdbotProtocol.AnyCodable]:
|
||||||
|
dict.mapValues { $0.foundationValue }
|
||||||
|
case let array as [ClawdbotProtocol.AnyCodable]:
|
||||||
|
array.map(\.foundationValue)
|
||||||
|
default:
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -426,24 +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 {
|
||||||
if let detail = self.store.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
|
self.store.resolveChannelDetailLabel(id)
|
||||||
return detail
|
|
||||||
}
|
|
||||||
return self.resolveChannelTitle(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||||
if let symbol = self.store.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
|
self.store.resolveChannelSystemImage(id)
|
||||||
return symbol
|
|
||||||
}
|
|
||||||
return "message"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||||
|
|||||||
@@ -153,11 +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 channelDetailLabels: [String: String]?
|
||||||
let channelSystemImages: [String: String]? = nil
|
let channelSystemImages: [String: String]?
|
||||||
|
let channelMeta: [ChannelUiMetaEntry]?
|
||||||
let channels: [String: AnyCodable]
|
let channels: [String: AnyCodable]
|
||||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
||||||
let channelDefaultAccountId: [String: String]
|
let channelDefaultAccountId: [String: String]
|
||||||
@@ -219,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(\.id)
|
||||||
|
}
|
||||||
|
return self.snapshot?.channelOrder ?? []
|
||||||
|
}
|
||||||
|
|
||||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||||
self.isPreview = isPreview
|
self.isPreview = isPreview
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
|
|||||||
let seq: Int
|
let seq: Int
|
||||||
let stream: String
|
let stream: String
|
||||||
let ts: Double
|
let ts: Double
|
||||||
let data: [String: AnyCodable]
|
let data: [String: ClawdbotProtocol.AnyCodable]
|
||||||
let summary: String?
|
let summary: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +156,8 @@ final class ControlChannel {
|
|||||||
timeoutMs: Double? = nil) async throws -> Data
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) {
|
let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) {
|
||||||
$0[$1.key] = AnyCodable($1.value.base)
|
$0[$1.key] = ClawdbotKit.AnyCodable($1.value.base)
|
||||||
}
|
}
|
||||||
let data = try await GatewayConnection.shared.request(
|
let data = try await GatewayConnection.shared.request(
|
||||||
method: method,
|
method: method,
|
||||||
@@ -346,7 +346,7 @@ final class ControlChannel {
|
|||||||
let phase = event.data["phase"]?.value as? String ?? ""
|
let phase = event.data["phase"]?.value as? String ?? ""
|
||||||
let name = event.data["name"]?.value as? String
|
let name = event.data["name"]?.value as? String
|
||||||
let meta = event.data["meta"]?.value as? String
|
let meta = event.data["meta"]?.value as? String
|
||||||
let args = event.data["args"]?.value as? [String: AnyCodable]
|
let args = Self.bridgeToProtocolArgs(event.data["args"])
|
||||||
WorkActivityStore.shared.handleTool(
|
WorkActivityStore.shared.handleTool(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
phase: phase,
|
phase: phase,
|
||||||
@@ -357,6 +357,27 @@ final class ControlChannel {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func bridgeToProtocolArgs(
|
||||||
|
_ value: ClawdbotProtocol.AnyCodable?) -> [String: ClawdbotProtocol.AnyCodable]?
|
||||||
|
{
|
||||||
|
guard let value else { return nil }
|
||||||
|
if let dict = value.value as? [String: ClawdbotProtocol.AnyCodable] {
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
if let dict = value.value as? [String: ClawdbotKit.AnyCodable],
|
||||||
|
let data = try? JSONEncoder().encode(dict),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||||
|
{
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(value),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||||
|
{
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ struct CronJobEditor: View {
|
|||||||
@State var postPrefix: String = "Cron"
|
@State var postPrefix: String = "Cron"
|
||||||
|
|
||||||
var channelOptions: [String] {
|
var channelOptions: [String] {
|
||||||
let snapshot = self.channelsStore.snapshot
|
let ordered = self.channelsStore.orderedChannelIds()
|
||||||
let ordered = snapshot?.channelOrder ?? []
|
|
||||||
var options = ["last"] + ordered
|
var options = ["last"] + ordered
|
||||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty, !options.contains(trimmed) {
|
if !trimmed.isEmpty, !options.contains(trimmed) {
|
||||||
@@ -68,7 +67,7 @@ struct CronJobEditor: View {
|
|||||||
|
|
||||||
func channelLabel(for id: String) -> String {
|
func channelLabel(for id: String) -> String {
|
||||||
if id == "last" { return "last" }
|
if id == "last" { return "last" }
|
||||||
return self.channelsStore.snapshot?.channelLabels[id] ?? id
|
return self.channelsStore.resolveChannelLabel(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -259,6 +259,20 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private enum ExecHostExecutor {
|
private enum ExecHostExecutor {
|
||||||
|
private struct ExecApprovalContext {
|
||||||
|
let command: [String]
|
||||||
|
let displayCommand: String
|
||||||
|
let trimmedAgent: String?
|
||||||
|
let approvals: ExecApprovalsResolved
|
||||||
|
let security: ExecSecurity
|
||||||
|
let ask: ExecAsk
|
||||||
|
let autoAllowSkills: Bool
|
||||||
|
let env: [String: String]?
|
||||||
|
let resolution: ExecCommandResolution?
|
||||||
|
let allowlistMatch: ExecAllowlistEntry?
|
||||||
|
let skillAllow: Bool
|
||||||
|
}
|
||||||
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
private static let blockedEnvKeys: Set<String> = [
|
||||||
"PATH",
|
"PATH",
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
@@ -277,14 +291,93 @@ private enum ExecHostExecutor {
|
|||||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
guard !command.isEmpty else {
|
guard !command.isEmpty else {
|
||||||
return ExecHostResponse(
|
return self.errorResponse(
|
||||||
type: "exec-res",
|
code: "INVALID_REQUEST",
|
||||||
id: UUID().uuidString,
|
message: "command required",
|
||||||
ok: false,
|
reason: "invalid")
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let context = await self.buildContext(request: request, command: command)
|
||||||
|
if context.security == .deny {
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||||
|
reason: "security=deny")
|
||||||
|
}
|
||||||
|
|
||||||
|
let approvalDecision = request.approvalDecision
|
||||||
|
if approvalDecision == .deny {
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied",
|
||||||
|
reason: "user-denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
var approvedByAsk = approvalDecision != nil
|
||||||
|
if self.requiresAsk(
|
||||||
|
ask: context.ask,
|
||||||
|
security: context.security,
|
||||||
|
allowlistMatch: context.allowlistMatch,
|
||||||
|
skillAllow: context.skillAllow),
|
||||||
|
approvalDecision == nil
|
||||||
|
{
|
||||||
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
|
ExecApprovalPromptRequest(
|
||||||
|
command: context.displayCommand,
|
||||||
|
cwd: request.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: context.security.rawValue,
|
||||||
|
ask: context.ask.rawValue,
|
||||||
|
agentId: context.trimmedAgent,
|
||||||
|
resolvedPath: context.resolution?.resolvedPath))
|
||||||
|
|
||||||
|
switch decision {
|
||||||
|
case .deny:
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied",
|
||||||
|
reason: "user-denied")
|
||||||
|
case .allowAlways:
|
||||||
|
approvedByAsk = true
|
||||||
|
self.persistAllowlistEntry(decision: decision, context: context)
|
||||||
|
case .allowOnce:
|
||||||
|
approvedByAsk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.persistAllowlistEntry(decision: approvalDecision, context: context)
|
||||||
|
|
||||||
|
if context.security == .allowlist,
|
||||||
|
context.allowlistMatch == nil,
|
||||||
|
!context.skillAllow,
|
||||||
|
!approvedByAsk
|
||||||
|
{
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
||||||
|
reason: "allowlist-miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let match = context.allowlistMatch {
|
||||||
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
|
agentId: context.trimmedAgent,
|
||||||
|
pattern: match.pattern,
|
||||||
|
command: context.displayCommand,
|
||||||
|
resolvedPath: context.resolution?.resolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
|
||||||
|
return errorResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.runCommand(
|
||||||
|
command: command,
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: context.env,
|
||||||
|
timeoutMs: request.timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
|
||||||
let displayCommand = ExecCommandFormatter.displayString(
|
let displayCommand = ExecCommandFormatter.displayString(
|
||||||
for: command,
|
for: command,
|
||||||
rawCommand: request.rawCommand)
|
rawCommand: request.rawCommand)
|
||||||
@@ -310,122 +403,72 @@ private enum ExecHostExecutor {
|
|||||||
} else {
|
} else {
|
||||||
skillAllow = false
|
skillAllow = false
|
||||||
}
|
}
|
||||||
|
return ExecApprovalContext(
|
||||||
|
command: command,
|
||||||
|
displayCommand: displayCommand,
|
||||||
|
trimmedAgent: trimmedAgent,
|
||||||
|
approvals: approvals,
|
||||||
|
security: security,
|
||||||
|
ask: ask,
|
||||||
|
autoAllowSkills: autoAllowSkills,
|
||||||
|
env: env,
|
||||||
|
resolution: resolution,
|
||||||
|
allowlistMatch: allowlistMatch,
|
||||||
|
skillAllow: skillAllow)
|
||||||
|
}
|
||||||
|
|
||||||
if security == .deny {
|
private static func requiresAsk(
|
||||||
return ExecHostResponse(
|
ask: ExecAsk,
|
||||||
type: "exec-res",
|
security: ExecSecurity,
|
||||||
id: UUID().uuidString,
|
allowlistMatch: ExecAllowlistEntry?,
|
||||||
ok: false,
|
skillAllow: Bool) -> Bool
|
||||||
payload: nil,
|
{
|
||||||
error: ExecHostError(
|
if ask == .always { return true }
|
||||||
code: "UNAVAILABLE",
|
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||||
message: "SYSTEM_RUN_DISABLED: security=deny",
|
return false
|
||||||
reason: "security=deny"))
|
}
|
||||||
|
|
||||||
|
private static func persistAllowlistEntry(
|
||||||
|
decision: ExecApprovalDecision?,
|
||||||
|
context: ExecApprovalContext)
|
||||||
|
{
|
||||||
|
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||||
|
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||||
|
}
|
||||||
|
|
||||||
let requiresAsk: Bool = {
|
private static func allowlistPattern(
|
||||||
if ask == .always { return true }
|
command: [String],
|
||||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
resolution: ExecCommandResolution?) -> String?
|
||||||
return false
|
{
|
||||||
}()
|
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||||
|
return pattern.isEmpty ? nil : pattern
|
||||||
|
}
|
||||||
|
|
||||||
let approvalDecision = request.approvalDecision
|
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||||
if approvalDecision == .deny {
|
guard needsScreenRecording == true else { return nil }
|
||||||
return ExecHostResponse(
|
let authorized = await PermissionManager
|
||||||
type: "exec-res",
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
id: UUID().uuidString,
|
if authorized { return nil }
|
||||||
ok: false,
|
return self.errorResponse(
|
||||||
payload: nil,
|
code: "UNAVAILABLE",
|
||||||
error: ExecHostError(
|
message: "PERMISSION_MISSING: screenRecording",
|
||||||
code: "UNAVAILABLE",
|
reason: "permission:screenRecording")
|
||||||
message: "SYSTEM_RUN_DENIED: user denied",
|
}
|
||||||
reason: "user-denied"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var approvedByAsk = approvalDecision != nil
|
private static func runCommand(
|
||||||
if requiresAsk, approvalDecision == nil {
|
command: [String],
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
cwd: String?,
|
||||||
ExecApprovalPromptRequest(
|
env: [String: String]?,
|
||||||
command: displayCommand,
|
timeoutMs: Int?) async -> ExecHostResponse
|
||||||
cwd: request.cwd,
|
{
|
||||||
host: "node",
|
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||||
security: security.rawValue,
|
|
||||||
ask: ask.rawValue,
|
|
||||||
agentId: trimmedAgent,
|
|
||||||
resolvedPath: resolution?.resolvedPath))
|
|
||||||
|
|
||||||
switch decision {
|
|
||||||
case .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"))
|
|
||||||
case .allowAlways:
|
|
||||||
approvedByAsk = true
|
|
||||||
if security == .allowlist {
|
|
||||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
|
||||||
if !pattern.isEmpty {
|
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .allowOnce:
|
|
||||||
approvedByAsk = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if approvalDecision == .allowAlways, security == .allowlist {
|
|
||||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
|
||||||
if !pattern.isEmpty {
|
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
|
||||||
reason: "allowlist-miss"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
|
||||||
agentId: trimmedAgent,
|
|
||||||
pattern: match.pattern,
|
|
||||||
command: displayCommand,
|
|
||||||
resolvedPath: resolution?.resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.needsScreenRecording == true {
|
|
||||||
let authorized = await PermissionManager
|
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
|
||||||
if !authorized {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "PERMISSION_MISSING: screenRecording",
|
|
||||||
reason: "permission:screenRecording"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
|
||||||
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||||
await ShellExecutor.runDetailed(
|
await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: request.cwd,
|
cwd: cwd,
|
||||||
env: env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
}.value
|
}.value
|
||||||
@@ -436,7 +479,24 @@ private enum ExecHostExecutor {
|
|||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
error: result.errorMessage)
|
error: result.errorMessage)
|
||||||
return ExecHostResponse(
|
return self.successResponse(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func errorResponse(
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
reason: String?) -> ExecHostResponse
|
||||||
|
{
|
||||||
|
ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: code, message: message, reason: reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
|
||||||
|
ExecHostResponse(
|
||||||
type: "exec-res",
|
type: "exec-res",
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -244,9 +244,9 @@ actor GatewayConnection {
|
|||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
|
private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String {
|
||||||
(defaults?[key]?.stringValue ?? "")
|
let raw = defaults?[key]?.value as? String
|
||||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cachedMainSessionKey() -> String? {
|
func cachedMainSessionKey() -> String? {
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ actor GatewayEndpointStore {
|
|||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||||
!configToken.isEmpty
|
!configToken.isEmpty
|
||||||
{
|
{
|
||||||
@@ -177,7 +177,7 @@ actor GatewayEndpointStore {
|
|||||||
{
|
{
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
|
|||||||
struct OnboardingWizardStepView: View {
|
struct OnboardingWizardStepView: View {
|
||||||
let step: WizardStep
|
let step: WizardStep
|
||||||
let isSubmitting: Bool
|
let isSubmitting: Bool
|
||||||
let onSubmit: (AnyCodable?) -> Void
|
let onStepSubmit: (AnyCodable?) -> Void
|
||||||
|
|
||||||
@State private var textValue: String
|
@State private var textValue: String
|
||||||
@State private var confirmValue: Bool
|
@State private var confirmValue: Bool
|
||||||
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
|
|||||||
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
|
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
|
||||||
self.step = step
|
self.step = step
|
||||||
self.isSubmitting = isSubmitting
|
self.isSubmitting = isSubmitting
|
||||||
self.onSubmit = onSubmit
|
self.onStepSubmit = onSubmit
|
||||||
let options = parseWizardOptions(step.options).enumerated().map { index, option in
|
let options = parseWizardOptions(step.options).enumerated().map { index, option in
|
||||||
WizardOptionItem(index: index, option: option)
|
WizardOptionItem(index: index, option: option)
|
||||||
}
|
}
|
||||||
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
|
|||||||
private func submit() {
|
private func submit() {
|
||||||
switch wizardStepType(self.step) {
|
switch wizardStepType(self.step) {
|
||||||
case "note", "progress":
|
case "note", "progress":
|
||||||
self.onSubmit(nil)
|
self.onStepSubmit(nil)
|
||||||
case "text":
|
case "text":
|
||||||
self.onSubmit(AnyCodable(self.textValue))
|
self.onStepSubmit(AnyCodable(self.textValue))
|
||||||
case "confirm":
|
case "confirm":
|
||||||
self.onSubmit(AnyCodable(self.confirmValue))
|
self.onStepSubmit(AnyCodable(self.confirmValue))
|
||||||
case "select":
|
case "select":
|
||||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||||
self.onSubmit(nil)
|
self.onStepSubmit(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let option = self.optionItems[self.selectedIndex].option
|
let option = self.optionItems[self.selectedIndex].option
|
||||||
self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||||
case "multiselect":
|
case "multiselect":
|
||||||
let values = self.optionItems
|
let values = self.optionItems
|
||||||
.filter { self.selectedIndices.contains($0.index) }
|
.filter { self.selectedIndices.contains($0.index) }
|
||||||
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
||||||
self.onSubmit(AnyCodable(values))
|
self.onStepSubmit(AnyCodable(values))
|
||||||
case "action":
|
case "action":
|
||||||
self.onSubmit(AnyCodable(true))
|
self.onStepSubmit(AnyCodable(true))
|
||||||
default:
|
default:
|
||||||
self.onSubmit(nil)
|
self.onStepSubmit(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,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.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>202601200</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Clawdbot</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ enum WideAreaGatewayDiscovery {
|
|||||||
|
|
||||||
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||||
guard let ptrLines = context.dig(
|
guard let ptrLines = context.dig(
|
||||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||||
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
|
|||||||
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if ptr.isEmpty { continue }
|
if ptr.isEmpty { continue }
|
||||||
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
||||||
let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
|
let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)"
|
||||||
let rawInstanceName = ptrName.hasSuffix(suffix)
|
let rawInstanceName = ptrName.hasSuffix(suffix)
|
||||||
? String(ptrName.dropLast(suffix.count))
|
? String(ptrName.dropLast(suffix.count))
|
||||||
: ptrName
|
: ptrName
|
||||||
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
|
|||||||
{
|
{
|
||||||
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||||
|
|
||||||
let ips = candidates
|
let ips = candidates
|
||||||
candidates.removeAll(keepingCapacity: true)
|
candidates.removeAll(keepingCapacity: true)
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import ClawdbotDiscovery
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct DiscoveryOptions {
|
|
||||||
var timeoutMs: Int = 2000
|
|
||||||
var json: Bool = false
|
|
||||||
var includeLocal: Bool = false
|
|
||||||
var help: Bool = false
|
|
||||||
|
|
||||||
static func parse(_ args: [String]) -> DiscoveryOptions {
|
|
||||||
var opts = DiscoveryOptions()
|
|
||||||
var i = 0
|
|
||||||
while i < args.count {
|
|
||||||
let arg = args[i]
|
|
||||||
switch arg {
|
|
||||||
case "-h", "--help":
|
|
||||||
opts.help = true
|
|
||||||
case "--json":
|
|
||||||
opts.json = true
|
|
||||||
case "--include-local":
|
|
||||||
opts.includeLocal = true
|
|
||||||
case "--timeout":
|
|
||||||
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
|
||||||
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
|
||||||
opts.timeoutMs = max(100, parsed)
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
return opts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DiscoveryOutput: Encodable {
|
|
||||||
struct Gateway: Encodable {
|
|
||||||
var displayName: String
|
|
||||||
var lanHost: String?
|
|
||||||
var tailnetDns: String?
|
|
||||||
var sshPort: Int
|
|
||||||
var gatewayPort: Int?
|
|
||||||
var cliPath: String?
|
|
||||||
var stableID: String
|
|
||||||
var debugID: String
|
|
||||||
var isLocal: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var status: String
|
|
||||||
var timeoutMs: Int
|
|
||||||
var includeLocal: Bool
|
|
||||||
var count: Int
|
|
||||||
var gateways: [Gateway]
|
|
||||||
}
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct ClawdbotDiscoveryCLI {
|
|
||||||
static func main() async {
|
|
||||||
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
|
||||||
if opts.help {
|
|
||||||
print("""
|
|
||||||
clawdbot-mac-discovery
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
|
||||||
--json Emit JSON
|
|
||||||
--include-local Include gateways considered local
|
|
||||||
-h, --help Show help
|
|
||||||
""")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
|
||||||
let model = GatewayDiscoveryModel(
|
|
||||||
localDisplayName: displayName,
|
|
||||||
filterLocalGateways: !opts.includeLocal)
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
model.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
|
||||||
try? await Task.sleep(nanoseconds: nanos)
|
|
||||||
|
|
||||||
let gateways = await MainActor.run { model.gateways }
|
|
||||||
let status = await MainActor.run { model.statusText }
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
model.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.json {
|
|
||||||
let payload = DiscoveryOutput(
|
|
||||||
status: status,
|
|
||||||
timeoutMs: opts.timeoutMs,
|
|
||||||
includeLocal: opts.includeLocal,
|
|
||||||
count: gateways.count,
|
|
||||||
gateways: gateways.map {
|
|
||||||
DiscoveryOutput.Gateway(
|
|
||||||
displayName: $0.displayName,
|
|
||||||
lanHost: $0.lanHost,
|
|
||||||
tailnetDns: $0.tailnetDns,
|
|
||||||
sshPort: $0.sshPort,
|
|
||||||
gatewayPort: $0.gatewayPort,
|
|
||||||
cliPath: $0.cliPath,
|
|
||||||
stableID: $0.stableID,
|
|
||||||
debugID: $0.debugID,
|
|
||||||
isLocal: $0.isLocal)
|
|
||||||
})
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
if let data = try? encoder.encode(payload),
|
|
||||||
let json = String(data: data, encoding: .utf8)
|
|
||||||
{
|
|
||||||
print(json)
|
|
||||||
} else {
|
|
||||||
print("{\"error\":\"failed to encode JSON\"}")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Gateway Discovery (macOS NWBrowser)")
|
|
||||||
print("Status: \(status)")
|
|
||||||
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
|
||||||
if gateways.isEmpty { return }
|
|
||||||
|
|
||||||
for gateway in gateways {
|
|
||||||
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
|
||||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
.joined(separator: ", ")
|
|
||||||
print("- \(gateway.displayName)")
|
|
||||||
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
|
||||||
print(" ssh: \(gateway.sshPort)")
|
|
||||||
if let port = gateway.gatewayPort {
|
|
||||||
print(" gatewayPort: \(port)")
|
|
||||||
}
|
|
||||||
if let cliPath = gateway.cliPath {
|
|
||||||
print(" cliPath: \(cliPath)")
|
|
||||||
}
|
|
||||||
print(" isLocal: \(gateway.isLocal)")
|
|
||||||
print(" stableID: \(gateway.stableID)")
|
|
||||||
print(" debugID: \(gateway.debugID)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -408,8 +408,7 @@ extension Request: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shared transport settings
|
// Shared transport settings
|
||||||
public let controlSocketPath =
|
public let controlSocketPath = FileManager()
|
||||||
FileManager()
|
.homeDirectoryForCurrentUser
|
||||||
.homeDirectoryForCurrentUser
|
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
.path
|
||||||
.path
|
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import ClawdbotKit
|
||||||
|
import ClawdbotProtocol
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ConnectOptions {
|
||||||
|
var url: String?
|
||||||
|
var token: String?
|
||||||
|
var password: String?
|
||||||
|
var mode: String?
|
||||||
|
var timeoutMs: Int = 15_000
|
||||||
|
var json: Bool = false
|
||||||
|
var probe: Bool = false
|
||||||
|
var clientId: String = "clawdbot-macos"
|
||||||
|
var clientMode: String = "ui"
|
||||||
|
var displayName: String?
|
||||||
|
var role: String = "operator"
|
||||||
|
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
|
||||||
|
var help: Bool = false
|
||||||
|
|
||||||
|
static func parse(_ args: [String]) -> ConnectOptions {
|
||||||
|
var opts = ConnectOptions()
|
||||||
|
var i = 0
|
||||||
|
while i < args.count {
|
||||||
|
let arg = args[i]
|
||||||
|
switch arg {
|
||||||
|
case "-h", "--help":
|
||||||
|
opts.help = true
|
||||||
|
case "--json":
|
||||||
|
opts.json = true
|
||||||
|
case "--probe":
|
||||||
|
opts.probe = true
|
||||||
|
case "--url":
|
||||||
|
opts.url = self.nextValue(args, index: &i)
|
||||||
|
case "--token":
|
||||||
|
opts.token = self.nextValue(args, index: &i)
|
||||||
|
case "--password":
|
||||||
|
opts.password = self.nextValue(args, index: &i)
|
||||||
|
case "--mode":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.mode = value
|
||||||
|
}
|
||||||
|
case "--timeout":
|
||||||
|
if let raw = self.nextValue(args, index: &i),
|
||||||
|
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
{
|
||||||
|
opts.timeoutMs = max(250, parsed)
|
||||||
|
}
|
||||||
|
case "--client-id":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.clientId = value
|
||||||
|
}
|
||||||
|
case "--client-mode":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.clientMode = value
|
||||||
|
}
|
||||||
|
case "--display-name":
|
||||||
|
opts.displayName = self.nextValue(args, index: &i)
|
||||||
|
case "--role":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.role = value
|
||||||
|
}
|
||||||
|
case "--scopes":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nextValue(_ args: [String], index: inout Int) -> String? {
|
||||||
|
guard index + 1 < args.count else { return nil }
|
||||||
|
index += 1
|
||||||
|
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConnectOutput: Encodable {
|
||||||
|
var status: String
|
||||||
|
var url: String
|
||||||
|
var mode: String
|
||||||
|
var role: String
|
||||||
|
var clientId: String
|
||||||
|
var clientMode: String
|
||||||
|
var scopes: [String]
|
||||||
|
var snapshot: HelloOk?
|
||||||
|
var health: ProtoAnyCodable?
|
||||||
|
var error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SnapshotStore {
|
||||||
|
private var value: HelloOk?
|
||||||
|
|
||||||
|
func set(_ snapshot: HelloOk) {
|
||||||
|
self.value = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func get() -> HelloOk? {
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConnect(_ args: [String]) async {
|
||||||
|
let opts = ConnectOptions.parse(args)
|
||||||
|
if opts.help {
|
||||||
|
print("""
|
||||||
|
clawdbot-mac connect
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||||
|
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
|
||||||
|
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
|
||||||
|
[--role <role>] [--scopes <a,b,c>]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--url <url> Gateway WebSocket URL (overrides config)
|
||||||
|
--token <token> Gateway token (if required)
|
||||||
|
--password <pw> Gateway password (if required)
|
||||||
|
--mode <mode> Resolve from config: local|remote (default: config or local)
|
||||||
|
--timeout <ms> Request timeout (default: 15000)
|
||||||
|
--probe Force a fresh health probe
|
||||||
|
--json Emit JSON
|
||||||
|
--client-id <id> Override client id (default: clawdbot-macos)
|
||||||
|
--client-mode <m> Override client mode (default: ui)
|
||||||
|
--display-name <n> Override display name
|
||||||
|
--role <role> Override role (default: operator)
|
||||||
|
--scopes <a,b,c> Override scopes list
|
||||||
|
-h, --help Show help
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = loadGatewayConfig()
|
||||||
|
do {
|
||||||
|
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
|
||||||
|
let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI"
|
||||||
|
let connectOptions = GatewayConnectOptions(
|
||||||
|
role: opts.role,
|
||||||
|
scopes: opts.scopes,
|
||||||
|
caps: [],
|
||||||
|
commands: [],
|
||||||
|
permissions: [:],
|
||||||
|
clientId: opts.clientId,
|
||||||
|
clientMode: opts.clientMode,
|
||||||
|
clientDisplayName: displayName)
|
||||||
|
|
||||||
|
let snapshotStore = SnapshotStore()
|
||||||
|
let channel = GatewayChannelActor(
|
||||||
|
url: endpoint.url,
|
||||||
|
token: endpoint.token,
|
||||||
|
password: endpoint.password,
|
||||||
|
pushHandler: { push in
|
||||||
|
if case let .snapshot(ok) = push {
|
||||||
|
await snapshotStore.set(ok)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
connectOptions: connectOptions)
|
||||||
|
|
||||||
|
let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil
|
||||||
|
let data = try await channel.request(
|
||||||
|
method: "health",
|
||||||
|
params: params,
|
||||||
|
timeoutMs: Double(opts.timeoutMs))
|
||||||
|
let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data)
|
||||||
|
let snapshot = await snapshotStore.get()
|
||||||
|
await channel.shutdown()
|
||||||
|
|
||||||
|
let output = ConnectOutput(
|
||||||
|
status: "ok",
|
||||||
|
url: endpoint.url.absoluteString,
|
||||||
|
mode: endpoint.mode,
|
||||||
|
role: opts.role,
|
||||||
|
clientId: opts.clientId,
|
||||||
|
clientMode: opts.clientMode,
|
||||||
|
scopes: opts.scopes,
|
||||||
|
snapshot: snapshot,
|
||||||
|
health: health,
|
||||||
|
error: nil)
|
||||||
|
printConnectOutput(output, json: opts.json)
|
||||||
|
} catch {
|
||||||
|
let endpoint = bestEffortEndpoint(opts: opts, config: config)
|
||||||
|
let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
||||||
|
let output = ConnectOutput(
|
||||||
|
status: "error",
|
||||||
|
url: endpoint?.url.absoluteString ?? "unknown",
|
||||||
|
mode: endpoint?.mode ?? fallbackMode,
|
||||||
|
role: opts.role,
|
||||||
|
clientId: opts.clientId,
|
||||||
|
clientMode: opts.clientMode,
|
||||||
|
scopes: opts.scopes,
|
||||||
|
snapshot: nil,
|
||||||
|
health: nil,
|
||||||
|
error: error.localizedDescription)
|
||||||
|
printConnectOutput(output, json: opts.json)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
|
||||||
|
if json {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
if let data = try? encoder.encode(output),
|
||||||
|
let text = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
|
print(text)
|
||||||
|
} else {
|
||||||
|
print("{\"error\":\"failed to encode JSON\"}")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Clawdbot macOS Gateway Connect")
|
||||||
|
print("Status: \(output.status)")
|
||||||
|
print("URL: \(output.url)")
|
||||||
|
print("Mode: \(output.mode)")
|
||||||
|
print("Client: \(output.clientId) (\(output.clientMode))")
|
||||||
|
print("Role: \(output.role)")
|
||||||
|
print("Scopes: \(output.scopes.joined(separator: ", "))")
|
||||||
|
if let snapshot = output.snapshot {
|
||||||
|
print("Protocol: \(snapshot._protocol)")
|
||||||
|
if let version = snapshot.server["version"]?.value as? String {
|
||||||
|
print("Server: \(version)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let health = output.health,
|
||||||
|
let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool
|
||||||
|
{
|
||||||
|
print("Health: \(ok ? "ok" : "error")")
|
||||||
|
} else if output.health != nil {
|
||||||
|
print("Health: received")
|
||||||
|
}
|
||||||
|
if let error = output.error {
|
||||||
|
print("Error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||||
|
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
||||||
|
if let raw = opts.url, !raw.isEmpty {
|
||||||
|
guard let url = URL(string: raw) else {
|
||||||
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||||
|
}
|
||||||
|
return GatewayEndpoint(
|
||||||
|
url: url,
|
||||||
|
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
mode: resolvedMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolvedMode == "remote" {
|
||||||
|
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!raw.isEmpty else {
|
||||||
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
||||||
|
}
|
||||||
|
guard let url = URL(string: raw) else {
|
||||||
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||||
|
}
|
||||||
|
return GatewayEndpoint(
|
||||||
|
url: url,
|
||||||
|
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
mode: resolvedMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = config.port ?? 18789
|
||||||
|
let host = "127.0.0.1"
|
||||||
|
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||||
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
||||||
|
}
|
||||||
|
return GatewayEndpoint(
|
||||||
|
url: url,
|
||||||
|
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
mode: resolvedMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
||||||
|
return try? resolveGatewayEndpoint(opts: opts, config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||||
|
if let token = opts.token, !token.isEmpty { return token }
|
||||||
|
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
if mode == "remote" {
|
||||||
|
return config.remoteToken
|
||||||
|
}
|
||||||
|
return config.token
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||||
|
if let password = opts.password, !password.isEmpty { return password }
|
||||||
|
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
if mode == "remote" {
|
||||||
|
return config.remotePassword
|
||||||
|
}
|
||||||
|
return config.password
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DiscoveryOptions {
|
||||||
|
var timeoutMs: Int = 2000
|
||||||
|
var json: Bool = false
|
||||||
|
var includeLocal: Bool = false
|
||||||
|
var help: Bool = false
|
||||||
|
|
||||||
|
static func parse(_ args: [String]) -> DiscoveryOptions {
|
||||||
|
var opts = DiscoveryOptions()
|
||||||
|
var i = 0
|
||||||
|
while i < args.count {
|
||||||
|
let arg = args[i]
|
||||||
|
switch arg {
|
||||||
|
case "-h", "--help":
|
||||||
|
opts.help = true
|
||||||
|
case "--json":
|
||||||
|
opts.json = true
|
||||||
|
case "--include-local":
|
||||||
|
opts.includeLocal = true
|
||||||
|
case "--timeout":
|
||||||
|
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
||||||
|
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||||
|
opts.timeoutMs = max(100, parsed)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiscoveryOutput: Encodable {
|
||||||
|
struct Gateway: Encodable {
|
||||||
|
var displayName: String
|
||||||
|
var lanHost: String?
|
||||||
|
var tailnetDns: String?
|
||||||
|
var sshPort: Int
|
||||||
|
var gatewayPort: Int?
|
||||||
|
var cliPath: String?
|
||||||
|
var stableID: String
|
||||||
|
var debugID: String
|
||||||
|
var isLocal: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var status: String
|
||||||
|
var timeoutMs: Int
|
||||||
|
var includeLocal: Bool
|
||||||
|
var count: Int
|
||||||
|
var gateways: [Gateway]
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDiscover(_ args: [String]) async {
|
||||||
|
let opts = DiscoveryOptions.parse(args)
|
||||||
|
if opts.help {
|
||||||
|
print("""
|
||||||
|
clawdbot-mac discover
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
||||||
|
--json Emit JSON
|
||||||
|
--include-local Include gateways considered local
|
||||||
|
-h, --help Show help
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||||
|
let model = await MainActor.run {
|
||||||
|
GatewayDiscoveryModel(
|
||||||
|
localDisplayName: displayName,
|
||||||
|
filterLocalGateways: !opts.includeLocal)
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
model.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
||||||
|
try? await Task.sleep(nanoseconds: nanos)
|
||||||
|
|
||||||
|
let gateways = await MainActor.run { model.gateways }
|
||||||
|
let status = await MainActor.run { model.statusText }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
model.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.json {
|
||||||
|
let payload = DiscoveryOutput(
|
||||||
|
status: status,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
includeLocal: opts.includeLocal,
|
||||||
|
count: gateways.count,
|
||||||
|
gateways: gateways.map {
|
||||||
|
DiscoveryOutput.Gateway(
|
||||||
|
displayName: $0.displayName,
|
||||||
|
lanHost: $0.lanHost,
|
||||||
|
tailnetDns: $0.tailnetDns,
|
||||||
|
sshPort: $0.sshPort,
|
||||||
|
gatewayPort: $0.gatewayPort,
|
||||||
|
cliPath: $0.cliPath,
|
||||||
|
stableID: $0.stableID,
|
||||||
|
debugID: $0.debugID,
|
||||||
|
isLocal: $0.isLocal)
|
||||||
|
})
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
if let data = try? encoder.encode(payload),
|
||||||
|
let json = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
|
print(json)
|
||||||
|
} else {
|
||||||
|
print("{\"error\":\"failed to encode JSON\"}")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Gateway Discovery (macOS NWBrowser)")
|
||||||
|
print("Status: \(status)")
|
||||||
|
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
||||||
|
if gateways.isEmpty { return }
|
||||||
|
|
||||||
|
for gateway in gateways {
|
||||||
|
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
||||||
|
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
print("- \(gateway.displayName)")
|
||||||
|
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
||||||
|
print(" ssh: \(gateway.sshPort)")
|
||||||
|
if let port = gateway.gatewayPort {
|
||||||
|
print(" gatewayPort: \(port)")
|
||||||
|
}
|
||||||
|
if let cliPath = gateway.cliPath {
|
||||||
|
print(" cliPath: \(cliPath)")
|
||||||
|
}
|
||||||
|
print(" isLocal: \(gateway.isLocal)")
|
||||||
|
print(" stableID: \(gateway.stableID)")
|
||||||
|
print(" debugID: \(gateway.debugID)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
private struct RootCommand {
|
||||||
|
var name: String
|
||||||
|
var args: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ClawdbotMacCLI {
|
||||||
|
static func main() async {
|
||||||
|
let args = Array(CommandLine.arguments.dropFirst())
|
||||||
|
let command = parseRootCommand(args)
|
||||||
|
switch command?.name {
|
||||||
|
case nil:
|
||||||
|
printUsage()
|
||||||
|
case "-h", "--help", "help":
|
||||||
|
printUsage()
|
||||||
|
case "connect":
|
||||||
|
await runConnect(command?.args ?? [])
|
||||||
|
case "discover":
|
||||||
|
await runDiscover(command?.args ?? [])
|
||||||
|
case "wizard":
|
||||||
|
await runWizardCommand(command?.args ?? [])
|
||||||
|
default:
|
||||||
|
fputs("clawdbot-mac: unknown command\n", stderr)
|
||||||
|
printUsage()
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseRootCommand(_ args: [String]) -> RootCommand? {
|
||||||
|
guard let first = args.first else { return nil }
|
||||||
|
return RootCommand(name: first, args: Array(args.dropFirst()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func printUsage() {
|
||||||
|
print("""
|
||||||
|
clawdbot-mac
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||||
|
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
|
||||||
|
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
|
||||||
|
[--role <role>] [--scopes <a,b,c>]
|
||||||
|
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
|
||||||
|
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||||
|
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
clawdbot-mac connect
|
||||||
|
clawdbot-mac connect --url ws://127.0.0.1:18789 --json
|
||||||
|
clawdbot-mac discover --timeout 3000 --json
|
||||||
|
clawdbot-mac wizard --mode local
|
||||||
|
""")
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct GatewayConfig {
|
||||||
|
var mode: String?
|
||||||
|
var bind: String?
|
||||||
|
var port: Int?
|
||||||
|
var remoteUrl: String?
|
||||||
|
var token: String?
|
||||||
|
var password: String?
|
||||||
|
var remoteToken: String?
|
||||||
|
var remotePassword: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GatewayEndpoint {
|
||||||
|
let url: URL
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
|
let mode: String
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGatewayConfig() -> GatewayConfig {
|
||||||
|
let url = FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".clawdbot")
|
||||||
|
.appendingPathComponent("clawdbot.json")
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return GatewayConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg = GatewayConfig()
|
||||||
|
if let gateway = json["gateway"] as? [String: Any] {
|
||||||
|
cfg.mode = gateway["mode"] as? String
|
||||||
|
cfg.bind = gateway["bind"] as? String
|
||||||
|
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
|
||||||
|
|
||||||
|
if let auth = gateway["auth"] as? [String: Any] {
|
||||||
|
cfg.token = auth["token"] as? String
|
||||||
|
cfg.password = auth["password"] as? String
|
||||||
|
}
|
||||||
|
if let remote = gateway["remote"] as? [String: Any] {
|
||||||
|
cfg.remoteUrl = remote["url"] as? String
|
||||||
|
cfg.remoteToken = remote["token"] as? String
|
||||||
|
cfg.remotePassword = remote["password"] as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(_ value: Any?) -> Int? {
|
||||||
|
switch value {
|
||||||
|
case let number as Int:
|
||||||
|
number
|
||||||
|
case let number as Double:
|
||||||
|
Int(number)
|
||||||
|
case let raw as String:
|
||||||
|
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
default:
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import ClawdbotKit
|
||||||
|
import ClawdbotProtocol
|
||||||
|
|
||||||
|
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
typealias KitAnyCodable = ClawdbotKit.AnyCodable
|
||||||
+49
-111
@@ -3,8 +3,6 @@ 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?
|
||||||
@@ -51,17 +49,6 @@ struct WizardCliOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayConfig {
|
|
||||||
var mode: String?
|
|
||||||
var bind: String?
|
|
||||||
var port: Int?
|
|
||||||
var remoteUrl: String?
|
|
||||||
var token: String?
|
|
||||||
var password: String?
|
|
||||||
var remoteToken: String?
|
|
||||||
var remotePassword: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum WizardCliError: Error, CustomStringConvertible {
|
enum WizardCliError: Error, CustomStringConvertible {
|
||||||
case invalidUrl(String)
|
case invalidUrl(String)
|
||||||
case missingRemoteUrl
|
case missingRemoteUrl
|
||||||
@@ -80,68 +67,56 @@ enum WizardCliError: Error, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
func runWizardCommand(_ args: [String]) async {
|
||||||
struct ClawdbotWizardCLI {
|
let opts = WizardCliOptions.parse(args)
|
||||||
static func main() async {
|
if opts.help {
|
||||||
let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
print("""
|
||||||
if opts.help {
|
clawdbot-mac wizard
|
||||||
printUsage()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = loadGatewayConfig()
|
Usage:
|
||||||
do {
|
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||||
guard isatty(STDIN_FILENO) != 0 else {
|
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||||
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
|
||||||
}
|
Options:
|
||||||
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
|
--url <url> Gateway WebSocket URL (overrides config)
|
||||||
let client = GatewayWizardClient(
|
--token <token> Gateway token (if required)
|
||||||
url: endpoint.url,
|
--password <pw> Gateway password (if required)
|
||||||
token: endpoint.token,
|
--mode <mode> Wizard mode (local|remote). Default: local
|
||||||
password: endpoint.password,
|
--workspace <path> Wizard workspace override
|
||||||
json: opts.json)
|
--json Print raw wizard responses
|
||||||
try await client.connect()
|
-h, --help Show help
|
||||||
defer { Task { await client.close() } }
|
""")
|
||||||
try await runWizard(client: client, opts: opts)
|
return
|
||||||
} catch {
|
}
|
||||||
fputs("wizard: \(error)\n", stderr)
|
|
||||||
exit(1)
|
let config = loadGatewayConfig()
|
||||||
|
do {
|
||||||
|
guard isatty(STDIN_FILENO) != 0 else {
|
||||||
|
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
||||||
}
|
}
|
||||||
|
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
|
||||||
|
let client = GatewayWizardClient(
|
||||||
|
url: endpoint.url,
|
||||||
|
token: endpoint.token,
|
||||||
|
password: endpoint.password,
|
||||||
|
json: opts.json)
|
||||||
|
try await client.connect()
|
||||||
|
defer { Task { await client.close() } }
|
||||||
|
try await runWizard(client: client, opts: opts)
|
||||||
|
} catch {
|
||||||
|
fputs("wizard: \(error)\n", stderr)
|
||||||
|
exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct GatewayEndpoint {
|
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||||
let url: URL
|
|
||||||
let token: String?
|
|
||||||
let password: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
private func printUsage() {
|
|
||||||
print("""
|
|
||||||
clawdbot-mac-wizard
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
|
||||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--url <url> Gateway WebSocket URL (overrides config)
|
|
||||||
--token <token> Gateway token (if required)
|
|
||||||
--password <pw> Gateway password (if required)
|
|
||||||
--mode <mode> Wizard mode (local|remote). Default: local
|
|
||||||
--workspace <path> Wizard workspace override
|
|
||||||
--json Print raw wizard responses
|
|
||||||
-h, --help Show help
|
|
||||||
""")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
|
||||||
if let raw = opts.url, !raw.isEmpty {
|
if let raw = opts.url, !raw.isEmpty {
|
||||||
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
|
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
|
||||||
return GatewayEndpoint(
|
return GatewayEndpoint(
|
||||||
url: url,
|
url: url,
|
||||||
token: resolvedToken(opts: opts, config: config),
|
token: resolvedToken(opts: opts, config: config),
|
||||||
password: resolvedPassword(opts: opts, config: config))
|
password: resolvedPassword(opts: opts, config: config),
|
||||||
|
mode: (config.mode ?? "local").lowercased())
|
||||||
}
|
}
|
||||||
|
|
||||||
let mode = (config.mode ?? "local").lowercased()
|
let mode = (config.mode ?? "local").lowercased()
|
||||||
@@ -153,7 +128,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
|
|||||||
return GatewayEndpoint(
|
return GatewayEndpoint(
|
||||||
url: url,
|
url: url,
|
||||||
token: resolvedToken(opts: opts, config: config),
|
token: resolvedToken(opts: opts, config: config),
|
||||||
password: resolvedPassword(opts: opts, config: config))
|
password: resolvedPassword(opts: opts, config: config),
|
||||||
|
mode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = config.port ?? 18789
|
let port = config.port ?? 18789
|
||||||
@@ -164,7 +140,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
|
|||||||
return GatewayEndpoint(
|
return GatewayEndpoint(
|
||||||
url: url,
|
url: url,
|
||||||
token: resolvedToken(opts: opts, config: config),
|
token: resolvedToken(opts: opts, config: config),
|
||||||
password: resolvedPassword(opts: opts, config: config))
|
password: resolvedPassword(opts: opts, config: config),
|
||||||
|
mode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
|
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
|
||||||
@@ -189,47 +166,6 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
|
|||||||
return config.password
|
return config.password
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadGatewayConfig() -> GatewayConfig {
|
|
||||||
let url = FileManager().homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent(".clawdbot")
|
|
||||||
.appendingPathComponent("clawdbot.json")
|
|
||||||
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
return GatewayConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg = GatewayConfig()
|
|
||||||
if let gateway = json["gateway"] as? [String: Any] {
|
|
||||||
cfg.mode = gateway["mode"] as? String
|
|
||||||
cfg.bind = gateway["bind"] as? String
|
|
||||||
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
|
|
||||||
|
|
||||||
if let auth = gateway["auth"] as? [String: Any] {
|
|
||||||
cfg.token = auth["token"] as? String
|
|
||||||
cfg.password = auth["password"] as? String
|
|
||||||
}
|
|
||||||
if let remote = gateway["remote"] as? [String: Any] {
|
|
||||||
cfg.remoteUrl = remote["url"] as? String
|
|
||||||
cfg.remoteToken = remote["token"] as? String
|
|
||||||
cfg.remotePassword = remote["password"] as? String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
private func parseInt(_ value: Any?) -> Int? {
|
|
||||||
switch value {
|
|
||||||
case let number as Int:
|
|
||||||
number
|
|
||||||
case let number as Double:
|
|
||||||
Int(number)
|
|
||||||
case let raw as String:
|
|
||||||
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
||||||
default:
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actor GatewayWizardClient {
|
actor GatewayWizardClient {
|
||||||
private enum ConnectChallengeError: Error {
|
private enum ConnectChallengeError: Error {
|
||||||
case timeout
|
case timeout
|
||||||
@@ -365,7 +301,8 @@ actor GatewayWizardClient {
|
|||||||
}
|
}
|
||||||
let payload = payloadParts.joined(separator: "|")
|
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)
|
||||||
|
{
|
||||||
var device: [String: ProtoAnyCodable] = [
|
var device: [String: ProtoAnyCodable] = [
|
||||||
"id": ProtoAnyCodable(identity.deviceId),
|
"id": ProtoAnyCodable(identity.deviceId),
|
||||||
"publicKey": ProtoAnyCodable(publicKey),
|
"publicKey": ProtoAnyCodable(publicKey),
|
||||||
@@ -410,10 +347,11 @@ actor GatewayWizardClient {
|
|||||||
operation: {
|
operation: {
|
||||||
while true {
|
while true {
|
||||||
let message = try await task.receive()
|
let message = try await task.receive()
|
||||||
let frame = try decodeFrame(message)
|
let frame = try await self.decodeFrame(message)
|
||||||
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
||||||
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||||
let nonce = payload["nonce"]?.value as? String {
|
let nonce = payload["nonce"]?.value as? String
|
||||||
|
{
|
||||||
return nonce
|
return nonce
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1326,6 +1326,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
public let channellabels: [String: AnyCodable]
|
public let channellabels: [String: AnyCodable]
|
||||||
public let channeldetaillabels: [String: AnyCodable]?
|
public let channeldetaillabels: [String: AnyCodable]?
|
||||||
public let channelsystemimages: [String: AnyCodable]?
|
public let channelsystemimages: [String: AnyCodable]?
|
||||||
|
public let channelmeta: [[String: AnyCodable]]?
|
||||||
public let channels: [String: AnyCodable]
|
public let channels: [String: AnyCodable]
|
||||||
public let channelaccounts: [String: AnyCodable]
|
public let channelaccounts: [String: AnyCodable]
|
||||||
public let channeldefaultaccountid: [String: AnyCodable]
|
public let channeldefaultaccountid: [String: AnyCodable]
|
||||||
@@ -1336,6 +1337,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
channellabels: [String: AnyCodable],
|
channellabels: [String: AnyCodable],
|
||||||
channeldetaillabels: [String: AnyCodable]?,
|
channeldetaillabels: [String: AnyCodable]?,
|
||||||
channelsystemimages: [String: AnyCodable]?,
|
channelsystemimages: [String: AnyCodable]?,
|
||||||
|
channelmeta: [[String: AnyCodable]]?,
|
||||||
channels: [String: AnyCodable],
|
channels: [String: AnyCodable],
|
||||||
channelaccounts: [String: AnyCodable],
|
channelaccounts: [String: AnyCodable],
|
||||||
channeldefaultaccountid: [String: AnyCodable]
|
channeldefaultaccountid: [String: AnyCodable]
|
||||||
@@ -1345,6 +1347,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
self.channellabels = channellabels
|
self.channellabels = channellabels
|
||||||
self.channeldetaillabels = channeldetaillabels
|
self.channeldetaillabels = channeldetaillabels
|
||||||
self.channelsystemimages = channelsystemimages
|
self.channelsystemimages = channelsystemimages
|
||||||
|
self.channelmeta = channelmeta
|
||||||
self.channels = channels
|
self.channels = channels
|
||||||
self.channelaccounts = channelaccounts
|
self.channelaccounts = channelaccounts
|
||||||
self.channeldefaultaccountid = channeldefaultaccountid
|
self.channeldefaultaccountid = channeldefaultaccountid
|
||||||
@@ -1355,6 +1358,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
case channellabels = "channelLabels"
|
case channellabels = "channelLabels"
|
||||||
case channeldetaillabels = "channelDetailLabels"
|
case channeldetaillabels = "channelDetailLabels"
|
||||||
case channelsystemimages = "channelSystemImages"
|
case channelsystemimages = "channelSystemImages"
|
||||||
|
case channelmeta = "channelMeta"
|
||||||
case channels
|
case channels
|
||||||
case channelaccounts = "channelAccounts"
|
case channelaccounts = "channelAccounts"
|
||||||
case channeldefaultaccountid = "channelDefaultAccountId"
|
case channeldefaultaccountid = "channelDefaultAccountId"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ struct WideAreaGatewayDiscoveryTests {
|
|||||||
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
|
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
|
||||||
if recordType == "PTR" {
|
if recordType == "PTR" {
|
||||||
if nameserver == "@100.123.224.76" {
|
if nameserver == "@100.123.224.76" {
|
||||||
return "steipetacstudio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n"
|
return "steipetacstudio-gateway._clawdbot-gw._tcp.clawdbot.internal.\n"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
public enum ClawdbotBonjour {
|
public enum ClawdbotBonjour {
|
||||||
// v0: internal-only, subject to rename.
|
// v0: internal-only, subject to rename.
|
||||||
public static let gatewayServiceType = "_clawdbot-gateway._tcp"
|
public static let gatewayServiceType = "_clawdbot-gw._tcp"
|
||||||
public static let gatewayServiceDomain = "local."
|
public static let gatewayServiceDomain = "local."
|
||||||
public static let wideAreaGatewayServiceDomain = "clawdbot.internal."
|
public static let wideAreaGatewayServiceDomain = "clawdbot.internal."
|
||||||
|
|
||||||
|
|||||||
@@ -498,7 +498,7 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
|
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
|
||||||
let data: Data? = switch msg {
|
let data: Data? = switch msg {
|
||||||
case let .data(data): data
|
case let .data(data): data
|
||||||
case let .string(text): text.data(using: .utf8)
|
case let .string(text): text.data(using: .utf8)
|
||||||
|
|||||||
@@ -29,5 +29,10 @@ public struct GatewayDecodingError: LocalizedError, Sendable {
|
|||||||
public let method: String
|
public let method: String
|
||||||
public let message: String
|
public let message: String
|
||||||
|
|
||||||
|
public init(method: String, message: String) {
|
||||||
|
self.method = method
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
|
||||||
public var errorDescription: String? { "\(self.method): \(self.message)" }
|
public var errorDescription: String? { "\(self.method): \(self.message)" }
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ public enum GatewayTLSStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate {
|
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {
|
||||||
private let params: GatewayTLSParams
|
private let params: GatewayTLSParams
|
||||||
private lazy var session: URLSession = {
|
private lazy var session: URLSession = {
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
@@ -96,10 +96,12 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func certificateFingerprint(_ trust: SecTrust) -> String? {
|
private func certificateFingerprint(_ trust: SecTrust) -> String? {
|
||||||
let count = SecTrustGetCertificateCount(trust)
|
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
||||||
guard count > 0, let cert = SecTrustGetCertificateAtIndex(trust, 0) else { return nil }
|
let cert = chain.first
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
else {
|
||||||
return sha256Hex(data)
|
return nil
|
||||||
|
}
|
||||||
|
return sha256Hex(SecCertificateCopyData(cert) as Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sha256Hex(_ data: Data) -> String {
|
private func sha256Hex(_ data: Data) -> String {
|
||||||
@@ -108,5 +110,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)
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,11 @@ Isolated jobs can deliver output to a channel. The job payload can specify:
|
|||||||
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
||||||
(the last place the agent replied).
|
(the last place the agent replied).
|
||||||
|
|
||||||
|
Delivery notes:
|
||||||
|
- If `to` is set, cron auto-delivers the agent’s final output even if `deliver` is omitted.
|
||||||
|
- Use `deliver: true` when you want last-route delivery without an explicit `to`.
|
||||||
|
- Use `deliver: false` to keep output internal even if a `to` is present.
|
||||||
|
|
||||||
Target format reminders:
|
Target format reminders:
|
||||||
- Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
- Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||||
- Telegram topics should use the `:topic:` form (see below).
|
- Telegram topics should use the `:topic:` form (see below).
|
||||||
|
|||||||
+1
-1
@@ -116,7 +116,7 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
|
|||||||
|
|
||||||
## Discover gateways (Bonjour)
|
## Discover gateways (Bonjour)
|
||||||
|
|
||||||
`gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`).
|
`gateway discover` scans for Gateway beacons (`_clawdbot-gw._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))
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ clawdbot sandbox recreate --agent alfred
|
|||||||
|
|
||||||
**Solution:** Use `clawdbot sandbox recreate` to force removal of old containers. They'll be recreated automatically with current settings when next needed.
|
**Solution:** Use `clawdbot sandbox recreate` to force removal of old containers. They'll be recreated automatically with current settings when next needed.
|
||||||
|
|
||||||
|
Tip: prefer `clawdbot sandbox recreate` over manual `docker rm`. It uses the
|
||||||
|
Gateway’s container naming and avoids mismatches when scope/session keys change.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Sandbox settings live in `~/.clawdbot/clawdbot.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`):
|
Sandbox settings live in `~/.clawdbot/clawdbot.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`):
|
||||||
|
|||||||
@@ -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)).
|
||||||
+30
-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,44 @@ 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.
|
||||||
|
7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
|
||||||
|
|
||||||
## `--update` shorthand
|
## `--update` shorthand
|
||||||
|
|
||||||
@@ -49,5 +73,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)
|
||||||
@@ -256,6 +256,52 @@ Keep WhatsApp on the fast agent, but route one DM to Opus:
|
|||||||
|
|
||||||
Peer bindings always win, so keep them above the channel-wide rule.
|
Peer bindings always win, so keep them above the channel-wide rule.
|
||||||
|
|
||||||
|
## Family agent bound to a WhatsApp group
|
||||||
|
|
||||||
|
Bind a dedicated family agent to a single WhatsApp group, with mention gating
|
||||||
|
and a tighter tool policy:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "family",
|
||||||
|
name: "Family",
|
||||||
|
workspace: "~/clawd-family",
|
||||||
|
identity: { name: "Family Bot" },
|
||||||
|
groupChat: {
|
||||||
|
mentionPatterns: ["@family", "@familybot", "@Family Bot"]
|
||||||
|
},
|
||||||
|
sandbox: {
|
||||||
|
mode: "all",
|
||||||
|
scope: "agent"
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
allow: ["exec", "read", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status"],
|
||||||
|
deny: ["write", "edit", "apply_patch", "browser", "canvas", "nodes", "cron"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
agentId: "family",
|
||||||
|
match: {
|
||||||
|
channel: "whatsapp",
|
||||||
|
peer: { kind: "group", id: "120363999999999999@g.us" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Tool allow/deny lists are **tools**, not skills. If a skill needs to run a
|
||||||
|
binary, ensure `exec` is allowed and the binary exists in the sandbox.
|
||||||
|
- For stricter gating, set `agents.list[].groupChat.mentionPatterns` and keep
|
||||||
|
group allowlists enabled for the channel.
|
||||||
|
|
||||||
## Per-Agent Sandbox and Tool Configuration
|
## Per-Agent Sandbox and Tool Configuration
|
||||||
|
|
||||||
Starting with v2026.1.6, each agent can have its own sandbox and tool restrictions:
|
Starting with v2026.1.6, each agent can have its own sandbox and tool restrictions:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ boundary. You can keep the same discovery UX by switching to **unicast DNS‑SD*
|
|||||||
High‑level steps:
|
High‑level steps:
|
||||||
|
|
||||||
1) Run a DNS server on the gateway host (reachable over Tailnet).
|
1) Run a DNS server on the gateway host (reachable over Tailnet).
|
||||||
2) Publish DNS‑SD records for `_clawdbot-bridge._tcp` under a dedicated zone
|
2) Publish DNS‑SD records for `_clawdbot-gw._tcp` under a dedicated zone
|
||||||
(example: `clawdbot.internal.`).
|
(example: `clawdbot.internal.`).
|
||||||
3) Configure Tailscale **split DNS** so `clawdbot.internal` resolves via that
|
3) Configure Tailscale **split DNS** so `clawdbot.internal` resolves via that
|
||||||
DNS server for clients (including iOS).
|
DNS server for clients (including iOS).
|
||||||
@@ -49,8 +49,8 @@ This installs CoreDNS and configures it to:
|
|||||||
Validate from a tailnet‑connected machine:
|
Validate from a tailnet‑connected machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dns-sd -B _clawdbot-bridge._tcp clawdbot.internal.
|
dns-sd -B _clawdbot-gw._tcp clawdbot.internal.
|
||||||
dig @<TAILNET_IPV4> -p 53 _clawdbot-bridge._tcp.clawdbot.internal PTR +short
|
dig @<TAILNET_IPV4> -p 53 _clawdbot-gw._tcp.clawdbot.internal PTR +short
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tailscale DNS settings
|
### Tailscale DNS settings
|
||||||
@@ -61,7 +61,7 @@ In the Tailscale admin console:
|
|||||||
- Add split DNS so the domain `clawdbot.internal` uses that nameserver.
|
- Add split DNS so the domain `clawdbot.internal` uses that nameserver.
|
||||||
|
|
||||||
Once clients accept tailnet DNS, iOS nodes can browse
|
Once clients accept tailnet DNS, iOS nodes can browse
|
||||||
`_clawdbot-bridge._tcp` in `clawdbot.internal.` without multicast.
|
`_clawdbot-gw._tcp` in `clawdbot.internal.` without multicast.
|
||||||
|
|
||||||
### Bridge listener security (recommended)
|
### Bridge listener security (recommended)
|
||||||
|
|
||||||
@@ -74,11 +74,11 @@ For tailnet‑only setups:
|
|||||||
|
|
||||||
## What advertises
|
## What advertises
|
||||||
|
|
||||||
Only the Gateway (when the **bridge is enabled**) advertises `_clawdbot-bridge._tcp`.
|
Only the Gateway advertises `_clawdbot-gw._tcp`.
|
||||||
|
|
||||||
## Service types
|
## Service types
|
||||||
|
|
||||||
- `_clawdbot-bridge._tcp` — bridge transport beacon (used by macOS/iOS/Android nodes).
|
- `_clawdbot-gw._tcp` — gateway transport beacon (used by macOS/iOS/Android nodes).
|
||||||
|
|
||||||
## TXT keys (non‑secret hints)
|
## TXT keys (non‑secret hints)
|
||||||
|
|
||||||
@@ -101,11 +101,11 @@ Useful built‑in tools:
|
|||||||
|
|
||||||
- Browse instances:
|
- Browse instances:
|
||||||
```bash
|
```bash
|
||||||
dns-sd -B _clawdbot-bridge._tcp local.
|
dns-sd -B _clawdbot-gw._tcp local.
|
||||||
```
|
```
|
||||||
- Resolve one instance (replace `<instance>`):
|
- Resolve one instance (replace `<instance>`):
|
||||||
```bash
|
```bash
|
||||||
dns-sd -L "<instance>" _clawdbot-bridge._tcp local.
|
dns-sd -L "<instance>" _clawdbot-gw._tcp local.
|
||||||
```
|
```
|
||||||
|
|
||||||
If browsing works but resolving fails, you’re usually hitting a LAN policy or
|
If browsing works but resolving fails, you’re usually hitting a LAN policy or
|
||||||
@@ -122,7 +122,7 @@ The Gateway writes a rolling log file (printed on startup as
|
|||||||
|
|
||||||
## Debugging on iOS node
|
## Debugging on iOS node
|
||||||
|
|
||||||
The iOS node uses `NWBrowser` to discover `_clawdbot-bridge._tcp`.
|
The iOS node uses `NWBrowser` to discover `_clawdbot-gw._tcp`.
|
||||||
|
|
||||||
To capture logs:
|
To capture logs:
|
||||||
- Settings → Bridge → Advanced → **Discovery Debug Logs**
|
- Settings → Bridge → Advanced → **Discovery Debug Logs**
|
||||||
|
|||||||
@@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include:
|
|||||||
- `alias` (optional model shortcut, e.g. `/opus`).
|
- `alias` (optional model shortcut, e.g. `/opus`).
|
||||||
- `params` (optional provider-specific API params passed through to the model request).
|
- `params` (optional provider-specific API params passed through to the model request).
|
||||||
|
|
||||||
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`. These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change.
|
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Anthropic API defaults to `"1h"` unless you override (`cacheControlTtl: "5m"`). Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -2988,7 +2988,7 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge
|
|||||||
|
|
||||||
### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD)
|
### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD)
|
||||||
|
|
||||||
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
|
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-gw._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
|
||||||
|
|
||||||
To make iOS/Android discover across networks (Vienna ⇄ London), pair this with:
|
To make iOS/Android discover across networks (Vienna ⇄ London), pair this with:
|
||||||
- a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended)
|
- a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
|
|||||||
#### Service beacon details
|
#### Service beacon details
|
||||||
|
|
||||||
- Service types:
|
- Service types:
|
||||||
- `_clawdbot-bridge._tcp` (bridge transport beacon)
|
- `_clawdbot-gw._tcp` (gateway transport beacon)
|
||||||
- TXT keys (non-secret):
|
- TXT keys (non-secret):
|
||||||
- `role=gateway`
|
- `role=gateway`
|
||||||
- `lanHost=<hostname>.local`
|
- `lanHost=<hostname>.local`
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ Build it once:
|
|||||||
scripts/sandbox-setup.sh
|
scripts/sandbox-setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: the default image does **not** include Node. If a skill needs Node (or
|
||||||
|
other runtimes), either bake a custom image or install via
|
||||||
|
`sandbox.docker.setupCommand` (requires network egress + writable root +
|
||||||
|
root user).
|
||||||
|
|
||||||
Sandboxed browser image:
|
Sandboxed browser image:
|
||||||
```bash
|
```bash
|
||||||
scripts/sandbox-browser-setup.sh
|
scripts/sandbox-browser-setup.sh
|
||||||
@@ -129,6 +134,8 @@ Common pitfalls:
|
|||||||
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
|
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
|
||||||
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
|
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
|
||||||
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
|
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
|
||||||
|
- Sandbox exec does **not** inherit host `process.env`. Use
|
||||||
|
`agents.defaults.sandbox.docker.env` (or a custom image) for skill API keys.
|
||||||
|
|
||||||
## Tool policy + escape hatches
|
## Tool policy + escape hatches
|
||||||
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
||||||
|
|||||||
@@ -79,11 +79,25 @@ 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`
|
||||||
to migrate to a system Node install.
|
to migrate to a system Node install.
|
||||||
|
|
||||||
|
### Skill missing API key in sandbox
|
||||||
|
|
||||||
|
**Symptom:** Skill works on host but fails in sandbox with missing API key.
|
||||||
|
|
||||||
|
**Why:** sandboxed exec runs inside Docker and does **not** inherit host `process.env`.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- set `agents.defaults.sandbox.docker.env` (or per-agent `agents.list[].sandbox.docker.env`)
|
||||||
|
- or bake the key into your custom sandbox image
|
||||||
|
- then run `clawdbot sandbox recreate --agent <id>` (or `--all`)
|
||||||
|
|
||||||
### Service Running but Port Not Listening
|
### Service Running but Port Not Listening
|
||||||
|
|
||||||
If the service reports **running** but nothing is listening on the gateway port,
|
If the service reports **running** but nothing is listening on the gateway port,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
|
||||||
|
Last updated: 2026-01-20
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Plugins and channels
|
||||||
|
|
||||||
|
When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sources:
|
||||||
|
|
||||||
|
- `dev` prefers bundled plugins from the git checkout.
|
||||||
|
- `stable` and `beta` restore npm-installed plugin packages.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -136,6 +136,72 @@ Tool summaries can redact sensitive tokens before they hit the console:
|
|||||||
|
|
||||||
Redaction affects **console output only** and does not alter file logs.
|
Redaction affects **console output only** and does not alter file logs.
|
||||||
|
|
||||||
|
## Diagnostics + OpenTelemetry
|
||||||
|
|
||||||
|
Diagnostics are **opt-in** structured events for model runs (usage + cost +
|
||||||
|
context + duration). They do **not** replace logs; they exist to feed metrics,
|
||||||
|
traces, and other exporters.
|
||||||
|
|
||||||
|
Clawdbot currently emits a `model.usage` event after each agent run with:
|
||||||
|
|
||||||
|
- Token counts (input/output/cache/prompt/total)
|
||||||
|
- Estimated cost (USD)
|
||||||
|
- Context window used/limit
|
||||||
|
- Duration (ms)
|
||||||
|
- Provider/channel/model + session identifiers
|
||||||
|
|
||||||
|
### Enable diagnostics (no exporter)
|
||||||
|
|
||||||
|
Use this if you want diagnostics events available to plugins or custom sinks:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"diagnostics": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export to OpenTelemetry
|
||||||
|
|
||||||
|
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
|
||||||
|
works with any OpenTelemetry collector/backend that accepts OTLP/HTTP.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"allow": ["diagnostics-otel"],
|
||||||
|
"entries": {
|
||||||
|
"diagnostics-otel": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"enabled": true,
|
||||||
|
"otel": {
|
||||||
|
"enabled": true,
|
||||||
|
"endpoint": "http://otel-collector:4318",
|
||||||
|
"protocol": "http/protobuf",
|
||||||
|
"serviceName": "clawdbot-gateway",
|
||||||
|
"traces": true,
|
||||||
|
"metrics": true,
|
||||||
|
"sampleRate": 0.2,
|
||||||
|
"flushIntervalMs": 60000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- You can also enable the plugin with `clawdbot plugins enable diagnostics-otel`.
|
||||||
|
- `protocol` currently supports `http/protobuf`.
|
||||||
|
- Metrics include token usage, cost, context size, and run duration.
|
||||||
|
- Traces/metrics can be toggled with `traces` / `metrics` (default: on).
|
||||||
|
- Set `headers` when your collector requires auth.
|
||||||
|
- Environment variables supported: `OTEL_EXPORTER_OTLP_ENDPOINT`,
|
||||||
|
`OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||||
|
|
||||||
## Troubleshooting tips
|
## Troubleshooting tips
|
||||||
|
|
||||||
- **Gateway not reachable?** Run `clawdbot doctor` first.
|
- **Gateway not reachable?** Run `clawdbot doctor` first.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ For tailnet-only setups (recommended for Vienna ⇄ London), bind the gateway to
|
|||||||
From the gateway machine:
|
From the gateway machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dns-sd -B _clawdbot-gateway._tcp local.
|
dns-sd -B _clawdbot-gw._tcp local.
|
||||||
```
|
```
|
||||||
|
|
||||||
More debugging notes: [Bonjour](/gateway/bonjour).
|
More debugging notes: [Bonjour](/gateway/bonjour).
|
||||||
@@ -61,7 +61,7 @@ More debugging notes: [Bonjour](/gateway/bonjour).
|
|||||||
|
|
||||||
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
|
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
|
||||||
|
|
||||||
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-gateway._tcp` records.
|
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-gw._tcp` records.
|
||||||
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
|
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
|
||||||
|
|
||||||
Details and example CoreDNS config: [Bonjour](/gateway/bonjour).
|
Details and example CoreDNS config: [Bonjour](/gateway/bonjour).
|
||||||
|
|||||||
@@ -29,17 +29,17 @@ Notes:
|
|||||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=2026.1.13 \
|
APP_VERSION=2026.1.20 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||||
scripts/package-mac-app.sh
|
scripts/package-mac-app.sh
|
||||||
|
|
||||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.13.zip
|
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.20.zip
|
||||||
|
|
||||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.13.dmg
|
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.20.dmg
|
||||||
|
|
||||||
# Recommended: build + notarize/staple zip + DMG
|
# Recommended: build + notarize/staple zip + DMG
|
||||||
# First, create a keychain profile once:
|
# First, create a keychain profile once:
|
||||||
@@ -47,26 +47,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.13.dmg
|
|||||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=2026.1.13 \
|
APP_VERSION=2026.1.20 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||||
scripts/package-mac-dist.sh
|
scripts/package-mac-dist.sh
|
||||||
|
|
||||||
# Optional: ship dSYM alongside the release
|
# Optional: ship dSYM alongside the release
|
||||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.13.dSYM.zip
|
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.20.dSYM.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
## Appcast entry
|
## Appcast entry
|
||||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||||
```bash
|
```bash
|
||||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.13.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.20.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||||
```
|
```
|
||||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||||
|
|
||||||
## Publish & verify
|
## Publish & verify
|
||||||
- Upload `Clawdbot-2026.1.13.zip` (and `Clawdbot-2026.1.13.dSYM.zip`) to the GitHub release for tag `v2026.1.13`.
|
- Upload `Clawdbot-2026.1.20.zip` (and `Clawdbot-2026.1.20.dSYM.zip`) to the GitHub release for tag `v2026.1.20`.
|
||||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||||
- Sanity checks:
|
- Sanity checks:
|
||||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Clawdbot macOS IPC architecture
|
# Clawdbot macOS IPC architecture
|
||||||
|
|
||||||
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. There is no `clawdbot-mac` CLI; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
|
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||||
|
|||||||
+14
-6
@@ -140,19 +140,27 @@ Safety:
|
|||||||
- `swift run Clawdbot` (or Xcode)
|
- `swift run Clawdbot` (or Xcode)
|
||||||
- Package app: `scripts/package-mac-app.sh`
|
- Package app: `scripts/package-mac-app.sh`
|
||||||
|
|
||||||
## Debug gateway discovery (macOS CLI)
|
## Debug gateway connectivity (macOS CLI)
|
||||||
|
|
||||||
Use the debug CLI to exercise the same Bonjour + wide‑area discovery code that the
|
Use the debug CLI to exercise the same Gateway WebSocket handshake and discovery
|
||||||
macOS app uses, without launching the app.
|
logic that the macOS app uses, without launching the app.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/macos
|
cd apps/macos
|
||||||
swift run clawdbot-mac-discovery --timeout 3000 --json
|
swift run clawdbot-mac connect --json
|
||||||
|
swift run clawdbot-mac discover --timeout 3000 --json
|
||||||
```
|
```
|
||||||
|
|
||||||
Options:
|
Connect options:
|
||||||
|
- `--url <ws://host:port>`: override config
|
||||||
|
- `--mode <local|remote>`: resolve from config (default: config or local)
|
||||||
|
- `--probe`: force a fresh health probe
|
||||||
|
- `--timeout <ms>`: request timeout (default: `15000`)
|
||||||
|
- `--json`: structured output for diffing
|
||||||
|
|
||||||
|
Discovery options:
|
||||||
- `--include-local`: include gateways that would be filtered as “local”
|
- `--include-local`: include gateways that would be filtered as “local”
|
||||||
- `--timeout <ms>`: overall discovery window (default `2000`)
|
- `--timeout <ms>`: overall discovery window (default: `2000`)
|
||||||
- `--json`: structured output for diffing
|
- `--json`: structured output for diffing
|
||||||
|
|
||||||
Tip: compare against `clawdbot gateway discover --json` to see whether the
|
Tip: compare against `clawdbot gateway discover --json` to see whether the
|
||||||
|
|||||||
@@ -34,6 +34,30 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Prompt caching (Anthropic API)
|
||||||
|
|
||||||
|
Clawdbot enables **1-hour prompt caching by default** for Anthropic API keys.
|
||||||
|
This is **API-only**; Claude Code CLI OAuth ignores TTL settings.
|
||||||
|
|
||||||
|
To override the TTL per model, set `cacheControlTtl` in the model `params`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"anthropic/claude-opus-4-5": {
|
||||||
|
params: { cacheControlTtl: "5m" } // or "1h"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API
|
||||||
|
requests; keep it if you override provider headers (see [/gateway/configuration](/gateway/configuration)).
|
||||||
|
|
||||||
## Option B: Claude Code CLI (setup-token or OAuth)
|
## Option B: Claude Code CLI (setup-token or OAuth)
|
||||||
|
|
||||||
**Best for:** using your Claude subscription or existing Claude Code CLI login.
|
**Best for:** using your Claude subscription or existing Claude Code CLI login.
|
||||||
|
|||||||
+2
-1
@@ -57,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`.
|
||||||
|
|||||||
@@ -62,3 +62,14 @@ Per-skill fields:
|
|||||||
- Keys under `entries` map to the skill name by default. If a skill defines
|
- Keys under `entries` map to the skill name by default. If a skill defines
|
||||||
`metadata.clawdbot.skillKey`, use that key instead.
|
`metadata.clawdbot.skillKey`, use that key instead.
|
||||||
- Changes to skills are picked up on the next agent turn when the watcher is enabled.
|
- Changes to skills are picked up on the next agent turn when the watcher is enabled.
|
||||||
|
|
||||||
|
### Sandboxed skills + env vars
|
||||||
|
|
||||||
|
When a session is **sandboxed**, skill processes run inside Docker. The sandbox
|
||||||
|
does **not** inherit the host `process.env`.
|
||||||
|
|
||||||
|
Use one of:
|
||||||
|
- `agents.defaults.sandbox.docker.env` (or per-agent `agents.list[].sandbox.docker.env`)
|
||||||
|
- bake the env into your custom sandbox image
|
||||||
|
|
||||||
|
Global `env` and `skills.entries.<skill>.env/apiKey` apply to **host** runs only.
|
||||||
@@ -155,6 +155,10 @@ Bundled/managed skills can be toggled and supplied with env values:
|
|||||||
apiKey: "GEMINI_KEY_HERE",
|
apiKey: "GEMINI_KEY_HERE",
|
||||||
env: {
|
env: {
|
||||||
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
endpoint: "https://example.invalid",
|
||||||
|
model: "nano-pro"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
peekaboo: { enabled: true },
|
peekaboo: { enabled: true },
|
||||||
@@ -173,6 +177,7 @@ Rules:
|
|||||||
- `enabled: false` disables the skill even if it’s bundled/installed.
|
- `enabled: false` disables the skill even if it’s bundled/installed.
|
||||||
- `env`: injected **only if** the variable isn’t already set in the process.
|
- `env`: injected **only if** the variable isn’t already set in the process.
|
||||||
- `apiKey`: convenience for skills that declare `metadata.clawdbot.primaryEnv`.
|
- `apiKey`: convenience for skills that declare `metadata.clawdbot.primaryEnv`.
|
||||||
|
- `config`: optional bag for custom per-skill fields; custom keys must live here.
|
||||||
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only
|
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only
|
||||||
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +104,7 @@ 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).
|
||||||
- **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.
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ Note: the merge is additive, so main profiles are always available as fallbacks.
|
|||||||
Sub-agents report back via an announce step:
|
Sub-agents report back via an announce step:
|
||||||
- The announce step runs inside the sub-agent session (not the requester session).
|
- The announce step runs inside the sub-agent session (not the requester session).
|
||||||
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
|
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
|
||||||
- Otherwise the announce reply is posted to the requester chat channel via the gateway `send` method.
|
- Otherwise the announce reply is posted to the requester chat channel via a follow-up `agent` call (`deliver=true`).
|
||||||
|
- Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads).
|
||||||
- Announce messages are normalized to a stable template:
|
- Announce messages are normalized to a stable template:
|
||||||
- `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`).
|
- `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`).
|
||||||
- `Result:` the summary content from the announce step (or `(not available)` if missing).
|
- `Result:` the summary content from the announce step (or `(not available)` if missing).
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
BLUEBUBBLES_ACTION_NAMES,
|
||||||
|
BLUEBUBBLES_ACTIONS,
|
||||||
createActionGate,
|
createActionGate,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
readBooleanParam,
|
readBooleanParam,
|
||||||
@@ -49,19 +51,7 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Supported action names for BlueBubbles */
|
/** Supported action names for BlueBubbles */
|
||||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
|
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||||
"react",
|
|
||||||
"edit",
|
|
||||||
"unsend",
|
|
||||||
"reply",
|
|
||||||
"sendWithEffect",
|
|
||||||
"renameGroup",
|
|
||||||
"setGroupIcon",
|
|
||||||
"addParticipant",
|
|
||||||
"removeParticipant",
|
|
||||||
"leaveGroup",
|
|
||||||
"sendAttachment",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
@@ -69,19 +59,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
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>();
|
||||||
// Check if running on macOS 26+ (edit not supported)
|
|
||||||
const macOS26 = isMacOS26OrHigher(account.accountId);
|
const macOS26 = isMacOS26OrHigher(account.accountId);
|
||||||
if (gate("reactions")) actions.add("react");
|
for (const action of BLUEBUBBLES_ACTION_NAMES) {
|
||||||
if (gate("edit") && !macOS26) actions.add("edit");
|
const spec = BLUEBUBBLES_ACTIONS[action];
|
||||||
if (gate("unsend")) actions.add("unsend");
|
if (!spec?.gate) continue;
|
||||||
if (gate("reply")) actions.add("reply");
|
if (spec.unsupportedOnMacOS26 && macOS26) continue;
|
||||||
if (gate("sendWithEffect")) actions.add("sendWithEffect");
|
if (gate(spec.gate)) actions.add(action);
|
||||||
if (gate("renameGroup")) actions.add("renameGroup");
|
}
|
||||||
if (gate("setGroupIcon")) actions.add("setGroupIcon");
|
|
||||||
if (gate("addParticipant")) actions.add("addParticipant");
|
|
||||||
if (gate("removeParticipant")) actions.add("removeParticipant");
|
|
||||||
if (gate("leaveGroup")) actions.add("leaveGroup");
|
|
||||||
if (gate("sendAttachment")) actions.add("sendAttachment");
|
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "diagnostics-otel",
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { createDiagnosticsOtelService } from "./src/service.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "diagnostics-otel",
|
||||||
|
name: "Diagnostics OpenTelemetry",
|
||||||
|
description: "Export diagnostics events to OpenTelemetry",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
api.registerService(createDiagnosticsOtelService());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/diagnostics-otel",
|
||||||
|
"version": "2026.1.17-1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.210.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
|
||||||
|
"@opentelemetry/resources": "^2.4.0",
|
||||||
|
"@opentelemetry/sdk-metrics": "^2.4.0",
|
||||||
|
"@opentelemetry/sdk-node": "^0.210.0",
|
||||||
|
"@opentelemetry/sdk-trace-base": "^2.4.0",
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.39.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { metrics, trace } from "@opentelemetry/api";
|
||||||
|
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||||
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||||
|
import { Resource } from "@opentelemetry/resources";
|
||||||
|
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||||
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||||
|
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
||||||
|
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ClawdbotPluginService,
|
||||||
|
DiagnosticUsageEvent,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import { onDiagnosticEvent } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
const DEFAULT_SERVICE_NAME = "clawdbot";
|
||||||
|
|
||||||
|
function normalizeEndpoint(endpoint?: string): string | undefined {
|
||||||
|
const trimmed = endpoint?.trim();
|
||||||
|
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined {
|
||||||
|
if (!endpoint) return undefined;
|
||||||
|
if (endpoint.includes("/v1/")) return endpoint;
|
||||||
|
return `${endpoint}/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSampleRate(value: number | undefined): number | undefined {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||||
|
if (value < 0 || value > 1) return undefined;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDiagnosticsOtelService(): ClawdbotPluginService {
|
||||||
|
let sdk: NodeSDK | null = null;
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "diagnostics-otel",
|
||||||
|
async start(ctx) {
|
||||||
|
const cfg = ctx.config.diagnostics;
|
||||||
|
const otel = cfg?.otel;
|
||||||
|
if (!cfg?.enabled || !otel?.enabled) return;
|
||||||
|
|
||||||
|
const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
|
||||||
|
if (protocol !== "http/protobuf") {
|
||||||
|
ctx.logger.warn(`diagnostics-otel: unsupported protocol ${protocol}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = normalizeEndpoint(otel.endpoint ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
|
||||||
|
const headers = otel.headers ?? undefined;
|
||||||
|
const serviceName =
|
||||||
|
otel.serviceName?.trim() || process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME;
|
||||||
|
const sampleRate = resolveSampleRate(otel.sampleRate);
|
||||||
|
|
||||||
|
const tracesEnabled = otel.traces !== false;
|
||||||
|
const metricsEnabled = otel.metrics !== false;
|
||||||
|
if (!tracesEnabled && !metricsEnabled) return;
|
||||||
|
|
||||||
|
const resource = new Resource({
|
||||||
|
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const traceUrl = resolveOtelUrl(endpoint, "v1/traces");
|
||||||
|
const metricUrl = resolveOtelUrl(endpoint, "v1/metrics");
|
||||||
|
const traceExporter = tracesEnabled
|
||||||
|
? new OTLPTraceExporter({
|
||||||
|
...(traceUrl ? { url: traceUrl } : {}),
|
||||||
|
...(headers ? { headers } : {}),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const metricExporter = metricsEnabled
|
||||||
|
? new OTLPMetricExporter({
|
||||||
|
...(metricUrl ? { url: metricUrl } : {}),
|
||||||
|
...(headers ? { headers } : {}),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const metricReader = metricExporter
|
||||||
|
? new PeriodicExportingMetricReader({
|
||||||
|
exporter: metricExporter,
|
||||||
|
...(typeof otel.flushIntervalMs === "number"
|
||||||
|
? { exportIntervalMillis: Math.max(1000, otel.flushIntervalMs) }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
sdk = new NodeSDK({
|
||||||
|
resource,
|
||||||
|
...(traceExporter ? { traceExporter } : {}),
|
||||||
|
...(metricReader ? { metricReader } : {}),
|
||||||
|
...(sampleRate !== undefined
|
||||||
|
? {
|
||||||
|
sampler: new ParentBasedSampler({
|
||||||
|
root: new TraceIdRatioBasedSampler(sampleRate),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await sdk.start();
|
||||||
|
|
||||||
|
const meter = metrics.getMeter("clawdbot");
|
||||||
|
const tracer = trace.getTracer("clawdbot");
|
||||||
|
|
||||||
|
const tokensCounter = meter.createCounter("clawdbot.tokens", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Token usage by type",
|
||||||
|
});
|
||||||
|
const costCounter = meter.createCounter("clawdbot.cost.usd", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Estimated model cost (USD)",
|
||||||
|
});
|
||||||
|
const durationHistogram = meter.createHistogram("clawdbot.run.duration_ms", {
|
||||||
|
unit: "ms",
|
||||||
|
description: "Agent run duration",
|
||||||
|
});
|
||||||
|
const contextHistogram = meter.createHistogram("clawdbot.context.tokens", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Context window size and usage",
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribe = onDiagnosticEvent((evt) => {
|
||||||
|
if (evt.type !== "model.usage") return;
|
||||||
|
const usageEvent = evt as DiagnosticUsageEvent;
|
||||||
|
const attrs = {
|
||||||
|
"clawdbot.channel": usageEvent.channel ?? "unknown",
|
||||||
|
"clawdbot.provider": usageEvent.provider ?? "unknown",
|
||||||
|
"clawdbot.model": usageEvent.model ?? "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
const usage = usageEvent.usage;
|
||||||
|
if (usage.input) tokensCounter.add(usage.input, { ...attrs, "clawdbot.token": "input" });
|
||||||
|
if (usage.output)
|
||||||
|
tokensCounter.add(usage.output, { ...attrs, "clawdbot.token": "output" });
|
||||||
|
if (usage.cacheRead)
|
||||||
|
tokensCounter.add(usage.cacheRead, { ...attrs, "clawdbot.token": "cache_read" });
|
||||||
|
if (usage.cacheWrite)
|
||||||
|
tokensCounter.add(usage.cacheWrite, { ...attrs, "clawdbot.token": "cache_write" });
|
||||||
|
if (usage.promptTokens)
|
||||||
|
tokensCounter.add(usage.promptTokens, { ...attrs, "clawdbot.token": "prompt" });
|
||||||
|
if (usage.total)
|
||||||
|
tokensCounter.add(usage.total, { ...attrs, "clawdbot.token": "total" });
|
||||||
|
|
||||||
|
if (usageEvent.costUsd) costCounter.add(usageEvent.costUsd, attrs);
|
||||||
|
if (usageEvent.durationMs) durationHistogram.record(usageEvent.durationMs, attrs);
|
||||||
|
if (usageEvent.context?.limit)
|
||||||
|
contextHistogram.record(usageEvent.context.limit, {
|
||||||
|
...attrs,
|
||||||
|
"clawdbot.context": "limit",
|
||||||
|
});
|
||||||
|
if (usageEvent.context?.used)
|
||||||
|
contextHistogram.record(usageEvent.context.used, {
|
||||||
|
...attrs,
|
||||||
|
"clawdbot.context": "used",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tracesEnabled) return;
|
||||||
|
const spanAttrs: Record<string, string | number> = {
|
||||||
|
...attrs,
|
||||||
|
"clawdbot.sessionKey": usageEvent.sessionKey ?? "",
|
||||||
|
"clawdbot.sessionId": usageEvent.sessionId ?? "",
|
||||||
|
"clawdbot.tokens.input": usage.input ?? 0,
|
||||||
|
"clawdbot.tokens.output": usage.output ?? 0,
|
||||||
|
"clawdbot.tokens.cache_read": usage.cacheRead ?? 0,
|
||||||
|
"clawdbot.tokens.cache_write": usage.cacheWrite ?? 0,
|
||||||
|
"clawdbot.tokens.total": usage.total ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = usageEvent.durationMs
|
||||||
|
? Date.now() - Math.max(0, usageEvent.durationMs)
|
||||||
|
: undefined;
|
||||||
|
const span = tracer.startSpan("clawdbot.model.usage", {
|
||||||
|
attributes: spanAttrs,
|
||||||
|
...(startTime ? { startTime } : {}),
|
||||||
|
});
|
||||||
|
span.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (otel.logs) {
|
||||||
|
ctx.logger.warn("diagnostics-otel: logs exporter not wired yet");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
if (sdk) {
|
||||||
|
await sdk.shutdown().catch(() => undefined);
|
||||||
|
sdk = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} satisfies ClawdbotPluginService;
|
||||||
|
}
|
||||||
@@ -1,518 +1,15 @@
|
|||||||
import type { MatrixClient } from "matrix-bot-sdk";
|
export type {
|
||||||
|
MatrixActionClientOpts,
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
MatrixMessageSummary,
|
||||||
import type { CoreConfig } from "../types.js";
|
MatrixReactionSummary,
|
||||||
import { getActiveMatrixClient } from "./active-client.js";
|
} from "./actions/types.js";
|
||||||
import {
|
export {
|
||||||
createMatrixClient,
|
sendMatrixMessage,
|
||||||
isBunRuntime,
|
editMatrixMessage,
|
||||||
resolveMatrixAuth,
|
deleteMatrixMessage,
|
||||||
resolveSharedMatrixClient,
|
readMatrixMessages,
|
||||||
} from "./client.js";
|
} from "./actions/messages.js";
|
||||||
import {
|
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
|
||||||
reactMatrixMessage,
|
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
|
||||||
resolveMatrixRoomId,
|
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
|
||||||
sendMessageMatrix,
|
export { reactMatrixMessage } from "./send.js";
|
||||||
} from "./send.js";
|
|
||||||
|
|
||||||
// Constants that were previously from matrix-js-sdk
|
|
||||||
const MsgType = {
|
|
||||||
Text: "m.text",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const RelationType = {
|
|
||||||
Replace: "m.replace",
|
|
||||||
Annotation: "m.annotation",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const EventType = {
|
|
||||||
RoomMessage: "m.room.message",
|
|
||||||
RoomPinnedEvents: "m.room.pinned_events",
|
|
||||||
RoomTopic: "m.room.topic",
|
|
||||||
Reaction: "m.reaction",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Type definitions for matrix-bot-sdk event content
|
|
||||||
type RoomMessageEventContent = {
|
|
||||||
msgtype: string;
|
|
||||||
body: string;
|
|
||||||
"m.new_content"?: RoomMessageEventContent;
|
|
||||||
"m.relates_to"?: {
|
|
||||||
rel_type?: string;
|
|
||||||
event_id?: string;
|
|
||||||
"m.in_reply_to"?: { event_id?: string };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReactionEventContent = {
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: string;
|
|
||||||
event_id: string;
|
|
||||||
key: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type RoomPinnedEventsEventContent = {
|
|
||||||
pinned: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type RoomTopicEventContent = {
|
|
||||||
topic?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MatrixRawEvent = {
|
|
||||||
event_id: string;
|
|
||||||
sender: string;
|
|
||||||
type: string;
|
|
||||||
origin_server_ts: number;
|
|
||||||
content: Record<string, unknown>;
|
|
||||||
unsigned?: {
|
|
||||||
redacted_because?: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MatrixActionClientOpts = {
|
|
||||||
client?: MatrixClient;
|
|
||||||
timeoutMs?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MatrixMessageSummary = {
|
|
||||||
eventId?: string;
|
|
||||||
sender?: string;
|
|
||||||
body?: string;
|
|
||||||
msgtype?: string;
|
|
||||||
timestamp?: number;
|
|
||||||
relatesTo?: {
|
|
||||||
relType?: string;
|
|
||||||
eventId?: string;
|
|
||||||
key?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MatrixReactionSummary = {
|
|
||||||
key: string;
|
|
||||||
count: number;
|
|
||||||
users: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type MatrixActionClient = {
|
|
||||||
client: MatrixClient;
|
|
||||||
stopOnDone: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ensureNodeRuntime() {
|
|
||||||
if (isBunRuntime()) {
|
|
||||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<MatrixActionClient> {
|
|
||||||
ensureNodeRuntime();
|
|
||||||
if (opts.client) return { client: opts.client, stopOnDone: false };
|
|
||||||
const active = getActiveMatrixClient();
|
|
||||||
if (active) return { client: active, stopOnDone: false };
|
|
||||||
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
|
||||||
if (shouldShareClient) {
|
|
||||||
const client = await resolveSharedMatrixClient({
|
|
||||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
|
||||||
timeoutMs: opts.timeoutMs,
|
|
||||||
});
|
|
||||||
return { client, stopOnDone: false };
|
|
||||||
}
|
|
||||||
const auth = await resolveMatrixAuth({
|
|
||||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
|
||||||
});
|
|
||||||
const client = await createMatrixClient({
|
|
||||||
homeserver: auth.homeserver,
|
|
||||||
userId: auth.userId,
|
|
||||||
accessToken: auth.accessToken,
|
|
||||||
encryption: auth.encryption,
|
|
||||||
localTimeoutMs: opts.timeoutMs,
|
|
||||||
});
|
|
||||||
if (auth.encryption && client.crypto) {
|
|
||||||
try {
|
|
||||||
const joinedRooms = await client.getJoinedRooms();
|
|
||||||
await client.crypto.prepare(joinedRooms);
|
|
||||||
} catch {
|
|
||||||
// Ignore crypto prep failures for one-off actions.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client.start();
|
|
||||||
return { client, stopOnDone: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
|
|
||||||
const content = event.content as RoomMessageEventContent;
|
|
||||||
const relates = content["m.relates_to"];
|
|
||||||
let relType: string | undefined;
|
|
||||||
let eventId: string | undefined;
|
|
||||||
if (relates) {
|
|
||||||
if ("rel_type" in relates) {
|
|
||||||
relType = relates.rel_type;
|
|
||||||
eventId = relates.event_id;
|
|
||||||
} else if ("m.in_reply_to" in relates) {
|
|
||||||
eventId = relates["m.in_reply_to"]?.event_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const relatesTo =
|
|
||||||
relType || eventId
|
|
||||||
? {
|
|
||||||
relType,
|
|
||||||
eventId,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
return {
|
|
||||||
eventId: event.event_id,
|
|
||||||
sender: event.sender,
|
|
||||||
body: content.body,
|
|
||||||
msgtype: content.msgtype,
|
|
||||||
timestamp: event.origin_server_ts,
|
|
||||||
relatesTo,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const content = (await client.getRoomStateEvent(
|
|
||||||
roomId,
|
|
||||||
EventType.RoomPinnedEvents,
|
|
||||||
"",
|
|
||||||
)) as RoomPinnedEventsEventContent;
|
|
||||||
const pinned = content.pinned;
|
|
||||||
return pinned.filter((id) => id.trim().length > 0);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
|
|
||||||
const httpStatus = errObj.statusCode;
|
|
||||||
const errcode = errObj.body?.errcode;
|
|
||||||
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchEventSummary(
|
|
||||||
client: MatrixClient,
|
|
||||||
roomId: string,
|
|
||||||
eventId: string,
|
|
||||||
): Promise<MatrixMessageSummary | null> {
|
|
||||||
try {
|
|
||||||
const raw = await client.getEvent(roomId, eventId) as MatrixRawEvent;
|
|
||||||
if (raw.unsigned?.redacted_because) return null;
|
|
||||||
return summarizeMatrixRawEvent(raw);
|
|
||||||
} catch (err) {
|
|
||||||
// Event not found, redacted, or inaccessible - return null
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendMatrixMessage(
|
|
||||||
to: string,
|
|
||||||
content: string,
|
|
||||||
opts: MatrixActionClientOpts & {
|
|
||||||
mediaUrl?: string;
|
|
||||||
replyToId?: string;
|
|
||||||
threadId?: string;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
return await sendMessageMatrix(to, content, {
|
|
||||||
mediaUrl: opts.mediaUrl,
|
|
||||||
replyToId: opts.replyToId,
|
|
||||||
threadId: opts.threadId,
|
|
||||||
client: opts.client,
|
|
||||||
timeoutMs: opts.timeoutMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function editMatrixMessage(
|
|
||||||
roomId: string,
|
|
||||||
messageId: string,
|
|
||||||
content: string,
|
|
||||||
opts: MatrixActionClientOpts = {},
|
|
||||||
) {
|
|
||||||
const trimmed = content.trim();
|
|
||||||
if (!trimmed) throw new Error("Matrix edit requires content");
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
|
||||||
const newContent = {
|
|
||||||
msgtype: MsgType.Text,
|
|
||||||
body: trimmed,
|
|
||||||
} satisfies RoomMessageEventContent;
|
|
||||||
const payload: RoomMessageEventContent = {
|
|
||||||
msgtype: MsgType.Text,
|
|
||||||
body: `* ${trimmed}`,
|
|
||||||
"m.new_content": newContent,
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: RelationType.Replace,
|
|
||||||
event_id: messageId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const eventId = await client.sendMessage(resolvedRoom, payload);
|
|
||||||
return { eventId: eventId ?? null };
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMatrixMessage(
|
|
||||||
roomId: string,
|
|
||||||
messageId: string,
|
|
||||||
opts: MatrixActionClientOpts & { reason?: string } = {},
|
|
||||||
) {
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
|
||||||
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readMatrixMessages(
|
|
||||||
roomId: string,
|
|
||||||
opts: MatrixActionClientOpts & {
|
|
||||||
limit?: number;
|
|
||||||
before?: string;
|
|
||||||
after?: string;
|
|
||||||
} = {},
|
|
||||||
): Promise<{
|
|
||||||
messages: MatrixMessageSummary[];
|
|
||||||
nextBatch?: string | null;
|
|
||||||
prevBatch?: string | null;
|
|
||||||
}> {
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
|
||||||
const limit =
|
|
||||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
|
||||||
? Math.max(1, Math.floor(opts.limit))
|
|
||||||
: 20;
|
|
||||||
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
|
||||||
const dir = opts.after ? "f" : "b";
|
|
||||||
// matrix-bot-sdk uses doRequest for room messages
|
|
||||||
const res = await client.doRequest("GET", `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, {
|
|
||||||
dir,
|
|
||||||
limit,
|
|
||||||
from: token,
|
|
||||||
}) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
|
|
||||||
const messages = res.chunk
|
|
||||||
.filter((event) => event.type === EventType.RoomMessage)
|
|
||||||
.filter((event) => !event.unsigned?.redacted_because)
|
|
||||||
.map(summarizeMatrixRawEvent);
|
|
||||||
return {
|
|
||||||
messages,
|
|
||||||
nextBatch: res.end ?? null,
|
|
||||||
prevBatch: res.start ?? null,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listMatrixReactions(
|
|
||||||
roomId: string,
|
|
||||||
messageId: string,
|
|
||||||
opts: MatrixActionClientOpts & { limit?: number } = {},
|
|
||||||
): Promise<MatrixReactionSummary[]> {
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
|
||||||
const limit =
|
|
||||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
|
||||||
? Math.max(1, Math.floor(opts.limit))
|
|
||||||
: 100;
|
|
||||||
// matrix-bot-sdk uses doRequest for relations
|
|
||||||
const res = await client.doRequest(
|
|
||||||
"GET",
|
|
||||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
|
||||||
{ dir: "b", limit },
|
|
||||||
) as { chunk: MatrixRawEvent[] };
|
|
||||||
const summaries = new Map<string, MatrixReactionSummary>();
|
|
||||||
for (const event of res.chunk) {
|
|
||||||
const content = event.content as ReactionEventContent;
|
|
||||||
const key = content["m.relates_to"]?.key;
|
|
||||||
if (!key) continue;
|
|
||||||
const sender = event.sender ?? "";
|
|
||||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
|
||||||
key,
|
|
||||||
count: 0,
|
|
||||||
users: [],
|
|
||||||
};
|
|
||||||
entry.count += 1;
|
|
||||||
if (sender && !entry.users.includes(sender)) {
|
|
||||||
entry.users.push(sender);
|
|
||||||
}
|
|
||||||
summaries.set(key, entry);
|
|
||||||
}
|
|
||||||
return Array.from(summaries.values());
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeMatrixReactions(
|
|
||||||
roomId: string,
|
|
||||||
messageId: string,
|
|
||||||
opts: MatrixActionClientOpts & { emoji?: string } = {},
|
|
||||||
): Promise<{ removed: number }> {
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
|
||||||
const res = await client.doRequest(
|
|
||||||
"GET",
|
|
||||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
|
||||||
{ dir: "b", limit: 200 },
|
|
||||||
) as { chunk: MatrixRawEvent[] };
|
|
||||||
const userId = await client.getUserId();
|
|
||||||
if (!userId) return { removed: 0 };
|
|
||||||
const targetEmoji = opts.emoji?.trim();
|
|
||||||
const toRemove = res.chunk
|
|
||||||
.filter((event) => event.sender === userId)
|
|
||||||
.filter((event) => {
|
|
||||||
if (!targetEmoji) return true;
|
|
||||||
const content = event.content as ReactionEventContent;
|
|
||||||
return content["m.relates_to"]?.key === targetEmoji;
|
|
||||||
})
|
|
||||||
.map((event) => event.event_id)
|
|
||||||
.filter((id): id is string => Boolean(id));
|
|
||||||
if (toRemove.length === 0) return { removed: 0 };
|
|
||||||
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
|
||||||
return { removed: toRemove.length };
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pinMatrixMessage(
|
|
||||||
roomId: string,
|
|
||||||
messageId: string,
|
|
||||||
opts: MatrixActionClientOpts = {},
|
|
||||||
): Promise<{ pinned: string[] }> {
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
|
||||||
const current = await readPinnedEvents(client, resolvedRoom);
|
|
||||||
const next = current.includes(messageId) ? current : [...current, messageId];
|
|
||||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
|
||||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
|
||||||
return { pinned: next };
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unpinMatrixMessage(
|
|
||||||
roomId: string,
|
|
||||||
messageId: string,
|
|
||||||
opts: MatrixActionClientOpts = {},
|
|
||||||
): Promise<{ pinned: string[] }> {
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
|
||||||
const current = await readPinnedEvents(client, resolvedRoom);
|
|
||||||
const next = current.filter((id) => id !== messageId);
|
|
||||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
|
||||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
|
||||||
return { pinned: next };
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listMatrixPins(
|
|
||||||
roomId: string,
|
|
||||||
opts: MatrixActionClientOpts = {},
|
|
||||||
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
|
||||||
const pinned = await readPinnedEvents(client, resolvedRoom);
|
|
||||||
const events = (
|
|
||||||
await Promise.all(
|
|
||||||
pinned.map(async (eventId) => {
|
|
||||||
try {
|
|
||||||
return await fetchEventSummary(client, resolvedRoom, eventId);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
|
||||||
return { pinned, events };
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMatrixMemberInfo(
|
|
||||||
userId: string,
|
|
||||||
opts: MatrixActionClientOpts & { roomId?: string } = {},
|
|
||||||
) {
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
|
||||||
// matrix-bot-sdk uses getUserProfile
|
|
||||||
const profile = await client.getUserProfile(userId);
|
|
||||||
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
|
|
||||||
// We'd need to fetch room state separately if needed
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
profile: {
|
|
||||||
displayName: profile?.displayname ?? null,
|
|
||||||
avatarUrl: profile?.avatar_url ?? null,
|
|
||||||
},
|
|
||||||
membership: null, // Would need separate room state query
|
|
||||||
powerLevel: null, // Would need separate power levels state query
|
|
||||||
displayName: profile?.displayname ?? null,
|
|
||||||
roomId: roomId ?? null,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
|
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
|
||||||
try {
|
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
|
||||||
// matrix-bot-sdk uses getRoomState for state events
|
|
||||||
let name: string | null = null;
|
|
||||||
let topic: string | null = null;
|
|
||||||
let canonicalAlias: string | null = null;
|
|
||||||
let memberCount: number | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
|
|
||||||
name = nameState?.name ?? null;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
|
|
||||||
topic = topicState?.topic ?? null;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
|
|
||||||
canonicalAlias = aliasState?.alias ?? null;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const members = await client.getJoinedRoomMembers(resolvedRoom);
|
|
||||||
memberCount = members.length;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
return {
|
|
||||||
roomId: resolvedRoom,
|
|
||||||
name,
|
|
||||||
topic,
|
|
||||||
canonicalAlias,
|
|
||||||
altAliases: [], // Would need separate query
|
|
||||||
memberCount,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
if (stopOnDone) client.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { reactMatrixMessage };
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
import type { CoreConfig } from "../types.js";
|
||||||
|
import { getActiveMatrixClient } from "../active-client.js";
|
||||||
|
import {
|
||||||
|
createMatrixClient,
|
||||||
|
isBunRuntime,
|
||||||
|
resolveMatrixAuth,
|
||||||
|
resolveSharedMatrixClient,
|
||||||
|
} from "../client.js";
|
||||||
|
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||||
|
|
||||||
|
export function ensureNodeRuntime() {
|
||||||
|
if (isBunRuntime()) {
|
||||||
|
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveActionClient(
|
||||||
|
opts: MatrixActionClientOpts = {},
|
||||||
|
): Promise<MatrixActionClient> {
|
||||||
|
ensureNodeRuntime();
|
||||||
|
if (opts.client) return { client: opts.client, stopOnDone: false };
|
||||||
|
const active = getActiveMatrixClient();
|
||||||
|
if (active) return { client: active, stopOnDone: false };
|
||||||
|
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
||||||
|
if (shouldShareClient) {
|
||||||
|
const client = await resolveSharedMatrixClient({
|
||||||
|
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
});
|
||||||
|
return { client, stopOnDone: false };
|
||||||
|
}
|
||||||
|
const auth = await resolveMatrixAuth({
|
||||||
|
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
|
});
|
||||||
|
const client = await createMatrixClient({
|
||||||
|
homeserver: auth.homeserver,
|
||||||
|
userId: auth.userId,
|
||||||
|
accessToken: auth.accessToken,
|
||||||
|
encryption: auth.encryption,
|
||||||
|
localTimeoutMs: opts.timeoutMs,
|
||||||
|
});
|
||||||
|
if (auth.encryption && client.crypto) {
|
||||||
|
try {
|
||||||
|
const joinedRooms = await client.getJoinedRooms();
|
||||||
|
await client.crypto.prepare(joinedRooms);
|
||||||
|
} catch {
|
||||||
|
// Ignore crypto prep failures for one-off actions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.start();
|
||||||
|
return { client, stopOnDone: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
EventType,
|
||||||
|
MsgType,
|
||||||
|
RelationType,
|
||||||
|
type MatrixActionClientOpts,
|
||||||
|
type MatrixMessageSummary,
|
||||||
|
type MatrixRawEvent,
|
||||||
|
type RoomMessageEventContent,
|
||||||
|
} from "./types.js";
|
||||||
|
import { resolveActionClient } from "./client.js";
|
||||||
|
import { summarizeMatrixRawEvent } from "./summary.js";
|
||||||
|
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
|
||||||
|
|
||||||
|
export async function sendMatrixMessage(
|
||||||
|
to: string,
|
||||||
|
content: string,
|
||||||
|
opts: MatrixActionClientOpts & {
|
||||||
|
mediaUrl?: string;
|
||||||
|
replyToId?: string;
|
||||||
|
threadId?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
return await sendMessageMatrix(to, content, {
|
||||||
|
mediaUrl: opts.mediaUrl,
|
||||||
|
replyToId: opts.replyToId,
|
||||||
|
threadId: opts.threadId,
|
||||||
|
client: opts.client,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editMatrixMessage(
|
||||||
|
roomId: string,
|
||||||
|
messageId: string,
|
||||||
|
content: string,
|
||||||
|
opts: MatrixActionClientOpts = {},
|
||||||
|
) {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) throw new Error("Matrix edit requires content");
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
const newContent = {
|
||||||
|
msgtype: MsgType.Text,
|
||||||
|
body: trimmed,
|
||||||
|
} satisfies RoomMessageEventContent;
|
||||||
|
const payload: RoomMessageEventContent = {
|
||||||
|
msgtype: MsgType.Text,
|
||||||
|
body: `* ${trimmed}`,
|
||||||
|
"m.new_content": newContent,
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: RelationType.Replace,
|
||||||
|
event_id: messageId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const eventId = await client.sendMessage(resolvedRoom, payload);
|
||||||
|
return { eventId: eventId ?? null };
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMatrixMessage(
|
||||||
|
roomId: string,
|
||||||
|
messageId: string,
|
||||||
|
opts: MatrixActionClientOpts & { reason?: string } = {},
|
||||||
|
) {
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMatrixMessages(
|
||||||
|
roomId: string,
|
||||||
|
opts: MatrixActionClientOpts & {
|
||||||
|
limit?: number;
|
||||||
|
before?: string;
|
||||||
|
after?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<{
|
||||||
|
messages: MatrixMessageSummary[];
|
||||||
|
nextBatch?: string | null;
|
||||||
|
prevBatch?: string | null;
|
||||||
|
}> {
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
const limit =
|
||||||
|
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||||
|
? Math.max(1, Math.floor(opts.limit))
|
||||||
|
: 20;
|
||||||
|
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
||||||
|
const dir = opts.after ? "f" : "b";
|
||||||
|
// matrix-bot-sdk uses doRequest for room messages
|
||||||
|
const res = await client.doRequest(
|
||||||
|
"GET",
|
||||||
|
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
|
||||||
|
{
|
||||||
|
dir,
|
||||||
|
limit,
|
||||||
|
from: token,
|
||||||
|
},
|
||||||
|
) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
|
||||||
|
const messages = res.chunk
|
||||||
|
.filter((event) => event.type === EventType.RoomMessage)
|
||||||
|
.filter((event) => !event.unsigned?.redacted_because)
|
||||||
|
.map(summarizeMatrixRawEvent);
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
nextBatch: res.end ?? null,
|
||||||
|
prevBatch: res.start ?? null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
EventType,
|
||||||
|
type MatrixActionClientOpts,
|
||||||
|
type MatrixMessageSummary,
|
||||||
|
type RoomPinnedEventsEventContent,
|
||||||
|
} from "./types.js";
|
||||||
|
import { resolveActionClient } from "./client.js";
|
||||||
|
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
|
||||||
|
import { resolveMatrixRoomId } from "../send.js";
|
||||||
|
|
||||||
|
export async function pinMatrixMessage(
|
||||||
|
roomId: string,
|
||||||
|
messageId: string,
|
||||||
|
opts: MatrixActionClientOpts = {},
|
||||||
|
): Promise<{ pinned: string[] }> {
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
const current = await readPinnedEvents(client, resolvedRoom);
|
||||||
|
const next = current.includes(messageId) ? current : [...current, messageId];
|
||||||
|
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||||
|
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||||
|
return { pinned: next };
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpinMatrixMessage(
|
||||||
|
roomId: string,
|
||||||
|
messageId: string,
|
||||||
|
opts: MatrixActionClientOpts = {},
|
||||||
|
): Promise<{ pinned: string[] }> {
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
const current = await readPinnedEvents(client, resolvedRoom);
|
||||||
|
const next = current.filter((id) => id !== messageId);
|
||||||
|
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||||
|
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||||
|
return { pinned: next };
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMatrixPins(
|
||||||
|
roomId: string,
|
||||||
|
opts: MatrixActionClientOpts = {},
|
||||||
|
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
const pinned = await readPinnedEvents(client, resolvedRoom);
|
||||||
|
const events = (
|
||||||
|
await Promise.all(
|
||||||
|
pinned.map(async (eventId) => {
|
||||||
|
try {
|
||||||
|
return await fetchEventSummary(client, resolvedRoom, eventId);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
||||||
|
return { pinned, events };
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
EventType,
|
||||||
|
RelationType,
|
||||||
|
type MatrixActionClientOpts,
|
||||||
|
type MatrixRawEvent,
|
||||||
|
type MatrixReactionSummary,
|
||||||
|
type ReactionEventContent,
|
||||||
|
} from "./types.js";
|
||||||
|
import { resolveActionClient } from "./client.js";
|
||||||
|
import { resolveMatrixRoomId } from "../send.js";
|
||||||
|
|
||||||
|
export async function listMatrixReactions(
|
||||||
|
roomId: string,
|
||||||
|
messageId: string,
|
||||||
|
opts: MatrixActionClientOpts & { limit?: number } = {},
|
||||||
|
): Promise<MatrixReactionSummary[]> {
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
const limit =
|
||||||
|
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||||
|
? Math.max(1, Math.floor(opts.limit))
|
||||||
|
: 100;
|
||||||
|
// matrix-bot-sdk uses doRequest for relations
|
||||||
|
const res = await client.doRequest(
|
||||||
|
"GET",
|
||||||
|
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||||
|
{ dir: "b", limit },
|
||||||
|
) as { chunk: MatrixRawEvent[] };
|
||||||
|
const summaries = new Map<string, MatrixReactionSummary>();
|
||||||
|
for (const event of res.chunk) {
|
||||||
|
const content = event.content as ReactionEventContent;
|
||||||
|
const key = content["m.relates_to"]?.key;
|
||||||
|
if (!key) continue;
|
||||||
|
const sender = event.sender ?? "";
|
||||||
|
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||||
|
key,
|
||||||
|
count: 0,
|
||||||
|
users: [],
|
||||||
|
};
|
||||||
|
entry.count += 1;
|
||||||
|
if (sender && !entry.users.includes(sender)) {
|
||||||
|
entry.users.push(sender);
|
||||||
|
}
|
||||||
|
summaries.set(key, entry);
|
||||||
|
}
|
||||||
|
return Array.from(summaries.values());
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeMatrixReactions(
|
||||||
|
roomId: string,
|
||||||
|
messageId: string,
|
||||||
|
opts: MatrixActionClientOpts & { emoji?: string } = {},
|
||||||
|
): Promise<{ removed: number }> {
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
const res = await client.doRequest(
|
||||||
|
"GET",
|
||||||
|
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||||
|
{ dir: "b", limit: 200 },
|
||||||
|
) as { chunk: MatrixRawEvent[] };
|
||||||
|
const userId = await client.getUserId();
|
||||||
|
if (!userId) return { removed: 0 };
|
||||||
|
const targetEmoji = opts.emoji?.trim();
|
||||||
|
const toRemove = res.chunk
|
||||||
|
.filter((event) => event.sender === userId)
|
||||||
|
.filter((event) => {
|
||||||
|
if (!targetEmoji) return true;
|
||||||
|
const content = event.content as ReactionEventContent;
|
||||||
|
return content["m.relates_to"]?.key === targetEmoji;
|
||||||
|
})
|
||||||
|
.map((event) => event.event_id)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
if (toRemove.length === 0) return { removed: 0 };
|
||||||
|
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
||||||
|
return { removed: toRemove.length };
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { EventType, type MatrixActionClientOpts } from "./types.js";
|
||||||
|
import { resolveActionClient } from "./client.js";
|
||||||
|
import { resolveMatrixRoomId } from "../send.js";
|
||||||
|
|
||||||
|
export async function getMatrixMemberInfo(
|
||||||
|
userId: string,
|
||||||
|
opts: MatrixActionClientOpts & { roomId?: string } = {},
|
||||||
|
) {
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||||
|
// matrix-bot-sdk uses getUserProfile
|
||||||
|
const profile = await client.getUserProfile(userId);
|
||||||
|
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
|
||||||
|
// We'd need to fetch room state separately if needed
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
profile: {
|
||||||
|
displayName: profile?.displayname ?? null,
|
||||||
|
avatarUrl: profile?.avatar_url ?? null,
|
||||||
|
},
|
||||||
|
membership: null, // Would need separate room state query
|
||||||
|
powerLevel: null, // Would need separate power levels state query
|
||||||
|
displayName: profile?.displayname ?? null,
|
||||||
|
roomId: roomId ?? null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMatrixRoomInfo(
|
||||||
|
roomId: string,
|
||||||
|
opts: MatrixActionClientOpts = {},
|
||||||
|
) {
|
||||||
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
|
try {
|
||||||
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
|
// matrix-bot-sdk uses getRoomState for state events
|
||||||
|
let name: string | null = null;
|
||||||
|
let topic: string | null = null;
|
||||||
|
let canonicalAlias: string | null = null;
|
||||||
|
let memberCount: number | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
|
||||||
|
name = nameState?.name ?? null;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
|
||||||
|
topic = topicState?.topic ?? null;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aliasState = await client.getRoomStateEvent(
|
||||||
|
resolvedRoom,
|
||||||
|
"m.room.canonical_alias",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
canonicalAlias = aliasState?.alias ?? null;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const members = await client.getJoinedRoomMembers(resolvedRoom);
|
||||||
|
memberCount = members.length;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomId: resolvedRoom,
|
||||||
|
name,
|
||||||
|
topic,
|
||||||
|
canonicalAlias,
|
||||||
|
altAliases: [], // Would need separate query
|
||||||
|
memberCount,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (stopOnDone) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
|
import {
|
||||||
|
EventType,
|
||||||
|
type MatrixMessageSummary,
|
||||||
|
type MatrixRawEvent,
|
||||||
|
type RoomMessageEventContent,
|
||||||
|
type RoomPinnedEventsEventContent,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
|
||||||
|
const content = event.content as RoomMessageEventContent;
|
||||||
|
const relates = content["m.relates_to"];
|
||||||
|
let relType: string | undefined;
|
||||||
|
let eventId: string | undefined;
|
||||||
|
if (relates) {
|
||||||
|
if ("rel_type" in relates) {
|
||||||
|
relType = relates.rel_type;
|
||||||
|
eventId = relates.event_id;
|
||||||
|
} else if ("m.in_reply_to" in relates) {
|
||||||
|
eventId = relates["m.in_reply_to"]?.event_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const relatesTo =
|
||||||
|
relType || eventId
|
||||||
|
? {
|
||||||
|
relType,
|
||||||
|
eventId,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
eventId: event.event_id,
|
||||||
|
sender: event.sender,
|
||||||
|
body: content.body,
|
||||||
|
msgtype: content.msgtype,
|
||||||
|
timestamp: event.origin_server_ts,
|
||||||
|
relatesTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPinnedEvents(
|
||||||
|
client: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const content = (await client.getRoomStateEvent(
|
||||||
|
roomId,
|
||||||
|
EventType.RoomPinnedEvents,
|
||||||
|
"",
|
||||||
|
)) as RoomPinnedEventsEventContent;
|
||||||
|
const pinned = content.pinned;
|
||||||
|
return pinned.filter((id) => id.trim().length > 0);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
|
||||||
|
const httpStatus = errObj.statusCode;
|
||||||
|
const errcode = errObj.body?.errcode;
|
||||||
|
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEventSummary(
|
||||||
|
client: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
eventId: string,
|
||||||
|
): Promise<MatrixMessageSummary | null> {
|
||||||
|
try {
|
||||||
|
const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent;
|
||||||
|
if (raw.unsigned?.redacted_because) return null;
|
||||||
|
return summarizeMatrixRawEvent(raw);
|
||||||
|
} catch {
|
||||||
|
// Event not found, redacted, or inaccessible - return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
|
export const MsgType = {
|
||||||
|
Text: "m.text",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const RelationType = {
|
||||||
|
Replace: "m.replace",
|
||||||
|
Annotation: "m.annotation",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EventType = {
|
||||||
|
RoomMessage: "m.room.message",
|
||||||
|
RoomPinnedEvents: "m.room.pinned_events",
|
||||||
|
RoomTopic: "m.room.topic",
|
||||||
|
Reaction: "m.reaction",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RoomMessageEventContent = {
|
||||||
|
msgtype: string;
|
||||||
|
body: string;
|
||||||
|
"m.new_content"?: RoomMessageEventContent;
|
||||||
|
"m.relates_to"?: {
|
||||||
|
rel_type?: string;
|
||||||
|
event_id?: string;
|
||||||
|
"m.in_reply_to"?: { event_id?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReactionEventContent = {
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: string;
|
||||||
|
event_id: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoomPinnedEventsEventContent = {
|
||||||
|
pinned: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoomTopicEventContent = {
|
||||||
|
topic?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixRawEvent = {
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
type: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
unsigned?: {
|
||||||
|
redacted_because?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixActionClientOpts = {
|
||||||
|
client?: MatrixClient;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixMessageSummary = {
|
||||||
|
eventId?: string;
|
||||||
|
sender?: string;
|
||||||
|
body?: string;
|
||||||
|
msgtype?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
relatesTo?: {
|
||||||
|
relType?: string;
|
||||||
|
eventId?: string;
|
||||||
|
key?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixReactionSummary = {
|
||||||
|
key: string;
|
||||||
|
count: number;
|
||||||
|
users: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixActionClient = {
|
||||||
|
client: MatrixClient;
|
||||||
|
stopOnDone: boolean;
|
||||||
|
};
|
||||||
@@ -1,645 +1,9 @@
|
|||||||
import crypto from "node:crypto";
|
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
|
||||||
import fs from "node:fs";
|
export { isBunRuntime } from "./client/runtime.js";
|
||||||
import os from "node:os";
|
export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js";
|
||||||
import path from "node:path";
|
export { createMatrixClient } from "./client/create-client.js";
|
||||||
|
export {
|
||||||
import {
|
resolveSharedMatrixClient,
|
||||||
ConsoleLogger,
|
waitForMatrixSync,
|
||||||
LogService,
|
stopSharedClient,
|
||||||
MatrixClient,
|
} from "./client/shared.js";
|
||||||
SimpleFsStorageProvider,
|
|
||||||
RustSdkCryptoStorageProvider,
|
|
||||||
} from "matrix-bot-sdk";
|
|
||||||
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
|
|
||||||
|
|
||||||
import type { CoreConfig } from "../types.js";
|
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
|
||||||
|
|
||||||
export type MatrixResolvedConfig = {
|
|
||||||
homeserver: string;
|
|
||||||
userId: string;
|
|
||||||
accessToken?: string;
|
|
||||||
password?: string;
|
|
||||||
deviceName?: string;
|
|
||||||
initialSyncLimit?: number;
|
|
||||||
encryption?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticated Matrix configuration.
|
|
||||||
* Note: deviceId is NOT included here because it's implicit in the accessToken.
|
|
||||||
* The crypto storage assumes the device ID (and thus access token) does not change
|
|
||||||
* between restarts. If the access token becomes invalid or crypto storage is lost,
|
|
||||||
* both will need to be recreated together.
|
|
||||||
*/
|
|
||||||
export type MatrixAuth = {
|
|
||||||
homeserver: string;
|
|
||||||
userId: string;
|
|
||||||
accessToken: string;
|
|
||||||
deviceName?: string;
|
|
||||||
initialSyncLimit?: number;
|
|
||||||
encryption?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SharedMatrixClientState = {
|
|
||||||
client: MatrixClient;
|
|
||||||
key: string;
|
|
||||||
started: boolean;
|
|
||||||
cryptoReady: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
let sharedClientState: SharedMatrixClientState | null = null;
|
|
||||||
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
|
|
||||||
let sharedClientStartPromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
export function isBunRuntime(): boolean {
|
|
||||||
const versions = process.versions as { bun?: string };
|
|
||||||
return typeof versions.bun === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
let matrixSdkLoggingConfigured = false;
|
|
||||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
|
||||||
|
|
||||||
function shouldSuppressMatrixHttpNotFound(
|
|
||||||
module: string,
|
|
||||||
messageOrObject: unknown[],
|
|
||||||
): boolean {
|
|
||||||
if (module !== "MatrixHttpClient") return false;
|
|
||||||
return messageOrObject.some((entry) => {
|
|
||||||
if (!entry || typeof entry !== "object") return false;
|
|
||||||
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureMatrixSdkLoggingConfigured(): void {
|
|
||||||
if (matrixSdkLoggingConfigured) return;
|
|
||||||
matrixSdkLoggingConfigured = true;
|
|
||||||
|
|
||||||
LogService.setLogger({
|
|
||||||
trace: (module, ...messageOrObject) =>
|
|
||||||
matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
|
||||||
debug: (module, ...messageOrObject) =>
|
|
||||||
matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
|
||||||
info: (module, ...messageOrObject) =>
|
|
||||||
matrixSdkBaseLogger.info(module, ...messageOrObject),
|
|
||||||
warn: (module, ...messageOrObject) =>
|
|
||||||
matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
|
||||||
error: (module, ...messageOrObject) => {
|
|
||||||
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
|
|
||||||
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clean(value?: string): string {
|
|
||||||
return value?.trim() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_ACCOUNT_KEY = "default";
|
|
||||||
const STORAGE_META_FILENAME = "storage-meta.json";
|
|
||||||
|
|
||||||
type MatrixStoragePaths = {
|
|
||||||
rootDir: string;
|
|
||||||
storagePath: string;
|
|
||||||
cryptoPath: string;
|
|
||||||
metaPath: string;
|
|
||||||
accountKey: string;
|
|
||||||
tokenHash: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function sanitizePathSegment(value: string): string {
|
|
||||||
const cleaned = value
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9._-]+/g, "_")
|
|
||||||
.replace(/^_+|_+$/g, "");
|
|
||||||
return cleaned || "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveHomeserverKey(homeserver: string): string {
|
|
||||||
try {
|
|
||||||
const url = new URL(homeserver);
|
|
||||||
if (url.host) return sanitizePathSegment(url.host);
|
|
||||||
} catch {
|
|
||||||
// fall through
|
|
||||||
}
|
|
||||||
return sanitizePathSegment(homeserver);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashAccessToken(accessToken: string): string {
|
|
||||||
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
|
|
||||||
storagePath: string;
|
|
||||||
cryptoPath: string;
|
|
||||||
} {
|
|
||||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
|
||||||
return {
|
|
||||||
storagePath: path.join(stateDir, "matrix", "bot-storage.json"),
|
|
||||||
cryptoPath: path.join(stateDir, "matrix", "crypto"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMatrixStoragePaths(params: {
|
|
||||||
homeserver: string;
|
|
||||||
userId: string;
|
|
||||||
accessToken: string;
|
|
||||||
accountId?: string | null;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}): MatrixStoragePaths {
|
|
||||||
const env = params.env ?? process.env;
|
|
||||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
|
||||||
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
|
|
||||||
const userKey = sanitizePathSegment(params.userId);
|
|
||||||
const serverKey = resolveHomeserverKey(params.homeserver);
|
|
||||||
const tokenHash = hashAccessToken(params.accessToken);
|
|
||||||
const rootDir = path.join(
|
|
||||||
stateDir,
|
|
||||||
"matrix",
|
|
||||||
"accounts",
|
|
||||||
accountKey,
|
|
||||||
`${serverKey}__${userKey}`,
|
|
||||||
tokenHash,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
rootDir,
|
|
||||||
storagePath: path.join(rootDir, "bot-storage.json"),
|
|
||||||
cryptoPath: path.join(rootDir, "crypto"),
|
|
||||||
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
|
|
||||||
accountKey,
|
|
||||||
tokenHash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeMigrateLegacyStorage(params: {
|
|
||||||
storagePaths: MatrixStoragePaths;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}): void {
|
|
||||||
const legacy = resolveLegacyStoragePaths(params.env);
|
|
||||||
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
|
|
||||||
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
|
|
||||||
const hasNewStorage =
|
|
||||||
fs.existsSync(params.storagePaths.storagePath) ||
|
|
||||||
fs.existsSync(params.storagePaths.cryptoPath);
|
|
||||||
|
|
||||||
if (!hasLegacyStorage && !hasLegacyCrypto) return;
|
|
||||||
if (hasNewStorage) return;
|
|
||||||
|
|
||||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
|
||||||
if (hasLegacyStorage) {
|
|
||||||
try {
|
|
||||||
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
|
|
||||||
} catch {
|
|
||||||
// Ignore migration failures; new store will be created.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasLegacyCrypto) {
|
|
||||||
try {
|
|
||||||
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
|
|
||||||
} catch {
|
|
||||||
// Ignore migration failures; new store will be created.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeStorageMeta(params: {
|
|
||||||
storagePaths: MatrixStoragePaths;
|
|
||||||
homeserver: string;
|
|
||||||
userId: string;
|
|
||||||
accountId?: string | null;
|
|
||||||
}): void {
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
homeserver: params.homeserver,
|
|
||||||
userId: params.userId,
|
|
||||||
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
|
|
||||||
accessTokenHash: params.storagePaths.tokenHash,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
params.storagePaths.metaPath,
|
|
||||||
JSON.stringify(payload, null, 2),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// ignore meta write failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
|
||||||
if (input == null) return [];
|
|
||||||
if (!Array.isArray(input)) {
|
|
||||||
LogService.warn(
|
|
||||||
"MatrixClientLite",
|
|
||||||
`Expected ${label} list to be an array, got ${typeof input}`,
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const filtered = input.filter(
|
|
||||||
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
|
||||||
);
|
|
||||||
if (filtered.length !== input.length) {
|
|
||||||
LogService.warn(
|
|
||||||
"MatrixClientLite",
|
|
||||||
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveMatrixConfig(
|
|
||||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
|
||||||
): MatrixResolvedConfig {
|
|
||||||
const matrix = cfg.channels?.matrix ?? {};
|
|
||||||
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
|
|
||||||
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
|
|
||||||
const accessToken =
|
|
||||||
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
|
|
||||||
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
|
|
||||||
const deviceName =
|
|
||||||
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
|
|
||||||
const initialSyncLimit =
|
|
||||||
typeof matrix.initialSyncLimit === "number"
|
|
||||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
|
||||||
: undefined;
|
|
||||||
const encryption = matrix.encryption ?? false;
|
|
||||||
return {
|
|
||||||
homeserver,
|
|
||||||
userId,
|
|
||||||
accessToken,
|
|
||||||
password,
|
|
||||||
deviceName,
|
|
||||||
initialSyncLimit,
|
|
||||||
encryption,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveMatrixAuth(params?: {
|
|
||||||
cfg?: CoreConfig;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}): Promise<MatrixAuth> {
|
|
||||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
|
||||||
const env = params?.env ?? process.env;
|
|
||||||
const resolved = resolveMatrixConfig(cfg, env);
|
|
||||||
if (!resolved.homeserver) {
|
|
||||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
loadMatrixCredentials,
|
|
||||||
saveMatrixCredentials,
|
|
||||||
credentialsMatchConfig,
|
|
||||||
touchMatrixCredentials,
|
|
||||||
} = await import("./credentials.js");
|
|
||||||
|
|
||||||
const cached = loadMatrixCredentials(env);
|
|
||||||
const cachedCredentials =
|
|
||||||
cached &&
|
|
||||||
credentialsMatchConfig(cached, {
|
|
||||||
homeserver: resolved.homeserver,
|
|
||||||
userId: resolved.userId || "",
|
|
||||||
})
|
|
||||||
? cached
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// If we have an access token, we can fetch userId via whoami if not provided
|
|
||||||
if (resolved.accessToken) {
|
|
||||||
let userId = resolved.userId;
|
|
||||||
if (!userId) {
|
|
||||||
// Fetch userId from access token via whoami
|
|
||||||
ensureMatrixSdkLoggingConfigured();
|
|
||||||
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
|
||||||
const whoami = await tempClient.getUserId();
|
|
||||||
userId = whoami;
|
|
||||||
// Save the credentials with the fetched userId
|
|
||||||
saveMatrixCredentials({
|
|
||||||
homeserver: resolved.homeserver,
|
|
||||||
userId,
|
|
||||||
accessToken: resolved.accessToken,
|
|
||||||
});
|
|
||||||
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
|
||||||
touchMatrixCredentials(env);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
homeserver: resolved.homeserver,
|
|
||||||
userId,
|
|
||||||
accessToken: resolved.accessToken,
|
|
||||||
deviceName: resolved.deviceName,
|
|
||||||
initialSyncLimit: resolved.initialSyncLimit,
|
|
||||||
encryption: resolved.encryption,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cachedCredentials) {
|
|
||||||
touchMatrixCredentials(env);
|
|
||||||
return {
|
|
||||||
homeserver: cachedCredentials.homeserver,
|
|
||||||
userId: cachedCredentials.userId,
|
|
||||||
accessToken: cachedCredentials.accessToken,
|
|
||||||
deviceName: resolved.deviceName,
|
|
||||||
initialSyncLimit: resolved.initialSyncLimit,
|
|
||||||
encryption: resolved.encryption,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolved.userId) {
|
|
||||||
throw new Error(
|
|
||||||
"Matrix userId is required when no access token is configured (matrix.userId)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolved.password) {
|
|
||||||
throw new Error(
|
|
||||||
"Matrix password is required when no access token is configured (matrix.password)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login with password using HTTP API
|
|
||||||
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: "m.login.password",
|
|
||||||
identifier: { type: "m.id.user", user: resolved.userId },
|
|
||||||
password: resolved.password,
|
|
||||||
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!loginResponse.ok) {
|
|
||||||
const errorText = await loginResponse.text();
|
|
||||||
throw new Error(`Matrix login failed: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const login = (await loginResponse.json()) as {
|
|
||||||
access_token?: string;
|
|
||||||
user_id?: string;
|
|
||||||
device_id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const accessToken = login.access_token?.trim();
|
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error("Matrix login did not return an access token");
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth: MatrixAuth = {
|
|
||||||
homeserver: resolved.homeserver,
|
|
||||||
userId: login.user_id ?? resolved.userId,
|
|
||||||
accessToken,
|
|
||||||
deviceName: resolved.deviceName,
|
|
||||||
initialSyncLimit: resolved.initialSyncLimit,
|
|
||||||
encryption: resolved.encryption,
|
|
||||||
};
|
|
||||||
|
|
||||||
saveMatrixCredentials({
|
|
||||||
homeserver: auth.homeserver,
|
|
||||||
userId: auth.userId,
|
|
||||||
accessToken: auth.accessToken,
|
|
||||||
deviceId: login.device_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return auth;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createMatrixClient(params: {
|
|
||||||
homeserver: string;
|
|
||||||
userId: string;
|
|
||||||
accessToken: string;
|
|
||||||
encryption?: boolean;
|
|
||||||
localTimeoutMs?: number;
|
|
||||||
accountId?: string | null;
|
|
||||||
}): Promise<MatrixClient> {
|
|
||||||
ensureMatrixSdkLoggingConfigured();
|
|
||||||
const env = process.env;
|
|
||||||
|
|
||||||
// Create storage provider
|
|
||||||
const storagePaths = resolveMatrixStoragePaths({
|
|
||||||
homeserver: params.homeserver,
|
|
||||||
userId: params.userId,
|
|
||||||
accessToken: params.accessToken,
|
|
||||||
accountId: params.accountId,
|
|
||||||
env,
|
|
||||||
});
|
|
||||||
maybeMigrateLegacyStorage({ storagePaths, env });
|
|
||||||
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
|
|
||||||
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
|
|
||||||
|
|
||||||
// Create crypto storage if encryption is enabled
|
|
||||||
let cryptoStorage: ICryptoStorageProvider | undefined;
|
|
||||||
if (params.encryption) {
|
|
||||||
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
|
||||||
cryptoStorage = new RustSdkCryptoStorageProvider(
|
|
||||||
storagePaths.cryptoPath,
|
|
||||||
StoreType.Sqlite,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeStorageMeta({
|
|
||||||
storagePaths,
|
|
||||||
homeserver: params.homeserver,
|
|
||||||
userId: params.userId,
|
|
||||||
accountId: params.accountId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new MatrixClient(
|
|
||||||
params.homeserver,
|
|
||||||
params.accessToken,
|
|
||||||
storage,
|
|
||||||
cryptoStorage,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (client.crypto) {
|
|
||||||
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
|
|
||||||
client.crypto.updateSyncData = async (
|
|
||||||
toDeviceMessages,
|
|
||||||
otkCounts,
|
|
||||||
unusedFallbackKeyAlgs,
|
|
||||||
changedDeviceLists,
|
|
||||||
leftDeviceLists,
|
|
||||||
) => {
|
|
||||||
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
|
|
||||||
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
|
|
||||||
try {
|
|
||||||
return await originalUpdateSyncData(
|
|
||||||
toDeviceMessages,
|
|
||||||
otkCounts,
|
|
||||||
unusedFallbackKeyAlgs,
|
|
||||||
safeChanged,
|
|
||||||
safeLeft,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
|
|
||||||
if (message.includes("Expect value to be String")) {
|
|
||||||
LogService.warn(
|
|
||||||
"MatrixClientLite",
|
|
||||||
"Ignoring malformed device list entries during crypto sync",
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
|
||||||
return [
|
|
||||||
auth.homeserver,
|
|
||||||
auth.userId,
|
|
||||||
auth.accessToken,
|
|
||||||
auth.encryption ? "e2ee" : "plain",
|
|
||||||
accountId ?? DEFAULT_ACCOUNT_KEY,
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSharedMatrixClient(params: {
|
|
||||||
auth: MatrixAuth;
|
|
||||||
timeoutMs?: number;
|
|
||||||
accountId?: string | null;
|
|
||||||
}): Promise<SharedMatrixClientState> {
|
|
||||||
const client = await createMatrixClient({
|
|
||||||
homeserver: params.auth.homeserver,
|
|
||||||
userId: params.auth.userId,
|
|
||||||
accessToken: params.auth.accessToken,
|
|
||||||
encryption: params.auth.encryption,
|
|
||||||
localTimeoutMs: params.timeoutMs,
|
|
||||||
accountId: params.accountId,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
client,
|
|
||||||
key: buildSharedClientKey(params.auth, params.accountId),
|
|
||||||
started: false,
|
|
||||||
cryptoReady: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureSharedClientStarted(params: {
|
|
||||||
state: SharedMatrixClientState;
|
|
||||||
timeoutMs?: number;
|
|
||||||
initialSyncLimit?: number;
|
|
||||||
encryption?: boolean;
|
|
||||||
}): Promise<void> {
|
|
||||||
if (params.state.started) return;
|
|
||||||
if (sharedClientStartPromise) {
|
|
||||||
await sharedClientStartPromise;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sharedClientStartPromise = (async () => {
|
|
||||||
const client = params.state.client;
|
|
||||||
|
|
||||||
// Initialize crypto if enabled
|
|
||||||
if (params.encryption && !params.state.cryptoReady) {
|
|
||||||
try {
|
|
||||||
const joinedRooms = await client.getJoinedRooms();
|
|
||||||
if (client.crypto) {
|
|
||||||
await client.crypto.prepare(joinedRooms);
|
|
||||||
params.state.cryptoReady = true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.start();
|
|
||||||
params.state.started = true;
|
|
||||||
})();
|
|
||||||
try {
|
|
||||||
await sharedClientStartPromise;
|
|
||||||
} finally {
|
|
||||||
sharedClientStartPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveSharedMatrixClient(
|
|
||||||
params: {
|
|
||||||
cfg?: CoreConfig;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
timeoutMs?: number;
|
|
||||||
auth?: MatrixAuth;
|
|
||||||
startClient?: boolean;
|
|
||||||
accountId?: string | null;
|
|
||||||
} = {},
|
|
||||||
): Promise<MatrixClient> {
|
|
||||||
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
|
|
||||||
const key = buildSharedClientKey(auth, params.accountId);
|
|
||||||
const shouldStart = params.startClient !== false;
|
|
||||||
|
|
||||||
if (sharedClientState?.key === key) {
|
|
||||||
if (shouldStart) {
|
|
||||||
await ensureSharedClientStarted({
|
|
||||||
state: sharedClientState,
|
|
||||||
timeoutMs: params.timeoutMs,
|
|
||||||
initialSyncLimit: auth.initialSyncLimit,
|
|
||||||
encryption: auth.encryption,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sharedClientState.client;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedClientPromise) {
|
|
||||||
const pending = await sharedClientPromise;
|
|
||||||
if (pending.key === key) {
|
|
||||||
if (shouldStart) {
|
|
||||||
await ensureSharedClientStarted({
|
|
||||||
state: pending,
|
|
||||||
timeoutMs: params.timeoutMs,
|
|
||||||
initialSyncLimit: auth.initialSyncLimit,
|
|
||||||
encryption: auth.encryption,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return pending.client;
|
|
||||||
}
|
|
||||||
pending.client.stop();
|
|
||||||
sharedClientState = null;
|
|
||||||
sharedClientPromise = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedClientPromise = createSharedMatrixClient({
|
|
||||||
auth,
|
|
||||||
timeoutMs: params.timeoutMs,
|
|
||||||
accountId: params.accountId,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const created = await sharedClientPromise;
|
|
||||||
sharedClientState = created;
|
|
||||||
if (shouldStart) {
|
|
||||||
await ensureSharedClientStarted({
|
|
||||||
state: created,
|
|
||||||
timeoutMs: params.timeoutMs,
|
|
||||||
initialSyncLimit: auth.initialSyncLimit,
|
|
||||||
encryption: auth.encryption,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return created.client;
|
|
||||||
} finally {
|
|
||||||
sharedClientPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForMatrixSync(_params: {
|
|
||||||
client: MatrixClient;
|
|
||||||
timeoutMs?: number;
|
|
||||||
abortSignal?: AbortSignal;
|
|
||||||
}): Promise<void> {
|
|
||||||
// matrix-bot-sdk handles sync internally in start()
|
|
||||||
// This is kept for API compatibility but is essentially a no-op now
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopSharedClient(): void {
|
|
||||||
if (sharedClientState) {
|
|
||||||
sharedClientState.client.stop();
|
|
||||||
sharedClientState = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
|
import type { CoreConfig } from "../types.js";
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||||
|
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||||
|
|
||||||
|
function clean(value?: string): string {
|
||||||
|
return value?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatrixConfig(
|
||||||
|
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): MatrixResolvedConfig {
|
||||||
|
const matrix = cfg.channels?.matrix ?? {};
|
||||||
|
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
|
||||||
|
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
|
||||||
|
const accessToken =
|
||||||
|
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
|
||||||
|
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
|
||||||
|
const deviceName =
|
||||||
|
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
|
||||||
|
const initialSyncLimit =
|
||||||
|
typeof matrix.initialSyncLimit === "number"
|
||||||
|
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||||
|
: undefined;
|
||||||
|
const encryption = matrix.encryption ?? false;
|
||||||
|
return {
|
||||||
|
homeserver,
|
||||||
|
userId,
|
||||||
|
accessToken,
|
||||||
|
password,
|
||||||
|
deviceName,
|
||||||
|
initialSyncLimit,
|
||||||
|
encryption,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMatrixAuth(params?: {
|
||||||
|
cfg?: CoreConfig;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<MatrixAuth> {
|
||||||
|
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||||
|
const env = params?.env ?? process.env;
|
||||||
|
const resolved = resolveMatrixConfig(cfg, env);
|
||||||
|
if (!resolved.homeserver) {
|
||||||
|
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadMatrixCredentials,
|
||||||
|
saveMatrixCredentials,
|
||||||
|
credentialsMatchConfig,
|
||||||
|
touchMatrixCredentials,
|
||||||
|
} = await import("./credentials.js");
|
||||||
|
|
||||||
|
const cached = loadMatrixCredentials(env);
|
||||||
|
const cachedCredentials =
|
||||||
|
cached &&
|
||||||
|
credentialsMatchConfig(cached, {
|
||||||
|
homeserver: resolved.homeserver,
|
||||||
|
userId: resolved.userId || "",
|
||||||
|
})
|
||||||
|
? cached
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// If we have an access token, we can fetch userId via whoami if not provided
|
||||||
|
if (resolved.accessToken) {
|
||||||
|
let userId = resolved.userId;
|
||||||
|
if (!userId) {
|
||||||
|
// Fetch userId from access token via whoami
|
||||||
|
ensureMatrixSdkLoggingConfigured();
|
||||||
|
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
||||||
|
const whoami = await tempClient.getUserId();
|
||||||
|
userId = whoami;
|
||||||
|
// Save the credentials with the fetched userId
|
||||||
|
saveMatrixCredentials({
|
||||||
|
homeserver: resolved.homeserver,
|
||||||
|
userId,
|
||||||
|
accessToken: resolved.accessToken,
|
||||||
|
});
|
||||||
|
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||||
|
touchMatrixCredentials(env);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
homeserver: resolved.homeserver,
|
||||||
|
userId,
|
||||||
|
accessToken: resolved.accessToken,
|
||||||
|
deviceName: resolved.deviceName,
|
||||||
|
initialSyncLimit: resolved.initialSyncLimit,
|
||||||
|
encryption: resolved.encryption,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedCredentials) {
|
||||||
|
touchMatrixCredentials(env);
|
||||||
|
return {
|
||||||
|
homeserver: cachedCredentials.homeserver,
|
||||||
|
userId: cachedCredentials.userId,
|
||||||
|
accessToken: cachedCredentials.accessToken,
|
||||||
|
deviceName: resolved.deviceName,
|
||||||
|
initialSyncLimit: resolved.initialSyncLimit,
|
||||||
|
encryption: resolved.encryption,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.userId) {
|
||||||
|
throw new Error(
|
||||||
|
"Matrix userId is required when no access token is configured (matrix.userId)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.password) {
|
||||||
|
throw new Error(
|
||||||
|
"Matrix password is required when no access token is configured (matrix.password)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login with password using HTTP API
|
||||||
|
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "m.login.password",
|
||||||
|
identifier: { type: "m.id.user", user: resolved.userId },
|
||||||
|
password: resolved.password,
|
||||||
|
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
const errorText = await loginResponse.text();
|
||||||
|
throw new Error(`Matrix login failed: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = (await loginResponse.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
user_id?: string;
|
||||||
|
device_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = login.access_token?.trim();
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error("Matrix login did not return an access token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth: MatrixAuth = {
|
||||||
|
homeserver: resolved.homeserver,
|
||||||
|
userId: login.user_id ?? resolved.userId,
|
||||||
|
accessToken,
|
||||||
|
deviceName: resolved.deviceName,
|
||||||
|
initialSyncLimit: resolved.initialSyncLimit,
|
||||||
|
encryption: resolved.encryption,
|
||||||
|
};
|
||||||
|
|
||||||
|
saveMatrixCredentials({
|
||||||
|
homeserver: auth.homeserver,
|
||||||
|
userId: auth.userId,
|
||||||
|
accessToken: auth.accessToken,
|
||||||
|
deviceId: login.device_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LogService,
|
||||||
|
MatrixClient,
|
||||||
|
SimpleFsStorageProvider,
|
||||||
|
RustSdkCryptoStorageProvider,
|
||||||
|
} from "matrix-bot-sdk";
|
||||||
|
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
|
||||||
|
|
||||||
|
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||||
|
import {
|
||||||
|
maybeMigrateLegacyStorage,
|
||||||
|
resolveMatrixStoragePaths,
|
||||||
|
writeStorageMeta,
|
||||||
|
} from "./storage.js";
|
||||||
|
|
||||||
|
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||||
|
if (input == null) return [];
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
LogService.warn(
|
||||||
|
"MatrixClientLite",
|
||||||
|
`Expected ${label} list to be an array, got ${typeof input}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const filtered = input.filter(
|
||||||
|
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||||
|
);
|
||||||
|
if (filtered.length !== input.length) {
|
||||||
|
LogService.warn(
|
||||||
|
"MatrixClientLite",
|
||||||
|
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMatrixClient(params: {
|
||||||
|
homeserver: string;
|
||||||
|
userId: string;
|
||||||
|
accessToken: string;
|
||||||
|
encryption?: boolean;
|
||||||
|
localTimeoutMs?: number;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): Promise<MatrixClient> {
|
||||||
|
ensureMatrixSdkLoggingConfigured();
|
||||||
|
const env = process.env;
|
||||||
|
|
||||||
|
// Create storage provider
|
||||||
|
const storagePaths = resolveMatrixStoragePaths({
|
||||||
|
homeserver: params.homeserver,
|
||||||
|
userId: params.userId,
|
||||||
|
accessToken: params.accessToken,
|
||||||
|
accountId: params.accountId,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
maybeMigrateLegacyStorage({ storagePaths, env });
|
||||||
|
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
|
||||||
|
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
|
||||||
|
|
||||||
|
// Create crypto storage if encryption is enabled
|
||||||
|
let cryptoStorage: ICryptoStorageProvider | undefined;
|
||||||
|
if (params.encryption) {
|
||||||
|
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||||
|
cryptoStorage = new RustSdkCryptoStorageProvider(
|
||||||
|
storagePaths.cryptoPath,
|
||||||
|
StoreType.Sqlite,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeStorageMeta({
|
||||||
|
storagePaths,
|
||||||
|
homeserver: params.homeserver,
|
||||||
|
userId: params.userId,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new MatrixClient(
|
||||||
|
params.homeserver,
|
||||||
|
params.accessToken,
|
||||||
|
storage,
|
||||||
|
cryptoStorage,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (client.crypto) {
|
||||||
|
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
|
||||||
|
client.crypto.updateSyncData = async (
|
||||||
|
toDeviceMessages,
|
||||||
|
otkCounts,
|
||||||
|
unusedFallbackKeyAlgs,
|
||||||
|
changedDeviceLists,
|
||||||
|
leftDeviceLists,
|
||||||
|
) => {
|
||||||
|
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
|
||||||
|
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
|
||||||
|
try {
|
||||||
|
return await originalUpdateSyncData(
|
||||||
|
toDeviceMessages,
|
||||||
|
otkCounts,
|
||||||
|
unusedFallbackKeyAlgs,
|
||||||
|
safeChanged,
|
||||||
|
safeLeft,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
|
||||||
|
if (message.includes("Expect value to be String")) {
|
||||||
|
LogService.warn(
|
||||||
|
"MatrixClientLite",
|
||||||
|
"Ignoring malformed device list entries during crypto sync",
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ConsoleLogger, LogService } from "matrix-bot-sdk";
|
||||||
|
|
||||||
|
let matrixSdkLoggingConfigured = false;
|
||||||
|
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||||
|
|
||||||
|
function shouldSuppressMatrixHttpNotFound(
|
||||||
|
module: string,
|
||||||
|
messageOrObject: unknown[],
|
||||||
|
): boolean {
|
||||||
|
if (module !== "MatrixHttpClient") return false;
|
||||||
|
return messageOrObject.some((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object") return false;
|
||||||
|
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureMatrixSdkLoggingConfigured(): void {
|
||||||
|
if (matrixSdkLoggingConfigured) return;
|
||||||
|
matrixSdkLoggingConfigured = true;
|
||||||
|
|
||||||
|
LogService.setLogger({
|
||||||
|
trace: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
||||||
|
debug: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
||||||
|
info: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.info(module, ...messageOrObject),
|
||||||
|
warn: (module, ...messageOrObject) =>
|
||||||
|
matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
||||||
|
error: (module, ...messageOrObject) => {
|
||||||
|
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
|
||||||
|
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function isBunRuntime(): boolean {
|
||||||
|
const versions = process.versions as { bun?: string };
|
||||||
|
return typeof versions.bun === "string";
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { LogService } from "matrix-bot-sdk";
|
||||||
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
|
import type { CoreConfig } from "../types.js";
|
||||||
|
import { createMatrixClient } from "./create-client.js";
|
||||||
|
import { resolveMatrixAuth } from "./config.js";
|
||||||
|
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
|
||||||
|
import type { MatrixAuth } from "./types.js";
|
||||||
|
|
||||||
|
type SharedMatrixClientState = {
|
||||||
|
client: MatrixClient;
|
||||||
|
key: string;
|
||||||
|
started: boolean;
|
||||||
|
cryptoReady: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let sharedClientState: SharedMatrixClientState | null = null;
|
||||||
|
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
|
||||||
|
let sharedClientStartPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
||||||
|
return [
|
||||||
|
auth.homeserver,
|
||||||
|
auth.userId,
|
||||||
|
auth.accessToken,
|
||||||
|
auth.encryption ? "e2ee" : "plain",
|
||||||
|
accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSharedMatrixClient(params: {
|
||||||
|
auth: MatrixAuth;
|
||||||
|
timeoutMs?: number;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): Promise<SharedMatrixClientState> {
|
||||||
|
const client = await createMatrixClient({
|
||||||
|
homeserver: params.auth.homeserver,
|
||||||
|
userId: params.auth.userId,
|
||||||
|
accessToken: params.auth.accessToken,
|
||||||
|
encryption: params.auth.encryption,
|
||||||
|
localTimeoutMs: params.timeoutMs,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
key: buildSharedClientKey(params.auth, params.accountId),
|
||||||
|
started: false,
|
||||||
|
cryptoReady: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSharedClientStarted(params: {
|
||||||
|
state: SharedMatrixClientState;
|
||||||
|
timeoutMs?: number;
|
||||||
|
initialSyncLimit?: number;
|
||||||
|
encryption?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (params.state.started) return;
|
||||||
|
if (sharedClientStartPromise) {
|
||||||
|
await sharedClientStartPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sharedClientStartPromise = (async () => {
|
||||||
|
const client = params.state.client;
|
||||||
|
|
||||||
|
// Initialize crypto if enabled
|
||||||
|
if (params.encryption && !params.state.cryptoReady) {
|
||||||
|
try {
|
||||||
|
const joinedRooms = await client.getJoinedRooms();
|
||||||
|
if (client.crypto) {
|
||||||
|
await client.crypto.prepare(joinedRooms);
|
||||||
|
params.state.cryptoReady = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.start();
|
||||||
|
params.state.started = true;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
await sharedClientStartPromise;
|
||||||
|
} finally {
|
||||||
|
sharedClientStartPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSharedMatrixClient(
|
||||||
|
params: {
|
||||||
|
cfg?: CoreConfig;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
timeoutMs?: number;
|
||||||
|
auth?: MatrixAuth;
|
||||||
|
startClient?: boolean;
|
||||||
|
accountId?: string | null;
|
||||||
|
} = {},
|
||||||
|
): Promise<MatrixClient> {
|
||||||
|
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
|
||||||
|
const key = buildSharedClientKey(auth, params.accountId);
|
||||||
|
const shouldStart = params.startClient !== false;
|
||||||
|
|
||||||
|
if (sharedClientState?.key === key) {
|
||||||
|
if (shouldStart) {
|
||||||
|
await ensureSharedClientStarted({
|
||||||
|
state: sharedClientState,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
|
encryption: auth.encryption,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sharedClientState.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedClientPromise) {
|
||||||
|
const pending = await sharedClientPromise;
|
||||||
|
if (pending.key === key) {
|
||||||
|
if (shouldStart) {
|
||||||
|
await ensureSharedClientStarted({
|
||||||
|
state: pending,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
|
encryption: auth.encryption,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pending.client;
|
||||||
|
}
|
||||||
|
pending.client.stop();
|
||||||
|
sharedClientState = null;
|
||||||
|
sharedClientPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedClientPromise = createSharedMatrixClient({
|
||||||
|
auth,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const created = await sharedClientPromise;
|
||||||
|
sharedClientState = created;
|
||||||
|
if (shouldStart) {
|
||||||
|
await ensureSharedClientStarted({
|
||||||
|
state: created,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
initialSyncLimit: auth.initialSyncLimit,
|
||||||
|
encryption: auth.encryption,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return created.client;
|
||||||
|
} finally {
|
||||||
|
sharedClientPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForMatrixSync(_params: {
|
||||||
|
client: MatrixClient;
|
||||||
|
timeoutMs?: number;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
}): Promise<void> {
|
||||||
|
// matrix-bot-sdk handles sync internally in start()
|
||||||
|
// This is kept for API compatibility but is essentially a no-op now
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSharedClient(): void {
|
||||||
|
if (sharedClientState) {
|
||||||
|
sharedClientState.client.stop();
|
||||||
|
sharedClientState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
import type { MatrixStoragePaths } from "./types.js";
|
||||||
|
|
||||||
|
export const DEFAULT_ACCOUNT_KEY = "default";
|
||||||
|
const STORAGE_META_FILENAME = "storage-meta.json";
|
||||||
|
|
||||||
|
function sanitizePathSegment(value: string): string {
|
||||||
|
const cleaned = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "");
|
||||||
|
return cleaned || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHomeserverKey(homeserver: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(homeserver);
|
||||||
|
if (url.host) return sanitizePathSegment(url.host);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return sanitizePathSegment(homeserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashAccessToken(accessToken: string): string {
|
||||||
|
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
|
||||||
|
storagePath: string;
|
||||||
|
cryptoPath: string;
|
||||||
|
} {
|
||||||
|
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||||
|
return {
|
||||||
|
storagePath: path.join(stateDir, "matrix", "bot-storage.json"),
|
||||||
|
cryptoPath: path.join(stateDir, "matrix", "crypto"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatrixStoragePaths(params: {
|
||||||
|
homeserver: string;
|
||||||
|
userId: string;
|
||||||
|
accessToken: string;
|
||||||
|
accountId?: string | null;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): MatrixStoragePaths {
|
||||||
|
const env = params.env ?? process.env;
|
||||||
|
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||||
|
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
|
||||||
|
const userKey = sanitizePathSegment(params.userId);
|
||||||
|
const serverKey = resolveHomeserverKey(params.homeserver);
|
||||||
|
const tokenHash = hashAccessToken(params.accessToken);
|
||||||
|
const rootDir = path.join(
|
||||||
|
stateDir,
|
||||||
|
"matrix",
|
||||||
|
"accounts",
|
||||||
|
accountKey,
|
||||||
|
`${serverKey}__${userKey}`,
|
||||||
|
tokenHash,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
rootDir,
|
||||||
|
storagePath: path.join(rootDir, "bot-storage.json"),
|
||||||
|
cryptoPath: path.join(rootDir, "crypto"),
|
||||||
|
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
|
||||||
|
accountKey,
|
||||||
|
tokenHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeMigrateLegacyStorage(params: {
|
||||||
|
storagePaths: MatrixStoragePaths;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): void {
|
||||||
|
const legacy = resolveLegacyStoragePaths(params.env);
|
||||||
|
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
|
||||||
|
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
|
||||||
|
const hasNewStorage =
|
||||||
|
fs.existsSync(params.storagePaths.storagePath) ||
|
||||||
|
fs.existsSync(params.storagePaths.cryptoPath);
|
||||||
|
|
||||||
|
if (!hasLegacyStorage && !hasLegacyCrypto) return;
|
||||||
|
if (hasNewStorage) return;
|
||||||
|
|
||||||
|
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||||
|
if (hasLegacyStorage) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore migration failures; new store will be created.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasLegacyCrypto) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore migration failures; new store will be created.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStorageMeta(params: {
|
||||||
|
storagePaths: MatrixStoragePaths;
|
||||||
|
homeserver: string;
|
||||||
|
userId: string;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): void {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
homeserver: params.homeserver,
|
||||||
|
userId: params.userId,
|
||||||
|
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||||
|
accessTokenHash: params.storagePaths.tokenHash,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
params.storagePaths.metaPath,
|
||||||
|
JSON.stringify(payload, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore meta write failures
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type MatrixResolvedConfig = {
|
||||||
|
homeserver: string;
|
||||||
|
userId: string;
|
||||||
|
accessToken?: string;
|
||||||
|
password?: string;
|
||||||
|
deviceName?: string;
|
||||||
|
initialSyncLimit?: number;
|
||||||
|
encryption?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated Matrix configuration.
|
||||||
|
* Note: deviceId is NOT included here because it's implicit in the accessToken.
|
||||||
|
* The crypto storage assumes the device ID (and thus access token) does not change
|
||||||
|
* between restarts. If the access token becomes invalid or crypto storage is lost,
|
||||||
|
* both will need to be recreated together.
|
||||||
|
*/
|
||||||
|
export type MatrixAuth = {
|
||||||
|
homeserver: string;
|
||||||
|
userId: string;
|
||||||
|
accessToken: string;
|
||||||
|
deviceName?: string;
|
||||||
|
initialSyncLimit?: number;
|
||||||
|
encryption?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixStoragePaths = {
|
||||||
|
rootDir: string;
|
||||||
|
storagePath: string;
|
||||||
|
cryptoPath: string;
|
||||||
|
metaPath: string;
|
||||||
|
accountKey: string;
|
||||||
|
tokenHash: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import type { MatrixAuth } from "../client.js";
|
||||||
|
import type { MatrixRawEvent } from "./types.js";
|
||||||
|
import { EventType } from "./types.js";
|
||||||
|
|
||||||
|
export function registerMatrixMonitorEvents(params: {
|
||||||
|
client: MatrixClient;
|
||||||
|
auth: MatrixAuth;
|
||||||
|
logVerboseMessage: (message: string) => void;
|
||||||
|
warnedEncryptedRooms: Set<string>;
|
||||||
|
warnedCryptoMissingRooms: Set<string>;
|
||||||
|
logger: { warn: (meta: Record<string, unknown>, message: string) => void };
|
||||||
|
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
|
||||||
|
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
|
||||||
|
}): void {
|
||||||
|
const {
|
||||||
|
client,
|
||||||
|
auth,
|
||||||
|
logVerboseMessage,
|
||||||
|
warnedEncryptedRooms,
|
||||||
|
warnedCryptoMissingRooms,
|
||||||
|
logger,
|
||||||
|
formatNativeDependencyHint,
|
||||||
|
onRoomMessage,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
client.on("room.message", onRoomMessage);
|
||||||
|
|
||||||
|
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
const eventId = event?.event_id ?? "unknown";
|
||||||
|
const eventType = event?.type ?? "unknown";
|
||||||
|
logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
const eventId = event?.event_id ?? "unknown";
|
||||||
|
const eventType = event?.type ?? "unknown";
|
||||||
|
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on(
|
||||||
|
"room.failed_decryption",
|
||||||
|
async (roomId: string, event: MatrixRawEvent, error: Error) => {
|
||||||
|
logger.warn(
|
||||||
|
{ roomId, eventId: event.event_id, error: error.message },
|
||||||
|
"Failed to decrypt message",
|
||||||
|
);
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
const eventId = event?.event_id ?? "unknown";
|
||||||
|
const sender = event?.sender ?? "unknown";
|
||||||
|
const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
const eventId = event?.event_id ?? "unknown";
|
||||||
|
logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
const eventType = event?.type ?? "unknown";
|
||||||
|
if (eventType === EventType.RoomMessageEncrypted) {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
|
||||||
|
warnedEncryptedRooms.add(roomId);
|
||||||
|
const warning =
|
||||||
|
"matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt";
|
||||||
|
logger.warn({ roomId }, warning);
|
||||||
|
}
|
||||||
|
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
|
||||||
|
warnedCryptoMissingRooms.add(roomId);
|
||||||
|
const hint = formatNativeDependencyHint({
|
||||||
|
packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
|
||||||
|
manager: "pnpm",
|
||||||
|
downloadCommand:
|
||||||
|
"node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
|
||||||
|
});
|
||||||
|
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
|
||||||
|
logger.warn({ roomId }, warning);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventType === EventType.RoomMember) {
|
||||||
|
const membership = (event?.content as { membership?: string } | undefined)?.membership;
|
||||||
|
const stateKey = (event as { state_key?: string }).state_key ?? "";
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatAllowlistMatchMeta,
|
||||||
|
type RuntimeEnv,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||||
|
import {
|
||||||
|
formatPollAsText,
|
||||||
|
isPollStartType,
|
||||||
|
parsePollStartContent,
|
||||||
|
type PollStartContent,
|
||||||
|
} from "../poll-types.js";
|
||||||
|
import { reactMatrixMessage, sendMessageMatrix, sendReadReceiptMatrix, sendTypingMatrix } from "../send.js";
|
||||||
|
import {
|
||||||
|
resolveMatrixAllowListMatch,
|
||||||
|
resolveMatrixAllowListMatches,
|
||||||
|
normalizeAllowListLower,
|
||||||
|
} from "./allowlist.js";
|
||||||
|
import { downloadMatrixMedia } from "./media.js";
|
||||||
|
import { resolveMentions } from "./mentions.js";
|
||||||
|
import { deliverMatrixReplies } from "./replies.js";
|
||||||
|
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||||
|
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||||
|
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
|
||||||
|
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
||||||
|
import { EventType, RelationType } from "./types.js";
|
||||||
|
|
||||||
|
export type MatrixMonitorHandlerParams = {
|
||||||
|
client: MatrixClient;
|
||||||
|
core: {
|
||||||
|
logging: {
|
||||||
|
shouldLogVerbose: () => boolean;
|
||||||
|
};
|
||||||
|
channel: typeof import("clawdbot/plugin-sdk")["channel"];
|
||||||
|
system: {
|
||||||
|
enqueueSystemEvent: (
|
||||||
|
text: string,
|
||||||
|
meta: { sessionKey?: string | null; contextKey?: string | null },
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
cfg: CoreConfig;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
logger: {
|
||||||
|
info: (message: string | Record<string, unknown>, ...meta: unknown[]) => void;
|
||||||
|
warn: (meta: Record<string, unknown>, message: string) => void;
|
||||||
|
};
|
||||||
|
logVerboseMessage: (message: string) => void;
|
||||||
|
allowFrom: string[];
|
||||||
|
roomsConfig: CoreConfig["channels"] extends { matrix?: infer MatrixConfig }
|
||||||
|
? MatrixConfig extends { groups?: infer Groups }
|
||||||
|
? Groups
|
||||||
|
: Record<string, unknown> | undefined
|
||||||
|
: Record<string, unknown> | undefined;
|
||||||
|
mentionRegexes: ReturnType<
|
||||||
|
typeof import("clawdbot/plugin-sdk")["channel"]["mentions"]["buildMentionRegexes"]
|
||||||
|
>;
|
||||||
|
groupPolicy: "open" | "allowlist" | "disabled";
|
||||||
|
replyToMode: ReplyToMode;
|
||||||
|
threadReplies: "off" | "inbound" | "always";
|
||||||
|
dmEnabled: boolean;
|
||||||
|
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
|
||||||
|
textLimit: number;
|
||||||
|
mediaMaxBytes: number;
|
||||||
|
startupMs: number;
|
||||||
|
startupGraceMs: number;
|
||||||
|
directTracker: {
|
||||||
|
isDirectMessage: (params: {
|
||||||
|
roomId: string;
|
||||||
|
senderId: string;
|
||||||
|
selfUserId: string;
|
||||||
|
}) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
getRoomInfo: (roomId: string) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
|
||||||
|
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
|
||||||
|
const {
|
||||||
|
client,
|
||||||
|
core,
|
||||||
|
cfg,
|
||||||
|
runtime,
|
||||||
|
logger,
|
||||||
|
logVerboseMessage,
|
||||||
|
allowFrom,
|
||||||
|
roomsConfig,
|
||||||
|
mentionRegexes,
|
||||||
|
groupPolicy,
|
||||||
|
replyToMode,
|
||||||
|
threadReplies,
|
||||||
|
dmEnabled,
|
||||||
|
dmPolicy,
|
||||||
|
textLimit,
|
||||||
|
mediaMaxBytes,
|
||||||
|
startupMs,
|
||||||
|
startupGraceMs,
|
||||||
|
directTracker,
|
||||||
|
getRoomInfo,
|
||||||
|
getMemberDisplayName,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
return async (roomId: string, event: MatrixRawEvent) => {
|
||||||
|
try {
|
||||||
|
const eventType = event.type;
|
||||||
|
if (eventType === EventType.RoomMessageEncrypted) {
|
||||||
|
// Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPollEvent = isPollStartType(eventType);
|
||||||
|
const locationContent = event.content as LocationMessageEventContent;
|
||||||
|
const isLocationEvent =
|
||||||
|
eventType === EventType.Location ||
|
||||||
|
(eventType === EventType.RoomMessage &&
|
||||||
|
locationContent.msgtype === EventType.Location);
|
||||||
|
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return;
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
if (event.unsigned?.redacted_because) return;
|
||||||
|
const senderId = event.sender;
|
||||||
|
if (!senderId) return;
|
||||||
|
const selfUserId = await client.getUserId();
|
||||||
|
if (senderId === selfUserId) return;
|
||||||
|
const eventTs = event.origin_server_ts;
|
||||||
|
const eventAge = event.unsigned?.age;
|
||||||
|
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof eventTs !== "number" &&
|
||||||
|
typeof eventAge === "number" &&
|
||||||
|
eventAge > startupGraceMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomInfo = await getRoomInfo(roomId);
|
||||||
|
const roomName = roomInfo.name;
|
||||||
|
const roomAliases = [
|
||||||
|
roomInfo.canonicalAlias ?? "",
|
||||||
|
...roomInfo.altAliases,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
let content = event.content as RoomMessageEventContent;
|
||||||
|
if (isPollEvent) {
|
||||||
|
const pollStartContent = event.content as PollStartContent;
|
||||||
|
const pollSummary = parsePollStartContent(pollStartContent);
|
||||||
|
if (pollSummary) {
|
||||||
|
pollSummary.eventId = event.event_id ?? "";
|
||||||
|
pollSummary.roomId = roomId;
|
||||||
|
pollSummary.sender = senderId;
|
||||||
|
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
|
||||||
|
pollSummary.senderName = senderDisplayName;
|
||||||
|
const pollText = formatPollAsText(pollSummary);
|
||||||
|
content = {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: pollText,
|
||||||
|
} as unknown as RoomMessageEventContent;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
|
||||||
|
eventType,
|
||||||
|
content: content as LocationMessageEventContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const relates = content["m.relates_to"];
|
||||||
|
if (relates && "rel_type" in relates) {
|
||||||
|
if (relates.rel_type === RelationType.Replace) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectMessage = await directTracker.isDirectMessage({
|
||||||
|
roomId,
|
||||||
|
senderId,
|
||||||
|
selfUserId,
|
||||||
|
});
|
||||||
|
const isRoom = !isDirectMessage;
|
||||||
|
|
||||||
|
if (isRoom && groupPolicy === "disabled") return;
|
||||||
|
|
||||||
|
const roomConfigInfo = isRoom
|
||||||
|
? resolveMatrixRoomConfig({
|
||||||
|
rooms: roomsConfig,
|
||||||
|
roomId,
|
||||||
|
aliases: roomAliases,
|
||||||
|
name: roomName,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const roomConfig = roomConfigInfo?.config;
|
||||||
|
const roomMatchMeta = roomConfigInfo
|
||||||
|
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
|
||||||
|
roomConfigInfo.matchSource ?? "none"
|
||||||
|
}`
|
||||||
|
: "matchKey=none matchSource=none";
|
||||||
|
|
||||||
|
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
|
||||||
|
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRoom && groupPolicy === "allowlist") {
|
||||||
|
if (!roomConfigInfo?.allowlistConfigured) {
|
||||||
|
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!roomConfig) {
|
||||||
|
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderName = await getMemberDisplayName(roomId, senderId);
|
||||||
|
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
||||||
|
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
||||||
|
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
||||||
|
const effectiveGroupAllowFrom = normalizeAllowListLower([
|
||||||
|
...groupAllowFrom,
|
||||||
|
...storeAllowFrom,
|
||||||
|
]);
|
||||||
|
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
|
||||||
|
|
||||||
|
if (isDirectMessage) {
|
||||||
|
if (!dmEnabled || dmPolicy === "disabled") return;
|
||||||
|
if (dmPolicy !== "open") {
|
||||||
|
const allowMatch = resolveMatrixAllowListMatch({
|
||||||
|
allowList: effectiveAllowFrom,
|
||||||
|
userId: senderId,
|
||||||
|
userName: senderName,
|
||||||
|
});
|
||||||
|
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||||
|
if (!allowMatch.allowed) {
|
||||||
|
if (dmPolicy === "pairing") {
|
||||||
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
|
channel: "matrix",
|
||||||
|
id: senderId,
|
||||||
|
meta: { name: senderName },
|
||||||
|
});
|
||||||
|
if (created) {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await sendMessageMatrix(
|
||||||
|
`room:${roomId}`,
|
||||||
|
[
|
||||||
|
"Clawdbot: access not configured.",
|
||||||
|
"",
|
||||||
|
`Pairing code: ${code}`,
|
||||||
|
"",
|
||||||
|
"Ask the bot owner to approve with:",
|
||||||
|
"clawdbot pairing approve matrix <code>",
|
||||||
|
].join("\n"),
|
||||||
|
{ client },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dmPolicy !== "pairing") {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomUsers = roomConfig?.users ?? [];
|
||||||
|
if (isRoom && roomUsers.length > 0) {
|
||||||
|
const userMatch = resolveMatrixAllowListMatch({
|
||||||
|
allowList: normalizeAllowListLower(roomUsers),
|
||||||
|
userId: senderId,
|
||||||
|
userName: senderName,
|
||||||
|
});
|
||||||
|
if (!userMatch.allowed) {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||||
|
userMatch,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
|
||||||
|
const groupAllowMatch = resolveMatrixAllowListMatch({
|
||||||
|
allowList: effectiveGroupAllowFrom,
|
||||||
|
userId: senderId,
|
||||||
|
userName: senderName,
|
||||||
|
});
|
||||||
|
if (!groupAllowMatch.allowed) {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||||
|
groupAllowMatch,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRoom) {
|
||||||
|
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = locationPayload?.text
|
||||||
|
?? (typeof content.body === "string" ? content.body.trim() : "");
|
||||||
|
let media: {
|
||||||
|
path: string;
|
||||||
|
contentType?: string;
|
||||||
|
placeholder: string;
|
||||||
|
} | null = null;
|
||||||
|
const contentUrl =
|
||||||
|
"url" in content && typeof content.url === "string" ? content.url : undefined;
|
||||||
|
const contentFile =
|
||||||
|
"file" in content && content.file && typeof content.file === "object"
|
||||||
|
? content.file
|
||||||
|
: undefined;
|
||||||
|
const mediaUrl = contentUrl ?? contentFile?.url;
|
||||||
|
if (!rawBody && !mediaUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
"info" in content && content.info && "mimetype" in content.info
|
||||||
|
? (content.info as { mimetype?: string }).mimetype
|
||||||
|
: undefined;
|
||||||
|
if (mediaUrl?.startsWith("mxc://")) {
|
||||||
|
try {
|
||||||
|
media = await downloadMatrixMedia({
|
||||||
|
client,
|
||||||
|
mxcUrl: mediaUrl,
|
||||||
|
contentType,
|
||||||
|
maxBytes: mediaMaxBytes,
|
||||||
|
file: contentFile,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyText = rawBody || media?.placeholder || "";
|
||||||
|
if (!bodyText) return;
|
||||||
|
|
||||||
|
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
||||||
|
content,
|
||||||
|
userId: selfUserId,
|
||||||
|
text: bodyText,
|
||||||
|
mentionRegexes,
|
||||||
|
});
|
||||||
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||||
|
cfg,
|
||||||
|
surface: "matrix",
|
||||||
|
});
|
||||||
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||||
|
const senderAllowedForCommands = resolveMatrixAllowListMatches({
|
||||||
|
allowList: effectiveAllowFrom,
|
||||||
|
userId: senderId,
|
||||||
|
userName: senderName,
|
||||||
|
});
|
||||||
|
const senderAllowedForGroup = groupAllowConfigured
|
||||||
|
? resolveMatrixAllowListMatches({
|
||||||
|
allowList: effectiveGroupAllowFrom,
|
||||||
|
userId: senderId,
|
||||||
|
userName: senderName,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
const senderAllowedForRoomUsers =
|
||||||
|
isRoom && roomUsers.length > 0
|
||||||
|
? resolveMatrixAllowListMatches({
|
||||||
|
allowList: normalizeAllowListLower(roomUsers),
|
||||||
|
userId: senderId,
|
||||||
|
userName: senderName,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
|
useAccessGroups,
|
||||||
|
authorizers: [
|
||||||
|
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||||
|
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
|
||||||
|
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
isRoom &&
|
||||||
|
allowTextCommands &&
|
||||||
|
core.channel.text.hasControlCommand(bodyText, cfg) &&
|
||||||
|
!commandAuthorized
|
||||||
|
) {
|
||||||
|
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const shouldRequireMention = isRoom
|
||||||
|
? roomConfig?.autoReply === true
|
||||||
|
? false
|
||||||
|
: roomConfig?.autoReply === false
|
||||||
|
? true
|
||||||
|
: typeof roomConfig?.requireMention === "boolean"
|
||||||
|
? roomConfig?.requireMention
|
||||||
|
: true
|
||||||
|
: false;
|
||||||
|
const shouldBypassMention =
|
||||||
|
allowTextCommands &&
|
||||||
|
isRoom &&
|
||||||
|
shouldRequireMention &&
|
||||||
|
!wasMentioned &&
|
||||||
|
!hasExplicitMention &&
|
||||||
|
commandAuthorized &&
|
||||||
|
core.channel.text.hasControlCommand(bodyText);
|
||||||
|
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||||
|
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = event.event_id ?? "";
|
||||||
|
const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
|
||||||
|
const threadRootId = resolveMatrixThreadRootId({ event, content });
|
||||||
|
const threadTarget = resolveMatrixThreadTarget({
|
||||||
|
threadReplies,
|
||||||
|
messageId,
|
||||||
|
threadRootId,
|
||||||
|
isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "matrix",
|
||||||
|
peer: {
|
||||||
|
kind: isDirectMessage ? "dm" : "channel",
|
||||||
|
id: isDirectMessage ? senderId : roomId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||||
|
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
|
channel: "Matrix",
|
||||||
|
from: envelopeFrom,
|
||||||
|
timestamp: eventTs ?? undefined,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
body: textWithId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
|
||||||
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
|
Body: body,
|
||||||
|
RawBody: bodyText,
|
||||||
|
CommandBody: bodyText,
|
||||||
|
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
|
||||||
|
To: `room:${roomId}`,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
|
ChatType: isDirectMessage ? "direct" : "channel",
|
||||||
|
ConversationLabel: envelopeFrom,
|
||||||
|
SenderName: senderName,
|
||||||
|
SenderId: senderId,
|
||||||
|
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
|
||||||
|
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
||||||
|
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
|
||||||
|
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
||||||
|
Provider: "matrix" as const,
|
||||||
|
Surface: "matrix" as const,
|
||||||
|
WasMentioned: isRoom ? wasMentioned : undefined,
|
||||||
|
MessageSid: messageId,
|
||||||
|
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
|
||||||
|
MessageThreadId: threadTarget,
|
||||||
|
Timestamp: eventTs ?? undefined,
|
||||||
|
MediaPath: media?.path,
|
||||||
|
MediaType: media?.contentType,
|
||||||
|
MediaUrl: media?.path,
|
||||||
|
...(locationPayload?.context ?? {}),
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
|
CommandSource: "text" as const,
|
||||||
|
OriginatingChannel: "matrix" as const,
|
||||||
|
OriginatingTo: `room:${roomId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
void core.channel.session
|
||||||
|
.recordSessionMetaFromInbound({
|
||||||
|
storePath,
|
||||||
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.warn(
|
||||||
|
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
||||||
|
"failed updating session meta",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDirectMessage) {
|
||||||
|
await core.channel.session.updateLastRoute({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.mainSessionKey,
|
||||||
|
channel: "matrix",
|
||||||
|
to: `room:${roomId}`,
|
||||||
|
accountId: route.accountId,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||||
|
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||||
|
|
||||||
|
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||||
|
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
|
const shouldAckReaction = () => {
|
||||||
|
if (!ackReaction) return false;
|
||||||
|
if (ackScope === "all") return true;
|
||||||
|
if (ackScope === "direct") return isDirectMessage;
|
||||||
|
if (ackScope === "group-all") return isRoom;
|
||||||
|
if (ackScope === "group-mentions") {
|
||||||
|
if (!isRoom) return false;
|
||||||
|
if (!shouldRequireMention) return false;
|
||||||
|
return wasMentioned || shouldBypassMention;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (shouldAckReaction() && messageId) {
|
||||||
|
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
||||||
|
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyTarget = ctxPayload.To;
|
||||||
|
if (!replyTarget) {
|
||||||
|
runtime.error?.("matrix: missing reply target");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageId) {
|
||||||
|
sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let didSendReply = false;
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||||
|
.responsePrefix,
|
||||||
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
|
deliver: async (payload) => {
|
||||||
|
await deliverMatrixReplies({
|
||||||
|
replies: [payload],
|
||||||
|
roomId,
|
||||||
|
client,
|
||||||
|
runtime,
|
||||||
|
textLimit,
|
||||||
|
replyToMode,
|
||||||
|
threadId: threadTarget,
|
||||||
|
});
|
||||||
|
didSendReply = true;
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
onReplyStart: () =>
|
||||||
|
sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
|
||||||
|
onIdle: () =>
|
||||||
|
sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
skillFilter: roomConfig?.skills,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
markDispatchIdle();
|
||||||
|
if (!queuedFinal) return;
|
||||||
|
didSendReply = true;
|
||||||
|
const finalCount = counts.final;
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||||
|
);
|
||||||
|
if (didSendReply) {
|
||||||
|
const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||||
|
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, {
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(`matrix handler failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,19 +1,8 @@
|
|||||||
import type {
|
|
||||||
EncryptedFile,
|
|
||||||
LocationMessageEventContent,
|
|
||||||
MatrixClient,
|
|
||||||
MessageEventContent,
|
|
||||||
} from "matrix-bot-sdk";
|
|
||||||
import { format } from "node:util";
|
import { format } from "node:util";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatAllowlistMatchMeta,
|
|
||||||
formatLocationText,
|
|
||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
summarizeMapping,
|
summarizeMapping,
|
||||||
toLocationContext,
|
|
||||||
type NormalizedLocation,
|
|
||||||
type ReplyPayload,
|
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||||
@@ -24,146 +13,14 @@ import {
|
|||||||
resolveSharedMatrixClient,
|
resolveSharedMatrixClient,
|
||||||
stopSharedClient,
|
stopSharedClient,
|
||||||
} from "../client.js";
|
} from "../client.js";
|
||||||
import {
|
|
||||||
formatPollAsText,
|
|
||||||
isPollStartType,
|
|
||||||
type PollStartContent,
|
|
||||||
parsePollStartContent,
|
|
||||||
} from "../poll-types.js";
|
|
||||||
import {
|
|
||||||
reactMatrixMessage,
|
|
||||||
sendMessageMatrix,
|
|
||||||
sendReadReceiptMatrix,
|
|
||||||
sendTypingMatrix,
|
|
||||||
} from "../send.js";
|
|
||||||
import {
|
|
||||||
resolveMatrixAllowListMatch,
|
|
||||||
resolveMatrixAllowListMatches,
|
|
||||||
normalizeAllowListLower,
|
|
||||||
} from "./allowlist.js";
|
|
||||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||||
import { createDirectRoomTracker } from "./direct.js";
|
import { createDirectRoomTracker } from "./direct.js";
|
||||||
import { downloadMatrixMedia } from "./media.js";
|
import { registerMatrixMonitorEvents } from "./events.js";
|
||||||
import { resolveMentions } from "./mentions.js";
|
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||||
import { deliverMatrixReplies } from "./replies.js";
|
import { createMatrixRoomInfoResolver } from "./room-info.js";
|
||||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
|
||||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
|
||||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
// Constants that were previously from matrix-js-sdk
|
|
||||||
const EventType = {
|
|
||||||
RoomMessage: "m.room.message",
|
|
||||||
RoomMessageEncrypted: "m.room.encrypted",
|
|
||||||
RoomMember: "m.room.member",
|
|
||||||
Location: "m.location",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const RelationType = {
|
|
||||||
Replace: "m.replace",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Type for raw Matrix events from matrix-bot-sdk
|
|
||||||
type MatrixRawEvent = {
|
|
||||||
event_id: string;
|
|
||||||
sender: string;
|
|
||||||
type: string;
|
|
||||||
origin_server_ts: number;
|
|
||||||
content: Record<string, unknown>;
|
|
||||||
unsigned?: {
|
|
||||||
age?: number;
|
|
||||||
redacted_because?: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type RoomMessageEventContent = MessageEventContent & {
|
|
||||||
url?: string;
|
|
||||||
file?: EncryptedFile;
|
|
||||||
info?: {
|
|
||||||
mimetype?: string;
|
|
||||||
};
|
|
||||||
"m.relates_to"?: {
|
|
||||||
rel_type?: string;
|
|
||||||
event_id?: string;
|
|
||||||
"m.in_reply_to"?: { event_id?: string };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type MatrixLocationPayload = {
|
|
||||||
text: string;
|
|
||||||
context: ReturnType<typeof toLocationContext>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GeoUriParams = {
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
accuracy?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseGeoUri(value: string): GeoUriParams | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
if (!trimmed.toLowerCase().startsWith("geo:")) return null;
|
|
||||||
const payload = trimmed.slice(4);
|
|
||||||
const [coordsPart, ...paramParts] = payload.split(";");
|
|
||||||
const coords = coordsPart.split(",");
|
|
||||||
if (coords.length < 2) return null;
|
|
||||||
const latitude = Number.parseFloat(coords[0] ?? "");
|
|
||||||
const longitude = Number.parseFloat(coords[1] ?? "");
|
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
|
||||||
|
|
||||||
const params = new Map<string, string>();
|
|
||||||
for (const part of paramParts) {
|
|
||||||
const segment = part.trim();
|
|
||||||
if (!segment) continue;
|
|
||||||
const eqIndex = segment.indexOf("=");
|
|
||||||
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
|
|
||||||
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
|
|
||||||
const key = rawKey.trim().toLowerCase();
|
|
||||||
if (!key) continue;
|
|
||||||
const valuePart = rawValue.trim();
|
|
||||||
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const accuracyRaw = params.get("u");
|
|
||||||
const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
accuracy: Number.isFinite(accuracy) ? accuracy : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMatrixLocation(params: {
|
|
||||||
eventType: string;
|
|
||||||
content: LocationMessageEventContent;
|
|
||||||
}): MatrixLocationPayload | null {
|
|
||||||
const { eventType, content } = params;
|
|
||||||
const isLocation =
|
|
||||||
eventType === EventType.Location ||
|
|
||||||
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
|
|
||||||
if (!isLocation) return null;
|
|
||||||
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
|
|
||||||
if (!geoUri) return null;
|
|
||||||
const parsed = parseGeoUri(geoUri);
|
|
||||||
if (!parsed) return null;
|
|
||||||
const caption = typeof content.body === "string" ? content.body.trim() : "";
|
|
||||||
const location: NormalizedLocation = {
|
|
||||||
latitude: parsed.latitude,
|
|
||||||
longitude: parsed.longitude,
|
|
||||||
accuracy: parsed.accuracy,
|
|
||||||
caption: caption || undefined,
|
|
||||||
source: "pin",
|
|
||||||
isLive: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: formatLocationText(location),
|
|
||||||
context: toLocationContext(location),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MonitorMatrixOpts = {
|
export type MonitorMatrixOpts = {
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
@@ -343,604 +200,40 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
const warnedEncryptedRooms = new Set<string>();
|
const warnedEncryptedRooms = new Set<string>();
|
||||||
const warnedCryptoMissingRooms = new Set<string>();
|
const warnedCryptoMissingRooms = new Set<string>();
|
||||||
|
|
||||||
const roomInfoCache = new Map<
|
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
|
||||||
string,
|
const handleRoomMessage = createMatrixRoomMessageHandler({
|
||||||
{ name?: string; canonicalAlias?: string; altAliases: string[] }
|
client,
|
||||||
>();
|
core,
|
||||||
|
cfg,
|
||||||
// Helper to get room info
|
runtime,
|
||||||
const getRoomInfo = async (roomId: string) => {
|
logger,
|
||||||
const cached = roomInfoCache.get(roomId);
|
logVerboseMessage,
|
||||||
if (cached) return cached;
|
allowFrom,
|
||||||
let name: string | undefined;
|
roomsConfig,
|
||||||
let canonicalAlias: string | undefined;
|
mentionRegexes,
|
||||||
let altAliases: string[] = [];
|
groupPolicy,
|
||||||
try {
|
replyToMode,
|
||||||
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
|
threadReplies,
|
||||||
name = nameState?.name;
|
dmEnabled,
|
||||||
} catch { /* ignore */ }
|
dmPolicy,
|
||||||
try {
|
textLimit,
|
||||||
const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", "").catch(() => null);
|
mediaMaxBytes,
|
||||||
canonicalAlias = aliasState?.alias;
|
startupMs,
|
||||||
altAliases = aliasState?.alt_aliases ?? [];
|
startupGraceMs,
|
||||||
} catch { /* ignore */ }
|
directTracker,
|
||||||
const info = { name, canonicalAlias, altAliases };
|
getRoomInfo,
|
||||||
roomInfoCache.set(roomId, info);
|
getMemberDisplayName,
|
||||||
return info;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to get member display name
|
|
||||||
const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
|
|
||||||
try {
|
|
||||||
const memberState = await client.getRoomStateEvent(roomId, "m.room.member", userId).catch(() => null);
|
|
||||||
return memberState?.displayname ?? userId;
|
|
||||||
} catch {
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRoomMessage = async (
|
|
||||||
roomId: string,
|
|
||||||
event: MatrixRawEvent,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const eventType = event.type;
|
|
||||||
if (eventType === EventType.RoomMessageEncrypted) {
|
|
||||||
// Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPollEvent = isPollStartType(eventType);
|
|
||||||
const locationContent = event.content as LocationMessageEventContent;
|
|
||||||
const isLocationEvent =
|
|
||||||
eventType === EventType.Location ||
|
|
||||||
(eventType === EventType.RoomMessage &&
|
|
||||||
locationContent.msgtype === EventType.Location);
|
|
||||||
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return;
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
|
||||||
);
|
|
||||||
if (event.unsigned?.redacted_because) return;
|
|
||||||
const senderId = event.sender;
|
|
||||||
if (!senderId) return;
|
|
||||||
const selfUserId = await client.getUserId();
|
|
||||||
if (senderId === selfUserId) return;
|
|
||||||
const eventTs = event.origin_server_ts;
|
|
||||||
const eventAge = event.unsigned?.age;
|
|
||||||
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof eventTs !== "number" &&
|
|
||||||
typeof eventAge === "number" &&
|
|
||||||
eventAge > startupGraceMs
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomInfo = await getRoomInfo(roomId);
|
|
||||||
const roomName = roomInfo.name;
|
|
||||||
const roomAliases = [
|
|
||||||
roomInfo.canonicalAlias ?? "",
|
|
||||||
...roomInfo.altAliases,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
let content = event.content as RoomMessageEventContent;
|
|
||||||
if (isPollEvent) {
|
|
||||||
const pollStartContent = event.content as PollStartContent;
|
|
||||||
const pollSummary = parsePollStartContent(pollStartContent);
|
|
||||||
if (pollSummary) {
|
|
||||||
pollSummary.eventId = event.event_id ?? "";
|
|
||||||
pollSummary.roomId = roomId;
|
|
||||||
pollSummary.sender = senderId;
|
|
||||||
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
|
|
||||||
pollSummary.senderName = senderDisplayName;
|
|
||||||
const pollText = formatPollAsText(pollSummary);
|
|
||||||
content = {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: pollText,
|
|
||||||
} as unknown as RoomMessageEventContent;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const locationPayload = resolveMatrixLocation({
|
|
||||||
eventType,
|
|
||||||
content: content as LocationMessageEventContent,
|
|
||||||
});
|
|
||||||
|
|
||||||
const relates = content["m.relates_to"];
|
|
||||||
if (relates && "rel_type" in relates) {
|
|
||||||
if (relates.rel_type === RelationType.Replace) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDirectMessage = await directTracker.isDirectMessage({
|
|
||||||
roomId,
|
|
||||||
senderId,
|
|
||||||
selfUserId,
|
|
||||||
});
|
|
||||||
const isRoom = !isDirectMessage;
|
|
||||||
|
|
||||||
if (isRoom && groupPolicy === "disabled") return;
|
|
||||||
|
|
||||||
const roomConfigInfo = isRoom
|
|
||||||
? resolveMatrixRoomConfig({
|
|
||||||
rooms: roomsConfig,
|
|
||||||
roomId,
|
|
||||||
aliases: roomAliases,
|
|
||||||
name: roomName,
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
const roomConfig = roomConfigInfo?.config;
|
|
||||||
const roomMatchMeta = roomConfigInfo
|
|
||||||
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
|
|
||||||
roomConfigInfo.matchSource ?? "none"
|
|
||||||
}`
|
|
||||||
: "matchKey=none matchSource=none";
|
|
||||||
|
|
||||||
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
|
|
||||||
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isRoom && groupPolicy === "allowlist") {
|
|
||||||
if (!roomConfigInfo?.allowlistConfigured) {
|
|
||||||
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!roomConfig) {
|
|
||||||
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const senderName = await getMemberDisplayName(roomId, senderId);
|
|
||||||
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
|
||||||
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
|
||||||
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
|
||||||
const effectiveGroupAllowFrom = normalizeAllowListLower([
|
|
||||||
...groupAllowFrom,
|
|
||||||
...storeAllowFrom,
|
|
||||||
]);
|
|
||||||
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
|
|
||||||
|
|
||||||
if (isDirectMessage) {
|
|
||||||
if (!dmEnabled || dmPolicy === "disabled") return;
|
|
||||||
if (dmPolicy !== "open") {
|
|
||||||
const allowMatch = resolveMatrixAllowListMatch({
|
|
||||||
allowList: effectiveAllowFrom,
|
|
||||||
userId: senderId,
|
|
||||||
userName: senderName,
|
|
||||||
});
|
|
||||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
|
||||||
if (!allowMatch.allowed) {
|
|
||||||
if (dmPolicy === "pairing") {
|
|
||||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
||||||
channel: "matrix",
|
|
||||||
id: senderId,
|
|
||||||
meta: { name: senderName },
|
|
||||||
});
|
|
||||||
if (created) {
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await sendMessageMatrix(
|
|
||||||
`room:${roomId}`,
|
|
||||||
[
|
|
||||||
"Clawdbot: access not configured.",
|
|
||||||
"",
|
|
||||||
`Pairing code: ${code}`,
|
|
||||||
"",
|
|
||||||
"Ask the bot owner to approve with:",
|
|
||||||
"clawdbot pairing approve matrix <code>",
|
|
||||||
].join("\n"),
|
|
||||||
{ client },
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dmPolicy !== "pairing") {
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomUsers = roomConfig?.users ?? [];
|
|
||||||
if (isRoom && roomUsers.length > 0) {
|
|
||||||
const userMatch = resolveMatrixAllowListMatch({
|
|
||||||
allowList: normalizeAllowListLower(roomUsers),
|
|
||||||
userId: senderId,
|
|
||||||
userName: senderName,
|
|
||||||
});
|
|
||||||
if (!userMatch.allowed) {
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
|
||||||
userMatch,
|
|
||||||
)})`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
|
|
||||||
const groupAllowMatch = resolveMatrixAllowListMatch({
|
|
||||||
allowList: effectiveGroupAllowFrom,
|
|
||||||
userId: senderId,
|
|
||||||
userName: senderName,
|
|
||||||
});
|
|
||||||
if (!groupAllowMatch.allowed) {
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
|
||||||
groupAllowMatch,
|
|
||||||
)})`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isRoom) {
|
|
||||||
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawBody = locationPayload?.text
|
|
||||||
?? (typeof content.body === "string" ? content.body.trim() : "");
|
|
||||||
let media: {
|
|
||||||
path: string;
|
|
||||||
contentType?: string;
|
|
||||||
placeholder: string;
|
|
||||||
} | null = null;
|
|
||||||
const contentUrl =
|
|
||||||
"url" in content && typeof content.url === "string" ? content.url : undefined;
|
|
||||||
const contentFile =
|
|
||||||
"file" in content && content.file && typeof content.file === "object"
|
|
||||||
? (content.file as EncryptedFile)
|
|
||||||
: undefined;
|
|
||||||
const mediaUrl = contentUrl ?? contentFile?.url;
|
|
||||||
if (!rawBody && !mediaUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType =
|
|
||||||
"info" in content && content.info && "mimetype" in content.info
|
|
||||||
? (content.info as { mimetype?: string }).mimetype
|
|
||||||
: undefined;
|
|
||||||
if (mediaUrl?.startsWith("mxc://")) {
|
|
||||||
try {
|
|
||||||
media = await downloadMatrixMedia({
|
|
||||||
client,
|
|
||||||
mxcUrl: mediaUrl,
|
|
||||||
contentType,
|
|
||||||
maxBytes: mediaMaxBytes,
|
|
||||||
file: contentFile,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyText = rawBody || media?.placeholder || "";
|
|
||||||
if (!bodyText) return;
|
|
||||||
|
|
||||||
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
|
||||||
content,
|
|
||||||
userId: selfUserId,
|
|
||||||
text: bodyText,
|
|
||||||
mentionRegexes,
|
|
||||||
});
|
|
||||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
||||||
cfg,
|
|
||||||
surface: "matrix",
|
|
||||||
});
|
|
||||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
||||||
const senderAllowedForCommands = resolveMatrixAllowListMatches({
|
|
||||||
allowList: effectiveAllowFrom,
|
|
||||||
userId: senderId,
|
|
||||||
userName: senderName,
|
|
||||||
});
|
|
||||||
const senderAllowedForGroup = groupAllowConfigured
|
|
||||||
? resolveMatrixAllowListMatches({
|
|
||||||
allowList: effectiveGroupAllowFrom,
|
|
||||||
userId: senderId,
|
|
||||||
userName: senderName,
|
|
||||||
})
|
|
||||||
: false;
|
|
||||||
const senderAllowedForRoomUsers =
|
|
||||||
isRoom && roomUsers.length > 0
|
|
||||||
? resolveMatrixAllowListMatches({
|
|
||||||
allowList: normalizeAllowListLower(roomUsers),
|
|
||||||
userId: senderId,
|
|
||||||
userName: senderName,
|
|
||||||
})
|
|
||||||
: false;
|
|
||||||
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
||||||
useAccessGroups,
|
|
||||||
authorizers: [
|
|
||||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
||||||
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
|
|
||||||
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
isRoom &&
|
|
||||||
allowTextCommands &&
|
|
||||||
core.channel.text.hasControlCommand(bodyText, cfg) &&
|
|
||||||
!commandAuthorized
|
|
||||||
) {
|
|
||||||
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const shouldRequireMention = isRoom
|
|
||||||
? roomConfig?.autoReply === true
|
|
||||||
? false
|
|
||||||
: roomConfig?.autoReply === false
|
|
||||||
? true
|
|
||||||
: typeof roomConfig?.requireMention === "boolean"
|
|
||||||
? roomConfig?.requireMention
|
|
||||||
: true
|
|
||||||
: false;
|
|
||||||
const shouldBypassMention =
|
|
||||||
allowTextCommands &&
|
|
||||||
isRoom &&
|
|
||||||
shouldRequireMention &&
|
|
||||||
!wasMentioned &&
|
|
||||||
!hasExplicitMention &&
|
|
||||||
commandAuthorized &&
|
|
||||||
core.channel.text.hasControlCommand(bodyText);
|
|
||||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
|
||||||
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageId = event.event_id ?? "";
|
|
||||||
const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
|
|
||||||
const threadRootId = resolveMatrixThreadRootId({ event, content });
|
|
||||||
const threadTarget = resolveMatrixThreadTarget({
|
|
||||||
threadReplies,
|
|
||||||
messageId,
|
|
||||||
threadRootId,
|
|
||||||
isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
|
||||||
cfg,
|
|
||||||
channel: "matrix",
|
|
||||||
peer: {
|
|
||||||
kind: isDirectMessage ? "dm" : "channel",
|
|
||||||
id: isDirectMessage ? senderId : roomId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
|
||||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
||||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
||||||
storePath,
|
|
||||||
sessionKey: route.sessionKey,
|
|
||||||
});
|
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
|
||||||
channel: "Matrix",
|
|
||||||
from: envelopeFrom,
|
|
||||||
timestamp: eventTs ?? undefined,
|
|
||||||
previousTimestamp,
|
|
||||||
envelope: envelopeOptions,
|
|
||||||
body: textWithId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
||||||
Body: body,
|
|
||||||
RawBody: bodyText,
|
|
||||||
CommandBody: bodyText,
|
|
||||||
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
|
|
||||||
To: `room:${roomId}`,
|
|
||||||
SessionKey: route.sessionKey,
|
|
||||||
AccountId: route.accountId,
|
|
||||||
ChatType: isDirectMessage ? "direct" : "channel",
|
|
||||||
ConversationLabel: envelopeFrom,
|
|
||||||
SenderName: senderName,
|
|
||||||
SenderId: senderId,
|
|
||||||
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
|
|
||||||
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
|
||||||
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
|
|
||||||
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
|
||||||
Provider: "matrix" as const,
|
|
||||||
Surface: "matrix" as const,
|
|
||||||
WasMentioned: isRoom ? wasMentioned : undefined,
|
|
||||||
MessageSid: messageId,
|
|
||||||
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
|
|
||||||
MessageThreadId: threadTarget,
|
|
||||||
Timestamp: eventTs ?? undefined,
|
|
||||||
MediaPath: media?.path,
|
|
||||||
MediaType: media?.contentType,
|
|
||||||
MediaUrl: media?.path,
|
|
||||||
...(locationPayload?.context ?? {}),
|
|
||||||
CommandAuthorized: commandAuthorized,
|
|
||||||
CommandSource: "text" as const,
|
|
||||||
OriginatingChannel: "matrix" as const,
|
|
||||||
OriginatingTo: `room:${roomId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
void core.channel.session.recordSessionMetaFromInbound({
|
|
||||||
storePath,
|
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
||||||
ctx: ctxPayload,
|
|
||||||
}).catch((err) => {
|
|
||||||
logger.warn(
|
|
||||||
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
|
||||||
"failed updating session meta",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDirectMessage) {
|
|
||||||
await core.channel.session.updateLastRoute({
|
|
||||||
storePath,
|
|
||||||
sessionKey: route.mainSessionKey,
|
|
||||||
channel: "matrix",
|
|
||||||
to: `room:${roomId}`,
|
|
||||||
accountId: route.accountId,
|
|
||||||
ctx: ctxPayload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
|
||||||
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
|
||||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
||||||
const shouldAckReaction = () => {
|
|
||||||
if (!ackReaction) return false;
|
|
||||||
if (ackScope === "all") return true;
|
|
||||||
if (ackScope === "direct") return isDirectMessage;
|
|
||||||
if (ackScope === "group-all") return isRoom;
|
|
||||||
if (ackScope === "group-mentions") {
|
|
||||||
if (!isRoom) return false;
|
|
||||||
if (!shouldRequireMention) return false;
|
|
||||||
return wasMentioned || shouldBypassMention;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if (shouldAckReaction() && messageId) {
|
|
||||||
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
|
||||||
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const replyTarget = ctxPayload.To;
|
|
||||||
if (!replyTarget) {
|
|
||||||
runtime.error?.("matrix: missing reply target");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageId) {
|
|
||||||
sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let didSendReply = false;
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
||||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
||||||
deliver: async (payload) => {
|
|
||||||
await deliverMatrixReplies({
|
|
||||||
replies: [payload],
|
|
||||||
roomId,
|
|
||||||
client,
|
|
||||||
runtime,
|
|
||||||
textLimit,
|
|
||||||
replyToMode,
|
|
||||||
threadId: threadTarget,
|
|
||||||
});
|
|
||||||
didSendReply = true;
|
|
||||||
},
|
|
||||||
onError: (err, info) => {
|
|
||||||
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
|
||||||
},
|
|
||||||
onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
|
|
||||||
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions: {
|
|
||||||
...replyOptions,
|
|
||||||
skillFilter: roomConfig?.skills,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
markDispatchIdle();
|
|
||||||
if (!queuedFinal) return;
|
|
||||||
didSendReply = true;
|
|
||||||
const finalCount = counts.final;
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
|
||||||
);
|
|
||||||
if (didSendReply) {
|
|
||||||
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
|
||||||
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
|
|
||||||
sessionKey: route.sessionKey,
|
|
||||||
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
runtime.error?.(`matrix handler failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// matrix-bot-sdk uses on("room.message", handler)
|
|
||||||
client.on("room.message", handleRoomMessage);
|
|
||||||
|
|
||||||
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
|
||||||
const eventId = event?.event_id ?? "unknown";
|
|
||||||
const eventType = event?.type ?? "unknown";
|
|
||||||
logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
registerMatrixMonitorEvents({
|
||||||
const eventId = event?.event_id ?? "unknown";
|
client,
|
||||||
const eventType = event?.type ?? "unknown";
|
auth,
|
||||||
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
logVerboseMessage,
|
||||||
});
|
warnedEncryptedRooms,
|
||||||
|
warnedCryptoMissingRooms,
|
||||||
// Handle failed E2EE decryption
|
logger,
|
||||||
client.on("room.failed_decryption", async (roomId: string, event: MatrixRawEvent, error: Error) => {
|
formatNativeDependencyHint: core.system.formatNativeDependencyHint,
|
||||||
logger.warn({ roomId, eventId: event.event_id, error: error.message }, "Failed to decrypt message");
|
onRoomMessage: handleRoomMessage,
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
|
|
||||||
const eventId = event?.event_id ?? "unknown";
|
|
||||||
const sender = event?.sender ?? "unknown";
|
|
||||||
const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
|
|
||||||
const eventId = event?.event_id ?? "unknown";
|
|
||||||
logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
|
|
||||||
const eventType = event?.type ?? "unknown";
|
|
||||||
if (eventType === EventType.RoomMessageEncrypted) {
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
|
|
||||||
);
|
|
||||||
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
|
|
||||||
warnedEncryptedRooms.add(roomId);
|
|
||||||
const warning =
|
|
||||||
"matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt";
|
|
||||||
logger.warn({ roomId }, warning);
|
|
||||||
}
|
|
||||||
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
|
|
||||||
warnedCryptoMissingRooms.add(roomId);
|
|
||||||
const warning =
|
|
||||||
"matrix: encryption enabled but crypto is unavailable; install @matrix-org/matrix-sdk-crypto-nodejs and restart";
|
|
||||||
logger.warn({ roomId }, warning);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (eventType === EventType.RoomMember) {
|
|
||||||
const membership = (event?.content as { membership?: string } | undefined)?.membership;
|
|
||||||
const stateKey = (event as { state_key?: string }).state_key ?? "";
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logVerboseMessage("matrix: starting client");
|
logVerboseMessage("matrix: starting client");
|
||||||
|
|||||||
Loaded 100 of 273 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user