Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
206071be69 | ||
|
|
98f399b7fd | ||
|
|
38cc2a3288 | ||
|
|
28c49db494 | ||
|
|
026e6c4df4 | ||
|
|
841dfc693e | ||
|
|
f38278d919 | ||
|
|
9545edcb49 | ||
|
|
f3554a3ad8 | ||
|
|
b30359e9cd | ||
|
|
d3898ee8df | ||
|
|
d1c2fc4bc8 | ||
|
|
a5a3ab958f | ||
|
|
165861e78d | ||
|
|
e7c355ee85 | ||
|
|
052a58f2f7 | ||
|
|
5ff56ffb4e | ||
|
|
9a3dd626a1 | ||
|
|
aae4b2952f | ||
|
|
aec622fe63 | ||
|
|
e6287270d9 | ||
|
|
caf9dec89c | ||
|
|
438a41f91f | ||
|
|
a0cd295c0f | ||
|
|
14d3d72bcc | ||
|
|
03916ed10e | ||
|
|
5bd55037e4 | ||
|
|
ec51bb700c | ||
|
|
051d518078 | ||
|
|
294886b54f | ||
|
|
6629e31789 | ||
|
|
9d7087168f | ||
|
|
daceeaa24c | ||
|
|
778800be70 | ||
|
|
1b973caf7a | ||
|
|
ea775025c0 | ||
|
|
0b2830470c | ||
|
|
e81ca7ab00 | ||
|
|
27acfa59c5 | ||
|
|
b333c4a994 | ||
|
|
23f7dd8b25 | ||
|
|
77d9451712 | ||
|
|
a4fc2b4536 | ||
|
|
20a7dd8a80 | ||
|
|
450d2d25e2 | ||
|
|
df024afc97 | ||
|
|
12168dc64f | ||
|
|
4232081fcb | ||
|
|
17f3635109 | ||
|
|
9206d21c76 | ||
|
|
96be166bd6 | ||
|
|
d8abd53a1d | ||
|
|
eff292eda4 | ||
|
|
c74551c2ae | ||
|
|
48b0d08493 | ||
|
|
dd38185e6c | ||
|
|
ec01e5c7e6 | ||
|
|
e447233533 | ||
|
|
00bcb01bb4 | ||
|
|
458850483a | ||
|
|
76bae8da40 | ||
|
|
c33c0629ec | ||
|
|
e5ea8a0d22 | ||
|
|
e083f678fd | ||
|
|
51dfd6efdb | ||
|
|
4fad74738a | ||
|
|
69f0469530 | ||
|
|
eb1ee36f59 | ||
|
|
b341512564 | ||
|
|
6734f2d71c | ||
|
|
e12abf3114 | ||
|
|
4ad9622efb | ||
|
|
2dfd3b9a81 | ||
|
|
7b6cbf5869 | ||
|
|
8686b3b951 | ||
|
|
2e7e135bc0 | ||
|
|
c287664923 | ||
|
|
e4f9555f21 | ||
|
|
7e59c15496 | ||
|
|
e74fd9196c | ||
|
|
079af0d0b0 | ||
|
|
f6abe62e5f | ||
|
|
bd8f4b052d | ||
|
|
929d50b7d1 | ||
|
|
716546824f |
No files matched your search
+21
-1
@@ -9,6 +9,8 @@ Docs: https://docs.clawd.bot
|
|||||||
- 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`.
|
- 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: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
|
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
|
||||||
|
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
||||||
- 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.
|
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
||||||
@@ -16,14 +18,32 @@ Docs: https://docs.clawd.bot
|
|||||||
- 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.
|
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
||||||
|
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
||||||
|
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
|
||||||
|
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
|
||||||
|
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
|
||||||
### Fixes
|
### Fixes
|
||||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||||
|
- Telegram: answer callback queries immediately to prevent retries. (#1349) — thanks @siddhantjain.
|
||||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
- 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.
|
- 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.
|
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||||
|
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
||||||
|
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
||||||
|
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||||
|
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
||||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||||
|
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
|
||||||
|
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
|
||||||
|
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
||||||
|
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||||
|
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
|
||||||
|
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
||||||
|
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
|
||||||
|
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
|
||||||
- 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).
|
||||||
|
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
|
||||||
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -479,27 +479,27 @@ Core contributors:
|
|||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
|
||||||
<a href="https://github.com/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/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
|
||||||
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/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/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/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/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
|
||||||
<a href="https://github.com/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/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||||
<a href="https://github.com/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/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
|
||||||
<a href="https://github.com/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/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
|
||||||
<a href="https://github.com/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/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/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/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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/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/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/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/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/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/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/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||||
<a href="https://github.com/search?q=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/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||||
<a href="https://github.com/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/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/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/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/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/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/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||||
<a href="https://github.com/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/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
|
||||||
<a href="https://github.com/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/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=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/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/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/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=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/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/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
|
||||||
<a href="https://github.com/search?q=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=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||||
<a href="https://github.com/search?q=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/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||||
<a href="https://github.com/search?q=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/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
|
||||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
<a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
|
|||||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||||
@Bindable var store: ChannelsStore
|
@Bindable var store: ChannelsStore
|
||||||
@State private var hasLoaded = false
|
@State private var hasLoaded = false
|
||||||
|
@State private var activeSectionKey: String?
|
||||||
|
@State private var activeSubsection: SubsectionSelection?
|
||||||
|
|
||||||
init(store: ChannelsStore = .shared) {
|
init(store: ChannelsStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
HStack(spacing: 16) {
|
||||||
self.content
|
self.sidebar
|
||||||
|
self.detail
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.task {
|
.task {
|
||||||
guard !self.hasLoaded else { return }
|
guard !self.hasLoaded else { return }
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
|
|||||||
await self.store.loadConfigSchema()
|
await self.store.loadConfigSchema()
|
||||||
await self.store.loadConfig()
|
await self.store.loadConfig()
|
||||||
}
|
}
|
||||||
|
.onAppear { self.ensureSelection() }
|
||||||
|
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||||
|
if !loading { self.ensureSelection() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConfigSettings {
|
extension ConfigSettings {
|
||||||
private var content: some View {
|
private enum SubsectionSelection: Hashable {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
case all
|
||||||
self.header
|
case key(String)
|
||||||
if let status = self.store.configStatus {
|
}
|
||||||
Text(status)
|
|
||||||
.font(.callout)
|
private struct ConfigSection: Identifiable {
|
||||||
.foregroundStyle(.secondary)
|
let key: String
|
||||||
}
|
let label: String
|
||||||
self.actionRow
|
let help: String?
|
||||||
Group {
|
let node: ConfigSchemaNode
|
||||||
if self.store.configSchemaLoading {
|
|
||||||
ProgressView().controlSize(.small)
|
var id: String { self.key }
|
||||||
} else if let schema = self.store.configSchema {
|
}
|
||||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
|
||||||
.disabled(self.isNixMode)
|
private struct ConfigSubsection: Identifiable {
|
||||||
} else {
|
let key: String
|
||||||
Text("Schema unavailable.")
|
let label: String
|
||||||
|
let help: String?
|
||||||
|
let node: ConfigSchemaNode
|
||||||
|
let path: ConfigPath
|
||||||
|
|
||||||
|
var id: String { self.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sections: [ConfigSection] {
|
||||||
|
guard let schema = self.store.configSchema else { return [] }
|
||||||
|
return self.resolveSections(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeSection: ConfigSection? {
|
||||||
|
self.sections.first { $0.key == self.activeSectionKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebar: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
if self.sections.isEmpty {
|
||||||
|
Text("No config sections available.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
ForEach(self.sections) { section in
|
||||||
|
self.sidebarRow(section)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.store.configDirty, !self.isNixMode {
|
.padding(.vertical, 10)
|
||||||
Text("Unsaved changes")
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if self.store.configSchemaLoading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else if let section = self.activeSection {
|
||||||
|
self.sectionDetail(section)
|
||||||
|
} else if self.store.configSchema != nil {
|
||||||
|
self.emptyDetail
|
||||||
|
} else {
|
||||||
|
Text("Schema unavailable.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyDetail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
self.header
|
||||||
|
Text("Select a config section to view settings.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 18)
|
.padding(.vertical, 18)
|
||||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
}
|
||||||
|
|
||||||
|
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
self.header
|
||||||
|
if let status = self.store.configStatus {
|
||||||
|
Text(status)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
self.actionRow
|
||||||
|
self.sectionHeader(section)
|
||||||
|
self.subsectionNav(section)
|
||||||
|
self.sectionForm(section)
|
||||||
|
if self.store.configDirty, !self.isNixMode {
|
||||||
|
Text("Unsaved changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -71,6 +158,18 @@ extension ConfigSettings {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ section: ConfigSection) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(section.label)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
if let help = section.help {
|
||||||
|
Text(help)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var actionRow: some View {
|
private var actionRow: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button("Reload") {
|
Button("Reload") {
|
||||||
@@ -85,6 +184,204 @@ extension ConfigSettings {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sidebarRow(_ section: ConfigSection) -> some View {
|
||||||
|
let isSelected = self.activeSectionKey == section.key
|
||||||
|
return Button {
|
||||||
|
self.selectSection(section)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(section.label)
|
||||||
|
if let help = section.help {
|
||||||
|
Text(help)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.background(Color.clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func subsectionNav(_ section: ConfigSection) -> some View {
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
if subsections.isEmpty {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
self.subsectionButton(
|
||||||
|
title: "All",
|
||||||
|
isSelected: self.activeSubsection == .all)
|
||||||
|
{
|
||||||
|
self.activeSubsection = .all
|
||||||
|
}
|
||||||
|
ForEach(subsections) { subsection in
|
||||||
|
self.subsectionButton(
|
||||||
|
title: subsection.label,
|
||||||
|
isSelected: self.activeSubsection == .key(subsection.key))
|
||||||
|
{
|
||||||
|
self.activeSubsection = .key(subsection.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subsectionButton(
|
||||||
|
title: String,
|
||||||
|
isSelected: Bool,
|
||||||
|
action: @escaping () -> Void) -> some View
|
||||||
|
{
|
||||||
|
Button(action: action) {
|
||||||
|
Text(title)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.foregroundStyle(isSelected ? Color.accentColor : .primary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||||
|
let subsection = self.activeSubsection
|
||||||
|
let defaultPath: ConfigPath = [.key(section.key)]
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
let resolved: (ConfigSchemaNode, ConfigPath) = {
|
||||||
|
if case let .key(key) = subsection,
|
||||||
|
let match = subsections.first(where: { $0.key == key })
|
||||||
|
{
|
||||||
|
return (match.node, match.path)
|
||||||
|
}
|
||||||
|
return (self.resolvedSchemaNode(section.node), defaultPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
|
||||||
|
.disabled(self.isNixMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSelection() {
|
||||||
|
guard let schema = self.store.configSchema else { return }
|
||||||
|
let sections = self.resolveSections(schema)
|
||||||
|
guard !sections.isEmpty else { return }
|
||||||
|
|
||||||
|
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
|
||||||
|
if self.activeSectionKey != active.key {
|
||||||
|
self.activeSectionKey = active.key
|
||||||
|
}
|
||||||
|
self.ensureSubsection(for: active)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSubsection(for section: ConfigSection) {
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
guard !subsections.isEmpty else {
|
||||||
|
self.activeSubsection = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self.activeSubsection {
|
||||||
|
case .all:
|
||||||
|
return
|
||||||
|
case let .key(key):
|
||||||
|
if subsections.contains(where: { $0.key == key }) { return }
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if let first = subsections.first {
|
||||||
|
self.activeSubsection = .key(first.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectSection(_ section: ConfigSection) {
|
||||||
|
guard self.activeSectionKey != section.key else { return }
|
||||||
|
self.activeSectionKey = section.key
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
if let first = subsections.first {
|
||||||
|
self.activeSubsection = .key(first.key)
|
||||||
|
} else {
|
||||||
|
self.activeSubsection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
|
||||||
|
let node = self.resolvedSchemaNode(root)
|
||||||
|
let hints = self.store.configUiHints
|
||||||
|
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||||
|
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
|
||||||
|
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
|
||||||
|
if orderA != orderB { return orderA < orderB }
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.compactMap { key in
|
||||||
|
guard let child = node.properties[key] else { return nil }
|
||||||
|
let path: ConfigPath = [.key(key)]
|
||||||
|
let hint = hintForPath(path, hints: hints)
|
||||||
|
let label = hint?.label
|
||||||
|
?? child.title
|
||||||
|
?? self.humanize(key)
|
||||||
|
let help = hint?.help ?? child.description
|
||||||
|
return ConfigSection(key: key, label: label, help: help, node: child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
|
||||||
|
let node = self.resolvedSchemaNode(section.node)
|
||||||
|
guard node.schemaType == "object" else { return [] }
|
||||||
|
let hints = self.store.configUiHints
|
||||||
|
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||||
|
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
|
||||||
|
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
|
||||||
|
if orderA != orderB { return orderA < orderB }
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.compactMap { key in
|
||||||
|
guard let child = node.properties[key] else { return nil }
|
||||||
|
let path: ConfigPath = [.key(section.key), .key(key)]
|
||||||
|
let hint = hintForPath(path, hints: hints)
|
||||||
|
let label = hint?.label
|
||||||
|
?? child.title
|
||||||
|
?? self.humanize(key)
|
||||||
|
let help = hint?.help ?? child.description
|
||||||
|
return ConfigSubsection(
|
||||||
|
key: key,
|
||||||
|
label: label,
|
||||||
|
help: help,
|
||||||
|
node: child,
|
||||||
|
path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
|
||||||
|
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
|
||||||
|
if !variants.isEmpty {
|
||||||
|
let nonNull = variants.filter { !$0.isNullSchema }
|
||||||
|
if nonNull.count == 1, let only = nonNull.first { return only }
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
private func humanize(_ key: String) -> String {
|
||||||
|
key.replacingOccurrences(of: "_", with: " ")
|
||||||
|
.replacingOccurrences(of: "-", with: " ")
|
||||||
|
.capitalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ConfigSettings_Previews: PreviewProvider {
|
struct ConfigSettings_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ private enum ExecHostExecutor {
|
|||||||
security: context.security,
|
security: context.security,
|
||||||
allowlistMatch: context.allowlistMatch,
|
allowlistMatch: context.allowlistMatch,
|
||||||
skillAllow: context.skillAllow),
|
skillAllow: context.skillAllow),
|
||||||
approvalDecision == nil
|
approvalDecision == nil
|
||||||
{
|
{
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
ExecApprovalPromptRequest(
|
ExecApprovalPromptRequest(
|
||||||
|
|||||||
@@ -148,6 +148,27 @@ actor GatewayConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nsError = lastError as NSError
|
||||||
|
if nsError.domain == URLError.errorDomain,
|
||||||
|
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
|
||||||
|
{
|
||||||
|
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
|
||||||
|
for delayMs in [150, 400, 900] {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||||
|
do {
|
||||||
|
guard let client = self.client else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||||
|
}
|
||||||
|
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw lastError
|
throw lastError
|
||||||
case .remote:
|
case .remote:
|
||||||
let nsError = error as NSError
|
let nsError = error as NSError
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ actor GatewayEndpointStore {
|
|||||||
return configToken
|
return configToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isRemote {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!token.isEmpty
|
!token.isEmpty
|
||||||
{
|
{
|
||||||
@@ -469,6 +473,35 @@ actor GatewayEndpointStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
|
||||||
|
let mode = await self.deps.mode()
|
||||||
|
guard mode == .local else { return nil }
|
||||||
|
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
guard bind == "auto" else { return nil }
|
||||||
|
|
||||||
|
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||||
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
|
|
||||||
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let port = self.deps.localPort()
|
||||||
|
let token = self.deps.token()
|
||||||
|
let password = self.deps.password()
|
||||||
|
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
|
||||||
|
|
||||||
|
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
|
||||||
|
self.setState(.ready(mode: .local, url: url, token: token, password: password))
|
||||||
|
return (url, token, password)
|
||||||
|
}
|
||||||
|
|
||||||
private static func resolveGatewayBindMode(
|
private static func resolveGatewayBindMode(
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String]) -> String?
|
env: [String: String]) -> String?
|
||||||
@@ -524,12 +557,17 @@ actor GatewayEndpointStore {
|
|||||||
tailscaleIP: String?) -> String
|
tailscaleIP: String?) -> String
|
||||||
{
|
{
|
||||||
switch bindMode {
|
switch bindMode {
|
||||||
case "tailnet", "auto":
|
case "tailnet":
|
||||||
tailscaleIP ?? "127.0.0.1"
|
return tailscaleIP ?? "127.0.0.1"
|
||||||
|
case "auto":
|
||||||
|
if let tailscaleIP, !tailscaleIP.isEmpty {
|
||||||
|
return tailscaleIP
|
||||||
|
}
|
||||||
|
return "127.0.0.1"
|
||||||
case "custom":
|
case "custom":
|
||||||
customBindHost ?? "127.0.0.1"
|
return customBindHost ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
"127.0.0.1"
|
return "127.0.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,11 +638,12 @@ extension GatewayEndpointStore {
|
|||||||
|
|
||||||
static func _testResolveLocalGatewayHost(
|
static func _testResolveLocalGatewayHost(
|
||||||
bindMode: String?,
|
bindMode: String?,
|
||||||
tailscaleIP: String?) -> String
|
tailscaleIP: String?,
|
||||||
|
customBindHost: String? = nil) -> String
|
||||||
{
|
{
|
||||||
self.resolveLocalGatewayHost(
|
self.resolveLocalGatewayHost(
|
||||||
bindMode: bindMode,
|
bindMode: bindMode,
|
||||||
customBindHost: nil,
|
customBindHost: customBindHost,
|
||||||
tailscaleIP: tailscaleIP)
|
tailscaleIP: tailscaleIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import AppKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import os
|
import os
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Manages Tailscale integration and status checking.
|
/// Manages Tailscale integration and status checking.
|
||||||
@Observable
|
@Observable
|
||||||
@@ -101,15 +104,12 @@ final class TailscaleService {
|
|||||||
|
|
||||||
func checkTailscaleStatus() async {
|
func checkTailscaleStatus() async {
|
||||||
self.isInstalled = self.checkAppInstallation()
|
self.isInstalled = self.checkAppInstallation()
|
||||||
guard self.isInstalled else {
|
if !self.isInstalled {
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
self.tailscaleHostname = nil
|
self.tailscaleHostname = nil
|
||||||
self.tailscaleIP = nil
|
self.tailscaleIP = nil
|
||||||
self.statusError = "Tailscale is not installed"
|
self.statusError = "Tailscale is not installed"
|
||||||
return
|
} else if let apiResponse = await fetchTailscaleStatus() {
|
||||||
}
|
|
||||||
|
|
||||||
if let apiResponse = await fetchTailscaleStatus() {
|
|
||||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
self.isRunning = apiResponse.status.lowercased() == "running"
|
||||||
|
|
||||||
if self.isRunning {
|
if self.isRunning {
|
||||||
@@ -138,6 +138,15 @@ final class TailscaleService {
|
|||||||
self.statusError = "Please start the Tailscale app"
|
self.statusError = "Please start the Tailscale app"
|
||||||
self.logger.info("Tailscale API not responding; app likely not running")
|
self.logger.info("Tailscale API not responding; app likely not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
|
||||||
|
self.tailscaleIP = fallback
|
||||||
|
if !self.isRunning {
|
||||||
|
self.isRunning = true
|
||||||
|
}
|
||||||
|
self.statusError = nil
|
||||||
|
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func openTailscaleApp() {
|
func openTailscaleApp() {
|
||||||
@@ -163,4 +172,46 @@ final class TailscaleService {
|
|||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func isTailnetIPv4(_ address: String) -> Bool {
|
||||||
|
let parts = address.split(separator: ".")
|
||||||
|
guard parts.count == 4 else { return false }
|
||||||
|
let octets = parts.compactMap { Int($0) }
|
||||||
|
guard octets.count == 4 else { return false }
|
||||||
|
let a = octets[0]
|
||||||
|
let b = octets[1]
|
||||||
|
return a == 100 && b >= 64 && b <= 127
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func detectTailnetIPv4() -> String? {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
if Self.isTailnetIPv4(ip) { return ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
struct ConnectOptions {
|
struct ConnectOptions {
|
||||||
var url: String?
|
var url: String?
|
||||||
var token: String?
|
var token: String?
|
||||||
var password: String?
|
var password: String?
|
||||||
var mode: String?
|
var mode: String?
|
||||||
var timeoutMs: Int = 15_000
|
var timeoutMs: Int = 15000
|
||||||
var json: Bool = false
|
var json: Bool = false
|
||||||
var probe: Bool = false
|
var probe: Bool = false
|
||||||
var clientId: String = "clawdbot-macos"
|
var clientId: String = "clawdbot-macos"
|
||||||
@@ -19,53 +22,43 @@ struct ConnectOptions {
|
|||||||
|
|
||||||
static func parse(_ args: [String]) -> ConnectOptions {
|
static func parse(_ args: [String]) -> ConnectOptions {
|
||||||
var opts = ConnectOptions()
|
var opts = ConnectOptions()
|
||||||
|
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
|
||||||
|
"-h": { $0.help = true },
|
||||||
|
"--help": { $0.help = true },
|
||||||
|
"--json": { $0.json = true },
|
||||||
|
"--probe": { $0.probe = true },
|
||||||
|
]
|
||||||
|
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
|
||||||
|
"--url": { $0.url = $1 },
|
||||||
|
"--token": { $0.token = $1 },
|
||||||
|
"--password": { $0.password = $1 },
|
||||||
|
"--mode": { $0.mode = $1 },
|
||||||
|
"--timeout": { opts, raw in
|
||||||
|
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||||
|
opts.timeoutMs = max(250, parsed)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"--client-id": { $0.clientId = $1 },
|
||||||
|
"--client-mode": { $0.clientMode = $1 },
|
||||||
|
"--display-name": { $0.displayName = $1 },
|
||||||
|
"--role": { $0.role = $1 },
|
||||||
|
"--scopes": { opts, raw in
|
||||||
|
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
},
|
||||||
|
]
|
||||||
var i = 0
|
var i = 0
|
||||||
while i < args.count {
|
while i < args.count {
|
||||||
let arg = args[i]
|
let arg = args[i]
|
||||||
switch arg {
|
if let handler = flagHandlers[arg] {
|
||||||
case "-h", "--help":
|
handler(&opts)
|
||||||
opts.help = true
|
i += 1
|
||||||
case "--json":
|
continue
|
||||||
opts.json = true
|
}
|
||||||
case "--probe":
|
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
|
||||||
opts.probe = true
|
handler(&opts, value)
|
||||||
case "--url":
|
i += 1
|
||||||
opts.url = self.nextValue(args, index: &i)
|
continue
|
||||||
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
|
i += 1
|
||||||
}
|
}
|
||||||
@@ -254,8 +247,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
|
|
||||||
if resolvedMode == "remote" {
|
if resolvedMode == "remote" {
|
||||||
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!raw.isEmpty else {
|
!raw.isEmpty
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
||||||
}
|
}
|
||||||
guard let url = URL(string: raw) else {
|
guard let url = URL(string: raw) else {
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||||
@@ -268,9 +265,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
let port = config.port ?? 18789
|
let port = config.port ?? 18789
|
||||||
let host = "127.0.0.1"
|
let host = resolveLocalHost(bind: config.bind)
|
||||||
guard let url = URL(string: "ws://\(host):\(port)") else {
|
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
||||||
}
|
}
|
||||||
return GatewayEndpoint(
|
return GatewayEndpoint(
|
||||||
url: url,
|
url: url,
|
||||||
@@ -280,7 +280,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
||||||
return try? resolveGatewayEndpoint(opts: opts, config: config)
|
try? resolveGatewayEndpoint(opts: opts, config: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||||
@@ -304,3 +304,56 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
|
|||||||
}
|
}
|
||||||
return config.password
|
return config.password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolveLocalHost(bind: String?) -> String {
|
||||||
|
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let tailnetIP = detectTailnetIPv4()
|
||||||
|
switch normalized {
|
||||||
|
case "tailnet", "auto":
|
||||||
|
return tailnetIP ?? "127.0.0.1"
|
||||||
|
default:
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectTailnetIPv4() -> String? {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
if isTailnetIPv4(ip) { return ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isTailnetIPv4(_ address: String) -> Bool {
|
||||||
|
let parts = address.split(separator: ".")
|
||||||
|
guard parts.count == 4 else { return false }
|
||||||
|
let octets = parts.compactMap { Int($0) }
|
||||||
|
guard octets.count == 4 else { return false }
|
||||||
|
let a = octets[0]
|
||||||
|
let b = octets[1]
|
||||||
|
return a == 100 && b >= 64 && b <= 127
|
||||||
|
}
|
||||||
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let replychannel: String?
|
public let replychannel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
|
public let threadid: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
replychannel: String?,
|
replychannel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
|
threadid: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.replychannel = replychannel
|
self.replychannel = replychannel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
|
self.threadid = threadid
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case replychannel = "replyChannel"
|
case replychannel = "replyChannel"
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
|
case threadid = "threadId"
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
public let includeglobal: Bool?
|
||||||
public let includeunknown: Bool?
|
public let includeunknown: Bool?
|
||||||
|
public let includederivedtitles: Bool?
|
||||||
|
public let includelastmessage: Bool?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
|
public let search: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
limit: Int?,
|
limit: Int?,
|
||||||
activeminutes: Int?,
|
activeminutes: Int?,
|
||||||
includeglobal: Bool?,
|
includeglobal: Bool?,
|
||||||
includeunknown: Bool?,
|
includeunknown: Bool?,
|
||||||
|
includederivedtitles: Bool?,
|
||||||
|
includelastmessage: Bool?,
|
||||||
label: String?,
|
label: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
agentid: String?
|
agentid: String?,
|
||||||
|
search: String?
|
||||||
) {
|
) {
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.activeminutes = activeminutes
|
self.activeminutes = activeminutes
|
||||||
self.includeglobal = includeglobal
|
self.includeglobal = includeglobal
|
||||||
self.includeunknown = includeunknown
|
self.includeunknown = includeunknown
|
||||||
|
self.includederivedtitles = includederivedtitles
|
||||||
|
self.includelastmessage = includelastmessage
|
||||||
self.label = label
|
self.label = label
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
|
self.search = search
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case limit
|
case limit
|
||||||
case activeminutes = "activeMinutes"
|
case activeminutes = "activeMinutes"
|
||||||
case includeglobal = "includeGlobal"
|
case includeglobal = "includeGlobal"
|
||||||
case includeunknown = "includeUnknown"
|
case includeunknown = "includeUnknown"
|
||||||
|
case includederivedtitles = "includeDerivedTitles"
|
||||||
|
case includelastmessage = "includeLastMessage"
|
||||||
case label
|
case label
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
|
case search
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import SwiftUI
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
|
||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ChannelsSettingsSmokeTests {
|
struct ChannelsSettingsSmokeTests {
|
||||||
@@ -17,8 +19,11 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
|
channelDetailLabels: nil,
|
||||||
|
channelSystemImages: nil,
|
||||||
|
channelMeta: nil,
|
||||||
channels: [
|
channels: [
|
||||||
"whatsapp": AnyCodable([
|
"whatsapp": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"linked": true,
|
"linked": true,
|
||||||
"authAgeMs": 86_400_000,
|
"authAgeMs": 86_400_000,
|
||||||
@@ -37,7 +42,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"lastEventAt": 1_700_000_060_000,
|
"lastEventAt": 1_700_000_060_000,
|
||||||
"lastError": "needs login",
|
"lastError": "needs login",
|
||||||
]),
|
]),
|
||||||
"telegram": AnyCodable([
|
"telegram": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"tokenSource": "env",
|
"tokenSource": "env",
|
||||||
"running": true,
|
"running": true,
|
||||||
@@ -52,7 +57,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_050_000,
|
"lastProbeAt": 1_700_000_050_000,
|
||||||
]),
|
]),
|
||||||
"signal": AnyCodable([
|
"signal": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"baseUrl": "http://127.0.0.1:8080",
|
"baseUrl": "http://127.0.0.1:8080",
|
||||||
"running": true,
|
"running": true,
|
||||||
@@ -65,7 +70,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_050_000,
|
"lastProbeAt": 1_700_000_050_000,
|
||||||
]),
|
]),
|
||||||
"imessage": AnyCodable([
|
"imessage": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastError": "not configured",
|
"lastError": "not configured",
|
||||||
@@ -100,15 +105,18 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
|
channelDetailLabels: nil,
|
||||||
|
channelSystemImages: nil,
|
||||||
|
channelMeta: nil,
|
||||||
channels: [
|
channels: [
|
||||||
"whatsapp": AnyCodable([
|
"whatsapp": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"linked": false,
|
"linked": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"connected": false,
|
"connected": false,
|
||||||
"reconnectAttempts": 0,
|
"reconnectAttempts": 0,
|
||||||
]),
|
]),
|
||||||
"telegram": AnyCodable([
|
"telegram": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastError": "bot missing",
|
"lastError": "bot missing",
|
||||||
@@ -120,7 +128,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_100_000,
|
"lastProbeAt": 1_700_000_100_000,
|
||||||
]),
|
]),
|
||||||
"signal": AnyCodable([
|
"signal": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"baseUrl": "http://127.0.0.1:8080",
|
"baseUrl": "http://127.0.0.1:8080",
|
||||||
"running": false,
|
"running": false,
|
||||||
@@ -133,7 +141,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_200_000,
|
"lastProbeAt": 1_700_000_200_000,
|
||||||
]),
|
]),
|
||||||
"imessage": AnyCodable([
|
"imessage": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastError": "not configured",
|
"lastError": "not configured",
|
||||||
|
|||||||
@@ -11,16 +11,19 @@ struct CronJobEditorSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorBuildsBodyForNewJob() {
|
@Test func cronJobEditorBuildsBodyForNewJob() {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
let view = CronJobEditor(
|
let view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
let job = CronJob(
|
let job = CronJob(
|
||||||
id: "job-1",
|
id: "job-1",
|
||||||
agentId: "ops",
|
agentId: "ops",
|
||||||
@@ -54,31 +57,36 @@ struct CronJobEditorSmokeTests {
|
|||||||
job: job,
|
job: job,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorExercisesBuilders() {
|
@Test func cronJobEditorExercisesBuilders() {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
var view = CronJobEditor(
|
var view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
view.exerciseForTesting()
|
view.exerciseForTesting()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
let view = CronJobEditor(
|
let view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
|
|
||||||
var root: [String: Any] = [:]
|
var root: [String: Any] = [:]
|
||||||
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
|
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
|
||||||
let raw = root["deleteAfterRun"] as? Bool
|
let raw = root["deleteAfterRun"] as? Bool
|
||||||
#expect(raw == true)
|
#expect(raw == true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|||||||
@@ -139,4 +139,40 @@ import Testing
|
|||||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||||
#expect(resolved.mode == .remote)
|
#expect(resolved.mode == .remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "auto",
|
||||||
|
tailscaleIP: "100.64.1.2")
|
||||||
|
#expect(host == "100.64.1.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "auto",
|
||||||
|
tailscaleIP: nil)
|
||||||
|
#expect(host == "127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "tailnet",
|
||||||
|
tailscaleIP: "100.64.1.5")
|
||||||
|
#expect(host == "100.64.1.5")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "tailnet",
|
||||||
|
tailscaleIP: nil)
|
||||||
|
#expect(host == "127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "custom",
|
||||||
|
tailscaleIP: "100.64.1.9",
|
||||||
|
customBindHost: "192.168.1.10")
|
||||||
|
#expect(host == "192.168.1.10")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,15 +7,17 @@ import Testing
|
|||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
struct LowCoverageHelperTests {
|
struct LowCoverageHelperTests {
|
||||||
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
@Test func anyCodableHelperAccessors() throws {
|
@Test func anyCodableHelperAccessors() throws {
|
||||||
let payload: [String: AnyCodable] = [
|
let payload: [String: ProtoAnyCodable] = [
|
||||||
"title": AnyCodable("Hello"),
|
"title": ProtoAnyCodable("Hello"),
|
||||||
"flag": AnyCodable(true),
|
"flag": ProtoAnyCodable(true),
|
||||||
"count": AnyCodable(3),
|
"count": ProtoAnyCodable(3),
|
||||||
"ratio": AnyCodable(1.25),
|
"ratio": ProtoAnyCodable(1.25),
|
||||||
"list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
|
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
|
||||||
]
|
]
|
||||||
let any = AnyCodable(payload)
|
let any = ProtoAnyCodable(payload)
|
||||||
let dict = try #require(any.dictionaryValue)
|
let dict = try #require(any.dictionaryValue)
|
||||||
#expect(dict["title"]?.stringValue == "Hello")
|
#expect(dict["title"]?.stringValue == "Hello")
|
||||||
#expect(dict["flag"]?.boolValue == true)
|
#expect(dict["flag"]?.boolValue == true)
|
||||||
@@ -76,31 +78,27 @@ struct LowCoverageHelperTests {
|
|||||||
#expect(result.stderr.contains("stderr-1999"))
|
#expect(result.stderr.contains("stderr-1999"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func pairedNodesStorePersists() async throws {
|
@Test func nodeInfoCodableRoundTrip() throws {
|
||||||
let dir = FileManager().temporaryDirectory
|
let info = NodeInfo(
|
||||||
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
let url = dir.appendingPathComponent("nodes.json")
|
|
||||||
let store = PairedNodesStore(fileURL: url)
|
|
||||||
await store.load()
|
|
||||||
#expect(await store.all().isEmpty)
|
|
||||||
|
|
||||||
let node = PairedNode(
|
|
||||||
nodeId: "node-1",
|
nodeId: "node-1",
|
||||||
displayName: "Node One",
|
displayName: "Node One",
|
||||||
platform: "macOS",
|
platform: "macOS",
|
||||||
version: "1.0",
|
version: "1.0",
|
||||||
|
coreVersion: "1.0-core",
|
||||||
|
uiVersion: "1.0-ui",
|
||||||
deviceFamily: "Mac",
|
deviceFamily: "Mac",
|
||||||
modelIdentifier: "MacBookPro",
|
modelIdentifier: "MacBookPro",
|
||||||
token: "token",
|
remoteIp: "192.168.1.2",
|
||||||
createdAtMs: 1,
|
caps: ["chat"],
|
||||||
lastSeenAtMs: nil)
|
commands: ["send"],
|
||||||
try await store.upsert(node)
|
permissions: ["send": true],
|
||||||
#expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
|
paired: true,
|
||||||
|
connected: false)
|
||||||
try await store.touchSeen(nodeId: "node-1")
|
let data = try JSONEncoder().encode(info)
|
||||||
let updated = await store.find(nodeId: "node-1")
|
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
|
||||||
#expect(updated?.lastSeenAtMs != nil)
|
#expect(decoded.nodeId == "node-1")
|
||||||
|
#expect(decoded.isPaired == true)
|
||||||
|
#expect(decoded.isConnected == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func presenceReporterHelpers() {
|
@Test @MainActor func presenceReporterHelpers() {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import Testing
|
|||||||
features: [:],
|
features: [:],
|
||||||
snapshot: snapshot,
|
snapshot: snapshot,
|
||||||
canvashosturl: nil,
|
canvashosturl: nil,
|
||||||
|
auth: nil,
|
||||||
policy: [:])
|
policy: [:])
|
||||||
|
|
||||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
|
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import SwiftUI
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
@MainActor
|
@MainActor
|
||||||
struct OnboardingWizardStepViewTests {
|
struct OnboardingWizardStepViewTests {
|
||||||
@Test func noteStepBuilds() {
|
@Test func noteStepBuilds() {
|
||||||
let step = WizardStep(
|
let step = WizardStep(
|
||||||
id: "step-1",
|
id: "step-1",
|
||||||
type: AnyCodable("note"),
|
type: ProtoAnyCodable("note"),
|
||||||
title: "Welcome",
|
title: "Welcome",
|
||||||
message: "Hello",
|
message: "Hello",
|
||||||
options: nil,
|
options: nil,
|
||||||
@@ -22,17 +24,17 @@ struct OnboardingWizardStepViewTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func selectStepBuilds() {
|
@Test func selectStepBuilds() {
|
||||||
let options: [[String: AnyCodable]] = [
|
let options: [[String: ProtoAnyCodable]] = [
|
||||||
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
|
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
|
||||||
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
|
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
|
||||||
]
|
]
|
||||||
let step = WizardStep(
|
let step = WizardStep(
|
||||||
id: "step-2",
|
id: "step-2",
|
||||||
type: AnyCodable("select"),
|
type: ProtoAnyCodable("select"),
|
||||||
title: "Mode",
|
title: "Mode",
|
||||||
message: "Choose a mode",
|
message: "Choose a mode",
|
||||||
options: options,
|
options: options,
|
||||||
initialvalue: AnyCodable("local"),
|
initialvalue: ProtoAnyCodable("local"),
|
||||||
placeholder: nil,
|
placeholder: nil,
|
||||||
sensitive: nil,
|
sensitive: nil,
|
||||||
executor: nil)
|
executor: nil)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
|
|||||||
|
|
||||||
public struct WebSocketTaskBox: @unchecked Sendable {
|
public struct WebSocketTaskBox: @unchecked Sendable {
|
||||||
public let task: any WebSocketTasking
|
public let task: any WebSocketTasking
|
||||||
|
public init(task: any WebSocketTasking) {
|
||||||
|
self.task = task
|
||||||
|
}
|
||||||
|
|
||||||
public var state: URLSessionTask.State { self.task.state }
|
public var state: URLSessionTask.State { self.task.state }
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let replychannel: String?
|
public let replychannel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
|
public let threadid: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
replychannel: String?,
|
replychannel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
|
threadid: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.replychannel = replychannel
|
self.replychannel = replychannel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
|
self.threadid = threadid
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case replychannel = "replyChannel"
|
case replychannel = "replyChannel"
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
|
case threadid = "threadId"
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
public let includeglobal: Bool?
|
||||||
public let includeunknown: Bool?
|
public let includeunknown: Bool?
|
||||||
|
public let includederivedtitles: Bool?
|
||||||
|
public let includelastmessage: Bool?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
|
public let search: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
limit: Int?,
|
limit: Int?,
|
||||||
activeminutes: Int?,
|
activeminutes: Int?,
|
||||||
includeglobal: Bool?,
|
includeglobal: Bool?,
|
||||||
includeunknown: Bool?,
|
includeunknown: Bool?,
|
||||||
|
includederivedtitles: Bool?,
|
||||||
|
includelastmessage: Bool?,
|
||||||
label: String?,
|
label: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
agentid: String?
|
agentid: String?,
|
||||||
|
search: String?
|
||||||
) {
|
) {
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.activeminutes = activeminutes
|
self.activeminutes = activeminutes
|
||||||
self.includeglobal = includeglobal
|
self.includeglobal = includeglobal
|
||||||
self.includeunknown = includeunknown
|
self.includeunknown = includeunknown
|
||||||
|
self.includederivedtitles = includederivedtitles
|
||||||
|
self.includelastmessage = includelastmessage
|
||||||
self.label = label
|
self.label = label
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
|
self.search = search
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case limit
|
case limit
|
||||||
case activeminutes = "activeMinutes"
|
case activeminutes = "activeMinutes"
|
||||||
case includeglobal = "includeGlobal"
|
case includeglobal = "includeGlobal"
|
||||||
case includeunknown = "includeUnknown"
|
case includeunknown = "includeUnknown"
|
||||||
|
case includederivedtitles = "includeDerivedTitles"
|
||||||
|
case includelastmessage = "includeLastMessage"
|
||||||
case label
|
case label
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
|
case search
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
public let ts: Int
|
public let ts: Int
|
||||||
public let channelorder: [String]
|
public let channelorder: [String]
|
||||||
public let channellabels: [String: AnyCodable]
|
public let channellabels: [String: AnyCodable]
|
||||||
|
public let channeldetaillabels: [String: AnyCodable]?
|
||||||
|
public let channelsystemimages: [String: AnyCodable]?
|
||||||
|
public let channelmeta: [[String: AnyCodable]]?
|
||||||
public let channels: [String: AnyCodable]
|
public let channels: [String: AnyCodable]
|
||||||
public let channelaccounts: [String: AnyCodable]
|
public let channelaccounts: [String: AnyCodable]
|
||||||
public let channeldefaultaccountid: [String: AnyCodable]
|
public let channeldefaultaccountid: [String: AnyCodable]
|
||||||
@@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
ts: Int,
|
ts: Int,
|
||||||
channelorder: [String],
|
channelorder: [String],
|
||||||
channellabels: [String: AnyCodable],
|
channellabels: [String: AnyCodable],
|
||||||
|
channeldetaillabels: [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]
|
||||||
@@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
self.ts = ts
|
self.ts = ts
|
||||||
self.channelorder = channelorder
|
self.channelorder = channelorder
|
||||||
self.channellabels = channellabels
|
self.channellabels = channellabels
|
||||||
|
self.channeldetaillabels = channeldetaillabels
|
||||||
|
self.channelsystemimages = channelsystemimages
|
||||||
|
self.channelmeta = channelmeta
|
||||||
self.channels = channels
|
self.channels = channels
|
||||||
self.channelaccounts = channelaccounts
|
self.channelaccounts = channelaccounts
|
||||||
self.channeldefaultaccountid = channeldefaultaccountid
|
self.channeldefaultaccountid = channeldefaultaccountid
|
||||||
@@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
case ts
|
case ts
|
||||||
case channelorder = "channelOrder"
|
case channelorder = "channelOrder"
|
||||||
case channellabels = "channelLabels"
|
case channellabels = "channelLabels"
|
||||||
|
case channeldetaillabels = "channelDetailLabels"
|
||||||
|
case channelsystemimages = "channelSystemImages"
|
||||||
|
case channelmeta = "channelMeta"
|
||||||
case channels
|
case channels
|
||||||
case channelaccounts = "channelAccounts"
|
case channelaccounts = "channelAccounts"
|
||||||
case channeldefaultaccountid = "channelDefaultAccountId"
|
case channeldefaultaccountid = "channelDefaultAccountId"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
|
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
---
|
||||||
|
summary: "Nostr DM channel via NIP-04 encrypted messages"
|
||||||
|
read_when:
|
||||||
|
- You want Clawdbot to receive DMs via Nostr
|
||||||
|
- You're setting up decentralized messaging
|
||||||
|
---
|
||||||
|
# Nostr
|
||||||
|
|
||||||
|
**Status:** Optional plugin (disabled by default).
|
||||||
|
|
||||||
|
Nostr is a decentralized protocol for social networking. This channel enables Clawdbot to receive and respond to encrypted direct messages (DMs) via NIP-04.
|
||||||
|
|
||||||
|
## Install (on demand)
|
||||||
|
|
||||||
|
### Onboarding (recommended)
|
||||||
|
|
||||||
|
- The onboarding wizard (`clawdbot onboard`) and `clawdbot channels add` list optional channel plugins.
|
||||||
|
- Selecting Nostr prompts you to install the plugin on demand.
|
||||||
|
|
||||||
|
Install defaults:
|
||||||
|
|
||||||
|
- **Dev channel + git checkout available:** uses the local plugin path.
|
||||||
|
- **Stable/Beta:** downloads from npm.
|
||||||
|
|
||||||
|
You can always override the choice in the prompt.
|
||||||
|
|
||||||
|
### Manual install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/nostr
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a local checkout (dev workflows):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install --link <path-to-clawdbot>/extensions/nostr
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the Gateway after installing or enabling plugins.
|
||||||
|
|
||||||
|
## Quick setup
|
||||||
|
|
||||||
|
1) Generate a Nostr keypair (if needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using nak
|
||||||
|
nak key generate
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Add to config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"nostr": {
|
||||||
|
"privateKey": "${NOSTR_PRIVATE_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Export the key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NOSTR_PRIVATE_KEY="nsec1..."
|
||||||
|
```
|
||||||
|
|
||||||
|
4) Restart the Gateway.
|
||||||
|
|
||||||
|
## Configuration reference
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `privateKey` | string | required | Private key in `nsec` or hex format |
|
||||||
|
| `relays` | string[] | `['wss://relay.damus.io', 'wss://nos.lol']` | Relay URLs (WebSocket) |
|
||||||
|
| `dmPolicy` | string | `pairing` | DM access policy |
|
||||||
|
| `allowFrom` | string[] | `[]` | Allowed sender pubkeys |
|
||||||
|
| `enabled` | boolean | `true` | Enable/disable channel |
|
||||||
|
| `name` | string | - | Display name |
|
||||||
|
| `profile` | object | - | NIP-01 profile metadata |
|
||||||
|
|
||||||
|
## Profile metadata
|
||||||
|
|
||||||
|
Profile data is published as a NIP-01 `kind:0` event. You can manage it from the Control UI (Channels -> Nostr -> Profile) or set it directly in config.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"nostr": {
|
||||||
|
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
||||||
|
"profile": {
|
||||||
|
"name": "clawdbot",
|
||||||
|
"displayName": "Clawdbot",
|
||||||
|
"about": "Personal assistant DM bot",
|
||||||
|
"picture": "https://example.com/avatar.png",
|
||||||
|
"banner": "https://example.com/banner.png",
|
||||||
|
"website": "https://example.com",
|
||||||
|
"nip05": "clawdbot@example.com",
|
||||||
|
"lud16": "clawdbot@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Profile URLs must use `https://`.
|
||||||
|
- Importing from relays merges fields and preserves local overrides.
|
||||||
|
|
||||||
|
## Access control
|
||||||
|
|
||||||
|
### DM policies
|
||||||
|
|
||||||
|
- **pairing** (default): unknown senders get a pairing code.
|
||||||
|
- **allowlist**: only pubkeys in `allowFrom` can DM.
|
||||||
|
- **open**: public inbound DMs (requires `allowFrom: ["*"]`).
|
||||||
|
- **disabled**: ignore inbound DMs.
|
||||||
|
|
||||||
|
### Allowlist example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"nostr": {
|
||||||
|
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
||||||
|
"dmPolicy": "allowlist",
|
||||||
|
"allowFrom": ["npub1abc...", "npub1xyz..."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key formats
|
||||||
|
|
||||||
|
Accepted formats:
|
||||||
|
|
||||||
|
- **Private key:** `nsec...` or 64-char hex
|
||||||
|
- **Pubkeys (`allowFrom`):** `npub...` or hex
|
||||||
|
|
||||||
|
## Relays
|
||||||
|
|
||||||
|
Defaults: `relay.damus.io` and `nos.lol`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"nostr": {
|
||||||
|
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
||||||
|
"relays": [
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://nostr.wine"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tips:
|
||||||
|
|
||||||
|
- Use 2-3 relays for redundancy.
|
||||||
|
- Avoid too many relays (latency, duplication).
|
||||||
|
- Paid relays can improve reliability.
|
||||||
|
- Local relays are fine for testing (`ws://localhost:7777`).
|
||||||
|
|
||||||
|
## Protocol support
|
||||||
|
|
||||||
|
| NIP | Status | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| NIP-01 | Supported | Basic event format + profile metadata |
|
||||||
|
| NIP-04 | Supported | Encrypted DMs (`kind:4`) |
|
||||||
|
| NIP-17 | Planned | Gift-wrapped DMs |
|
||||||
|
| NIP-44 | Planned | Versioned encryption |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Local relay
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start strfry
|
||||||
|
docker run -p 7777:7777 ghcr.io/hoytech/strfry
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"nostr": {
|
||||||
|
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
||||||
|
"relays": ["ws://localhost:7777"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual test
|
||||||
|
|
||||||
|
1) Note the bot pubkey (npub) from logs.
|
||||||
|
2) Open a Nostr client (Damus, Amethyst, etc.).
|
||||||
|
3) DM the bot pubkey.
|
||||||
|
4) Verify the response.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Not receiving messages
|
||||||
|
|
||||||
|
- Verify the private key is valid.
|
||||||
|
- Ensure relay URLs are reachable and use `wss://` (or `ws://` for local).
|
||||||
|
- Confirm `enabled` is not `false`.
|
||||||
|
- Check Gateway logs for relay connection errors.
|
||||||
|
|
||||||
|
### Not sending responses
|
||||||
|
|
||||||
|
- Check relay accepts writes.
|
||||||
|
- Verify outbound connectivity.
|
||||||
|
- Watch for relay rate limits.
|
||||||
|
|
||||||
|
### Duplicate responses
|
||||||
|
|
||||||
|
- Expected when using multiple relays.
|
||||||
|
- Messages are deduplicated by event ID; only the first delivery triggers a response.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never commit private keys.
|
||||||
|
- Use environment variables for keys.
|
||||||
|
- Consider `allowlist` for production bots.
|
||||||
|
|
||||||
|
## Limitations (MVP)
|
||||||
|
|
||||||
|
- Direct messages only (no group chats).
|
||||||
|
- No media attachments.
|
||||||
|
- NIP-04 only (NIP-17 gift-wrap planned).
|
||||||
@@ -286,6 +286,11 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
- CLI: `clawdbot message send --media <mp4> --gif-playback`
|
- CLI: `clawdbot message send --media <mp4> --gif-playback`
|
||||||
- Gateway: `send` params include `gifPlayback: true`
|
- Gateway: `send` params include `gifPlayback: true`
|
||||||
|
|
||||||
|
## Voice notes (PTT audio)
|
||||||
|
WhatsApp sends audio as **voice notes** (PTT bubble).
|
||||||
|
- Best results: OGG/Opus. Clawdbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
|
||||||
|
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
|
||||||
|
|
||||||
## Media limits + optimization
|
## Media limits + optimization
|
||||||
- Default outbound cap: 5 MB (per media item).
|
- Default outbound cap: 5 MB (per media item).
|
||||||
- Override: `agents.defaults.mediaMaxMb`.
|
- Override: `agents.defaults.mediaMaxMb`.
|
||||||
|
|||||||
@@ -14,3 +14,16 @@ Related:
|
|||||||
|
|
||||||
Tip: run `clawdbot cron --help` for the full command surface.
|
Tip: run `clawdbot cron --help` for the full command surface.
|
||||||
|
|
||||||
|
## Common edits
|
||||||
|
|
||||||
|
Update delivery settings without changing the message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron edit <job-id> --deliver --channel telegram --to "123456789"
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable delivery for an isolated job:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron edit <job-id> --no-deliver
|
||||||
|
```
|
||||||
+2
-1
@@ -23,10 +23,11 @@ clawdbot nodes approve <requestId>
|
|||||||
clawdbot nodes status
|
clawdbot nodes status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
|
||||||
|
|
||||||
## Invoke / run
|
## Invoke / run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
||||||
clawdbot nodes run --node <id|name|ip> <command...>
|
clawdbot nodes run --node <id|name|ip> <command...>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -21,3 +21,4 @@ clawdbot security audit --fix
|
|||||||
```
|
```
|
||||||
|
|
||||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
||||||
|
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||||
@@ -77,6 +77,21 @@ Client Gateway
|
|||||||
safely retry; the server keeps a short‑lived dedupe cache.
|
safely retry; the server keeps a short‑lived dedupe cache.
|
||||||
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
|
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
|
||||||
|
|
||||||
|
## Pairing + local trust
|
||||||
|
|
||||||
|
- All WS clients (operators + nodes) include a **device identity** on `connect`.
|
||||||
|
- New device IDs require pairing approval; the Gateway issues a **device token**
|
||||||
|
for subsequent connects.
|
||||||
|
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
|
||||||
|
auto‑approved to keep same‑host UX smooth.
|
||||||
|
- **Non‑local** connects must sign the `connect.challenge` nonce and require
|
||||||
|
explicit approval.
|
||||||
|
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
|
||||||
|
remote.
|
||||||
|
|
||||||
|
Details: [Gateway protocol](/gateway/protocol), [Pairing](/start/pairing),
|
||||||
|
[Security](/gateway/security).
|
||||||
|
|
||||||
## Protocol typing and codegen
|
## Protocol typing and codegen
|
||||||
|
|
||||||
- TypeBox schemas define the protocol.
|
- TypeBox schemas define the protocol.
|
||||||
|
|||||||
@@ -1774,6 +1774,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
|||||||
- `tools.web.fetch.maxChars` (default 50000)
|
- `tools.web.fetch.maxChars` (default 50000)
|
||||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||||
|
- `tools.web.fetch.maxRedirects` (default 3)
|
||||||
- `tools.web.fetch.userAgent` (optional override)
|
- `tools.web.fetch.userAgent` (optional override)
|
||||||
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
||||||
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
||||||
@@ -2614,10 +2615,13 @@ Defaults:
|
|||||||
// noSandbox: false,
|
// noSandbox: false,
|
||||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||||
|
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
|
||||||
|
|
||||||
### `ui` (Appearance)
|
### `ui` (Appearance)
|
||||||
|
|
||||||
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||||||
- Gateways issue tokens per device + role.
|
- Gateways issue tokens per device + role.
|
||||||
- Pairing approvals are required for new device IDs unless local auto-approval
|
- Pairing approvals are required for new device IDs unless local auto-approval
|
||||||
is enabled.
|
is enabled.
|
||||||
|
- **Local** connects include loopback and the gateway host’s own tailnet address
|
||||||
|
(so same‑host tailnet binds can still auto‑approve).
|
||||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||||
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ Recommendations:
|
|||||||
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
||||||
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
||||||
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
||||||
|
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
|
||||||
|
|
||||||
## Reasoning & verbose output in groups
|
## Reasoning & verbose output in groups
|
||||||
|
|
||||||
@@ -269,6 +270,12 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
|||||||
protect local WS access.
|
protect local WS access.
|
||||||
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
||||||
|
|
||||||
|
Local device pairing:
|
||||||
|
- Device pairing is auto‑approved for **local** connects (loopback or the
|
||||||
|
gateway host’s own tailnet address) to keep same‑host clients smooth.
|
||||||
|
- Other tailnet peers are **not** treated as local; they still need pairing
|
||||||
|
approval.
|
||||||
|
|
||||||
Auth modes:
|
Auth modes:
|
||||||
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
||||||
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
|
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||||
|
|||||||
+119
-12
@@ -138,17 +138,45 @@ Redaction affects **console output only** and does not alter file logs.
|
|||||||
|
|
||||||
## Diagnostics + OpenTelemetry
|
## Diagnostics + OpenTelemetry
|
||||||
|
|
||||||
Diagnostics are **opt-in** structured events for model runs (usage + cost +
|
Diagnostics are structured, machine-readable events for model runs **and**
|
||||||
context + duration). They do **not** replace logs; they exist to feed metrics,
|
message-flow telemetry (webhooks, queueing, session state). They do **not**
|
||||||
traces, and other exporters.
|
replace logs; they exist to feed metrics, traces, and other exporters.
|
||||||
|
|
||||||
Clawdbot currently emits a `model.usage` event after each agent run with:
|
Diagnostics events are emitted in-process, but exporters only attach when
|
||||||
|
diagnostics + the exporter plugin are enabled.
|
||||||
|
|
||||||
- Token counts (input/output/cache/prompt/total)
|
### OpenTelemetry vs OTLP
|
||||||
- Estimated cost (USD)
|
|
||||||
- Context window used/limit
|
- **OpenTelemetry (OTel)**: the data model + SDKs for traces, metrics, and logs.
|
||||||
- Duration (ms)
|
- **OTLP**: the wire protocol used to export OTel data to a collector/backend.
|
||||||
- Provider/channel/model + session identifiers
|
- Clawdbot exports via **OTLP/HTTP (protobuf)** today.
|
||||||
|
|
||||||
|
### Signals exported
|
||||||
|
|
||||||
|
- **Metrics**: counters + histograms (token usage, message flow, queueing).
|
||||||
|
- **Traces**: spans for model usage + webhook/message processing.
|
||||||
|
- **Logs**: exported over OTLP when `diagnostics.otel.logs` is enabled. Log
|
||||||
|
volume can be high; keep `logging.level` and exporter filters in mind.
|
||||||
|
|
||||||
|
### Diagnostic event catalog
|
||||||
|
|
||||||
|
Model usage:
|
||||||
|
- `model.usage`: tokens, cost, duration, context, provider/model/channel, session ids.
|
||||||
|
|
||||||
|
Message flow:
|
||||||
|
- `webhook.received`: webhook ingress per channel.
|
||||||
|
- `webhook.processed`: webhook handled + duration.
|
||||||
|
- `webhook.error`: webhook handler errors.
|
||||||
|
- `message.queued`: message enqueued for processing.
|
||||||
|
- `message.processed`: outcome + duration + optional error.
|
||||||
|
|
||||||
|
Queue + session:
|
||||||
|
- `queue.lane.enqueue`: command queue lane enqueue + depth.
|
||||||
|
- `queue.lane.dequeue`: command queue lane dequeue + wait time.
|
||||||
|
- `session.state`: session state transition + reason.
|
||||||
|
- `session.stuck`: session stuck warning + age.
|
||||||
|
- `run.attempt`: run retry/attempt metadata.
|
||||||
|
- `diagnostic.heartbeat`: aggregate counters (webhooks/queue/session).
|
||||||
|
|
||||||
### Enable diagnostics (no exporter)
|
### Enable diagnostics (no exporter)
|
||||||
|
|
||||||
@@ -186,6 +214,7 @@ works with any OpenTelemetry collector/backend that accepts OTLP/HTTP.
|
|||||||
"serviceName": "clawdbot-gateway",
|
"serviceName": "clawdbot-gateway",
|
||||||
"traces": true,
|
"traces": true,
|
||||||
"metrics": true,
|
"metrics": true,
|
||||||
|
"logs": true,
|
||||||
"sampleRate": 0.2,
|
"sampleRate": 0.2,
|
||||||
"flushIntervalMs": 60000
|
"flushIntervalMs": 60000
|
||||||
}
|
}
|
||||||
@@ -195,13 +224,91 @@ works with any OpenTelemetry collector/backend that accepts OTLP/HTTP.
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- You can also enable the plugin with `clawdbot plugins enable diagnostics-otel`.
|
- You can also enable the plugin with `clawdbot plugins enable diagnostics-otel`.
|
||||||
- `protocol` currently supports `http/protobuf`.
|
- `protocol` currently supports `http/protobuf` only. `grpc` is ignored.
|
||||||
- Metrics include token usage, cost, context size, and run duration.
|
- Metrics include token usage, cost, context size, run duration, and message-flow
|
||||||
- Traces/metrics can be toggled with `traces` / `metrics` (default: on).
|
counters/histograms (webhooks, queueing, session state, queue depth/wait).
|
||||||
|
- Traces/metrics can be toggled with `traces` / `metrics` (default: on). Traces
|
||||||
|
include model usage spans plus webhook/message processing spans when enabled.
|
||||||
- Set `headers` when your collector requires auth.
|
- Set `headers` when your collector requires auth.
|
||||||
- Environment variables supported: `OTEL_EXPORTER_OTLP_ENDPOINT`,
|
- Environment variables supported: `OTEL_EXPORTER_OTLP_ENDPOINT`,
|
||||||
`OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
`OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||||
|
|
||||||
|
### Exported metrics (names + types)
|
||||||
|
|
||||||
|
Model usage:
|
||||||
|
- `clawdbot.tokens` (counter, attrs: `clawdbot.token`, `clawdbot.channel`,
|
||||||
|
`clawdbot.provider`, `clawdbot.model`)
|
||||||
|
- `clawdbot.cost.usd` (counter, attrs: `clawdbot.channel`, `clawdbot.provider`,
|
||||||
|
`clawdbot.model`)
|
||||||
|
- `clawdbot.run.duration_ms` (histogram, attrs: `clawdbot.channel`,
|
||||||
|
`clawdbot.provider`, `clawdbot.model`)
|
||||||
|
- `clawdbot.context.tokens` (histogram, attrs: `clawdbot.context`,
|
||||||
|
`clawdbot.channel`, `clawdbot.provider`, `clawdbot.model`)
|
||||||
|
|
||||||
|
Message flow:
|
||||||
|
- `clawdbot.webhook.received` (counter, attrs: `clawdbot.channel`,
|
||||||
|
`clawdbot.webhook`)
|
||||||
|
- `clawdbot.webhook.error` (counter, attrs: `clawdbot.channel`,
|
||||||
|
`clawdbot.webhook`)
|
||||||
|
- `clawdbot.webhook.duration_ms` (histogram, attrs: `clawdbot.channel`,
|
||||||
|
`clawdbot.webhook`)
|
||||||
|
- `clawdbot.message.queued` (counter, attrs: `clawdbot.channel`,
|
||||||
|
`clawdbot.source`)
|
||||||
|
- `clawdbot.message.processed` (counter, attrs: `clawdbot.channel`,
|
||||||
|
`clawdbot.outcome`)
|
||||||
|
- `clawdbot.message.duration_ms` (histogram, attrs: `clawdbot.channel`,
|
||||||
|
`clawdbot.outcome`)
|
||||||
|
|
||||||
|
Queues + sessions:
|
||||||
|
- `clawdbot.queue.lane.enqueue` (counter, attrs: `clawdbot.lane`)
|
||||||
|
- `clawdbot.queue.lane.dequeue` (counter, attrs: `clawdbot.lane`)
|
||||||
|
- `clawdbot.queue.depth` (histogram, attrs: `clawdbot.lane` or
|
||||||
|
`clawdbot.channel=heartbeat`)
|
||||||
|
- `clawdbot.queue.wait_ms` (histogram, attrs: `clawdbot.lane`)
|
||||||
|
- `clawdbot.session.state` (counter, attrs: `clawdbot.state`, `clawdbot.reason`)
|
||||||
|
- `clawdbot.session.stuck` (counter, attrs: `clawdbot.state`)
|
||||||
|
- `clawdbot.session.stuck_age_ms` (histogram, attrs: `clawdbot.state`)
|
||||||
|
- `clawdbot.run.attempt` (counter, attrs: `clawdbot.attempt`)
|
||||||
|
|
||||||
|
### Exported spans (names + key attributes)
|
||||||
|
|
||||||
|
- `clawdbot.model.usage`
|
||||||
|
- `clawdbot.channel`, `clawdbot.provider`, `clawdbot.model`
|
||||||
|
- `clawdbot.sessionKey`, `clawdbot.sessionId`
|
||||||
|
- `clawdbot.tokens.*` (input/output/cache_read/cache_write/total)
|
||||||
|
- `clawdbot.webhook.processed`
|
||||||
|
- `clawdbot.channel`, `clawdbot.webhook`, `clawdbot.chatId`
|
||||||
|
- `clawdbot.webhook.error`
|
||||||
|
- `clawdbot.channel`, `clawdbot.webhook`, `clawdbot.chatId`,
|
||||||
|
`clawdbot.error`
|
||||||
|
- `clawdbot.message.processed`
|
||||||
|
- `clawdbot.channel`, `clawdbot.outcome`, `clawdbot.chatId`,
|
||||||
|
`clawdbot.messageId`, `clawdbot.sessionKey`, `clawdbot.sessionId`,
|
||||||
|
`clawdbot.reason`
|
||||||
|
- `clawdbot.session.stuck`
|
||||||
|
- `clawdbot.state`, `clawdbot.ageMs`, `clawdbot.queueDepth`,
|
||||||
|
`clawdbot.sessionKey`, `clawdbot.sessionId`
|
||||||
|
|
||||||
|
### Sampling + flushing
|
||||||
|
|
||||||
|
- Trace sampling: `diagnostics.otel.sampleRate` (0.0–1.0, root spans only).
|
||||||
|
- Metric export interval: `diagnostics.otel.flushIntervalMs` (min 1000ms).
|
||||||
|
|
||||||
|
### Protocol notes
|
||||||
|
|
||||||
|
- OTLP/HTTP endpoints can be set via `diagnostics.otel.endpoint` or
|
||||||
|
`OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||||
|
- If the endpoint already contains `/v1/traces` or `/v1/metrics`, it is used as-is.
|
||||||
|
- If the endpoint already contains `/v1/logs`, it is used as-is for logs.
|
||||||
|
- `diagnostics.otel.logs` enables OTLP log export for the main logger output.
|
||||||
|
|
||||||
|
### Log export behavior
|
||||||
|
|
||||||
|
- OTLP logs use the same structured records written to `logging.file`.
|
||||||
|
- Respect `logging.level` (file log level). Console redaction does **not** apply
|
||||||
|
to OTLP logs.
|
||||||
|
- High-volume installs should prefer OTLP collector sampling/filtering.
|
||||||
|
|
||||||
## Troubleshooting tips
|
## Troubleshooting tips
|
||||||
|
|
||||||
- **Gateway not reachable?** Run `clawdbot doctor` first.
|
- **Gateway not reachable?** Run `clawdbot doctor` first.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
summary: "Network hub: gateway surfaces, pairing, discovery, and security"
|
||||||
|
read_when:
|
||||||
|
- You need the network architecture + security overview
|
||||||
|
- You are debugging local vs tailnet access or pairing
|
||||||
|
- You want the canonical list of networking docs
|
||||||
|
---
|
||||||
|
# Network hub
|
||||||
|
|
||||||
|
This hub links the core docs for how Clawdbot connects, pairs, and secures
|
||||||
|
devices across localhost, LAN, and tailnet.
|
||||||
|
|
||||||
|
## Core model
|
||||||
|
|
||||||
|
- [Gateway architecture](/concepts/architecture)
|
||||||
|
- [Gateway protocol](/gateway/protocol)
|
||||||
|
- [Gateway runbook](/gateway)
|
||||||
|
- [Web surfaces + bind modes](/web)
|
||||||
|
|
||||||
|
## Pairing + identity
|
||||||
|
|
||||||
|
- [Pairing overview (DM + nodes)](/start/pairing)
|
||||||
|
- [Gateway-owned node pairing](/gateway/pairing)
|
||||||
|
- [Devices CLI (pairing + token rotation)](/cli/devices)
|
||||||
|
- [Pairing CLI (DM approvals)](/cli/pairing)
|
||||||
|
|
||||||
|
Local trust:
|
||||||
|
- Local connections (loopback or the gateway host’s own tailnet address) can be
|
||||||
|
auto‑approved for pairing to keep same‑host UX smooth.
|
||||||
|
- Non‑local tailnet/LAN clients still require explicit pairing approval.
|
||||||
|
|
||||||
|
## Discovery + transports
|
||||||
|
|
||||||
|
- [Discovery & transports](/gateway/discovery)
|
||||||
|
- [Bonjour / mDNS](/gateway/bonjour)
|
||||||
|
- [Remote access (SSH)](/gateway/remote)
|
||||||
|
- [Tailscale](/gateway/tailscale)
|
||||||
|
|
||||||
|
## Nodes + bridge
|
||||||
|
|
||||||
|
- [Nodes overview](/nodes)
|
||||||
|
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
|
||||||
|
- [Node runbook: iOS](/platforms/ios)
|
||||||
|
- [Node runbook: Android](/platforms/android)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- [Security overview](/gateway/security)
|
||||||
|
- [Gateway config reference](/gateway/configuration)
|
||||||
|
- [Troubleshooting](/gateway/troubleshooting)
|
||||||
|
- [Doctor](/gateway/doctor)
|
||||||
@@ -49,6 +49,50 @@ Repair/migrate:
|
|||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Advanced: expose WSL services over LAN (portproxy)
|
||||||
|
|
||||||
|
WSL has its own virtual network. If another machine needs to reach a service
|
||||||
|
running **inside WSL** (SSH, a local TTS server, or the Gateway), you must
|
||||||
|
forward a Windows port to the current WSL IP. The WSL IP changes after restarts,
|
||||||
|
so you may need to refresh the forwarding rule.
|
||||||
|
|
||||||
|
Example (PowerShell **as Administrator**):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$Distro = "Ubuntu-24.04"
|
||||||
|
$ListenPort = 2222
|
||||||
|
$TargetPort = 22
|
||||||
|
|
||||||
|
$WslIp = (wsl -d $Distro -- hostname -I).Trim().Split(" ")[0]
|
||||||
|
if (-not $WslIp) { throw "WSL IP not found." }
|
||||||
|
|
||||||
|
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort `
|
||||||
|
connectaddress=$WslIp connectport=$TargetPort
|
||||||
|
```
|
||||||
|
|
||||||
|
Allow the port through Windows Firewall (one-time):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallRule -DisplayName "WSL SSH $ListenPort" -Direction Inbound `
|
||||||
|
-Protocol TCP -LocalPort $ListenPort -Action Allow
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh the portproxy after WSL restarts:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
netsh interface portproxy delete v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 | Out-Null
|
||||||
|
netsh interface portproxy add v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 `
|
||||||
|
connectaddress=$WslIp connectport=$TargetPort | Out-Null
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- SSH from another machine targets the **Windows host IP** (example: `ssh user@windows-host -p 2222`).
|
||||||
|
- Remote nodes must point at a **reachable** Gateway URL (not `127.0.0.1`); use
|
||||||
|
`clawdbot status --all` to confirm.
|
||||||
|
- Use `listenaddress=0.0.0.0` for LAN access; `127.0.0.1` keeps it local only.
|
||||||
|
- If you want this automatic, register a Scheduled Task to run the refresh
|
||||||
|
step at login.
|
||||||
|
|
||||||
## Step-by-step WSL2 install
|
## Step-by-step WSL2 install
|
||||||
|
|
||||||
### 1) Install WSL2 + Ubuntu
|
### 1) Install WSL2 + Ubuntu
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||||
|
- [Nostr](/channels/nostr) — `@clawdbot/nostr`
|
||||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
||||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
## Core concepts
|
## Core concepts
|
||||||
|
|
||||||
- [Architecture](/concepts/architecture)
|
- [Architecture](/concepts/architecture)
|
||||||
|
- [Network hub](/network)
|
||||||
- [Agent runtime](/concepts/agent)
|
- [Agent runtime](/concepts/agent)
|
||||||
- [Agent workspace](/concepts/agent-workspace)
|
- [Agent workspace](/concepts/agent-workspace)
|
||||||
- [Memory](/concepts/memory)
|
- [Memory](/concepts/memory)
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ Notes:
|
|||||||
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
||||||
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
||||||
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
||||||
|
- Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-clawd-managed-browser)).
|
||||||
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
||||||
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
||||||
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ Notes:
|
|||||||
|
|
||||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||||
- `tools.exec.host` (default: `sandbox`)
|
- `tools.exec.host` (default: `sandbox`)
|
||||||
- `tools.exec.security` (default: `deny`)
|
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
|
||||||
- `tools.exec.ask` (default: `on-miss`)
|
- `tools.exec.ask` (default: `on-miss`)
|
||||||
- `tools.exec.node` (default: unset)
|
- `tools.exec.node` (default: unset)
|
||||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`:
|
|||||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||||
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
|
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
|
||||||
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
||||||
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
|
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download).
|
||||||
|
|
||||||
Note on sandboxing:
|
Note on sandboxing:
|
||||||
- `requires.bins` is checked on the **host** at skill load time.
|
- `requires.bins` is checked on the **host** at skill load time.
|
||||||
@@ -134,10 +134,13 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
||||||
|
- If all installers are `download`, Clawdbot lists each entry so you can see the available artifacts.
|
||||||
|
- Installer specs can include `os: ["darwin"|"linux"|"win32"]` to filter options by platform.
|
||||||
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
|
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
|
||||||
This only affects **skill installs**; the Gateway runtime should still be Node
|
This only affects **skill installs**; the Gateway runtime should still be Node
|
||||||
(Bun is not recommended for WhatsApp/Telegram).
|
(Bun is not recommended for WhatsApp/Telegram).
|
||||||
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
|
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
|
||||||
|
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
|
||||||
|
|
||||||
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
||||||
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ Fetch a URL and extract readable content.
|
|||||||
maxChars: 50000,
|
maxChars: 50000,
|
||||||
timeoutSeconds: 30,
|
timeoutSeconds: 30,
|
||||||
cacheTtlMinutes: 15,
|
cacheTtlMinutes: 15,
|
||||||
|
maxRedirects: 3,
|
||||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||||
readability: true,
|
readability: true,
|
||||||
firecrawl: {
|
firecrawl: {
|
||||||
@@ -241,6 +242,7 @@ Notes:
|
|||||||
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
||||||
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
||||||
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
||||||
|
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
|
||||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||||
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
||||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||||
|
|||||||
@@ -8,9 +8,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/api-logs": "^0.210.0",
|
||||||
|
"@opentelemetry/exporter-logs-otlp-http": "^0.210.0",
|
||||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.210.0",
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.210.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
|
||||||
"@opentelemetry/resources": "^2.4.0",
|
"@opentelemetry/resources": "^2.4.0",
|
||||||
|
"@opentelemetry/sdk-logs": "^0.210.0",
|
||||||
"@opentelemetry/sdk-metrics": "^2.4.0",
|
"@opentelemetry/sdk-metrics": "^2.4.0",
|
||||||
"@opentelemetry/sdk-node": "^0.210.0",
|
"@opentelemetry/sdk-node": "^0.210.0",
|
||||||
"@opentelemetry/sdk-trace-base": "^2.4.0",
|
"@opentelemetry/sdk-trace-base": "^2.4.0",
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const registerLogTransportMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
const telemetryState = vi.hoisted(() => {
|
||||||
|
const counters = new Map<string, { add: ReturnType<typeof vi.fn> }>();
|
||||||
|
const histograms = new Map<string, { record: ReturnType<typeof vi.fn> }>();
|
||||||
|
const tracer = {
|
||||||
|
startSpan: vi.fn((_name: string, _opts?: unknown) => ({
|
||||||
|
end: vi.fn(),
|
||||||
|
setStatus: vi.fn(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const meter = {
|
||||||
|
createCounter: vi.fn((name: string) => {
|
||||||
|
const counter = { add: vi.fn() };
|
||||||
|
counters.set(name, counter);
|
||||||
|
return counter;
|
||||||
|
}),
|
||||||
|
createHistogram: vi.fn((name: string) => {
|
||||||
|
const histogram = { record: vi.fn() };
|
||||||
|
histograms.set(name, histogram);
|
||||||
|
return histogram;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { counters, histograms, tracer, meter };
|
||||||
|
});
|
||||||
|
|
||||||
|
const sdkStart = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
const sdkShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
const logEmit = vi.hoisted(() => vi.fn());
|
||||||
|
const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/api", () => ({
|
||||||
|
metrics: {
|
||||||
|
getMeter: () => telemetryState.meter,
|
||||||
|
},
|
||||||
|
trace: {
|
||||||
|
getTracer: () => telemetryState.tracer,
|
||||||
|
},
|
||||||
|
SpanStatusCode: {
|
||||||
|
ERROR: 2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/sdk-node", () => ({
|
||||||
|
NodeSDK: class {
|
||||||
|
start = sdkStart;
|
||||||
|
shutdown = sdkShutdown;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({
|
||||||
|
OTLPMetricExporter: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
|
||||||
|
OTLPTraceExporter: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
|
||||||
|
OTLPLogExporter: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/sdk-logs", () => ({
|
||||||
|
BatchLogRecordProcessor: class {},
|
||||||
|
LoggerProvider: class {
|
||||||
|
addLogRecordProcessor = vi.fn();
|
||||||
|
getLogger = vi.fn(() => ({
|
||||||
|
emit: logEmit,
|
||||||
|
}));
|
||||||
|
shutdown = logShutdown;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/sdk-metrics", () => ({
|
||||||
|
PeriodicExportingMetricReader: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/sdk-trace-base", () => ({
|
||||||
|
ParentBasedSampler: class {},
|
||||||
|
TraceIdRatioBasedSampler: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/resources", () => ({
|
||||||
|
Resource: class {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
||||||
|
constructor(_value?: unknown) {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@opentelemetry/semantic-conventions", () => ({
|
||||||
|
SemanticResourceAttributes: {
|
||||||
|
SERVICE_NAME: "service.name",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("clawdbot/plugin-sdk", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("clawdbot/plugin-sdk")>("clawdbot/plugin-sdk");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
registerLogTransport: registerLogTransportMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createDiagnosticsOtelService } from "./service.js";
|
||||||
|
import { emitDiagnosticEvent } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
describe("diagnostics-otel service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
telemetryState.counters.clear();
|
||||||
|
telemetryState.histograms.clear();
|
||||||
|
telemetryState.tracer.startSpan.mockClear();
|
||||||
|
telemetryState.meter.createCounter.mockClear();
|
||||||
|
telemetryState.meter.createHistogram.mockClear();
|
||||||
|
sdkStart.mockClear();
|
||||||
|
sdkShutdown.mockClear();
|
||||||
|
logEmit.mockClear();
|
||||||
|
logShutdown.mockClear();
|
||||||
|
registerLogTransportMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records message-flow metrics and spans", async () => {
|
||||||
|
const registeredTransports: Array<(logObj: Record<string, unknown>) => void> = [];
|
||||||
|
const stopTransport = vi.fn();
|
||||||
|
registerLogTransportMock.mockImplementation((transport) => {
|
||||||
|
registeredTransports.push(transport);
|
||||||
|
return stopTransport;
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createDiagnosticsOtelService();
|
||||||
|
await service.start({
|
||||||
|
config: {
|
||||||
|
diagnostics: {
|
||||||
|
enabled: true,
|
||||||
|
otel: {
|
||||||
|
enabled: true,
|
||||||
|
endpoint: "http://otel-collector:4318",
|
||||||
|
protocol: "http/protobuf",
|
||||||
|
traces: true,
|
||||||
|
metrics: true,
|
||||||
|
logs: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "webhook.received",
|
||||||
|
channel: "telegram",
|
||||||
|
updateType: "telegram-post",
|
||||||
|
});
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "webhook.processed",
|
||||||
|
channel: "telegram",
|
||||||
|
updateType: "telegram-post",
|
||||||
|
durationMs: 120,
|
||||||
|
});
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "message.queued",
|
||||||
|
channel: "telegram",
|
||||||
|
source: "telegram",
|
||||||
|
queueDepth: 2,
|
||||||
|
});
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "message.processed",
|
||||||
|
channel: "telegram",
|
||||||
|
outcome: "completed",
|
||||||
|
durationMs: 55,
|
||||||
|
});
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "queue.lane.dequeue",
|
||||||
|
lane: "main",
|
||||||
|
queueSize: 3,
|
||||||
|
waitMs: 10,
|
||||||
|
});
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "session.stuck",
|
||||||
|
state: "processing",
|
||||||
|
ageMs: 125_000,
|
||||||
|
});
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "run.attempt",
|
||||||
|
runId: "run-1",
|
||||||
|
attempt: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(telemetryState.counters.get("clawdbot.webhook.received")?.add).toHaveBeenCalled();
|
||||||
|
expect(telemetryState.histograms.get("clawdbot.webhook.duration_ms")?.record).toHaveBeenCalled();
|
||||||
|
expect(telemetryState.counters.get("clawdbot.message.queued")?.add).toHaveBeenCalled();
|
||||||
|
expect(telemetryState.counters.get("clawdbot.message.processed")?.add).toHaveBeenCalled();
|
||||||
|
expect(telemetryState.histograms.get("clawdbot.message.duration_ms")?.record).toHaveBeenCalled();
|
||||||
|
expect(telemetryState.histograms.get("clawdbot.queue.wait_ms")?.record).toHaveBeenCalled();
|
||||||
|
expect(telemetryState.counters.get("clawdbot.session.stuck")?.add).toHaveBeenCalled();
|
||||||
|
expect(telemetryState.histograms.get("clawdbot.session.stuck_age_ms")?.record).toHaveBeenCalled();
|
||||||
|
expect(telemetryState.counters.get("clawdbot.run.attempt")?.add).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const spanNames = telemetryState.tracer.startSpan.mock.calls.map((call) => call[0]);
|
||||||
|
expect(spanNames).toContain("clawdbot.webhook.processed");
|
||||||
|
expect(spanNames).toContain("clawdbot.message.processed");
|
||||||
|
expect(spanNames).toContain("clawdbot.session.stuck");
|
||||||
|
|
||||||
|
expect(registerLogTransportMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(registeredTransports).toHaveLength(1);
|
||||||
|
registeredTransports[0]?.({
|
||||||
|
0: "{\"subsystem\":\"diagnostic\"}",
|
||||||
|
1: "hello",
|
||||||
|
_meta: { logLevelName: "INFO", date: new Date() },
|
||||||
|
});
|
||||||
|
expect(logEmit).toHaveBeenCalled();
|
||||||
|
|
||||||
|
await service.stop?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { metrics, trace } from "@opentelemetry/api";
|
import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
|
||||||
|
import type { SeverityNumber } from "@opentelemetry/api-logs";
|
||||||
|
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
||||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||||
import { Resource } from "@opentelemetry/resources";
|
import { Resource } from "@opentelemetry/resources";
|
||||||
|
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
|
||||||
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||||
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
||||||
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
||||||
|
|
||||||
import type {
|
import type { ClawdbotPluginService, DiagnosticEventPayload } from "clawdbot/plugin-sdk";
|
||||||
ClawdbotPluginService,
|
import { onDiagnosticEvent, registerLogTransport } from "clawdbot/plugin-sdk";
|
||||||
DiagnosticUsageEvent,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
import { onDiagnosticEvent } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
const DEFAULT_SERVICE_NAME = "clawdbot";
|
const DEFAULT_SERVICE_NAME = "clawdbot";
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ function resolveSampleRate(value: number | undefined): number | undefined {
|
|||||||
|
|
||||||
export function createDiagnosticsOtelService(): ClawdbotPluginService {
|
export function createDiagnosticsOtelService(): ClawdbotPluginService {
|
||||||
let sdk: NodeSDK | null = null;
|
let sdk: NodeSDK | null = null;
|
||||||
|
let logProvider: LoggerProvider | null = null;
|
||||||
|
let stopLogTransport: (() => void) | null = null;
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -57,7 +59,8 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
|
|||||||
|
|
||||||
const tracesEnabled = otel.traces !== false;
|
const tracesEnabled = otel.traces !== false;
|
||||||
const metricsEnabled = otel.metrics !== false;
|
const metricsEnabled = otel.metrics !== false;
|
||||||
if (!tracesEnabled && !metricsEnabled) return;
|
const logsEnabled = otel.logs === true;
|
||||||
|
if (!tracesEnabled && !metricsEnabled && !logsEnabled) return;
|
||||||
|
|
||||||
const resource = new Resource({
|
const resource = new Resource({
|
||||||
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
||||||
@@ -65,6 +68,7 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
|
|||||||
|
|
||||||
const traceUrl = resolveOtelUrl(endpoint, "v1/traces");
|
const traceUrl = resolveOtelUrl(endpoint, "v1/traces");
|
||||||
const metricUrl = resolveOtelUrl(endpoint, "v1/metrics");
|
const metricUrl = resolveOtelUrl(endpoint, "v1/metrics");
|
||||||
|
const logUrl = resolveOtelUrl(endpoint, "v1/logs");
|
||||||
const traceExporter = tracesEnabled
|
const traceExporter = tracesEnabled
|
||||||
? new OTLPTraceExporter({
|
? new OTLPTraceExporter({
|
||||||
...(traceUrl ? { url: traceUrl } : {}),
|
...(traceUrl ? { url: traceUrl } : {}),
|
||||||
@@ -88,20 +92,31 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
|
|||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
sdk = new NodeSDK({
|
if (tracesEnabled || metricsEnabled) {
|
||||||
resource,
|
sdk = new NodeSDK({
|
||||||
...(traceExporter ? { traceExporter } : {}),
|
resource,
|
||||||
...(metricReader ? { metricReader } : {}),
|
...(traceExporter ? { traceExporter } : {}),
|
||||||
...(sampleRate !== undefined
|
...(metricReader ? { metricReader } : {}),
|
||||||
? {
|
...(sampleRate !== undefined
|
||||||
sampler: new ParentBasedSampler({
|
? {
|
||||||
root: new TraceIdRatioBasedSampler(sampleRate),
|
sampler: new ParentBasedSampler({
|
||||||
}),
|
root: new TraceIdRatioBasedSampler(sampleRate),
|
||||||
}
|
}),
|
||||||
: {}),
|
}
|
||||||
});
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
await sdk.start();
|
await sdk.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
const logSeverityMap: Record<string, SeverityNumber> = {
|
||||||
|
TRACE: 1 as SeverityNumber,
|
||||||
|
DEBUG: 5 as SeverityNumber,
|
||||||
|
INFO: 9 as SeverityNumber,
|
||||||
|
WARN: 13 as SeverityNumber,
|
||||||
|
ERROR: 17 as SeverityNumber,
|
||||||
|
FATAL: 21 as SeverityNumber,
|
||||||
|
};
|
||||||
|
|
||||||
const meter = metrics.getMeter("clawdbot");
|
const meter = metrics.getMeter("clawdbot");
|
||||||
const tracer = trace.getTracer("clawdbot");
|
const tracer = trace.getTracer("clawdbot");
|
||||||
@@ -122,38 +137,210 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
|
|||||||
unit: "1",
|
unit: "1",
|
||||||
description: "Context window size and usage",
|
description: "Context window size and usage",
|
||||||
});
|
});
|
||||||
|
const webhookReceivedCounter = meter.createCounter("clawdbot.webhook.received", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Webhook requests received",
|
||||||
|
});
|
||||||
|
const webhookErrorCounter = meter.createCounter("clawdbot.webhook.error", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Webhook processing errors",
|
||||||
|
});
|
||||||
|
const webhookDurationHistogram = meter.createHistogram("clawdbot.webhook.duration_ms", {
|
||||||
|
unit: "ms",
|
||||||
|
description: "Webhook processing duration",
|
||||||
|
});
|
||||||
|
const messageQueuedCounter = meter.createCounter("clawdbot.message.queued", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Messages queued for processing",
|
||||||
|
});
|
||||||
|
const messageProcessedCounter = meter.createCounter("clawdbot.message.processed", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Messages processed by outcome",
|
||||||
|
});
|
||||||
|
const messageDurationHistogram = meter.createHistogram("clawdbot.message.duration_ms", {
|
||||||
|
unit: "ms",
|
||||||
|
description: "Message processing duration",
|
||||||
|
});
|
||||||
|
const queueDepthHistogram = meter.createHistogram("clawdbot.queue.depth", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Queue depth on enqueue/dequeue",
|
||||||
|
});
|
||||||
|
const queueWaitHistogram = meter.createHistogram("clawdbot.queue.wait_ms", {
|
||||||
|
unit: "ms",
|
||||||
|
description: "Queue wait time before execution",
|
||||||
|
});
|
||||||
|
const laneEnqueueCounter = meter.createCounter("clawdbot.queue.lane.enqueue", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Command queue lane enqueue events",
|
||||||
|
});
|
||||||
|
const laneDequeueCounter = meter.createCounter("clawdbot.queue.lane.dequeue", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Command queue lane dequeue events",
|
||||||
|
});
|
||||||
|
const sessionStateCounter = meter.createCounter("clawdbot.session.state", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Session state transitions",
|
||||||
|
});
|
||||||
|
const sessionStuckCounter = meter.createCounter("clawdbot.session.stuck", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Sessions stuck in processing",
|
||||||
|
});
|
||||||
|
const sessionStuckAgeHistogram = meter.createHistogram("clawdbot.session.stuck_age_ms", {
|
||||||
|
unit: "ms",
|
||||||
|
description: "Age of stuck sessions",
|
||||||
|
});
|
||||||
|
const runAttemptCounter = meter.createCounter("clawdbot.run.attempt", {
|
||||||
|
unit: "1",
|
||||||
|
description: "Run attempts",
|
||||||
|
});
|
||||||
|
|
||||||
unsubscribe = onDiagnosticEvent((evt) => {
|
if (logsEnabled) {
|
||||||
if (evt.type !== "model.usage") return;
|
const logExporter = new OTLPLogExporter({
|
||||||
const usageEvent = evt as DiagnosticUsageEvent;
|
...(logUrl ? { url: logUrl } : {}),
|
||||||
|
...(headers ? { headers } : {}),
|
||||||
|
});
|
||||||
|
logProvider = new LoggerProvider({ resource });
|
||||||
|
logProvider.addLogRecordProcessor(
|
||||||
|
new BatchLogRecordProcessor(logExporter, {
|
||||||
|
...(typeof otel.flushIntervalMs === "number"
|
||||||
|
? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const otelLogger = logProvider.getLogger("clawdbot");
|
||||||
|
|
||||||
|
stopLogTransport = registerLogTransport((logObj) => {
|
||||||
|
const safeStringify = (value: unknown) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const meta = (logObj as Record<string, unknown>)._meta as
|
||||||
|
| {
|
||||||
|
logLevelName?: string;
|
||||||
|
date?: Date;
|
||||||
|
name?: string;
|
||||||
|
parentNames?: string[];
|
||||||
|
path?: {
|
||||||
|
filePath?: string;
|
||||||
|
fileLine?: string;
|
||||||
|
fileColumn?: string;
|
||||||
|
filePathWithLine?: string;
|
||||||
|
method?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const logLevelName = meta?.logLevelName ?? "INFO";
|
||||||
|
const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
|
||||||
|
|
||||||
|
const numericArgs = Object.entries(logObj)
|
||||||
|
.filter(([key]) => /^\d+$/.test(key))
|
||||||
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
|
.map(([, value]) => value);
|
||||||
|
|
||||||
|
let bindings: Record<string, unknown> | undefined;
|
||||||
|
if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(numericArgs[0]);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
bindings = parsed as Record<string, unknown>;
|
||||||
|
numericArgs.shift();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed json bindings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = "";
|
||||||
|
if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
|
||||||
|
message = String(numericArgs.pop());
|
||||||
|
} else if (numericArgs.length === 1) {
|
||||||
|
message = safeStringify(numericArgs[0]);
|
||||||
|
numericArgs.length = 0;
|
||||||
|
}
|
||||||
|
if (!message) {
|
||||||
|
message = "log";
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributes: Record<string, string | number | boolean> = {
|
||||||
|
"clawdbot.log.level": logLevelName,
|
||||||
|
};
|
||||||
|
if (meta?.name) attributes["clawdbot.logger"] = meta.name;
|
||||||
|
if (meta?.parentNames?.length) {
|
||||||
|
attributes["clawdbot.logger.parents"] = meta.parentNames.join(".");
|
||||||
|
}
|
||||||
|
if (bindings) {
|
||||||
|
for (const [key, value] of Object.entries(bindings)) {
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||||
|
attributes[`clawdbot.${key}`] = value;
|
||||||
|
} else if (value != null) {
|
||||||
|
attributes[`clawdbot.${key}`] = safeStringify(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (numericArgs.length > 0) {
|
||||||
|
attributes["clawdbot.log.args"] = safeStringify(numericArgs);
|
||||||
|
}
|
||||||
|
if (meta?.path?.filePath) attributes["code.filepath"] = meta.path.filePath;
|
||||||
|
if (meta?.path?.fileLine) attributes["code.lineno"] = Number(meta.path.fileLine);
|
||||||
|
if (meta?.path?.method) attributes["code.function"] = meta.path.method;
|
||||||
|
if (meta?.path?.filePathWithLine) {
|
||||||
|
attributes["clawdbot.code.location"] = meta.path.filePathWithLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
otelLogger.emit({
|
||||||
|
body: message,
|
||||||
|
severityText: logLevelName,
|
||||||
|
severityNumber,
|
||||||
|
attributes,
|
||||||
|
timestamp: meta?.date ?? new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const spanWithDuration = (
|
||||||
|
name: string,
|
||||||
|
attributes: Record<string, string | number>,
|
||||||
|
durationMs?: number,
|
||||||
|
) => {
|
||||||
|
const startTime =
|
||||||
|
typeof durationMs === "number" ? Date.now() - Math.max(0, durationMs) : undefined;
|
||||||
|
const span = tracer.startSpan(name, {
|
||||||
|
attributes,
|
||||||
|
...(startTime ? { startTime } : {}),
|
||||||
|
});
|
||||||
|
return span;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordModelUsage = (evt: Extract<DiagnosticEventPayload, { type: "model.usage" }>) => {
|
||||||
const attrs = {
|
const attrs = {
|
||||||
"clawdbot.channel": usageEvent.channel ?? "unknown",
|
"clawdbot.channel": evt.channel ?? "unknown",
|
||||||
"clawdbot.provider": usageEvent.provider ?? "unknown",
|
"clawdbot.provider": evt.provider ?? "unknown",
|
||||||
"clawdbot.model": usageEvent.model ?? "unknown",
|
"clawdbot.model": evt.model ?? "unknown",
|
||||||
};
|
};
|
||||||
|
|
||||||
const usage = usageEvent.usage;
|
const usage = evt.usage;
|
||||||
if (usage.input) tokensCounter.add(usage.input, { ...attrs, "clawdbot.token": "input" });
|
if (usage.input) tokensCounter.add(usage.input, { ...attrs, "clawdbot.token": "input" });
|
||||||
if (usage.output)
|
if (usage.output) tokensCounter.add(usage.output, { ...attrs, "clawdbot.token": "output" });
|
||||||
tokensCounter.add(usage.output, { ...attrs, "clawdbot.token": "output" });
|
|
||||||
if (usage.cacheRead)
|
if (usage.cacheRead)
|
||||||
tokensCounter.add(usage.cacheRead, { ...attrs, "clawdbot.token": "cache_read" });
|
tokensCounter.add(usage.cacheRead, { ...attrs, "clawdbot.token": "cache_read" });
|
||||||
if (usage.cacheWrite)
|
if (usage.cacheWrite)
|
||||||
tokensCounter.add(usage.cacheWrite, { ...attrs, "clawdbot.token": "cache_write" });
|
tokensCounter.add(usage.cacheWrite, { ...attrs, "clawdbot.token": "cache_write" });
|
||||||
if (usage.promptTokens)
|
if (usage.promptTokens)
|
||||||
tokensCounter.add(usage.promptTokens, { ...attrs, "clawdbot.token": "prompt" });
|
tokensCounter.add(usage.promptTokens, { ...attrs, "clawdbot.token": "prompt" });
|
||||||
if (usage.total)
|
if (usage.total) tokensCounter.add(usage.total, { ...attrs, "clawdbot.token": "total" });
|
||||||
tokensCounter.add(usage.total, { ...attrs, "clawdbot.token": "total" });
|
|
||||||
|
|
||||||
if (usageEvent.costUsd) costCounter.add(usageEvent.costUsd, attrs);
|
if (evt.costUsd) costCounter.add(evt.costUsd, attrs);
|
||||||
if (usageEvent.durationMs) durationHistogram.record(usageEvent.durationMs, attrs);
|
if (evt.durationMs) durationHistogram.record(evt.durationMs, attrs);
|
||||||
if (usageEvent.context?.limit)
|
if (evt.context?.limit)
|
||||||
contextHistogram.record(usageEvent.context.limit, {
|
contextHistogram.record(evt.context.limit, {
|
||||||
...attrs,
|
...attrs,
|
||||||
"clawdbot.context": "limit",
|
"clawdbot.context": "limit",
|
||||||
});
|
});
|
||||||
if (usageEvent.context?.used)
|
if (evt.context?.used)
|
||||||
contextHistogram.record(usageEvent.context.used, {
|
contextHistogram.record(evt.context.used, {
|
||||||
...attrs,
|
...attrs,
|
||||||
"clawdbot.context": "used",
|
"clawdbot.context": "used",
|
||||||
});
|
});
|
||||||
@@ -161,8 +348,8 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
|
|||||||
if (!tracesEnabled) return;
|
if (!tracesEnabled) return;
|
||||||
const spanAttrs: Record<string, string | number> = {
|
const spanAttrs: Record<string, string | number> = {
|
||||||
...attrs,
|
...attrs,
|
||||||
"clawdbot.sessionKey": usageEvent.sessionKey ?? "",
|
"clawdbot.sessionKey": evt.sessionKey ?? "",
|
||||||
"clawdbot.sessionId": usageEvent.sessionId ?? "",
|
"clawdbot.sessionId": evt.sessionId ?? "",
|
||||||
"clawdbot.tokens.input": usage.input ?? 0,
|
"clawdbot.tokens.input": usage.input ?? 0,
|
||||||
"clawdbot.tokens.output": usage.output ?? 0,
|
"clawdbot.tokens.output": usage.output ?? 0,
|
||||||
"clawdbot.tokens.cache_read": usage.cacheRead ?? 0,
|
"clawdbot.tokens.cache_read": usage.cacheRead ?? 0,
|
||||||
@@ -170,23 +357,206 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
|
|||||||
"clawdbot.tokens.total": usage.total ?? 0,
|
"clawdbot.tokens.total": usage.total ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTime = usageEvent.durationMs
|
const span = spanWithDuration("clawdbot.model.usage", spanAttrs, evt.durationMs);
|
||||||
? Date.now() - Math.max(0, usageEvent.durationMs)
|
|
||||||
: undefined;
|
|
||||||
const span = tracer.startSpan("clawdbot.model.usage", {
|
|
||||||
attributes: spanAttrs,
|
|
||||||
...(startTime ? { startTime } : {}),
|
|
||||||
});
|
|
||||||
span.end();
|
span.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordWebhookReceived = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "webhook.received" }>,
|
||||||
|
) => {
|
||||||
|
const attrs = {
|
||||||
|
"clawdbot.channel": evt.channel ?? "unknown",
|
||||||
|
"clawdbot.webhook": evt.updateType ?? "unknown",
|
||||||
|
};
|
||||||
|
webhookReceivedCounter.add(1, attrs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordWebhookProcessed = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "webhook.processed" }>,
|
||||||
|
) => {
|
||||||
|
const attrs = {
|
||||||
|
"clawdbot.channel": evt.channel ?? "unknown",
|
||||||
|
"clawdbot.webhook": evt.updateType ?? "unknown",
|
||||||
|
};
|
||||||
|
if (typeof evt.durationMs === "number") {
|
||||||
|
webhookDurationHistogram.record(evt.durationMs, attrs);
|
||||||
|
}
|
||||||
|
if (!tracesEnabled) return;
|
||||||
|
const spanAttrs: Record<string, string | number> = { ...attrs };
|
||||||
|
if (evt.chatId !== undefined) spanAttrs["clawdbot.chatId"] = String(evt.chatId);
|
||||||
|
const span = spanWithDuration("clawdbot.webhook.processed", spanAttrs, evt.durationMs);
|
||||||
|
span.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordWebhookError = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "webhook.error" }>,
|
||||||
|
) => {
|
||||||
|
const attrs = {
|
||||||
|
"clawdbot.channel": evt.channel ?? "unknown",
|
||||||
|
"clawdbot.webhook": evt.updateType ?? "unknown",
|
||||||
|
};
|
||||||
|
webhookErrorCounter.add(1, attrs);
|
||||||
|
if (!tracesEnabled) return;
|
||||||
|
const spanAttrs: Record<string, string | number> = {
|
||||||
|
...attrs,
|
||||||
|
"clawdbot.error": evt.error,
|
||||||
|
};
|
||||||
|
if (evt.chatId !== undefined) spanAttrs["clawdbot.chatId"] = String(evt.chatId);
|
||||||
|
const span = tracer.startSpan("clawdbot.webhook.error", {
|
||||||
|
attributes: spanAttrs,
|
||||||
|
});
|
||||||
|
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
|
||||||
|
span.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordMessageQueued = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "message.queued" }>,
|
||||||
|
) => {
|
||||||
|
const attrs = {
|
||||||
|
"clawdbot.channel": evt.channel ?? "unknown",
|
||||||
|
"clawdbot.source": evt.source ?? "unknown",
|
||||||
|
};
|
||||||
|
messageQueuedCounter.add(1, attrs);
|
||||||
|
if (typeof evt.queueDepth === "number") {
|
||||||
|
queueDepthHistogram.record(evt.queueDepth, attrs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordMessageProcessed = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
|
||||||
|
) => {
|
||||||
|
const attrs = {
|
||||||
|
"clawdbot.channel": evt.channel ?? "unknown",
|
||||||
|
"clawdbot.outcome": evt.outcome ?? "unknown",
|
||||||
|
};
|
||||||
|
messageProcessedCounter.add(1, attrs);
|
||||||
|
if (typeof evt.durationMs === "number") {
|
||||||
|
messageDurationHistogram.record(evt.durationMs, attrs);
|
||||||
|
}
|
||||||
|
if (!tracesEnabled) return;
|
||||||
|
const spanAttrs: Record<string, string | number> = { ...attrs };
|
||||||
|
if (evt.sessionKey) spanAttrs["clawdbot.sessionKey"] = evt.sessionKey;
|
||||||
|
if (evt.sessionId) spanAttrs["clawdbot.sessionId"] = evt.sessionId;
|
||||||
|
if (evt.chatId !== undefined) spanAttrs["clawdbot.chatId"] = String(evt.chatId);
|
||||||
|
if (evt.messageId !== undefined) spanAttrs["clawdbot.messageId"] = String(evt.messageId);
|
||||||
|
if (evt.reason) spanAttrs["clawdbot.reason"] = evt.reason;
|
||||||
|
const span = spanWithDuration("clawdbot.message.processed", spanAttrs, evt.durationMs);
|
||||||
|
if (evt.outcome === "error") {
|
||||||
|
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
|
||||||
|
}
|
||||||
|
span.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordLaneEnqueue = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.enqueue" }>,
|
||||||
|
) => {
|
||||||
|
const attrs = { "clawdbot.lane": evt.lane };
|
||||||
|
laneEnqueueCounter.add(1, attrs);
|
||||||
|
queueDepthHistogram.record(evt.queueSize, attrs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordLaneDequeue = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.dequeue" }>,
|
||||||
|
) => {
|
||||||
|
const attrs = { "clawdbot.lane": evt.lane };
|
||||||
|
laneDequeueCounter.add(1, attrs);
|
||||||
|
queueDepthHistogram.record(evt.queueSize, attrs);
|
||||||
|
if (typeof evt.waitMs === "number") {
|
||||||
|
queueWaitHistogram.record(evt.waitMs, attrs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordSessionState = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "session.state" }>,
|
||||||
|
) => {
|
||||||
|
const attrs: Record<string, string> = { "clawdbot.state": evt.state };
|
||||||
|
if (evt.reason) attrs["clawdbot.reason"] = evt.reason;
|
||||||
|
sessionStateCounter.add(1, attrs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordSessionStuck = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "session.stuck" }>,
|
||||||
|
) => {
|
||||||
|
const attrs: Record<string, string> = { "clawdbot.state": evt.state };
|
||||||
|
sessionStuckCounter.add(1, attrs);
|
||||||
|
if (typeof evt.ageMs === "number") {
|
||||||
|
sessionStuckAgeHistogram.record(evt.ageMs, attrs);
|
||||||
|
}
|
||||||
|
if (!tracesEnabled) return;
|
||||||
|
const spanAttrs: Record<string, string | number> = { ...attrs };
|
||||||
|
if (evt.sessionKey) spanAttrs["clawdbot.sessionKey"] = evt.sessionKey;
|
||||||
|
if (evt.sessionId) spanAttrs["clawdbot.sessionId"] = evt.sessionId;
|
||||||
|
spanAttrs["clawdbot.queueDepth"] = evt.queueDepth ?? 0;
|
||||||
|
spanAttrs["clawdbot.ageMs"] = evt.ageMs;
|
||||||
|
const span = tracer.startSpan("clawdbot.session.stuck", { attributes: spanAttrs });
|
||||||
|
span.setStatus({ code: SpanStatusCode.ERROR, message: "session stuck" });
|
||||||
|
span.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordRunAttempt = (evt: Extract<DiagnosticEventPayload, { type: "run.attempt" }>) => {
|
||||||
|
runAttemptCounter.add(1, { "clawdbot.attempt": evt.attempt });
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordHeartbeat = (
|
||||||
|
evt: Extract<DiagnosticEventPayload, { type: "diagnostic.heartbeat" }>,
|
||||||
|
) => {
|
||||||
|
queueDepthHistogram.record(evt.queued, { "clawdbot.channel": "heartbeat" });
|
||||||
|
};
|
||||||
|
|
||||||
|
unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {
|
||||||
|
switch (evt.type) {
|
||||||
|
case "model.usage":
|
||||||
|
recordModelUsage(evt);
|
||||||
|
return;
|
||||||
|
case "webhook.received":
|
||||||
|
recordWebhookReceived(evt);
|
||||||
|
return;
|
||||||
|
case "webhook.processed":
|
||||||
|
recordWebhookProcessed(evt);
|
||||||
|
return;
|
||||||
|
case "webhook.error":
|
||||||
|
recordWebhookError(evt);
|
||||||
|
return;
|
||||||
|
case "message.queued":
|
||||||
|
recordMessageQueued(evt);
|
||||||
|
return;
|
||||||
|
case "message.processed":
|
||||||
|
recordMessageProcessed(evt);
|
||||||
|
return;
|
||||||
|
case "queue.lane.enqueue":
|
||||||
|
recordLaneEnqueue(evt);
|
||||||
|
return;
|
||||||
|
case "queue.lane.dequeue":
|
||||||
|
recordLaneDequeue(evt);
|
||||||
|
return;
|
||||||
|
case "session.state":
|
||||||
|
recordSessionState(evt);
|
||||||
|
return;
|
||||||
|
case "session.stuck":
|
||||||
|
recordSessionStuck(evt);
|
||||||
|
return;
|
||||||
|
case "run.attempt":
|
||||||
|
recordRunAttempt(evt);
|
||||||
|
return;
|
||||||
|
case "diagnostic.heartbeat":
|
||||||
|
recordHeartbeat(evt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (otel.logs) {
|
if (logsEnabled) {
|
||||||
ctx.logger.warn("diagnostics-otel: logs exporter not wired yet");
|
ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async stop() {
|
async stop() {
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
unsubscribe = null;
|
unsubscribe = null;
|
||||||
|
stopLogTransport?.();
|
||||||
|
stopLogTransport = null;
|
||||||
|
if (logProvider) {
|
||||||
|
await logProvider.shutdown().catch(() => undefined);
|
||||||
|
logProvider = null;
|
||||||
|
}
|
||||||
if (sdk) {
|
if (sdk) {
|
||||||
await sdk.shutdown().catch(() => undefined);
|
await sdk.shutdown().catch(() => undefined);
|
||||||
sdk = null;
|
sdk = null;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.19-1
|
||||||
|
|
||||||
|
Initial release.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- NIP-04 encrypted DM support (kind:4 events)
|
||||||
|
- Key validation (hex and nsec formats)
|
||||||
|
- Multi-relay support with sequential fallback
|
||||||
|
- Event signature verification
|
||||||
|
- TTL-based deduplication (24h)
|
||||||
|
- Access control via dmPolicy (pairing, allowlist, open, disabled)
|
||||||
|
- Pubkey normalization (hex/npub)
|
||||||
|
|
||||||
|
### Protocol Support
|
||||||
|
|
||||||
|
- NIP-01: Basic event structure
|
||||||
|
- NIP-04: Encrypted direct messages
|
||||||
|
|
||||||
|
### Planned for v2
|
||||||
|
|
||||||
|
- NIP-17: Gift-wrapped DMs
|
||||||
|
- NIP-44: Versioned encryption
|
||||||
|
- Media attachments
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# @clawdbot/nostr
|
||||||
|
|
||||||
|
Nostr DM channel plugin for Clawdbot using NIP-04 encrypted direct messages.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This extension adds Nostr as a messaging channel to Clawdbot. It enables your bot to:
|
||||||
|
|
||||||
|
- Receive encrypted DMs from Nostr users
|
||||||
|
- Send encrypted responses back
|
||||||
|
- Work with any NIP-04 compatible Nostr client (Damus, Amethyst, etc.)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/nostr
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Setup
|
||||||
|
|
||||||
|
1. Generate a Nostr keypair (if you don't have one):
|
||||||
|
```bash
|
||||||
|
# Using nak CLI
|
||||||
|
nak key generate
|
||||||
|
|
||||||
|
# Or use any Nostr key generator
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add to your config:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"nostr": {
|
||||||
|
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
||||||
|
"relays": ["wss://relay.damus.io", "wss://nos.lol"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set the environment variable:
|
||||||
|
```bash
|
||||||
|
export NOSTR_PRIVATE_KEY="nsec1..." # or hex format
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart the gateway
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `privateKey` | string | required | Bot's private key (nsec or hex format) |
|
||||||
|
| `relays` | string[] | `["wss://relay.damus.io", "wss://nos.lol"]` | WebSocket relay URLs |
|
||||||
|
| `dmPolicy` | string | `"pairing"` | Access control: `pairing`, `allowlist`, `open`, `disabled` |
|
||||||
|
| `allowFrom` | string[] | `[]` | Allowed sender pubkeys (npub or hex) |
|
||||||
|
| `enabled` | boolean | `true` | Enable/disable the channel |
|
||||||
|
| `name` | string | - | Display name for the account |
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
### DM Policies
|
||||||
|
|
||||||
|
- **pairing** (default): Unknown senders receive a pairing code to request access
|
||||||
|
- **allowlist**: Only pubkeys in `allowFrom` can message the bot
|
||||||
|
- **open**: Anyone can message the bot (use with caution)
|
||||||
|
- **disabled**: DMs are disabled
|
||||||
|
|
||||||
|
### Example: Allowlist Mode
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"nostr": {
|
||||||
|
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
||||||
|
"dmPolicy": "allowlist",
|
||||||
|
"allowFrom": [
|
||||||
|
"npub1abc...",
|
||||||
|
"0123456789abcdef..."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Local Relay (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using strfry
|
||||||
|
docker run -p 7777:7777 ghcr.io/hoytech/strfry
|
||||||
|
|
||||||
|
# Configure clawdbot to use local relay
|
||||||
|
"relays": ["ws://localhost:7777"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Test
|
||||||
|
|
||||||
|
1. Start the gateway with Nostr configured
|
||||||
|
2. Open Damus, Amethyst, or another Nostr client
|
||||||
|
3. Send a DM to your bot's npub
|
||||||
|
4. Verify the bot responds
|
||||||
|
|
||||||
|
## Protocol Support
|
||||||
|
|
||||||
|
| NIP | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| NIP-01 | Supported | Basic event structure |
|
||||||
|
| NIP-04 | Supported | Encrypted DMs (kind:4) |
|
||||||
|
| NIP-17 | Planned | Gift-wrapped DMs (v2) |
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Private keys are never logged
|
||||||
|
- Event signatures are verified before processing
|
||||||
|
- Use environment variables for keys, never commit to config files
|
||||||
|
- Consider using `allowlist` mode in production
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Bot not receiving messages
|
||||||
|
|
||||||
|
1. Verify private key is correctly configured
|
||||||
|
2. Check relay connectivity
|
||||||
|
3. Ensure `enabled` is not set to `false`
|
||||||
|
4. Check the bot's public key matches what you're sending to
|
||||||
|
|
||||||
|
### Messages not being delivered
|
||||||
|
|
||||||
|
1. Check relay URLs are correct (must use `wss://`)
|
||||||
|
2. Verify relays are online and accepting connections
|
||||||
|
3. Check for rate limiting (reduce message frequency)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "nostr",
|
||||||
|
"channels": [
|
||||||
|
"nostr"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ClawdbotPluginApi, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { nostrPlugin } from "./src/channel.js";
|
||||||
|
import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js";
|
||||||
|
import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
|
||||||
|
import { resolveNostrAccount } from "./src/types.js";
|
||||||
|
import type { NostrProfile } from "./src/config-schema.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "nostr",
|
||||||
|
name: "Nostr",
|
||||||
|
description: "Nostr DM channel plugin via NIP-04",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setNostrRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: nostrPlugin });
|
||||||
|
|
||||||
|
// Register HTTP handler for profile management
|
||||||
|
const httpHandler = createNostrProfileHttpHandler({
|
||||||
|
getConfigProfile: (accountId: string) => {
|
||||||
|
const runtime = getNostrRuntime();
|
||||||
|
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
|
||||||
|
const account = resolveNostrAccount({ cfg, accountId });
|
||||||
|
return account.profile;
|
||||||
|
},
|
||||||
|
updateConfigProfile: async (accountId: string, profile: NostrProfile) => {
|
||||||
|
const runtime = getNostrRuntime();
|
||||||
|
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
|
||||||
|
|
||||||
|
// Build the config patch for channels.nostr.profile
|
||||||
|
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
||||||
|
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const updatedNostrConfig = {
|
||||||
|
...nostrConfig,
|
||||||
|
profile,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedChannels = {
|
||||||
|
...channels,
|
||||||
|
nostr: updatedNostrConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
await runtime.config.writeConfigFile({
|
||||||
|
...cfg,
|
||||||
|
channels: updatedChannels,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getAccountInfo: (accountId: string) => {
|
||||||
|
const runtime = getNostrRuntime();
|
||||||
|
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
|
||||||
|
const account = resolveNostrAccount({ cfg, accountId });
|
||||||
|
if (!account.configured || !account.publicKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pubkey: account.publicKey,
|
||||||
|
relays: account.relays,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
log: api.logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.registerHttpHandler(httpHandler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/nostr",
|
||||||
|
"version": "2026.1.19-1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": ["./index.ts"],
|
||||||
|
"channel": {
|
||||||
|
"id": "nostr",
|
||||||
|
"label": "Nostr",
|
||||||
|
"selectionLabel": "Nostr (NIP-04 DMs)",
|
||||||
|
"docsPath": "/channels/nostr",
|
||||||
|
"docsLabel": "nostr",
|
||||||
|
"blurb": "Decentralized protocol; encrypted DMs via NIP-04.",
|
||||||
|
"order": 55,
|
||||||
|
"quickstartAllowFrom": true
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@clawdbot/nostr",
|
||||||
|
"localPath": "extensions/nostr",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clawdbot": "workspace:*",
|
||||||
|
"nostr-tools": "^2.10.4",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { nostrPlugin } from "./channel.js";
|
||||||
|
|
||||||
|
describe("nostrPlugin", () => {
|
||||||
|
describe("meta", () => {
|
||||||
|
it("has correct id", () => {
|
||||||
|
expect(nostrPlugin.id).toBe("nostr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has required meta fields", () => {
|
||||||
|
expect(nostrPlugin.meta.label).toBe("Nostr");
|
||||||
|
expect(nostrPlugin.meta.docsPath).toBe("/channels/nostr");
|
||||||
|
expect(nostrPlugin.meta.blurb).toContain("NIP-04");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("capabilities", () => {
|
||||||
|
it("supports direct messages", () => {
|
||||||
|
expect(nostrPlugin.capabilities.chatTypes).toContain("direct");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not support groups (MVP)", () => {
|
||||||
|
expect(nostrPlugin.capabilities.chatTypes).not.toContain("group");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not support media (MVP)", () => {
|
||||||
|
expect(nostrPlugin.capabilities.media).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("config adapter", () => {
|
||||||
|
it("has required config functions", () => {
|
||||||
|
expect(nostrPlugin.config.listAccountIds).toBeTypeOf("function");
|
||||||
|
expect(nostrPlugin.config.resolveAccount).toBeTypeOf("function");
|
||||||
|
expect(nostrPlugin.config.isConfigured).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listAccountIds returns empty array for unconfigured", () => {
|
||||||
|
const cfg = { channels: {} };
|
||||||
|
const ids = nostrPlugin.config.listAccountIds(cfg);
|
||||||
|
expect(ids).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listAccountIds returns default for configured", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
nostr: {
|
||||||
|
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ids = nostrPlugin.config.listAccountIds(cfg);
|
||||||
|
expect(ids).toContain("default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("messaging", () => {
|
||||||
|
it("has target resolver", () => {
|
||||||
|
expect(nostrPlugin.messaging?.targetResolver?.looksLikeId).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes npub as valid target", () => {
|
||||||
|
const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
|
||||||
|
if (!looksLikeId) return;
|
||||||
|
|
||||||
|
expect(looksLikeId("npub1xyz123")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes hex pubkey as valid target", () => {
|
||||||
|
const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
|
||||||
|
if (!looksLikeId) return;
|
||||||
|
|
||||||
|
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(looksLikeId(hexPubkey)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid input", () => {
|
||||||
|
const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
|
||||||
|
if (!looksLikeId) return;
|
||||||
|
|
||||||
|
expect(looksLikeId("not-a-pubkey")).toBe(false);
|
||||||
|
expect(looksLikeId("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizeTarget strips nostr: prefix", () => {
|
||||||
|
const normalize = nostrPlugin.messaging?.normalizeTarget;
|
||||||
|
if (!normalize) return;
|
||||||
|
|
||||||
|
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outbound", () => {
|
||||||
|
it("has correct delivery mode", () => {
|
||||||
|
expect(nostrPlugin.outbound?.deliveryMode).toBe("direct");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has reasonable text chunk limit", () => {
|
||||||
|
expect(nostrPlugin.outbound?.textChunkLimit).toBe(4000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pairing", () => {
|
||||||
|
it("has id label for pairing", () => {
|
||||||
|
expect(nostrPlugin.pairing?.idLabel).toBe("nostrPubkey");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes nostr: prefix in allow entries", () => {
|
||||||
|
const normalize = nostrPlugin.pairing?.normalizeAllowEntry;
|
||||||
|
if (!normalize) return;
|
||||||
|
|
||||||
|
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("security", () => {
|
||||||
|
it("has resolveDmPolicy function", () => {
|
||||||
|
expect(nostrPlugin.security?.resolveDmPolicy).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gateway", () => {
|
||||||
|
it("has startAccount function", () => {
|
||||||
|
expect(nostrPlugin.gateway?.startAccount).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("status", () => {
|
||||||
|
it("has default runtime", () => {
|
||||||
|
expect(nostrPlugin.status?.defaultRuntime).toBeDefined();
|
||||||
|
expect(nostrPlugin.status?.defaultRuntime?.accountId).toBe("default");
|
||||||
|
expect(nostrPlugin.status?.defaultRuntime?.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has buildAccountSnapshot function", () => {
|
||||||
|
expect(nostrPlugin.status?.buildAccountSnapshot).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import {
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
type ChannelPlugin,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { NostrConfigSchema } from "./config-schema.js";
|
||||||
|
import { getNostrRuntime } from "./runtime.js";
|
||||||
|
import {
|
||||||
|
listNostrAccountIds,
|
||||||
|
resolveDefaultNostrAccountId,
|
||||||
|
resolveNostrAccount,
|
||||||
|
type ResolvedNostrAccount,
|
||||||
|
} from "./types.js";
|
||||||
|
import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
|
||||||
|
import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
|
||||||
|
import type { NostrProfile } from "./config-schema.js";
|
||||||
|
import type { ProfilePublishResult } from "./nostr-profile.js";
|
||||||
|
|
||||||
|
// Store active bus handles per account
|
||||||
|
const activeBuses = new Map<string, NostrBusHandle>();
|
||||||
|
|
||||||
|
// Store metrics snapshots per account (for status reporting)
|
||||||
|
const metricsSnapshots = new Map<string, MetricsSnapshot>();
|
||||||
|
|
||||||
|
export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
||||||
|
id: "nostr",
|
||||||
|
meta: {
|
||||||
|
id: "nostr",
|
||||||
|
label: "Nostr",
|
||||||
|
selectionLabel: "Nostr",
|
||||||
|
docsPath: "/channels/nostr",
|
||||||
|
docsLabel: "nostr",
|
||||||
|
blurb: "Decentralized DMs via Nostr relays (NIP-04)",
|
||||||
|
order: 100,
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct"], // DMs only for MVP
|
||||||
|
media: false, // No media for MVP
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.nostr"] },
|
||||||
|
configSchema: buildChannelConfigSchema(NostrConfigSchema),
|
||||||
|
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg) => listNostrAccountIds(cfg),
|
||||||
|
resolveAccount: (cfg, accountId) => resolveNostrAccount({ cfg, accountId }),
|
||||||
|
defaultAccountId: (cfg) => resolveDefaultNostrAccountId(cfg),
|
||||||
|
isConfigured: (account) => account.configured,
|
||||||
|
describeAccount: (account) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
publicKey: account.publicKey,
|
||||||
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(resolveNostrAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
||||||
|
String(entry)
|
||||||
|
),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => {
|
||||||
|
if (entry === "*") return "*";
|
||||||
|
try {
|
||||||
|
return normalizePubkey(entry);
|
||||||
|
} catch {
|
||||||
|
return entry; // Keep as-is if normalization fails
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
},
|
||||||
|
|
||||||
|
pairing: {
|
||||||
|
idLabel: "nostrPubkey",
|
||||||
|
normalizeAllowEntry: (entry) => {
|
||||||
|
try {
|
||||||
|
return normalizePubkey(entry.replace(/^nostr:/i, ""));
|
||||||
|
} catch {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notifyApproval: async ({ id }) => {
|
||||||
|
// Get the default account's bus and send approval message
|
||||||
|
const bus = activeBuses.get(DEFAULT_ACCOUNT_ID);
|
||||||
|
if (bus) {
|
||||||
|
await bus.sendDm(id, "Your pairing request has been approved!");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
security: {
|
||||||
|
resolveDmPolicy: ({ account }) => {
|
||||||
|
return {
|
||||||
|
policy: account.config.dmPolicy ?? "pairing",
|
||||||
|
allowFrom: account.config.allowFrom ?? [],
|
||||||
|
policyPath: "channels.nostr.dmPolicy",
|
||||||
|
allowFromPath: "channels.nostr.allowFrom",
|
||||||
|
approveHint: formatPairingApproveHint("nostr"),
|
||||||
|
normalizeEntry: (raw) => {
|
||||||
|
try {
|
||||||
|
return normalizePubkey(raw.replace(/^nostr:/i, "").trim());
|
||||||
|
} catch {
|
||||||
|
return raw.trim();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: (target) => {
|
||||||
|
// Strip nostr: prefix if present
|
||||||
|
const cleaned = target.replace(/^nostr:/i, "").trim();
|
||||||
|
try {
|
||||||
|
return normalizePubkey(cleaned);
|
||||||
|
} catch {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
targetResolver: {
|
||||||
|
looksLikeId: (input) => {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
|
||||||
|
},
|
||||||
|
hint: "<npub|hex pubkey|nostr:npub...>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
textChunkLimit: 4000,
|
||||||
|
sendText: async ({ to, text, accountId }) => {
|
||||||
|
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const bus = activeBuses.get(aid);
|
||||||
|
if (!bus) {
|
||||||
|
throw new Error(`Nostr bus not running for account ${aid}`);
|
||||||
|
}
|
||||||
|
const normalizedTo = normalizePubkey(to);
|
||||||
|
await bus.sendDm(normalizedTo, text);
|
||||||
|
return { channel: "nostr", to: normalizedTo };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
collectStatusIssues: (accounts) =>
|
||||||
|
accounts.flatMap((account) => {
|
||||||
|
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||||
|
if (!lastError) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
channel: "nostr",
|
||||||
|
accountId: account.accountId,
|
||||||
|
kind: "runtime" as const,
|
||||||
|
message: `Channel error: ${lastError}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
publicKey: snapshot.publicKey ?? null,
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
}),
|
||||||
|
buildAccountSnapshot: ({ account, runtime }) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
publicKey: account.publicKey,
|
||||||
|
profile: account.profile,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const account = ctx.account;
|
||||||
|
ctx.setStatus({
|
||||||
|
accountId: account.accountId,
|
||||||
|
publicKey: account.publicKey,
|
||||||
|
});
|
||||||
|
ctx.log?.info(`[${account.accountId}] starting Nostr provider (pubkey: ${account.publicKey})`);
|
||||||
|
|
||||||
|
if (!account.configured) {
|
||||||
|
throw new Error("Nostr private key not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = getNostrRuntime();
|
||||||
|
|
||||||
|
// Track bus handle for metrics callback
|
||||||
|
let busHandle: NostrBusHandle | null = null;
|
||||||
|
|
||||||
|
const bus = await startNostrBus({
|
||||||
|
accountId: account.accountId,
|
||||||
|
privateKey: account.privateKey,
|
||||||
|
relays: account.relays,
|
||||||
|
onMessage: async (senderPubkey, text, reply) => {
|
||||||
|
ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`);
|
||||||
|
|
||||||
|
// Forward to clawdbot's message pipeline
|
||||||
|
await runtime.channel.reply.handleInboundMessage({
|
||||||
|
channel: "nostr",
|
||||||
|
accountId: account.accountId,
|
||||||
|
senderId: senderPubkey,
|
||||||
|
chatType: "direct",
|
||||||
|
chatId: senderPubkey, // For DMs, chatId is the sender's pubkey
|
||||||
|
text,
|
||||||
|
reply: async (responseText: string) => {
|
||||||
|
await reply(responseText);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error, context) => {
|
||||||
|
ctx.log?.error(`[${account.accountId}] Nostr error (${context}): ${error.message}`);
|
||||||
|
},
|
||||||
|
onConnect: (relay) => {
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Connected to relay: ${relay}`);
|
||||||
|
},
|
||||||
|
onDisconnect: (relay) => {
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Disconnected from relay: ${relay}`);
|
||||||
|
},
|
||||||
|
onEose: (relays) => {
|
||||||
|
ctx.log?.debug(`[${account.accountId}] EOSE received from relays: ${relays}`);
|
||||||
|
},
|
||||||
|
onMetric: (event: MetricEvent) => {
|
||||||
|
// Log significant metrics at appropriate levels
|
||||||
|
if (event.name.startsWith("event.rejected.")) {
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Metric: ${event.name}`, event.labels);
|
||||||
|
} else if (event.name === "relay.circuit_breaker.open") {
|
||||||
|
ctx.log?.warn(`[${account.accountId}] Circuit breaker opened for relay: ${event.labels?.relay}`);
|
||||||
|
} else if (event.name === "relay.circuit_breaker.close") {
|
||||||
|
ctx.log?.info(`[${account.accountId}] Circuit breaker closed for relay: ${event.labels?.relay}`);
|
||||||
|
} else if (event.name === "relay.error") {
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Relay error: ${event.labels?.relay}`);
|
||||||
|
}
|
||||||
|
// Update cached metrics snapshot
|
||||||
|
if (busHandle) {
|
||||||
|
metricsSnapshots.set(account.accountId, busHandle.getMetrics());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
busHandle = bus;
|
||||||
|
|
||||||
|
// Store the bus handle
|
||||||
|
activeBuses.set(account.accountId, bus);
|
||||||
|
|
||||||
|
ctx.log?.info(`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
bus.close();
|
||||||
|
activeBuses.delete(account.accountId);
|
||||||
|
metricsSnapshots.delete(account.accountId);
|
||||||
|
ctx.log?.info(`[${account.accountId}] Nostr provider stopped`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metrics snapshot for a Nostr account.
|
||||||
|
* Returns undefined if account is not running.
|
||||||
|
*/
|
||||||
|
export function getNostrMetrics(accountId: string = DEFAULT_ACCOUNT_ID): MetricsSnapshot | undefined {
|
||||||
|
const bus = activeBuses.get(accountId);
|
||||||
|
if (bus) {
|
||||||
|
return bus.getMetrics();
|
||||||
|
}
|
||||||
|
return metricsSnapshots.get(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active Nostr bus handles.
|
||||||
|
* Useful for debugging and status reporting.
|
||||||
|
*/
|
||||||
|
export function getActiveNostrBuses(): Map<string, NostrBusHandle> {
|
||||||
|
return new Map(activeBuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a profile (kind:0) for a Nostr account.
|
||||||
|
* @param accountId - Account ID (defaults to "default")
|
||||||
|
* @param profile - Profile data to publish
|
||||||
|
* @returns Publish results with successes and failures
|
||||||
|
* @throws Error if account is not running
|
||||||
|
*/
|
||||||
|
export async function publishNostrProfile(
|
||||||
|
accountId: string = DEFAULT_ACCOUNT_ID,
|
||||||
|
profile: NostrProfile
|
||||||
|
): Promise<ProfilePublishResult> {
|
||||||
|
const bus = activeBuses.get(accountId);
|
||||||
|
if (!bus) {
|
||||||
|
throw new Error(`Nostr bus not running for account ${accountId}`);
|
||||||
|
}
|
||||||
|
return bus.publishProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get profile publish state for a Nostr account.
|
||||||
|
* @param accountId - Account ID (defaults to "default")
|
||||||
|
* @returns Profile publish state or null if account not running
|
||||||
|
*/
|
||||||
|
export async function getNostrProfileState(
|
||||||
|
accountId: string = DEFAULT_ACCOUNT_ID
|
||||||
|
): Promise<{
|
||||||
|
lastPublishedAt: number | null;
|
||||||
|
lastPublishedEventId: string | null;
|
||||||
|
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
||||||
|
} | null> {
|
||||||
|
const bus = activeBuses.get(accountId);
|
||||||
|
if (!bus) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return bus.getProfileState();
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates https:// URLs only (no javascript:, data:, file:, etc.)
|
||||||
|
*/
|
||||||
|
const safeUrlSchema = z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.refine(
|
||||||
|
(url) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ message: "URL must use https:// protocol" }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-01 profile metadata schema
|
||||||
|
* https://github.com/nostr-protocol/nips/blob/master/01.md
|
||||||
|
*/
|
||||||
|
export const NostrProfileSchema = z.object({
|
||||||
|
/** Username (NIP-01: name) - max 256 chars */
|
||||||
|
name: z.string().max(256).optional(),
|
||||||
|
|
||||||
|
/** Display name (NIP-01: display_name) - max 256 chars */
|
||||||
|
displayName: z.string().max(256).optional(),
|
||||||
|
|
||||||
|
/** Bio/description (NIP-01: about) - max 2000 chars */
|
||||||
|
about: z.string().max(2000).optional(),
|
||||||
|
|
||||||
|
/** Profile picture URL (must be https) */
|
||||||
|
picture: safeUrlSchema.optional(),
|
||||||
|
|
||||||
|
/** Banner image URL (must be https) */
|
||||||
|
banner: safeUrlSchema.optional(),
|
||||||
|
|
||||||
|
/** Website URL (must be https) */
|
||||||
|
website: safeUrlSchema.optional(),
|
||||||
|
|
||||||
|
/** NIP-05 identifier (e.g., "user@example.com") */
|
||||||
|
nip05: z.string().optional(),
|
||||||
|
|
||||||
|
/** Lightning address (LUD-16) */
|
||||||
|
lud16: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NostrProfile = z.infer<typeof NostrProfileSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schema for channels.nostr.* configuration
|
||||||
|
*/
|
||||||
|
export const NostrConfigSchema = z.object({
|
||||||
|
/** Account name (optional display name) */
|
||||||
|
name: z.string().optional(),
|
||||||
|
|
||||||
|
/** Whether this channel is enabled */
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
|
||||||
|
/** Private key in hex or nsec bech32 format */
|
||||||
|
privateKey: z.string().optional(),
|
||||||
|
|
||||||
|
/** WebSocket relay URLs to connect to */
|
||||||
|
relays: z.array(z.string()).optional(),
|
||||||
|
|
||||||
|
/** DM access policy: pairing, allowlist, open, or disabled */
|
||||||
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||||
|
|
||||||
|
/** Allowed sender pubkeys (npub or hex format) */
|
||||||
|
allowFrom: z.array(allowFromEntry).optional(),
|
||||||
|
|
||||||
|
/** Profile metadata (NIP-01 kind:0 content) */
|
||||||
|
profile: NostrProfileSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NostrConfig = z.infer<typeof NostrConfigSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Schema for Control UI (converted from Zod)
|
||||||
|
*/
|
||||||
|
export const nostrChannelConfigSchema = buildChannelConfigSchema(NostrConfigSchema);
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* Comprehensive metrics system for Nostr bus observability.
|
||||||
|
* Provides clear insight into what's happening with events, relays, and operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type EventMetricName =
|
||||||
|
| "event.received"
|
||||||
|
| "event.processed"
|
||||||
|
| "event.duplicate"
|
||||||
|
| "event.rejected.invalid_shape"
|
||||||
|
| "event.rejected.wrong_kind"
|
||||||
|
| "event.rejected.stale"
|
||||||
|
| "event.rejected.future"
|
||||||
|
| "event.rejected.rate_limited"
|
||||||
|
| "event.rejected.invalid_signature"
|
||||||
|
| "event.rejected.oversized_ciphertext"
|
||||||
|
| "event.rejected.oversized_plaintext"
|
||||||
|
| "event.rejected.decrypt_failed"
|
||||||
|
| "event.rejected.self_message";
|
||||||
|
|
||||||
|
export type RelayMetricName =
|
||||||
|
| "relay.connect"
|
||||||
|
| "relay.disconnect"
|
||||||
|
| "relay.reconnect"
|
||||||
|
| "relay.error"
|
||||||
|
| "relay.message.event"
|
||||||
|
| "relay.message.eose"
|
||||||
|
| "relay.message.closed"
|
||||||
|
| "relay.message.notice"
|
||||||
|
| "relay.message.ok"
|
||||||
|
| "relay.message.auth"
|
||||||
|
| "relay.circuit_breaker.open"
|
||||||
|
| "relay.circuit_breaker.close"
|
||||||
|
| "relay.circuit_breaker.half_open";
|
||||||
|
|
||||||
|
export type RateLimitMetricName = "rate_limit.per_sender" | "rate_limit.global";
|
||||||
|
|
||||||
|
export type DecryptMetricName = "decrypt.success" | "decrypt.failure";
|
||||||
|
|
||||||
|
export type MemoryMetricName =
|
||||||
|
| "memory.seen_tracker_size"
|
||||||
|
| "memory.rate_limiter_entries";
|
||||||
|
|
||||||
|
export type MetricName =
|
||||||
|
| EventMetricName
|
||||||
|
| RelayMetricName
|
||||||
|
| RateLimitMetricName
|
||||||
|
| DecryptMetricName
|
||||||
|
| MemoryMetricName;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metric Event
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MetricEvent {
|
||||||
|
/** Metric name (e.g., "event.received", "relay.connect") */
|
||||||
|
name: MetricName;
|
||||||
|
/** Metric value (usually 1 for counters, or a measured value) */
|
||||||
|
value: number;
|
||||||
|
/** Unix timestamp in milliseconds */
|
||||||
|
timestamp: number;
|
||||||
|
/** Optional labels for additional context */
|
||||||
|
labels?: Record<string, string | number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OnMetricCallback = (event: MetricEvent) => void;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metrics Snapshot (for getMetrics())
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MetricsSnapshot {
|
||||||
|
/** Total events received (before any filtering) */
|
||||||
|
eventsReceived: number;
|
||||||
|
/** Events successfully processed */
|
||||||
|
eventsProcessed: number;
|
||||||
|
/** Duplicate events skipped */
|
||||||
|
eventsDuplicate: number;
|
||||||
|
/** Events rejected by reason */
|
||||||
|
eventsRejected: {
|
||||||
|
invalidShape: number;
|
||||||
|
wrongKind: number;
|
||||||
|
stale: number;
|
||||||
|
future: number;
|
||||||
|
rateLimited: number;
|
||||||
|
invalidSignature: number;
|
||||||
|
oversizedCiphertext: number;
|
||||||
|
oversizedPlaintext: number;
|
||||||
|
decryptFailed: number;
|
||||||
|
selfMessage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Relay stats by URL */
|
||||||
|
relays: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
connects: number;
|
||||||
|
disconnects: number;
|
||||||
|
reconnects: number;
|
||||||
|
errors: number;
|
||||||
|
messagesReceived: {
|
||||||
|
event: number;
|
||||||
|
eose: number;
|
||||||
|
closed: number;
|
||||||
|
notice: number;
|
||||||
|
ok: number;
|
||||||
|
auth: number;
|
||||||
|
};
|
||||||
|
circuitBreakerState: "closed" | "open" | "half_open";
|
||||||
|
circuitBreakerOpens: number;
|
||||||
|
circuitBreakerCloses: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Rate limiting stats */
|
||||||
|
rateLimiting: {
|
||||||
|
perSenderHits: number;
|
||||||
|
globalHits: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Decrypt stats */
|
||||||
|
decrypt: {
|
||||||
|
success: number;
|
||||||
|
failure: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Memory/capacity stats */
|
||||||
|
memory: {
|
||||||
|
seenTrackerSize: number;
|
||||||
|
rateLimiterEntries: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Snapshot timestamp */
|
||||||
|
snapshotAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metrics Collector
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NostrMetrics {
|
||||||
|
/** Emit a metric event */
|
||||||
|
emit: (
|
||||||
|
name: MetricName,
|
||||||
|
value?: number,
|
||||||
|
labels?: Record<string, string | number>
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** Get current metrics snapshot */
|
||||||
|
getSnapshot: () => MetricsSnapshot;
|
||||||
|
|
||||||
|
/** Reset all metrics to zero */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a metrics collector instance.
|
||||||
|
* Optionally pass an onMetric callback to receive real-time metric events.
|
||||||
|
*/
|
||||||
|
export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
|
||||||
|
// Counters
|
||||||
|
let eventsReceived = 0;
|
||||||
|
let eventsProcessed = 0;
|
||||||
|
let eventsDuplicate = 0;
|
||||||
|
const eventsRejected = {
|
||||||
|
invalidShape: 0,
|
||||||
|
wrongKind: 0,
|
||||||
|
stale: 0,
|
||||||
|
future: 0,
|
||||||
|
rateLimited: 0,
|
||||||
|
invalidSignature: 0,
|
||||||
|
oversizedCiphertext: 0,
|
||||||
|
oversizedPlaintext: 0,
|
||||||
|
decryptFailed: 0,
|
||||||
|
selfMessage: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-relay stats
|
||||||
|
const relays = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
connects: number;
|
||||||
|
disconnects: number;
|
||||||
|
reconnects: number;
|
||||||
|
errors: number;
|
||||||
|
messagesReceived: {
|
||||||
|
event: number;
|
||||||
|
eose: number;
|
||||||
|
closed: number;
|
||||||
|
notice: number;
|
||||||
|
ok: number;
|
||||||
|
auth: number;
|
||||||
|
};
|
||||||
|
circuitBreakerState: "closed" | "open" | "half_open";
|
||||||
|
circuitBreakerOpens: number;
|
||||||
|
circuitBreakerCloses: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Rate limiting stats
|
||||||
|
const rateLimiting = {
|
||||||
|
perSenderHits: 0,
|
||||||
|
globalHits: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decrypt stats
|
||||||
|
const decrypt = {
|
||||||
|
success: 0,
|
||||||
|
failure: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memory stats (updated via gauge-style metrics)
|
||||||
|
const memory = {
|
||||||
|
seenTrackerSize: 0,
|
||||||
|
rateLimiterEntries: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getOrCreateRelay(url: string) {
|
||||||
|
let relay = relays.get(url);
|
||||||
|
if (!relay) {
|
||||||
|
relay = {
|
||||||
|
connects: 0,
|
||||||
|
disconnects: 0,
|
||||||
|
reconnects: 0,
|
||||||
|
errors: 0,
|
||||||
|
messagesReceived: {
|
||||||
|
event: 0,
|
||||||
|
eose: 0,
|
||||||
|
closed: 0,
|
||||||
|
notice: 0,
|
||||||
|
ok: 0,
|
||||||
|
auth: 0,
|
||||||
|
},
|
||||||
|
circuitBreakerState: "closed",
|
||||||
|
circuitBreakerOpens: 0,
|
||||||
|
circuitBreakerCloses: 0,
|
||||||
|
};
|
||||||
|
relays.set(url, relay);
|
||||||
|
}
|
||||||
|
return relay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(
|
||||||
|
name: MetricName,
|
||||||
|
value: number = 1,
|
||||||
|
labels?: Record<string, string | number>
|
||||||
|
): void {
|
||||||
|
// Fire callback if provided
|
||||||
|
if (onMetric) {
|
||||||
|
onMetric({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
labels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update internal counters
|
||||||
|
const relayUrl = labels?.relay as string | undefined;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
// Event metrics
|
||||||
|
case "event.received":
|
||||||
|
eventsReceived += value;
|
||||||
|
break;
|
||||||
|
case "event.processed":
|
||||||
|
eventsProcessed += value;
|
||||||
|
break;
|
||||||
|
case "event.duplicate":
|
||||||
|
eventsDuplicate += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.invalid_shape":
|
||||||
|
eventsRejected.invalidShape += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.wrong_kind":
|
||||||
|
eventsRejected.wrongKind += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.stale":
|
||||||
|
eventsRejected.stale += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.future":
|
||||||
|
eventsRejected.future += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.rate_limited":
|
||||||
|
eventsRejected.rateLimited += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.invalid_signature":
|
||||||
|
eventsRejected.invalidSignature += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.oversized_ciphertext":
|
||||||
|
eventsRejected.oversizedCiphertext += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.oversized_plaintext":
|
||||||
|
eventsRejected.oversizedPlaintext += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.decrypt_failed":
|
||||||
|
eventsRejected.decryptFailed += value;
|
||||||
|
break;
|
||||||
|
case "event.rejected.self_message":
|
||||||
|
eventsRejected.selfMessage += value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Relay metrics
|
||||||
|
case "relay.connect":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).connects += value;
|
||||||
|
break;
|
||||||
|
case "relay.disconnect":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).disconnects += value;
|
||||||
|
break;
|
||||||
|
case "relay.reconnect":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).reconnects += value;
|
||||||
|
break;
|
||||||
|
case "relay.error":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).errors += value;
|
||||||
|
break;
|
||||||
|
case "relay.message.event":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.event += value;
|
||||||
|
break;
|
||||||
|
case "relay.message.eose":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.eose += value;
|
||||||
|
break;
|
||||||
|
case "relay.message.closed":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.closed += value;
|
||||||
|
break;
|
||||||
|
case "relay.message.notice":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.notice += value;
|
||||||
|
break;
|
||||||
|
case "relay.message.ok":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.ok += value;
|
||||||
|
break;
|
||||||
|
case "relay.message.auth":
|
||||||
|
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.auth += value;
|
||||||
|
break;
|
||||||
|
case "relay.circuit_breaker.open":
|
||||||
|
if (relayUrl) {
|
||||||
|
const r = getOrCreateRelay(relayUrl);
|
||||||
|
r.circuitBreakerState = "open";
|
||||||
|
r.circuitBreakerOpens += value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "relay.circuit_breaker.close":
|
||||||
|
if (relayUrl) {
|
||||||
|
const r = getOrCreateRelay(relayUrl);
|
||||||
|
r.circuitBreakerState = "closed";
|
||||||
|
r.circuitBreakerCloses += value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "relay.circuit_breaker.half_open":
|
||||||
|
if (relayUrl) {
|
||||||
|
getOrCreateRelay(relayUrl).circuitBreakerState = "half_open";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
case "rate_limit.per_sender":
|
||||||
|
rateLimiting.perSenderHits += value;
|
||||||
|
break;
|
||||||
|
case "rate_limit.global":
|
||||||
|
rateLimiting.globalHits += value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
case "decrypt.success":
|
||||||
|
decrypt.success += value;
|
||||||
|
break;
|
||||||
|
case "decrypt.failure":
|
||||||
|
decrypt.failure += value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Memory (gauge-style - value replaces, not adds)
|
||||||
|
case "memory.seen_tracker_size":
|
||||||
|
memory.seenTrackerSize = value;
|
||||||
|
break;
|
||||||
|
case "memory.rate_limiter_entries":
|
||||||
|
memory.rateLimiterEntries = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): MetricsSnapshot {
|
||||||
|
// Convert relay map to object
|
||||||
|
const relaysObj: MetricsSnapshot["relays"] = {};
|
||||||
|
for (const [url, stats] of relays) {
|
||||||
|
relaysObj[url] = { ...stats, messagesReceived: { ...stats.messagesReceived } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventsReceived,
|
||||||
|
eventsProcessed,
|
||||||
|
eventsDuplicate,
|
||||||
|
eventsRejected: { ...eventsRejected },
|
||||||
|
relays: relaysObj,
|
||||||
|
rateLimiting: { ...rateLimiting },
|
||||||
|
decrypt: { ...decrypt },
|
||||||
|
memory: { ...memory },
|
||||||
|
snapshotAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
eventsReceived = 0;
|
||||||
|
eventsProcessed = 0;
|
||||||
|
eventsDuplicate = 0;
|
||||||
|
Object.assign(eventsRejected, {
|
||||||
|
invalidShape: 0,
|
||||||
|
wrongKind: 0,
|
||||||
|
stale: 0,
|
||||||
|
future: 0,
|
||||||
|
rateLimited: 0,
|
||||||
|
invalidSignature: 0,
|
||||||
|
oversizedCiphertext: 0,
|
||||||
|
oversizedPlaintext: 0,
|
||||||
|
decryptFailed: 0,
|
||||||
|
selfMessage: 0,
|
||||||
|
});
|
||||||
|
relays.clear();
|
||||||
|
rateLimiting.perSenderHits = 0;
|
||||||
|
rateLimiting.globalHits = 0;
|
||||||
|
decrypt.success = 0;
|
||||||
|
decrypt.failure = 0;
|
||||||
|
memory.seenTrackerSize = 0;
|
||||||
|
memory.rateLimiterEntries = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { emit, getSnapshot, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a no-op metrics instance (for when metrics are disabled).
|
||||||
|
*/
|
||||||
|
export function createNoopMetrics(): NostrMetrics {
|
||||||
|
const emptySnapshot: MetricsSnapshot = {
|
||||||
|
eventsReceived: 0,
|
||||||
|
eventsProcessed: 0,
|
||||||
|
eventsDuplicate: 0,
|
||||||
|
eventsRejected: {
|
||||||
|
invalidShape: 0,
|
||||||
|
wrongKind: 0,
|
||||||
|
stale: 0,
|
||||||
|
future: 0,
|
||||||
|
rateLimited: 0,
|
||||||
|
invalidSignature: 0,
|
||||||
|
oversizedCiphertext: 0,
|
||||||
|
oversizedPlaintext: 0,
|
||||||
|
decryptFailed: 0,
|
||||||
|
selfMessage: 0,
|
||||||
|
},
|
||||||
|
relays: {},
|
||||||
|
rateLimiting: { perSenderHits: 0, globalHits: 0 },
|
||||||
|
decrypt: { success: 0, failure: 0 },
|
||||||
|
memory: { seenTrackerSize: 0, rateLimiterEntries: 0 },
|
||||||
|
snapshotAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
emit: () => {},
|
||||||
|
getSnapshot: () => ({ ...emptySnapshot, snapshotAt: Date.now() }),
|
||||||
|
reset: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js";
|
||||||
|
import { createSeenTracker } from "./seen-tracker.js";
|
||||||
|
import { createMetrics, type MetricName } from "./metrics.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fuzz Tests for validatePrivateKey
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("validatePrivateKey fuzz", () => {
|
||||||
|
describe("type confusion", () => {
|
||||||
|
it("rejects null input", () => {
|
||||||
|
expect(() => validatePrivateKey(null as unknown as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects undefined input", () => {
|
||||||
|
expect(() => validatePrivateKey(undefined as unknown as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects number input", () => {
|
||||||
|
expect(() => validatePrivateKey(123 as unknown as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects boolean input", () => {
|
||||||
|
expect(() => validatePrivateKey(true as unknown as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects object input", () => {
|
||||||
|
expect(() => validatePrivateKey({} as unknown as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects array input", () => {
|
||||||
|
expect(() => validatePrivateKey([] as unknown as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects function input", () => {
|
||||||
|
expect(() => validatePrivateKey((() => {}) as unknown as string)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unicode attacks", () => {
|
||||||
|
it("rejects unicode lookalike characters", () => {
|
||||||
|
// Using zero-width characters
|
||||||
|
const withZeroWidth =
|
||||||
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf";
|
||||||
|
expect(() => validatePrivateKey(withZeroWidth)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects RTL override", () => {
|
||||||
|
const withRtl =
|
||||||
|
"\u202E0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(() => validatePrivateKey(withRtl)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects homoglyph 'a' (Cyrillic а)", () => {
|
||||||
|
// Using Cyrillic 'а' (U+0430) instead of Latin 'a'
|
||||||
|
const withCyrillicA =
|
||||||
|
"0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(() => validatePrivateKey(withCyrillicA)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects emoji", () => {
|
||||||
|
const withEmoji =
|
||||||
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀";
|
||||||
|
expect(() => validatePrivateKey(withEmoji)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects combining characters", () => {
|
||||||
|
// 'a' followed by combining acute accent
|
||||||
|
const withCombining =
|
||||||
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301";
|
||||||
|
expect(() => validatePrivateKey(withCombining)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("injection attempts", () => {
|
||||||
|
it("rejects null byte injection", () => {
|
||||||
|
const withNullByte =
|
||||||
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f";
|
||||||
|
expect(() => validatePrivateKey(withNullByte)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects newline injection", () => {
|
||||||
|
const withNewline =
|
||||||
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf";
|
||||||
|
expect(() => validatePrivateKey(withNewline)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects carriage return injection", () => {
|
||||||
|
const withCR =
|
||||||
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf";
|
||||||
|
expect(() => validatePrivateKey(withCR)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tab injection", () => {
|
||||||
|
const withTab =
|
||||||
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf";
|
||||||
|
expect(() => validatePrivateKey(withTab)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects form feed injection", () => {
|
||||||
|
const withFormFeed =
|
||||||
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff";
|
||||||
|
expect(() => validatePrivateKey(withFormFeed)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("rejects very long string", () => {
|
||||||
|
const veryLong = "a".repeat(10000);
|
||||||
|
expect(() => validatePrivateKey(veryLong)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects string of spaces matching length", () => {
|
||||||
|
const spaces = " ".repeat(64);
|
||||||
|
expect(() => validatePrivateKey(spaces)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects hex with spaces between characters", () => {
|
||||||
|
const withSpaces =
|
||||||
|
"01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef";
|
||||||
|
expect(() => validatePrivateKey(withSpaces)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nsec format edge cases", () => {
|
||||||
|
it("rejects nsec with invalid bech32 characters", () => {
|
||||||
|
// 'b', 'i', 'o' are not valid bech32 characters
|
||||||
|
const invalidBech32 = "nsec1qypqxpq9qtpqscx7peytbfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
|
||||||
|
expect(() => validatePrivateKey(invalidBech32)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects nsec with wrong prefix", () => {
|
||||||
|
expect(() => validatePrivateKey("nsec0aaaa")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects partial nsec", () => {
|
||||||
|
expect(() => validatePrivateKey("nsec1")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fuzz Tests for isValidPubkey
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("isValidPubkey fuzz", () => {
|
||||||
|
describe("type confusion", () => {
|
||||||
|
it("handles null gracefully", () => {
|
||||||
|
expect(isValidPubkey(null as unknown as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles undefined gracefully", () => {
|
||||||
|
expect(isValidPubkey(undefined as unknown as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles number gracefully", () => {
|
||||||
|
expect(isValidPubkey(123 as unknown as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles object gracefully", () => {
|
||||||
|
expect(isValidPubkey({} as unknown as string)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("malicious inputs", () => {
|
||||||
|
it("rejects __proto__ key", () => {
|
||||||
|
expect(isValidPubkey("__proto__")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects constructor key", () => {
|
||||||
|
expect(isValidPubkey("constructor")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects toString key", () => {
|
||||||
|
expect(isValidPubkey("toString")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fuzz Tests for normalizePubkey
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("normalizePubkey fuzz", () => {
|
||||||
|
describe("prototype pollution attempts", () => {
|
||||||
|
it("throws for __proto__", () => {
|
||||||
|
expect(() => normalizePubkey("__proto__")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for constructor", () => {
|
||||||
|
expect(() => normalizePubkey("constructor")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for prototype", () => {
|
||||||
|
expect(() => normalizePubkey("prototype")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("case sensitivity", () => {
|
||||||
|
it("normalizes uppercase to lowercase", () => {
|
||||||
|
const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||||
|
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(normalizePubkey(upper)).toBe(lower);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes mixed case to lowercase", () => {
|
||||||
|
const mixed = "0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf";
|
||||||
|
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(normalizePubkey(mixed)).toBe(lower);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fuzz Tests for SeenTracker
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("SeenTracker fuzz", () => {
|
||||||
|
describe("malformed IDs", () => {
|
||||||
|
it("handles empty string IDs", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||||
|
expect(() => tracker.add("")).not.toThrow();
|
||||||
|
expect(tracker.peek("")).toBe(true);
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles very long IDs", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||||
|
const longId = "a".repeat(100000);
|
||||||
|
expect(() => tracker.add(longId)).not.toThrow();
|
||||||
|
expect(tracker.peek(longId)).toBe(true);
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unicode IDs", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||||
|
const unicodeId = "事件ID_🎉_тест";
|
||||||
|
expect(() => tracker.add(unicodeId)).not.toThrow();
|
||||||
|
expect(tracker.peek(unicodeId)).toBe(true);
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles IDs with null bytes", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||||
|
const idWithNull = "event\x00id";
|
||||||
|
expect(() => tracker.add(idWithNull)).not.toThrow();
|
||||||
|
expect(tracker.peek(idWithNull)).toBe(true);
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles prototype property names as IDs", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||||
|
|
||||||
|
// These should not affect the tracker's internal operation
|
||||||
|
expect(() => tracker.add("__proto__")).not.toThrow();
|
||||||
|
expect(() => tracker.add("constructor")).not.toThrow();
|
||||||
|
expect(() => tracker.add("toString")).not.toThrow();
|
||||||
|
expect(() => tracker.add("hasOwnProperty")).not.toThrow();
|
||||||
|
|
||||||
|
expect(tracker.peek("__proto__")).toBe(true);
|
||||||
|
expect(tracker.peek("constructor")).toBe(true);
|
||||||
|
expect(tracker.peek("toString")).toBe(true);
|
||||||
|
expect(tracker.peek("hasOwnProperty")).toBe(true);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rapid operations", () => {
|
||||||
|
it("handles rapid add/check cycles", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 1000 });
|
||||||
|
|
||||||
|
for (let i = 0; i < 10000; i++) {
|
||||||
|
const id = `event-${i}`;
|
||||||
|
tracker.add(id);
|
||||||
|
// Recently added should be findable
|
||||||
|
if (i < 1000) {
|
||||||
|
tracker.peek(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size should be capped at maxEntries
|
||||||
|
expect(tracker.size()).toBeLessThanOrEqual(1000);
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles concurrent-style operations", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||||
|
|
||||||
|
// Simulate interleaved operations
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
tracker.add(`add-${i}`);
|
||||||
|
tracker.peek(`peek-${i}`);
|
||||||
|
tracker.has(`has-${i}`);
|
||||||
|
if (i % 10 === 0) {
|
||||||
|
tracker.delete(`add-${i - 5}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => tracker.size()).not.toThrow();
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seed edge cases", () => {
|
||||||
|
it("handles empty seed array", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||||
|
expect(() => tracker.seed([])).not.toThrow();
|
||||||
|
expect(tracker.size()).toBe(0);
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles seed with duplicate IDs", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100 });
|
||||||
|
tracker.seed(["id1", "id1", "id1", "id2", "id2"]);
|
||||||
|
expect(tracker.size()).toBe(2);
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles seed larger than maxEntries", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 5 });
|
||||||
|
const ids = Array.from({ length: 100 }, (_, i) => `id-${i}`);
|
||||||
|
tracker.seed(ids);
|
||||||
|
expect(tracker.size()).toBeLessThanOrEqual(5);
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fuzz Tests for Metrics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("Metrics fuzz", () => {
|
||||||
|
describe("invalid metric names", () => {
|
||||||
|
it("handles unknown metric names gracefully", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
// Cast to bypass type checking - testing runtime behavior
|
||||||
|
expect(() => {
|
||||||
|
metrics.emit("invalid.metric.name" as MetricName);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid label values", () => {
|
||||||
|
it("handles null relay label", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
expect(() => {
|
||||||
|
metrics.emit("relay.connect", 1, { relay: null as unknown as string });
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles undefined relay label", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
expect(() => {
|
||||||
|
metrics.emit("relay.connect", 1, { relay: undefined as unknown as string });
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles very long relay URL", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
const longUrl = "wss://" + "a".repeat(10000) + ".com";
|
||||||
|
expect(() => {
|
||||||
|
metrics.emit("relay.connect", 1, { relay: longUrl });
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.relays[longUrl]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extreme values", () => {
|
||||||
|
it("handles NaN value", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
expect(() => metrics.emit("event.received", NaN)).not.toThrow();
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(isNaN(snapshot.eventsReceived)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Infinity value", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
expect(() => metrics.emit("event.received", Infinity)).not.toThrow();
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.eventsReceived).toBe(Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles negative value", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
metrics.emit("event.received", -1);
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.eventsReceived).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles very large value", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
metrics.emit("event.received", Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.eventsReceived).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rapid emissions", () => {
|
||||||
|
it("handles many rapid emissions", () => {
|
||||||
|
const events: unknown[] = [];
|
||||||
|
const metrics = createMetrics((e) => events.push(e));
|
||||||
|
|
||||||
|
for (let i = 0; i < 10000; i++) {
|
||||||
|
metrics.emit("event.received");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(events).toHaveLength(10000);
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.eventsReceived).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset during operation", () => {
|
||||||
|
it("handles reset mid-operation safely", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
metrics.emit("event.received");
|
||||||
|
metrics.emit("event.received");
|
||||||
|
metrics.reset();
|
||||||
|
metrics.emit("event.received");
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.eventsReceived).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Event Shape Validation (simulating malformed events)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("Event shape validation", () => {
|
||||||
|
describe("malformed event structures", () => {
|
||||||
|
// These test what happens if malformed data somehow gets through
|
||||||
|
|
||||||
|
it("identifies missing required fields", () => {
|
||||||
|
const malformedEvents = [
|
||||||
|
{}, // empty
|
||||||
|
{ id: "abc" }, // missing pubkey, created_at, etc.
|
||||||
|
{ id: null, pubkey: null }, // null values
|
||||||
|
{ id: 123, pubkey: 456 }, // wrong types
|
||||||
|
{ tags: "not-an-array" }, // wrong type for tags
|
||||||
|
{ tags: [[1, 2, 3]] }, // wrong type for tag elements
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const event of malformedEvents) {
|
||||||
|
// These should be caught by shape validation before processing
|
||||||
|
const hasId = typeof event?.id === "string";
|
||||||
|
const hasPubkey = typeof (event as { pubkey?: unknown })?.pubkey === "string";
|
||||||
|
const hasTags = Array.isArray((event as { tags?: unknown })?.tags);
|
||||||
|
|
||||||
|
// At least one should be invalid
|
||||||
|
expect(hasId && hasPubkey && hasTags).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("timestamp edge cases", () => {
|
||||||
|
const testTimestamps = [
|
||||||
|
{ value: NaN, desc: "NaN" },
|
||||||
|
{ value: Infinity, desc: "Infinity" },
|
||||||
|
{ value: -Infinity, desc: "-Infinity" },
|
||||||
|
{ value: -1, desc: "negative" },
|
||||||
|
{ value: 0, desc: "zero" },
|
||||||
|
{ value: 253402300800, desc: "year 10000" }, // Far future
|
||||||
|
{ value: -62135596800, desc: "year 0001" }, // Far past
|
||||||
|
{ value: 1.5, desc: "float" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { value, desc } of testTimestamps) {
|
||||||
|
it(`handles ${desc} timestamp`, () => {
|
||||||
|
const isValidTimestamp =
|
||||||
|
typeof value === "number" &&
|
||||||
|
!isNaN(value) &&
|
||||||
|
isFinite(value) &&
|
||||||
|
value >= 0 &&
|
||||||
|
Number.isInteger(value);
|
||||||
|
|
||||||
|
// Timestamps should be validated as positive integers
|
||||||
|
if (["NaN", "Infinity", "-Infinity", "negative", "float"].includes(desc)) {
|
||||||
|
expect(isValidTimestamp).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JSON parsing edge cases (simulating relay responses)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("JSON parsing edge cases", () => {
|
||||||
|
const malformedJsonCases = [
|
||||||
|
{ input: "", desc: "empty string" },
|
||||||
|
{ input: "null", desc: "null literal" },
|
||||||
|
{ input: "undefined", desc: "undefined literal" },
|
||||||
|
{ input: "{", desc: "incomplete object" },
|
||||||
|
{ input: "[", desc: "incomplete array" },
|
||||||
|
{ input: '{"key": undefined}', desc: "undefined value" },
|
||||||
|
{ input: "{'key': 'value'}", desc: "single quotes" },
|
||||||
|
{ input: '{"key": NaN}', desc: "NaN value" },
|
||||||
|
{ input: '{"key": Infinity}', desc: "Infinity value" },
|
||||||
|
{ input: "\x00", desc: "null byte" },
|
||||||
|
{ input: "abc", desc: "plain string" },
|
||||||
|
{ input: "123", desc: "plain number" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { input, desc } of malformedJsonCases) {
|
||||||
|
it(`handles malformed JSON: ${desc}`, () => {
|
||||||
|
let parsed: unknown;
|
||||||
|
let parseError = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(input);
|
||||||
|
} catch {
|
||||||
|
parseError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either it throws or produces something that needs validation
|
||||||
|
if (!parseError) {
|
||||||
|
// If it parsed, we need to validate the structure
|
||||||
|
const isValidRelayMessage =
|
||||||
|
Array.isArray(parsed) &&
|
||||||
|
parsed.length >= 2 &&
|
||||||
|
typeof parsed[0] === "string";
|
||||||
|
|
||||||
|
// Most malformed cases won't produce valid relay messages
|
||||||
|
if (["null literal", "plain number", "plain string"].includes(desc)) {
|
||||||
|
expect(isValidRelayMessage).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { createSeenTracker } from "./seen-tracker.js";
|
||||||
|
import {
|
||||||
|
createMetrics,
|
||||||
|
createNoopMetrics,
|
||||||
|
type MetricEvent,
|
||||||
|
} from "./metrics.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Seen Tracker Integration Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("SeenTracker", () => {
|
||||||
|
describe("basic operations", () => {
|
||||||
|
it("tracks seen IDs", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||||
|
|
||||||
|
// First check returns false and adds
|
||||||
|
expect(tracker.has("id1")).toBe(false);
|
||||||
|
// Second check returns true (already seen)
|
||||||
|
expect(tracker.has("id1")).toBe(true);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("peek does not add", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||||
|
|
||||||
|
expect(tracker.peek("id1")).toBe(false);
|
||||||
|
expect(tracker.peek("id1")).toBe(false); // Still false
|
||||||
|
|
||||||
|
tracker.add("id1");
|
||||||
|
expect(tracker.peek("id1")).toBe(true);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete removes entries", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||||
|
|
||||||
|
tracker.add("id1");
|
||||||
|
expect(tracker.peek("id1")).toBe(true);
|
||||||
|
|
||||||
|
tracker.delete("id1");
|
||||||
|
expect(tracker.peek("id1")).toBe(false);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear removes all entries", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||||
|
|
||||||
|
tracker.add("id1");
|
||||||
|
tracker.add("id2");
|
||||||
|
tracker.add("id3");
|
||||||
|
expect(tracker.size()).toBe(3);
|
||||||
|
|
||||||
|
tracker.clear();
|
||||||
|
expect(tracker.size()).toBe(0);
|
||||||
|
expect(tracker.peek("id1")).toBe(false);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seed pre-populates entries", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
|
||||||
|
|
||||||
|
tracker.seed(["id1", "id2", "id3"]);
|
||||||
|
expect(tracker.size()).toBe(3);
|
||||||
|
expect(tracker.peek("id1")).toBe(true);
|
||||||
|
expect(tracker.peek("id2")).toBe(true);
|
||||||
|
expect(tracker.peek("id3")).toBe(true);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LRU eviction", () => {
|
||||||
|
it("evicts least recently used when at capacity", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
|
||||||
|
|
||||||
|
tracker.add("id1");
|
||||||
|
tracker.add("id2");
|
||||||
|
tracker.add("id3");
|
||||||
|
expect(tracker.size()).toBe(3);
|
||||||
|
|
||||||
|
// Adding fourth should evict oldest (id1)
|
||||||
|
tracker.add("id4");
|
||||||
|
expect(tracker.size()).toBe(3);
|
||||||
|
expect(tracker.peek("id1")).toBe(false); // Evicted
|
||||||
|
expect(tracker.peek("id2")).toBe(true);
|
||||||
|
expect(tracker.peek("id3")).toBe(true);
|
||||||
|
expect(tracker.peek("id4")).toBe(true);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accessing an entry moves it to front (prevents eviction)", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
|
||||||
|
|
||||||
|
tracker.add("id1");
|
||||||
|
tracker.add("id2");
|
||||||
|
tracker.add("id3");
|
||||||
|
|
||||||
|
// Access id1, moving it to front
|
||||||
|
tracker.has("id1");
|
||||||
|
|
||||||
|
// Add id4 - should evict id2 (now oldest)
|
||||||
|
tracker.add("id4");
|
||||||
|
expect(tracker.peek("id1")).toBe(true); // Not evicted, was accessed
|
||||||
|
expect(tracker.peek("id2")).toBe(false); // Evicted
|
||||||
|
expect(tracker.peek("id3")).toBe(true);
|
||||||
|
expect(tracker.peek("id4")).toBe(true);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles capacity of 1", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 1, ttlMs: 60000 });
|
||||||
|
|
||||||
|
tracker.add("id1");
|
||||||
|
expect(tracker.peek("id1")).toBe(true);
|
||||||
|
|
||||||
|
tracker.add("id2");
|
||||||
|
expect(tracker.peek("id1")).toBe(false);
|
||||||
|
expect(tracker.peek("id2")).toBe(true);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seed respects maxEntries", () => {
|
||||||
|
const tracker = createSeenTracker({ maxEntries: 2, ttlMs: 60000 });
|
||||||
|
|
||||||
|
tracker.seed(["id1", "id2", "id3", "id4"]);
|
||||||
|
expect(tracker.size()).toBe(2);
|
||||||
|
// Seed stops when maxEntries reached, processing from end to start
|
||||||
|
// So id4 and id3 get added first, then we're at capacity
|
||||||
|
expect(tracker.peek("id3")).toBe(true);
|
||||||
|
expect(tracker.peek("id4")).toBe(true);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TTL expiration", () => {
|
||||||
|
it("expires entries after TTL", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const tracker = createSeenTracker({
|
||||||
|
maxEntries: 100,
|
||||||
|
ttlMs: 100,
|
||||||
|
pruneIntervalMs: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.add("id1");
|
||||||
|
expect(tracker.peek("id1")).toBe(true);
|
||||||
|
|
||||||
|
// Advance past TTL
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
// Entry should be expired
|
||||||
|
expect(tracker.peek("id1")).toBe(false);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has() refreshes TTL", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const tracker = createSeenTracker({
|
||||||
|
maxEntries: 100,
|
||||||
|
ttlMs: 100,
|
||||||
|
pruneIntervalMs: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.add("id1");
|
||||||
|
|
||||||
|
// Advance halfway
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
// Access to refresh
|
||||||
|
expect(tracker.has("id1")).toBe(true);
|
||||||
|
|
||||||
|
// Advance another 75ms (total 125ms from add, but only 75ms from last access)
|
||||||
|
vi.advanceTimersByTime(75);
|
||||||
|
|
||||||
|
// Should still be valid (refreshed at 50ms)
|
||||||
|
expect(tracker.peek("id1")).toBe(true);
|
||||||
|
|
||||||
|
tracker.stop();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metrics Integration Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("Metrics", () => {
|
||||||
|
describe("createMetrics", () => {
|
||||||
|
it("emits metric events to callback", () => {
|
||||||
|
const events: MetricEvent[] = [];
|
||||||
|
const metrics = createMetrics((event) => events.push(event));
|
||||||
|
|
||||||
|
metrics.emit("event.received");
|
||||||
|
metrics.emit("event.processed");
|
||||||
|
metrics.emit("event.duplicate");
|
||||||
|
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
expect(events[0].name).toBe("event.received");
|
||||||
|
expect(events[1].name).toBe("event.processed");
|
||||||
|
expect(events[2].name).toBe("event.duplicate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes labels in metric events", () => {
|
||||||
|
const events: MetricEvent[] = [];
|
||||||
|
const metrics = createMetrics((event) => events.push(event));
|
||||||
|
|
||||||
|
metrics.emit("relay.connect", 1, { relay: "wss://relay.example.com" });
|
||||||
|
|
||||||
|
expect(events[0].labels).toEqual({ relay: "wss://relay.example.com" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates counters in snapshot", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
metrics.emit("event.received");
|
||||||
|
metrics.emit("event.received");
|
||||||
|
metrics.emit("event.processed");
|
||||||
|
metrics.emit("event.duplicate");
|
||||||
|
metrics.emit("event.duplicate");
|
||||||
|
metrics.emit("event.duplicate");
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.eventsReceived).toBe(2);
|
||||||
|
expect(snapshot.eventsProcessed).toBe(1);
|
||||||
|
expect(snapshot.eventsDuplicate).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks per-relay stats", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
metrics.emit("relay.connect", 1, { relay: "wss://relay1.com" });
|
||||||
|
metrics.emit("relay.connect", 1, { relay: "wss://relay2.com" });
|
||||||
|
metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
|
||||||
|
metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.relays["wss://relay1.com"]).toBeDefined();
|
||||||
|
expect(snapshot.relays["wss://relay1.com"].connects).toBe(1);
|
||||||
|
expect(snapshot.relays["wss://relay1.com"].errors).toBe(2);
|
||||||
|
expect(snapshot.relays["wss://relay2.com"].connects).toBe(1);
|
||||||
|
expect(snapshot.relays["wss://relay2.com"].errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks circuit breaker state changes", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
|
||||||
|
|
||||||
|
let snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("open");
|
||||||
|
expect(snapshot.relays["wss://relay.com"].circuitBreakerOpens).toBe(1);
|
||||||
|
|
||||||
|
metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
|
||||||
|
|
||||||
|
snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("closed");
|
||||||
|
expect(snapshot.relays["wss://relay.com"].circuitBreakerCloses).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks all rejection reasons", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
metrics.emit("event.rejected.invalid_shape");
|
||||||
|
metrics.emit("event.rejected.wrong_kind");
|
||||||
|
metrics.emit("event.rejected.stale");
|
||||||
|
metrics.emit("event.rejected.future");
|
||||||
|
metrics.emit("event.rejected.rate_limited");
|
||||||
|
metrics.emit("event.rejected.invalid_signature");
|
||||||
|
metrics.emit("event.rejected.oversized_ciphertext");
|
||||||
|
metrics.emit("event.rejected.oversized_plaintext");
|
||||||
|
metrics.emit("event.rejected.decrypt_failed");
|
||||||
|
metrics.emit("event.rejected.self_message");
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.eventsRejected.invalidShape).toBe(1);
|
||||||
|
expect(snapshot.eventsRejected.wrongKind).toBe(1);
|
||||||
|
expect(snapshot.eventsRejected.stale).toBe(1);
|
||||||
|
expect(snapshot.eventsRejected.future).toBe(1);
|
||||||
|
expect(snapshot.eventsRejected.rateLimited).toBe(1);
|
||||||
|
expect(snapshot.eventsRejected.invalidSignature).toBe(1);
|
||||||
|
expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1);
|
||||||
|
expect(snapshot.eventsRejected.oversizedPlaintext).toBe(1);
|
||||||
|
expect(snapshot.eventsRejected.decryptFailed).toBe(1);
|
||||||
|
expect(snapshot.eventsRejected.selfMessage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks relay message types", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
metrics.emit("relay.message.event", 1, { relay: "wss://relay.com" });
|
||||||
|
metrics.emit("relay.message.eose", 1, { relay: "wss://relay.com" });
|
||||||
|
metrics.emit("relay.message.closed", 1, { relay: "wss://relay.com" });
|
||||||
|
metrics.emit("relay.message.notice", 1, { relay: "wss://relay.com" });
|
||||||
|
metrics.emit("relay.message.ok", 1, { relay: "wss://relay.com" });
|
||||||
|
metrics.emit("relay.message.auth", 1, { relay: "wss://relay.com" });
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
const relay = snapshot.relays["wss://relay.com"];
|
||||||
|
expect(relay.messagesReceived.event).toBe(1);
|
||||||
|
expect(relay.messagesReceived.eose).toBe(1);
|
||||||
|
expect(relay.messagesReceived.closed).toBe(1);
|
||||||
|
expect(relay.messagesReceived.notice).toBe(1);
|
||||||
|
expect(relay.messagesReceived.ok).toBe(1);
|
||||||
|
expect(relay.messagesReceived.auth).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks decrypt success/failure", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
metrics.emit("decrypt.success");
|
||||||
|
metrics.emit("decrypt.success");
|
||||||
|
metrics.emit("decrypt.failure");
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.decrypt.success).toBe(2);
|
||||||
|
expect(snapshot.decrypt.failure).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks memory gauges (replaces rather than accumulates)", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
metrics.emit("memory.seen_tracker_size", 100);
|
||||||
|
metrics.emit("memory.seen_tracker_size", 150);
|
||||||
|
metrics.emit("memory.seen_tracker_size", 125);
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.memory.seenTrackerSize).toBe(125); // Last value, not sum
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset clears all counters", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
metrics.emit("event.received");
|
||||||
|
metrics.emit("event.processed");
|
||||||
|
metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
|
||||||
|
|
||||||
|
metrics.reset();
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.eventsReceived).toBe(0);
|
||||||
|
expect(snapshot.eventsProcessed).toBe(0);
|
||||||
|
expect(Object.keys(snapshot.relays)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createNoopMetrics", () => {
|
||||||
|
it("does not throw on emit", () => {
|
||||||
|
const metrics = createNoopMetrics();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
metrics.emit("event.received");
|
||||||
|
metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty snapshot", () => {
|
||||||
|
const metrics = createNoopMetrics();
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.eventsReceived).toBe(0);
|
||||||
|
expect(snapshot.eventsProcessed).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Circuit Breaker Behavior Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("Circuit Breaker Behavior", () => {
|
||||||
|
// Test the circuit breaker logic through metrics emissions
|
||||||
|
it("emits circuit breaker metrics in correct sequence", () => {
|
||||||
|
const events: MetricEvent[] = [];
|
||||||
|
const metrics = createMetrics((event) => events.push(event));
|
||||||
|
|
||||||
|
// Simulate 5 failures -> open
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
metrics.emit("relay.error", 1, { relay: "wss://relay.com" });
|
||||||
|
}
|
||||||
|
metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
|
||||||
|
|
||||||
|
// Simulate recovery
|
||||||
|
metrics.emit("relay.circuit_breaker.half_open", 1, { relay: "wss://relay.com" });
|
||||||
|
metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
|
||||||
|
|
||||||
|
const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker"));
|
||||||
|
expect(cbEvents).toHaveLength(3);
|
||||||
|
expect(cbEvents[0].name).toBe("relay.circuit_breaker.open");
|
||||||
|
expect(cbEvents[1].name).toBe("relay.circuit_breaker.half_open");
|
||||||
|
expect(cbEvents[2].name).toBe("relay.circuit_breaker.close");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Health Scoring Behavior Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("Health Scoring", () => {
|
||||||
|
it("metrics track relay errors for health scoring", () => {
|
||||||
|
const metrics = createMetrics();
|
||||||
|
|
||||||
|
// Simulate mixed success/failure pattern
|
||||||
|
metrics.emit("relay.connect", 1, { relay: "wss://good-relay.com" });
|
||||||
|
metrics.emit("relay.connect", 1, { relay: "wss://bad-relay.com" });
|
||||||
|
|
||||||
|
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
|
||||||
|
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
|
||||||
|
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
|
||||||
|
|
||||||
|
const snapshot = metrics.getSnapshot();
|
||||||
|
expect(snapshot.relays["wss://good-relay.com"].errors).toBe(0);
|
||||||
|
expect(snapshot.relays["wss://bad-relay.com"].errors).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Reconnect Backoff Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("Reconnect Backoff", () => {
|
||||||
|
it("computes delays within expected bounds", () => {
|
||||||
|
// Compute expected delays (1s, 2s, 4s, 8s, 16s, 32s, 60s cap)
|
||||||
|
const BASE = 1000;
|
||||||
|
const MAX = 60000;
|
||||||
|
const JITTER = 0.3;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 10; attempt++) {
|
||||||
|
const exponential = BASE * Math.pow(2, attempt);
|
||||||
|
const capped = Math.min(exponential, MAX);
|
||||||
|
const minDelay = capped * (1 - JITTER);
|
||||||
|
const maxDelay = capped * (1 + JITTER);
|
||||||
|
|
||||||
|
// These are the expected bounds
|
||||||
|
expect(minDelay).toBeGreaterThanOrEqual(BASE * 0.7);
|
||||||
|
expect(maxDelay).toBeLessThanOrEqual(MAX * 1.3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
validatePrivateKey,
|
||||||
|
getPublicKeyFromPrivate,
|
||||||
|
isValidPubkey,
|
||||||
|
normalizePubkey,
|
||||||
|
pubkeyToNpub,
|
||||||
|
} from "./nostr-bus.js";
|
||||||
|
|
||||||
|
// Test private key (DO NOT use in production - this is a known test key)
|
||||||
|
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
|
||||||
|
|
||||||
|
describe("validatePrivateKey", () => {
|
||||||
|
describe("hex format", () => {
|
||||||
|
it("accepts valid 64-char hex key", () => {
|
||||||
|
const result = validatePrivateKey(TEST_HEX_KEY);
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(result.length).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts lowercase hex", () => {
|
||||||
|
const result = validatePrivateKey(TEST_HEX_KEY.toLowerCase());
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts uppercase hex", () => {
|
||||||
|
const result = validatePrivateKey(TEST_HEX_KEY.toUpperCase());
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts mixed case hex", () => {
|
||||||
|
const mixed = "0123456789ABCdef0123456789abcDEF0123456789abcdef0123456789ABCDEF";
|
||||||
|
const result = validatePrivateKey(mixed);
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace", () => {
|
||||||
|
const result = validatePrivateKey(` ${TEST_HEX_KEY} `);
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims newlines", () => {
|
||||||
|
const result = validatePrivateKey(`${TEST_HEX_KEY}\n`);
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 63-char hex (too short)", () => {
|
||||||
|
expect(() => validatePrivateKey(TEST_HEX_KEY.slice(0, 63))).toThrow(
|
||||||
|
"Private key must be 64 hex characters"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 65-char hex (too long)", () => {
|
||||||
|
expect(() => validatePrivateKey(TEST_HEX_KEY + "0")).toThrow(
|
||||||
|
"Private key must be 64 hex characters"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-hex characters", () => {
|
||||||
|
const invalid = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg"; // 'g' at end
|
||||||
|
expect(() => validatePrivateKey(invalid)).toThrow("Private key must be 64 hex characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty string", () => {
|
||||||
|
expect(() => validatePrivateKey("")).toThrow("Private key must be 64 hex characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects whitespace-only string", () => {
|
||||||
|
expect(() => validatePrivateKey(" ")).toThrow("Private key must be 64 hex characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects key with 0x prefix", () => {
|
||||||
|
expect(() => validatePrivateKey("0x" + TEST_HEX_KEY)).toThrow(
|
||||||
|
"Private key must be 64 hex characters"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nsec format", () => {
|
||||||
|
it("rejects invalid nsec (wrong checksum)", () => {
|
||||||
|
const badNsec = "nsec1invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalid";
|
||||||
|
expect(() => validatePrivateKey(badNsec)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects npub (wrong type)", () => {
|
||||||
|
const npub = "npub1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8s5epk55";
|
||||||
|
expect(() => validatePrivateKey(npub)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidPubkey", () => {
|
||||||
|
describe("hex format", () => {
|
||||||
|
it("accepts valid 64-char hex pubkey", () => {
|
||||||
|
const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(isValidPubkey(validHex)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts uppercase hex", () => {
|
||||||
|
const validHex = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||||
|
expect(isValidPubkey(validHex)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 63-char hex", () => {
|
||||||
|
const shortHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde";
|
||||||
|
expect(isValidPubkey(shortHex)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 65-char hex", () => {
|
||||||
|
const longHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0";
|
||||||
|
expect(isValidPubkey(longHex)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-hex characters", () => {
|
||||||
|
const invalid = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg";
|
||||||
|
expect(isValidPubkey(invalid)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("npub format", () => {
|
||||||
|
it("rejects invalid npub", () => {
|
||||||
|
expect(isValidPubkey("npub1invalid")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects nsec (wrong type)", () => {
|
||||||
|
expect(isValidPubkey(TEST_NSEC)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("rejects empty string", () => {
|
||||||
|
expect(isValidPubkey("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles whitespace-padded input", () => {
|
||||||
|
const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(isValidPubkey(` ${validHex} `)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizePubkey", () => {
|
||||||
|
describe("hex format", () => {
|
||||||
|
it("lowercases hex pubkey", () => {
|
||||||
|
const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||||
|
const result = normalizePubkey(upper);
|
||||||
|
expect(result).toBe(upper.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace", () => {
|
||||||
|
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
expect(normalizePubkey(` ${hex} `)).toBe(hex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid hex", () => {
|
||||||
|
expect(() => normalizePubkey("invalid")).toThrow("Pubkey must be 64 hex characters");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPublicKeyFromPrivate", () => {
|
||||||
|
it("derives public key from hex private key", () => {
|
||||||
|
const pubkey = getPublicKeyFromPrivate(TEST_HEX_KEY);
|
||||||
|
expect(pubkey).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(pubkey.length).toBe(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives consistent public key", () => {
|
||||||
|
const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_KEY);
|
||||||
|
const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_KEY);
|
||||||
|
expect(pubkey1).toBe(pubkey2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for invalid private key", () => {
|
||||||
|
expect(() => getPublicKeyFromPrivate("invalid")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pubkeyToNpub", () => {
|
||||||
|
it("converts hex pubkey to npub format", () => {
|
||||||
|
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
const npub = pubkeyToNpub(hex);
|
||||||
|
expect(npub).toMatch(/^npub1[a-z0-9]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces consistent output", () => {
|
||||||
|
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
const npub1 = pubkeyToNpub(hex);
|
||||||
|
const npub2 = pubkeyToNpub(hex);
|
||||||
|
expect(npub1).toBe(npub2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes uppercase hex first", () => {
|
||||||
|
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
const upper = lower.toUpperCase();
|
||||||
|
expect(pubkeyToNpub(lower)).toBe(pubkeyToNpub(upper));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,741 @@
|
|||||||
|
import {
|
||||||
|
SimplePool,
|
||||||
|
finalizeEvent,
|
||||||
|
getPublicKey,
|
||||||
|
verifyEvent,
|
||||||
|
nip19,
|
||||||
|
type Event,
|
||||||
|
} from "nostr-tools";
|
||||||
|
import { decrypt, encrypt } from "nostr-tools/nip04";
|
||||||
|
|
||||||
|
import {
|
||||||
|
readNostrBusState,
|
||||||
|
writeNostrBusState,
|
||||||
|
computeSinceTimestamp,
|
||||||
|
readNostrProfileState,
|
||||||
|
writeNostrProfileState,
|
||||||
|
} from "./nostr-state-store.js";
|
||||||
|
import {
|
||||||
|
publishProfile as publishProfileFn,
|
||||||
|
type ProfilePublishResult,
|
||||||
|
} from "./nostr-profile.js";
|
||||||
|
import type { NostrProfile } from "./config-schema.js";
|
||||||
|
import { createSeenTracker, type SeenTracker } from "./seen-tracker.js";
|
||||||
|
import {
|
||||||
|
createMetrics,
|
||||||
|
createNoopMetrics,
|
||||||
|
type NostrMetrics,
|
||||||
|
type MetricsSnapshot,
|
||||||
|
type MetricEvent,
|
||||||
|
} from "./metrics.js";
|
||||||
|
|
||||||
|
export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const STARTUP_LOOKBACK_SEC = 120; // tolerate relay lag / clock skew
|
||||||
|
const MAX_PERSISTED_EVENT_IDS = 5000;
|
||||||
|
const STATE_PERSIST_DEBOUNCE_MS = 5000; // Debounce state writes
|
||||||
|
|
||||||
|
// Reconnect configuration (exponential backoff with jitter)
|
||||||
|
const RECONNECT_BASE_MS = 1000; // 1 second base
|
||||||
|
const RECONNECT_MAX_MS = 60000; // 60 seconds max
|
||||||
|
const RECONNECT_JITTER = 0.3; // ±30% jitter
|
||||||
|
|
||||||
|
// Circuit breaker configuration
|
||||||
|
const CIRCUIT_BREAKER_THRESHOLD = 5; // failures before opening
|
||||||
|
const CIRCUIT_BREAKER_RESET_MS = 30000; // 30 seconds before half-open
|
||||||
|
|
||||||
|
// Health tracker configuration
|
||||||
|
const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NostrBusOptions {
|
||||||
|
/** Private key in hex or nsec format */
|
||||||
|
privateKey: string;
|
||||||
|
/** WebSocket relay URLs (defaults to damus + nos.lol) */
|
||||||
|
relays?: string[];
|
||||||
|
/** Account ID for state persistence (optional, defaults to pubkey prefix) */
|
||||||
|
accountId?: string;
|
||||||
|
/** Called when a DM is received */
|
||||||
|
onMessage: (
|
||||||
|
pubkey: string,
|
||||||
|
text: string,
|
||||||
|
reply: (text: string) => Promise<void>
|
||||||
|
) => Promise<void>;
|
||||||
|
/** Called on errors (optional) */
|
||||||
|
onError?: (error: Error, context: string) => void;
|
||||||
|
/** Called on connection status changes (optional) */
|
||||||
|
onConnect?: (relay: string) => void;
|
||||||
|
/** Called on disconnection (optional) */
|
||||||
|
onDisconnect?: (relay: string) => void;
|
||||||
|
/** Called on EOSE (end of stored events) for initial sync (optional) */
|
||||||
|
onEose?: (relay: string) => void;
|
||||||
|
/** Called on each metric event (optional) */
|
||||||
|
onMetric?: (event: MetricEvent) => void;
|
||||||
|
/** Maximum entries in seen tracker (default: 100,000) */
|
||||||
|
maxSeenEntries?: number;
|
||||||
|
/** Seen tracker TTL in ms (default: 1 hour) */
|
||||||
|
seenTtlMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NostrBusHandle {
|
||||||
|
/** Stop the bus and close connections */
|
||||||
|
close: () => void;
|
||||||
|
/** Get the bot's public key */
|
||||||
|
publicKey: string;
|
||||||
|
/** Send a DM to a pubkey */
|
||||||
|
sendDm: (toPubkey: string, text: string) => Promise<void>;
|
||||||
|
/** Get current metrics snapshot */
|
||||||
|
getMetrics: () => MetricsSnapshot;
|
||||||
|
/** Publish a profile (kind:0) to all relays */
|
||||||
|
publishProfile: (profile: NostrProfile) => Promise<ProfilePublishResult>;
|
||||||
|
/** Get the last profile publish state */
|
||||||
|
getProfileState: () => Promise<{
|
||||||
|
lastPublishedAt: number | null;
|
||||||
|
lastPublishedEventId: string | null;
|
||||||
|
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Circuit Breaker
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CircuitBreakerState {
|
||||||
|
state: "closed" | "open" | "half_open";
|
||||||
|
failures: number;
|
||||||
|
lastFailure: number;
|
||||||
|
lastSuccess: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CircuitBreaker {
|
||||||
|
/** Check if requests should be allowed */
|
||||||
|
canAttempt: () => boolean;
|
||||||
|
/** Record a success */
|
||||||
|
recordSuccess: () => void;
|
||||||
|
/** Record a failure */
|
||||||
|
recordFailure: () => void;
|
||||||
|
/** Get current state */
|
||||||
|
getState: () => CircuitBreakerState["state"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCircuitBreaker(
|
||||||
|
relay: string,
|
||||||
|
metrics: NostrMetrics,
|
||||||
|
threshold: number = CIRCUIT_BREAKER_THRESHOLD,
|
||||||
|
resetMs: number = CIRCUIT_BREAKER_RESET_MS
|
||||||
|
): CircuitBreaker {
|
||||||
|
const state: CircuitBreakerState = {
|
||||||
|
state: "closed",
|
||||||
|
failures: 0,
|
||||||
|
lastFailure: 0,
|
||||||
|
lastSuccess: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
canAttempt(): boolean {
|
||||||
|
if (state.state === "closed") return true;
|
||||||
|
|
||||||
|
if (state.state === "open") {
|
||||||
|
// Check if enough time has passed to try half-open
|
||||||
|
if (Date.now() - state.lastFailure >= resetMs) {
|
||||||
|
state.state = "half_open";
|
||||||
|
metrics.emit("relay.circuit_breaker.half_open", 1, { relay });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// half_open: allow one attempt
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
recordSuccess(): void {
|
||||||
|
if (state.state === "half_open") {
|
||||||
|
state.state = "closed";
|
||||||
|
state.failures = 0;
|
||||||
|
metrics.emit("relay.circuit_breaker.close", 1, { relay });
|
||||||
|
} else if (state.state === "closed") {
|
||||||
|
state.failures = 0;
|
||||||
|
}
|
||||||
|
state.lastSuccess = Date.now();
|
||||||
|
},
|
||||||
|
|
||||||
|
recordFailure(): void {
|
||||||
|
state.failures++;
|
||||||
|
state.lastFailure = Date.now();
|
||||||
|
|
||||||
|
if (state.state === "half_open") {
|
||||||
|
state.state = "open";
|
||||||
|
metrics.emit("relay.circuit_breaker.open", 1, { relay });
|
||||||
|
} else if (state.state === "closed" && state.failures >= threshold) {
|
||||||
|
state.state = "open";
|
||||||
|
metrics.emit("relay.circuit_breaker.open", 1, { relay });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getState(): CircuitBreakerState["state"] {
|
||||||
|
return state.state;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Relay Health Tracker
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface RelayHealthStats {
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
latencySum: number;
|
||||||
|
latencyCount: number;
|
||||||
|
lastSuccess: number;
|
||||||
|
lastFailure: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelayHealthTracker {
|
||||||
|
/** Record a successful operation */
|
||||||
|
recordSuccess: (relay: string, latencyMs: number) => void;
|
||||||
|
/** Record a failed operation */
|
||||||
|
recordFailure: (relay: string) => void;
|
||||||
|
/** Get health score (0-1, higher is better) */
|
||||||
|
getScore: (relay: string) => number;
|
||||||
|
/** Get relays sorted by health (best first) */
|
||||||
|
getSortedRelays: (relays: string[]) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRelayHealthTracker(): RelayHealthTracker {
|
||||||
|
const stats = new Map<string, RelayHealthStats>();
|
||||||
|
|
||||||
|
function getOrCreate(relay: string): RelayHealthStats {
|
||||||
|
let s = stats.get(relay);
|
||||||
|
if (!s) {
|
||||||
|
s = {
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
latencySum: 0,
|
||||||
|
latencyCount: 0,
|
||||||
|
lastSuccess: 0,
|
||||||
|
lastFailure: 0,
|
||||||
|
};
|
||||||
|
stats.set(relay, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recordSuccess(relay: string, latencyMs: number): void {
|
||||||
|
const s = getOrCreate(relay);
|
||||||
|
s.successCount++;
|
||||||
|
s.latencySum += latencyMs;
|
||||||
|
s.latencyCount++;
|
||||||
|
s.lastSuccess = Date.now();
|
||||||
|
},
|
||||||
|
|
||||||
|
recordFailure(relay: string): void {
|
||||||
|
const s = getOrCreate(relay);
|
||||||
|
s.failureCount++;
|
||||||
|
s.lastFailure = Date.now();
|
||||||
|
},
|
||||||
|
|
||||||
|
getScore(relay: string): number {
|
||||||
|
const s = stats.get(relay);
|
||||||
|
if (!s) return 0.5; // Unknown relay gets neutral score
|
||||||
|
|
||||||
|
const total = s.successCount + s.failureCount;
|
||||||
|
if (total === 0) return 0.5;
|
||||||
|
|
||||||
|
// Success rate (0-1)
|
||||||
|
const successRate = s.successCount / total;
|
||||||
|
|
||||||
|
// Recency bonus (prefer recently successful relays)
|
||||||
|
const now = Date.now();
|
||||||
|
const recencyBonus =
|
||||||
|
s.lastSuccess > s.lastFailure
|
||||||
|
? Math.max(0, 1 - (now - s.lastSuccess) / HEALTH_WINDOW_MS) * 0.2
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Latency penalty (lower is better)
|
||||||
|
const avgLatency =
|
||||||
|
s.latencyCount > 0 ? s.latencySum / s.latencyCount : 1000;
|
||||||
|
const latencyPenalty = Math.min(0.2, avgLatency / 10000);
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(1, successRate + recencyBonus - latencyPenalty));
|
||||||
|
},
|
||||||
|
|
||||||
|
getSortedRelays(relays: string[]): string[] {
|
||||||
|
return [...relays].sort((a, b) => this.getScore(b) - this.getScore(a));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Reconnect with Exponential Backoff + Jitter
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function computeReconnectDelay(attempt: number): number {
|
||||||
|
// Exponential backoff: base * 2^attempt
|
||||||
|
const exponential = RECONNECT_BASE_MS * Math.pow(2, attempt);
|
||||||
|
const capped = Math.min(exponential, RECONNECT_MAX_MS);
|
||||||
|
|
||||||
|
// Add jitter: ±JITTER%
|
||||||
|
const jitter = capped * RECONNECT_JITTER * (Math.random() * 2 - 1);
|
||||||
|
return Math.max(RECONNECT_BASE_MS, capped + jitter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Key Validation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalize a private key (accepts hex or nsec format)
|
||||||
|
*/
|
||||||
|
export function validatePrivateKey(key: string): Uint8Array {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
|
||||||
|
// Handle nsec (bech32) format
|
||||||
|
if (trimmed.startsWith("nsec1")) {
|
||||||
|
const decoded = nip19.decode(trimmed);
|
||||||
|
if (decoded.type !== "nsec") {
|
||||||
|
throw new Error("Invalid nsec key: wrong type");
|
||||||
|
}
|
||||||
|
return decoded.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle hex format
|
||||||
|
if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||||
|
throw new Error(
|
||||||
|
"Private key must be 64 hex characters or nsec bech32 format"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert hex string to Uint8Array
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
bytes[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public key from private key (hex or nsec format)
|
||||||
|
*/
|
||||||
|
export function getPublicKeyFromPrivate(privateKey: string): string {
|
||||||
|
const sk = validatePrivateKey(privateKey);
|
||||||
|
return getPublicKey(sk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Bus
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
|
||||||
|
*/
|
||||||
|
export async function startNostrBus(
|
||||||
|
options: NostrBusOptions
|
||||||
|
): Promise<NostrBusHandle> {
|
||||||
|
const {
|
||||||
|
privateKey,
|
||||||
|
relays = DEFAULT_RELAYS,
|
||||||
|
onMessage,
|
||||||
|
onError,
|
||||||
|
onEose,
|
||||||
|
onMetric,
|
||||||
|
maxSeenEntries = 100_000,
|
||||||
|
seenTtlMs = 60 * 60 * 1000,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const sk = validatePrivateKey(privateKey);
|
||||||
|
const pk = getPublicKey(sk);
|
||||||
|
const pool = new SimplePool();
|
||||||
|
const accountId = options.accountId ?? pk.slice(0, 16);
|
||||||
|
const gatewayStartedAt = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Initialize metrics
|
||||||
|
const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
|
||||||
|
|
||||||
|
// Initialize seen tracker with LRU
|
||||||
|
const seen: SeenTracker = createSeenTracker({
|
||||||
|
maxEntries: maxSeenEntries,
|
||||||
|
ttlMs: seenTtlMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize circuit breakers and health tracker
|
||||||
|
const circuitBreakers = new Map<string, CircuitBreaker>();
|
||||||
|
const healthTracker = createRelayHealthTracker();
|
||||||
|
|
||||||
|
for (const relay of relays) {
|
||||||
|
circuitBreakers.set(relay, createCircuitBreaker(relay, metrics));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read persisted state and compute `since` timestamp (with small overlap)
|
||||||
|
const state = await readNostrBusState({ accountId });
|
||||||
|
const baseSince = computeSinceTimestamp(state, gatewayStartedAt);
|
||||||
|
const since = Math.max(0, baseSince - STARTUP_LOOKBACK_SEC);
|
||||||
|
|
||||||
|
// Seed in-memory dedupe with recent IDs from disk (prevents restart replay)
|
||||||
|
if (state?.recentEventIds?.length) {
|
||||||
|
seen.seed(state.recentEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist startup timestamp
|
||||||
|
await writeNostrBusState({
|
||||||
|
accountId,
|
||||||
|
lastProcessedAt: state?.lastProcessedAt ?? gatewayStartedAt,
|
||||||
|
gatewayStartedAt,
|
||||||
|
recentEventIds: state?.recentEventIds ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounced state persistence
|
||||||
|
let pendingWrite: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt;
|
||||||
|
let recentEventIds = (state?.recentEventIds ?? []).slice(
|
||||||
|
-MAX_PERSISTED_EVENT_IDS
|
||||||
|
);
|
||||||
|
|
||||||
|
function scheduleStatePersist(eventCreatedAt: number, eventId: string): void {
|
||||||
|
lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt);
|
||||||
|
recentEventIds.push(eventId);
|
||||||
|
if (recentEventIds.length > MAX_PERSISTED_EVENT_IDS) {
|
||||||
|
recentEventIds = recentEventIds.slice(-MAX_PERSISTED_EVENT_IDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingWrite) clearTimeout(pendingWrite);
|
||||||
|
pendingWrite = setTimeout(() => {
|
||||||
|
writeNostrBusState({
|
||||||
|
accountId,
|
||||||
|
lastProcessedAt,
|
||||||
|
gatewayStartedAt,
|
||||||
|
recentEventIds,
|
||||||
|
}).catch((err) => onError?.(err as Error, "persist state"));
|
||||||
|
}, STATE_PERSIST_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = new Set<string>();
|
||||||
|
|
||||||
|
// Event handler
|
||||||
|
async function handleEvent(event: Event): Promise<void> {
|
||||||
|
try {
|
||||||
|
metrics.emit("event.received");
|
||||||
|
|
||||||
|
// Fast dedupe check (handles relay reconnections)
|
||||||
|
if (seen.peek(event.id) || inflight.has(event.id)) {
|
||||||
|
metrics.emit("event.duplicate");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inflight.add(event.id);
|
||||||
|
|
||||||
|
// Self-message loop prevention: skip our own messages
|
||||||
|
if (event.pubkey === pk) {
|
||||||
|
metrics.emit("event.rejected.self_message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip events older than our `since` (relay may ignore filter)
|
||||||
|
if (event.created_at < since) {
|
||||||
|
metrics.emit("event.rejected.stale");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast p-tag check BEFORE crypto (no allocation, cheaper)
|
||||||
|
let targetsUs = false;
|
||||||
|
for (const t of event.tags) {
|
||||||
|
if (t[0] === "p" && t[1] === pk) {
|
||||||
|
targetsUs = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!targetsUs) {
|
||||||
|
metrics.emit("event.rejected.wrong_kind");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature (must pass before we trust the event)
|
||||||
|
if (!verifyEvent(event)) {
|
||||||
|
metrics.emit("event.rejected.invalid_signature");
|
||||||
|
onError?.(new Error("Invalid signature"), `event ${event.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark seen AFTER verify (don't cache invalid IDs)
|
||||||
|
seen.add(event.id);
|
||||||
|
metrics.emit("memory.seen_tracker_size", seen.size());
|
||||||
|
|
||||||
|
// Decrypt the message
|
||||||
|
let plaintext: string;
|
||||||
|
try {
|
||||||
|
plaintext = await decrypt(sk, event.pubkey, event.content);
|
||||||
|
metrics.emit("decrypt.success");
|
||||||
|
} catch (err) {
|
||||||
|
metrics.emit("decrypt.failure");
|
||||||
|
metrics.emit("event.rejected.decrypt_failed");
|
||||||
|
onError?.(err as Error, `decrypt from ${event.pubkey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create reply function (try relays by health score)
|
||||||
|
const replyTo = async (text: string): Promise<void> => {
|
||||||
|
await sendEncryptedDm(
|
||||||
|
pool,
|
||||||
|
sk,
|
||||||
|
event.pubkey,
|
||||||
|
text,
|
||||||
|
relays,
|
||||||
|
metrics,
|
||||||
|
circuitBreakers,
|
||||||
|
healthTracker,
|
||||||
|
onError
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the message handler
|
||||||
|
await onMessage(event.pubkey, plaintext, replyTo);
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
metrics.emit("event.processed");
|
||||||
|
|
||||||
|
// Persist progress (debounced)
|
||||||
|
scheduleStatePersist(event.created_at, event.id);
|
||||||
|
} catch (err) {
|
||||||
|
onError?.(err as Error, `event ${event.id}`);
|
||||||
|
} finally {
|
||||||
|
inflight.delete(event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = pool.subscribeMany(
|
||||||
|
relays,
|
||||||
|
[{ kinds: [4], "#p": [pk], since }],
|
||||||
|
{
|
||||||
|
onevent: handleEvent,
|
||||||
|
oneose: () => {
|
||||||
|
// EOSE handler - called when all stored events have been received
|
||||||
|
for (const relay of relays) {
|
||||||
|
metrics.emit("relay.message.eose", 1, { relay });
|
||||||
|
}
|
||||||
|
onEose?.(relays.join(", "));
|
||||||
|
},
|
||||||
|
onclose: (reason) => {
|
||||||
|
// Handle subscription close
|
||||||
|
for (const relay of relays) {
|
||||||
|
metrics.emit("relay.message.closed", 1, { relay });
|
||||||
|
options.onDisconnect?.(relay);
|
||||||
|
}
|
||||||
|
onError?.(
|
||||||
|
new Error(`Subscription closed: ${reason}`),
|
||||||
|
"subscription"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Public sendDm function
|
||||||
|
const sendDm = async (toPubkey: string, text: string): Promise<void> => {
|
||||||
|
await sendEncryptedDm(
|
||||||
|
pool,
|
||||||
|
sk,
|
||||||
|
toPubkey,
|
||||||
|
text,
|
||||||
|
relays,
|
||||||
|
metrics,
|
||||||
|
circuitBreakers,
|
||||||
|
healthTracker,
|
||||||
|
onError
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Profile publishing function
|
||||||
|
const publishProfile = async (profile: NostrProfile): Promise<ProfilePublishResult> => {
|
||||||
|
// Read last published timestamp for monotonic ordering
|
||||||
|
const profileState = await readNostrProfileState({ accountId });
|
||||||
|
const lastPublishedAt = profileState?.lastPublishedAt ?? undefined;
|
||||||
|
|
||||||
|
// Publish the profile
|
||||||
|
const result = await publishProfileFn(pool, sk, relays, profile, lastPublishedAt);
|
||||||
|
|
||||||
|
// Convert results to state format
|
||||||
|
const publishResults: Record<string, "ok" | "failed" | "timeout"> = {};
|
||||||
|
for (const relay of result.successes) {
|
||||||
|
publishResults[relay] = "ok";
|
||||||
|
}
|
||||||
|
for (const { relay, error } of result.failures) {
|
||||||
|
publishResults[relay] = error === "timeout" ? "timeout" : "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the publish state
|
||||||
|
await writeNostrProfileState({
|
||||||
|
accountId,
|
||||||
|
lastPublishedAt: result.createdAt,
|
||||||
|
lastPublishedEventId: result.eventId,
|
||||||
|
lastPublishResults: publishResults,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get profile state function
|
||||||
|
const getProfileState = async () => {
|
||||||
|
const state = await readNostrProfileState({ accountId });
|
||||||
|
return {
|
||||||
|
lastPublishedAt: state?.lastPublishedAt ?? null,
|
||||||
|
lastPublishedEventId: state?.lastPublishedEventId ?? null,
|
||||||
|
lastPublishResults: state?.lastPublishResults ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: () => {
|
||||||
|
sub.close();
|
||||||
|
seen.stop();
|
||||||
|
// Flush pending state write synchronously on close
|
||||||
|
if (pendingWrite) {
|
||||||
|
clearTimeout(pendingWrite);
|
||||||
|
writeNostrBusState({
|
||||||
|
accountId,
|
||||||
|
lastProcessedAt,
|
||||||
|
gatewayStartedAt,
|
||||||
|
recentEventIds,
|
||||||
|
}).catch((err) => onError?.(err as Error, "persist state on close"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
publicKey: pk,
|
||||||
|
sendDm,
|
||||||
|
getMetrics: () => metrics.getSnapshot(),
|
||||||
|
publishProfile,
|
||||||
|
getProfileState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Send DM with Circuit Breaker + Health Scoring
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an encrypted DM to a pubkey
|
||||||
|
*/
|
||||||
|
async function sendEncryptedDm(
|
||||||
|
pool: SimplePool,
|
||||||
|
sk: Uint8Array,
|
||||||
|
toPubkey: string,
|
||||||
|
text: string,
|
||||||
|
relays: string[],
|
||||||
|
metrics: NostrMetrics,
|
||||||
|
circuitBreakers: Map<string, CircuitBreaker>,
|
||||||
|
healthTracker: RelayHealthTracker,
|
||||||
|
onError?: (error: Error, context: string) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const ciphertext = await encrypt(sk, toPubkey, text);
|
||||||
|
const reply = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 4,
|
||||||
|
content: ciphertext,
|
||||||
|
tags: [["p", toPubkey]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
sk
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort relays by health score (best first)
|
||||||
|
const sortedRelays = healthTracker.getSortedRelays(relays);
|
||||||
|
|
||||||
|
// Try relays in order of health, respecting circuit breakers
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
for (const relay of sortedRelays) {
|
||||||
|
const cb = circuitBreakers.get(relay);
|
||||||
|
|
||||||
|
// Skip if circuit breaker is open
|
||||||
|
if (cb && !cb.canAttempt()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
await pool.publish([relay], reply);
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Record success
|
||||||
|
cb?.recordSuccess();
|
||||||
|
healthTracker.recordSuccess(relay, latency);
|
||||||
|
|
||||||
|
return; // Success - exit early
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err as Error;
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Record failure
|
||||||
|
cb?.recordFailure();
|
||||||
|
healthTracker.recordFailure(relay);
|
||||||
|
metrics.emit("relay.error", 1, { relay, latency });
|
||||||
|
|
||||||
|
onError?.(lastError, `publish to ${relay}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pubkey Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string looks like a valid Nostr pubkey (hex or npub)
|
||||||
|
*/
|
||||||
|
export function isValidPubkey(input: string): boolean {
|
||||||
|
if (typeof input !== "string") return false;
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// npub format
|
||||||
|
if (trimmed.startsWith("npub1")) {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(trimmed);
|
||||||
|
return decoded.type === "npub";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex format
|
||||||
|
return /^[0-9a-fA-F]{64}$/.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a pubkey to hex format (accepts npub or hex)
|
||||||
|
*/
|
||||||
|
export function normalizePubkey(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// npub format - decode to hex
|
||||||
|
if (trimmed.startsWith("npub1")) {
|
||||||
|
const decoded = nip19.decode(trimmed);
|
||||||
|
if (decoded.type !== "npub") {
|
||||||
|
throw new Error("Invalid npub key");
|
||||||
|
}
|
||||||
|
// Convert Uint8Array to hex string
|
||||||
|
return Array.from(decoded.data)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already hex - validate and return lowercase
|
||||||
|
if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||||
|
throw new Error("Pubkey must be 64 hex characters or npub format");
|
||||||
|
}
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a hex pubkey to npub format
|
||||||
|
*/
|
||||||
|
export function pubkeyToNpub(hexPubkey: string): string {
|
||||||
|
const normalized = normalizePubkey(hexPubkey);
|
||||||
|
// npubEncode expects a hex string, not Uint8Array
|
||||||
|
return nip19.npubEncode(normalized);
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Nostr Profile HTTP Handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { Socket } from "node:net";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createNostrProfileHttpHandler,
|
||||||
|
type NostrProfileHttpContext,
|
||||||
|
} from "./nostr-profile-http.js";
|
||||||
|
|
||||||
|
// Mock the channel exports
|
||||||
|
vi.mock("./channel.js", () => ({
|
||||||
|
publishNostrProfile: vi.fn(),
|
||||||
|
getNostrProfileState: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the import module
|
||||||
|
vi.mock("./nostr-profile-import.js", () => ({
|
||||||
|
importProfileFromRelays: vi.fn(),
|
||||||
|
mergeProfiles: vi.fn((local, imported) => ({ ...imported, ...local })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
||||||
|
import { importProfileFromRelays } from "./nostr-profile-import.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function createMockRequest(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
body?: unknown
|
||||||
|
): IncomingMessage {
|
||||||
|
const socket = new Socket();
|
||||||
|
const req = new IncomingMessage(socket);
|
||||||
|
req.method = method;
|
||||||
|
req.url = url;
|
||||||
|
req.headers = { host: "localhost:3000" };
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
const bodyStr = JSON.stringify(body);
|
||||||
|
process.nextTick(() => {
|
||||||
|
req.emit("data", Buffer.from(bodyStr));
|
||||||
|
req.emit("end");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
process.nextTick(() => {
|
||||||
|
req.emit("end");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockResponse(): ServerResponse & { _getData: () => string; _getStatusCode: () => number } {
|
||||||
|
const socket = new Socket();
|
||||||
|
const res = new ServerResponse({} as IncomingMessage);
|
||||||
|
|
||||||
|
let data = "";
|
||||||
|
let statusCode = 200;
|
||||||
|
|
||||||
|
res.write = function (chunk: unknown) {
|
||||||
|
data += String(chunk);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
res.end = function (chunk?: unknown) {
|
||||||
|
if (chunk) data += String(chunk);
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(res, "statusCode", {
|
||||||
|
get: () => statusCode,
|
||||||
|
set: (code: number) => {
|
||||||
|
statusCode = code;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(res as unknown as { _getData: () => string })._getData = () => data;
|
||||||
|
(res as unknown as { _getStatusCode: () => number })._getStatusCode = () => statusCode;
|
||||||
|
|
||||||
|
return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrProfileHttpContext {
|
||||||
|
return {
|
||||||
|
getConfigProfile: vi.fn().mockReturnValue(undefined),
|
||||||
|
updateConfigProfile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getAccountInfo: vi.fn().mockReturnValue({
|
||||||
|
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||||
|
relays: ["wss://relay.damus.io"],
|
||||||
|
}),
|
||||||
|
log: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("nostr-profile-http", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("route matching", () => {
|
||||||
|
it("returns false for non-nostr paths", async () => {
|
||||||
|
const ctx = createMockContext();
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("GET", "/api/channels/telegram/profile");
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
const result = await handler(req, res);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for paths without accountId", async () => {
|
||||||
|
const ctx = createMockContext();
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("GET", "/api/channels/nostr/");
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
const result = await handler(req, res);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles /api/channels/nostr/:accountId/profile", async () => {
|
||||||
|
const ctx = createMockContext();
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
vi.mocked(getNostrProfileState).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await handler(req, res);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/channels/nostr/:accountId/profile", () => {
|
||||||
|
it("returns profile and publish state", async () => {
|
||||||
|
const ctx = createMockContext({
|
||||||
|
getConfigProfile: vi.fn().mockReturnValue({
|
||||||
|
name: "testuser",
|
||||||
|
displayName: "Test User",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
vi.mocked(getNostrProfileState).mockResolvedValue({
|
||||||
|
lastPublishedAt: 1234567890,
|
||||||
|
lastPublishedEventId: "abc123",
|
||||||
|
lastPublishResults: { "wss://relay.damus.io": "ok" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(200);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.profile.name).toBe("testuser");
|
||||||
|
expect(data.publishState.lastPublishedAt).toBe(1234567890);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/channels/nostr/:accountId/profile", () => {
|
||||||
|
it("validates profile and publishes", async () => {
|
||||||
|
const ctx = createMockContext();
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||||
|
name: "satoshi",
|
||||||
|
displayName: "Satoshi Nakamoto",
|
||||||
|
about: "Creator of Bitcoin",
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
vi.mocked(publishNostrProfile).mockResolvedValue({
|
||||||
|
eventId: "event123",
|
||||||
|
createdAt: 1234567890,
|
||||||
|
successes: ["wss://relay.damus.io"],
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(200);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.eventId).toBe("event123");
|
||||||
|
expect(data.successes).toContain("wss://relay.damus.io");
|
||||||
|
expect(data.persisted).toBe(true);
|
||||||
|
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects private IP in picture URL (SSRF protection)", async () => {
|
||||||
|
const ctx = createMockContext();
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||||
|
name: "hacker",
|
||||||
|
picture: "https://127.0.0.1/evil.jpg",
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.ok).toBe(false);
|
||||||
|
expect(data.error).toContain("private");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-https URLs", async () => {
|
||||||
|
const ctx = createMockContext();
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||||
|
name: "test",
|
||||||
|
picture: "http://example.com/pic.jpg",
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.ok).toBe(false);
|
||||||
|
// The schema validation catches non-https URLs before SSRF check
|
||||||
|
expect(data.error).toBe("Validation failed");
|
||||||
|
expect(data.details).toBeDefined();
|
||||||
|
expect(data.details.some((d: string) => d.includes("https"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist if all relays fail", async () => {
|
||||||
|
const ctx = createMockContext();
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
||||||
|
name: "test",
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
vi.mocked(publishNostrProfile).mockResolvedValue({
|
||||||
|
eventId: "event123",
|
||||||
|
createdAt: 1234567890,
|
||||||
|
successes: [],
|
||||||
|
failures: [{ relay: "wss://relay.damus.io", error: "timeout" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(200);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.persisted).toBe(false);
|
||||||
|
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces rate limiting", async () => {
|
||||||
|
const ctx = createMockContext();
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
|
||||||
|
vi.mocked(publishNostrProfile).mockResolvedValue({
|
||||||
|
eventId: "event123",
|
||||||
|
createdAt: 1234567890,
|
||||||
|
successes: ["wss://relay.damus.io"],
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make 6 requests (limit is 5/min)
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const req = createMockRequest("PUT", "/api/channels/nostr/rate-test/profile", {
|
||||||
|
name: `user${i}`,
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
if (i < 5) {
|
||||||
|
expect(res._getStatusCode()).toBe(200);
|
||||||
|
} else {
|
||||||
|
expect(res._getStatusCode()).toBe(429);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toContain("Rate limit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/channels/nostr/:accountId/profile/import", () => {
|
||||||
|
it("imports profile from relays", async () => {
|
||||||
|
const ctx = createMockContext();
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
vi.mocked(importProfileFromRelays).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
profile: {
|
||||||
|
name: "imported",
|
||||||
|
displayName: "Imported User",
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
id: "evt123",
|
||||||
|
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||||
|
created_at: 1234567890,
|
||||||
|
},
|
||||||
|
relaysQueried: ["wss://relay.damus.io"],
|
||||||
|
sourceRelay: "wss://relay.damus.io",
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(200);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.imported.name).toBe("imported");
|
||||||
|
expect(data.saved).toBe(false); // autoMerge not requested
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-merges when requested", async () => {
|
||||||
|
const ctx = createMockContext({
|
||||||
|
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
||||||
|
});
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {
|
||||||
|
autoMerge: true,
|
||||||
|
});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
vi.mocked(importProfileFromRelays).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
profile: {
|
||||||
|
name: "imported",
|
||||||
|
displayName: "Imported User",
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
id: "evt123",
|
||||||
|
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||||
|
created_at: 1234567890,
|
||||||
|
},
|
||||||
|
relaysQueried: ["wss://relay.damus.io"],
|
||||||
|
sourceRelay: "wss://relay.damus.io",
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(200);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.saved).toBe(true);
|
||||||
|
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when account not found", async () => {
|
||||||
|
const ctx = createMockContext({
|
||||||
|
getAccountInfo: vi.fn().mockReturnValue(null),
|
||||||
|
});
|
||||||
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
|
const req = createMockRequest("POST", "/api/channels/nostr/unknown/profile/import", {});
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(404);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toContain("not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* Nostr Profile HTTP Handler
|
||||||
|
*
|
||||||
|
* Handles HTTP requests for profile management:
|
||||||
|
* - PUT /api/channels/nostr/:accountId/profile - Update and publish profile
|
||||||
|
* - POST /api/channels/nostr/:accountId/profile/import - Import from relays
|
||||||
|
* - GET /api/channels/nostr/:accountId/profile - Get current profile state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
|
||||||
|
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
||||||
|
import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NostrProfileHttpContext {
|
||||||
|
/** Get current profile from config */
|
||||||
|
getConfigProfile: (accountId: string) => NostrProfile | undefined;
|
||||||
|
/** Update profile in config (after successful publish) */
|
||||||
|
updateConfigProfile: (accountId: string, profile: NostrProfile) => Promise<void>;
|
||||||
|
/** Get account's public key and relays */
|
||||||
|
getAccountInfo: (accountId: string) => { pubkey: string; relays: string[] } | null;
|
||||||
|
/** Logger */
|
||||||
|
log?: {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
|
error: (msg: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rate Limiting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
windowStart: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitMap = new Map<string, RateLimitEntry>();
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
|
||||||
|
const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute
|
||||||
|
|
||||||
|
function checkRateLimit(accountId: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimitMap.get(accountId);
|
||||||
|
|
||||||
|
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
rateLimitMap.set(accountId, { count: 1, windowStart: now });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mutex for Concurrent Publish Prevention
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const publishLocks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
// Atomic mutex using promise chaining - prevents TOCTOU race condition
|
||||||
|
const prev = publishLocks.get(accountId) ?? Promise.resolve();
|
||||||
|
let resolve: () => void;
|
||||||
|
const next = new Promise<void>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
// Atomically replace the lock before awaiting - any concurrent request
|
||||||
|
// will now wait on our `next` promise
|
||||||
|
publishLocks.set(accountId, next);
|
||||||
|
|
||||||
|
// Wait for previous operation to complete
|
||||||
|
await prev.catch(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
resolve!();
|
||||||
|
// Clean up if we're the last in chain
|
||||||
|
if (publishLocks.get(accountId) === next) {
|
||||||
|
publishLocks.delete(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSRF Protection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Block common private/internal hostnames (quick string check)
|
||||||
|
const BLOCKED_HOSTNAMES = new Set([
|
||||||
|
"localhost",
|
||||||
|
"localhost.localdomain",
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1",
|
||||||
|
"[::1]",
|
||||||
|
"0.0.0.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if an IP address (resolved) is in a private range
|
||||||
|
function isPrivateIp(ip: string): boolean {
|
||||||
|
// Handle IPv4
|
||||||
|
const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (ipv4Match) {
|
||||||
|
const [, a, b, c] = ipv4Match.map(Number);
|
||||||
|
// 127.0.0.0/8 (loopback)
|
||||||
|
if (a === 127) return true;
|
||||||
|
// 10.0.0.0/8 (private)
|
||||||
|
if (a === 10) return true;
|
||||||
|
// 172.16.0.0/12 (private)
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||||
|
// 192.168.0.0/16 (private)
|
||||||
|
if (a === 192 && b === 168) return true;
|
||||||
|
// 169.254.0.0/16 (link-local)
|
||||||
|
if (a === 169 && b === 254) return true;
|
||||||
|
// 0.0.0.0/8
|
||||||
|
if (a === 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle IPv6
|
||||||
|
const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, "");
|
||||||
|
// ::1 (loopback)
|
||||||
|
if (ipLower === "::1") return true;
|
||||||
|
// fe80::/10 (link-local)
|
||||||
|
if (ipLower.startsWith("fe80:")) return true;
|
||||||
|
// fc00::/7 (unique local)
|
||||||
|
if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) return true;
|
||||||
|
// ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4
|
||||||
|
const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (v4Mapped) return isPrivateIp(v4Mapped[1]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlStr);
|
||||||
|
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
return { ok: false, error: "URL must use https:// protocol" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = url.hostname.toLowerCase();
|
||||||
|
|
||||||
|
// Quick hostname block check
|
||||||
|
if (BLOCKED_HOSTNAMES.has(hostname)) {
|
||||||
|
return { ok: false, error: "URL must not point to private/internal addresses" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if hostname is an IP address directly
|
||||||
|
if (isPrivateIp(hostname)) {
|
||||||
|
return { ok: false, error: "URL must not point to private/internal addresses" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block suspicious TLDs that resolve to localhost
|
||||||
|
if (hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
|
||||||
|
return { ok: false, error: "URL must not point to private/internal addresses" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Invalid URL format" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in import validation
|
||||||
|
export { validateUrlSafety }
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// NIP-05 format: user@domain.com
|
||||||
|
const nip05FormatSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)")
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
// LUD-16 Lightning address format: user@domain.com
|
||||||
|
const lud16FormatSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format")
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
// Extended profile schema with additional format validation
|
||||||
|
const ProfileUpdateSchema = NostrProfileSchema.extend({
|
||||||
|
nip05: nip05FormatSchema,
|
||||||
|
lud16: lud16FormatSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Request Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(req: IncomingMessage, maxBytes = 64 * 1024): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
if (totalBytes > maxBytes) {
|
||||||
|
reject(new Error("Request body too large"));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const body = Buffer.concat(chunks).toString("utf-8");
|
||||||
|
resolve(body ? JSON.parse(body) : {});
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Invalid JSON"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAccountIdFromPath(pathname: string): string | null {
|
||||||
|
// Match: /api/channels/nostr/:accountId/profile
|
||||||
|
const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HTTP Handler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function createNostrProfileHttpHandler(
|
||||||
|
ctx: NostrProfileHttpContext
|
||||||
|
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
||||||
|
return async (req, res) => {
|
||||||
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||||
|
|
||||||
|
// Only handle /api/channels/nostr/:accountId/profile paths
|
||||||
|
if (!url.pathname.startsWith("/api/channels/nostr/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = parseAccountIdFromPath(url.pathname);
|
||||||
|
if (!accountId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImport = url.pathname.endsWith("/profile/import");
|
||||||
|
const isProfilePath = url.pathname.endsWith("/profile") || isImport;
|
||||||
|
|
||||||
|
if (!isProfilePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different HTTP methods
|
||||||
|
try {
|
||||||
|
if (req.method === "GET" && !isImport) {
|
||||||
|
return await handleGetProfile(accountId, ctx, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT" && !isImport) {
|
||||||
|
return await handleUpdateProfile(accountId, ctx, req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && isImport) {
|
||||||
|
return await handleImportProfile(accountId, ctx, req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method not allowed
|
||||||
|
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log?.error(`Profile HTTP error: ${String(err)}`);
|
||||||
|
sendJson(res, 500, { ok: false, error: "Internal server error" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GET /api/channels/nostr/:accountId/profile
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function handleGetProfile(
|
||||||
|
accountId: string,
|
||||||
|
ctx: NostrProfileHttpContext,
|
||||||
|
res: ServerResponse
|
||||||
|
): Promise<true> {
|
||||||
|
const configProfile = ctx.getConfigProfile(accountId);
|
||||||
|
const publishState = await getNostrProfileState(accountId);
|
||||||
|
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
profile: configProfile ?? null,
|
||||||
|
publishState: publishState ?? null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PUT /api/channels/nostr/:accountId/profile
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function handleUpdateProfile(
|
||||||
|
accountId: string,
|
||||||
|
ctx: NostrProfileHttpContext,
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse
|
||||||
|
): Promise<true> {
|
||||||
|
// Rate limiting
|
||||||
|
if (!checkRateLimit(accountId)) {
|
||||||
|
sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse body
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await readJsonBody(req);
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, 400, { ok: false, error: String(err) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate profile
|
||||||
|
const parseResult = ProfileUpdateSchema.safeParse(body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
const errors = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
|
||||||
|
sendJson(res, 400, { ok: false, error: "Validation failed", details: errors });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = parseResult.data;
|
||||||
|
|
||||||
|
// SSRF check for picture URL
|
||||||
|
if (profile.picture) {
|
||||||
|
const pictureCheck = validateUrlSafety(profile.picture);
|
||||||
|
if (!pictureCheck.ok) {
|
||||||
|
sendJson(res, 400, { ok: false, error: `picture: ${pictureCheck.error}` });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSRF check for banner URL
|
||||||
|
if (profile.banner) {
|
||||||
|
const bannerCheck = validateUrlSafety(profile.banner);
|
||||||
|
if (!bannerCheck.ok) {
|
||||||
|
sendJson(res, 400, { ok: false, error: `banner: ${bannerCheck.error}` });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSRF check for website URL
|
||||||
|
if (profile.website) {
|
||||||
|
const websiteCheck = validateUrlSafety(profile.website);
|
||||||
|
if (!websiteCheck.ok) {
|
||||||
|
sendJson(res, 400, { ok: false, error: `website: ${websiteCheck.error}` });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with existing profile to preserve unknown fields
|
||||||
|
const existingProfile = ctx.getConfigProfile(accountId) ?? {};
|
||||||
|
const mergedProfile: NostrProfile = {
|
||||||
|
...existingProfile,
|
||||||
|
...profile,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Publish with mutex to prevent concurrent publishes
|
||||||
|
try {
|
||||||
|
const result = await withPublishLock(accountId, async () => {
|
||||||
|
return await publishNostrProfile(accountId, mergedProfile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only persist if at least one relay succeeded
|
||||||
|
if (result.successes.length > 0) {
|
||||||
|
await ctx.updateConfigProfile(accountId, mergedProfile);
|
||||||
|
ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`);
|
||||||
|
} else {
|
||||||
|
ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
eventId: result.eventId,
|
||||||
|
createdAt: result.createdAt,
|
||||||
|
successes: result.successes,
|
||||||
|
failures: result.failures,
|
||||||
|
persisted: result.successes.length > 0,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`);
|
||||||
|
sendJson(res, 500, { ok: false, error: `Publish failed: ${String(err)}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// POST /api/channels/nostr/:accountId/profile/import
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function handleImportProfile(
|
||||||
|
accountId: string,
|
||||||
|
ctx: NostrProfileHttpContext,
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse
|
||||||
|
): Promise<true> {
|
||||||
|
// Get account info
|
||||||
|
const accountInfo = ctx.getAccountInfo(accountId);
|
||||||
|
if (!accountInfo) {
|
||||||
|
sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pubkey, relays } = accountInfo;
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
sendJson(res, 400, { ok: false, error: "Account has no public key configured" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse options from body
|
||||||
|
let autoMerge = false;
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
if (typeof body === "object" && body !== null) {
|
||||||
|
autoMerge = (body as { autoMerge?: boolean }).autoMerge === true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore body parse errors - use defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`);
|
||||||
|
|
||||||
|
// Import from relays
|
||||||
|
const result = await importProfileFromRelays({
|
||||||
|
pubkey,
|
||||||
|
relays,
|
||||||
|
timeoutMs: 10_000, // 10 seconds for import
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: false,
|
||||||
|
error: result.error,
|
||||||
|
relaysQueried: result.relaysQueried,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If autoMerge is requested, merge and save
|
||||||
|
if (autoMerge && result.profile) {
|
||||||
|
const localProfile = ctx.getConfigProfile(accountId);
|
||||||
|
const merged = mergeProfiles(localProfile, result.profile);
|
||||||
|
await ctx.updateConfigProfile(accountId, merged);
|
||||||
|
ctx.log?.info(`[${accountId}] Profile imported and merged`);
|
||||||
|
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
imported: result.profile,
|
||||||
|
merged,
|
||||||
|
saved: true,
|
||||||
|
event: result.event,
|
||||||
|
sourceRelay: result.sourceRelay,
|
||||||
|
relaysQueried: result.relaysQueried,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, just return the imported profile for review
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
imported: result.profile,
|
||||||
|
saved: false,
|
||||||
|
event: result.event,
|
||||||
|
sourceRelay: result.sourceRelay,
|
||||||
|
relaysQueried: result.relaysQueried,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Nostr Profile Import
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { mergeProfiles, type ProfileImportOptions } from "./nostr-profile-import.js";
|
||||||
|
import type { NostrProfile } from "./config-schema.js";
|
||||||
|
|
||||||
|
// Note: importProfileFromRelays requires real network calls or complex mocking
|
||||||
|
// of nostr-tools SimplePool, so we focus on unit testing mergeProfiles
|
||||||
|
|
||||||
|
describe("nostr-profile-import", () => {
|
||||||
|
describe("mergeProfiles", () => {
|
||||||
|
it("returns empty object when both are undefined", () => {
|
||||||
|
const result = mergeProfiles(undefined, undefined);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns imported when local is undefined", () => {
|
||||||
|
const imported: NostrProfile = {
|
||||||
|
name: "imported",
|
||||||
|
displayName: "Imported User",
|
||||||
|
about: "Bio from relay",
|
||||||
|
};
|
||||||
|
const result = mergeProfiles(undefined, imported);
|
||||||
|
expect(result).toEqual(imported);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns local when imported is undefined", () => {
|
||||||
|
const local: NostrProfile = {
|
||||||
|
name: "local",
|
||||||
|
displayName: "Local User",
|
||||||
|
};
|
||||||
|
const result = mergeProfiles(local, undefined);
|
||||||
|
expect(result).toEqual(local);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers local values over imported", () => {
|
||||||
|
const local: NostrProfile = {
|
||||||
|
name: "localname",
|
||||||
|
about: "Local bio",
|
||||||
|
};
|
||||||
|
const imported: NostrProfile = {
|
||||||
|
name: "importedname",
|
||||||
|
displayName: "Imported Display",
|
||||||
|
about: "Imported bio",
|
||||||
|
picture: "https://example.com/pic.jpg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeProfiles(local, imported);
|
||||||
|
|
||||||
|
expect(result.name).toBe("localname"); // local wins
|
||||||
|
expect(result.displayName).toBe("Imported Display"); // imported fills gap
|
||||||
|
expect(result.about).toBe("Local bio"); // local wins
|
||||||
|
expect(result.picture).toBe("https://example.com/pic.jpg"); // imported fills gap
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills all missing fields from imported", () => {
|
||||||
|
const local: NostrProfile = {
|
||||||
|
name: "myname",
|
||||||
|
};
|
||||||
|
const imported: NostrProfile = {
|
||||||
|
name: "theirname",
|
||||||
|
displayName: "Their Name",
|
||||||
|
about: "Their bio",
|
||||||
|
picture: "https://example.com/pic.jpg",
|
||||||
|
banner: "https://example.com/banner.jpg",
|
||||||
|
website: "https://example.com",
|
||||||
|
nip05: "user@example.com",
|
||||||
|
lud16: "user@getalby.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeProfiles(local, imported);
|
||||||
|
|
||||||
|
expect(result.name).toBe("myname");
|
||||||
|
expect(result.displayName).toBe("Their Name");
|
||||||
|
expect(result.about).toBe("Their bio");
|
||||||
|
expect(result.picture).toBe("https://example.com/pic.jpg");
|
||||||
|
expect(result.banner).toBe("https://example.com/banner.jpg");
|
||||||
|
expect(result.website).toBe("https://example.com");
|
||||||
|
expect(result.nip05).toBe("user@example.com");
|
||||||
|
expect(result.lud16).toBe("user@getalby.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty strings as falsy (prefers imported)", () => {
|
||||||
|
const local: NostrProfile = {
|
||||||
|
name: "",
|
||||||
|
displayName: "",
|
||||||
|
};
|
||||||
|
const imported: NostrProfile = {
|
||||||
|
name: "imported",
|
||||||
|
displayName: "Imported",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeProfiles(local, imported);
|
||||||
|
|
||||||
|
// Empty strings are still strings, so they "win" over imported
|
||||||
|
// This is JavaScript nullish coalescing behavior
|
||||||
|
expect(result.name).toBe("");
|
||||||
|
expect(result.displayName).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null values in local (prefers imported)", () => {
|
||||||
|
const local: NostrProfile = {
|
||||||
|
name: undefined,
|
||||||
|
displayName: undefined,
|
||||||
|
};
|
||||||
|
const imported: NostrProfile = {
|
||||||
|
name: "imported",
|
||||||
|
displayName: "Imported",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeProfiles(local, imported);
|
||||||
|
|
||||||
|
expect(result.name).toBe("imported");
|
||||||
|
expect(result.displayName).toBe("Imported");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Nostr Profile Import
|
||||||
|
*
|
||||||
|
* Fetches and verifies kind:0 profile events from relays.
|
||||||
|
* Used to import existing profiles before editing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SimplePool, verifyEvent, type Event } from "nostr-tools";
|
||||||
|
|
||||||
|
import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
|
||||||
|
import type { NostrProfile } from "./config-schema.js";
|
||||||
|
import { validateUrlSafety } from "./nostr-profile-http.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ProfileImportResult {
|
||||||
|
/** Whether the import was successful */
|
||||||
|
ok: boolean;
|
||||||
|
/** The imported profile (if found and valid) */
|
||||||
|
profile?: NostrProfile;
|
||||||
|
/** The raw event (for advanced users) */
|
||||||
|
event?: {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
/** Error message if import failed */
|
||||||
|
error?: string;
|
||||||
|
/** Which relays responded */
|
||||||
|
relaysQueried: string[];
|
||||||
|
/** Which relay provided the winning event */
|
||||||
|
sourceRelay?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileImportOptions {
|
||||||
|
/** The public key to fetch profile for */
|
||||||
|
pubkey: string;
|
||||||
|
/** Relay URLs to query */
|
||||||
|
relays: string[];
|
||||||
|
/** Timeout per relay in milliseconds (default: 5000) */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile Import
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize URLs in an imported profile to prevent SSRF attacks.
|
||||||
|
* Removes any URLs that don't pass SSRF validation.
|
||||||
|
*/
|
||||||
|
function sanitizeProfileUrls(profile: NostrProfile): NostrProfile {
|
||||||
|
const result = { ...profile };
|
||||||
|
const urlFields = ["picture", "banner", "website"] as const;
|
||||||
|
|
||||||
|
for (const field of urlFields) {
|
||||||
|
const value = result[field];
|
||||||
|
if (value && typeof value === "string") {
|
||||||
|
const validation = validateUrlSafety(value);
|
||||||
|
if (!validation.ok) {
|
||||||
|
// Remove unsafe URL
|
||||||
|
delete result[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the latest kind:0 profile event for a pubkey from relays.
|
||||||
|
*
|
||||||
|
* - Queries all relays in parallel
|
||||||
|
* - Takes the event with the highest created_at
|
||||||
|
* - Verifies the event signature
|
||||||
|
* - Parses and returns the profile
|
||||||
|
*/
|
||||||
|
export async function importProfileFromRelays(
|
||||||
|
opts: ProfileImportOptions
|
||||||
|
): Promise<ProfileImportResult> {
|
||||||
|
const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
|
||||||
|
|
||||||
|
if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid pubkey format (must be 64 hex characters)",
|
||||||
|
relaysQueried: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relays.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "No relays configured",
|
||||||
|
relaysQueried: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new SimplePool();
|
||||||
|
const relaysQueried: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Query all relays for kind:0 events from this pubkey
|
||||||
|
const events: Array<{ event: Event; relay: string }> = [];
|
||||||
|
|
||||||
|
// Create timeout promise
|
||||||
|
const timeoutPromise = new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create subscription promise
|
||||||
|
const subscriptionPromise = new Promise<void>((resolve) => {
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
for (const relay of relays) {
|
||||||
|
relaysQueried.push(relay);
|
||||||
|
|
||||||
|
const sub = pool.subscribeMany(
|
||||||
|
[relay],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
events.push({ event, relay });
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
completed++;
|
||||||
|
if (completed >= relays.length) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
completed++;
|
||||||
|
if (completed >= relays.length) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up subscription after timeout
|
||||||
|
setTimeout(() => {
|
||||||
|
sub.close();
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for either all relays to respond or timeout
|
||||||
|
await Promise.race([subscriptionPromise, timeoutPromise]);
|
||||||
|
|
||||||
|
// No events found
|
||||||
|
if (events.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "No profile found on any relay",
|
||||||
|
relaysQueried,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the event with the highest created_at (newest wins for replaceable events)
|
||||||
|
let bestEvent: { event: Event; relay: string } | null = null;
|
||||||
|
for (const item of events) {
|
||||||
|
if (!bestEvent || item.event.created_at > bestEvent.event.created_at) {
|
||||||
|
bestEvent = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestEvent) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "No valid profile event found",
|
||||||
|
relaysQueried,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the event signature
|
||||||
|
const isValid = verifyEvent(bestEvent.event);
|
||||||
|
if (!isValid) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Profile event has invalid signature",
|
||||||
|
relaysQueried,
|
||||||
|
sourceRelay: bestEvent.relay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the profile content
|
||||||
|
let content: ProfileContent;
|
||||||
|
try {
|
||||||
|
content = JSON.parse(bestEvent.event.content) as ProfileContent;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Profile event has invalid JSON content",
|
||||||
|
relaysQueried,
|
||||||
|
sourceRelay: bestEvent.relay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to our profile format
|
||||||
|
const profile = contentToProfile(content);
|
||||||
|
|
||||||
|
// Sanitize URLs from imported profile to prevent SSRF when auto-merging
|
||||||
|
const sanitizedProfile = sanitizeProfileUrls(profile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
profile: sanitizedProfile,
|
||||||
|
event: {
|
||||||
|
id: bestEvent.event.id,
|
||||||
|
pubkey: bestEvent.event.pubkey,
|
||||||
|
created_at: bestEvent.event.created_at,
|
||||||
|
},
|
||||||
|
relaysQueried,
|
||||||
|
sourceRelay: bestEvent.relay,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
pool.close(relays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge imported profile with local profile.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - For each field, prefer local if set, otherwise use imported
|
||||||
|
* - This preserves user customizations while filling in missing data
|
||||||
|
*/
|
||||||
|
export function mergeProfiles(
|
||||||
|
local: NostrProfile | undefined,
|
||||||
|
imported: NostrProfile | undefined
|
||||||
|
): NostrProfile {
|
||||||
|
if (!imported) return local ?? {};
|
||||||
|
if (!local) return imported;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: local.name ?? imported.name,
|
||||||
|
displayName: local.displayName ?? imported.displayName,
|
||||||
|
about: local.about ?? imported.about,
|
||||||
|
picture: local.picture ?? imported.picture,
|
||||||
|
banner: local.banner ?? imported.banner,
|
||||||
|
website: local.website ?? imported.website,
|
||||||
|
nip05: local.nip05 ?? imported.nip05,
|
||||||
|
lud16: local.lud16 ?? imported.lud16,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getPublicKey } from "nostr-tools";
|
||||||
|
import {
|
||||||
|
createProfileEvent,
|
||||||
|
profileToContent,
|
||||||
|
validateProfile,
|
||||||
|
sanitizeProfileForDisplay,
|
||||||
|
} from "./nostr-profile.js";
|
||||||
|
import type { NostrProfile } from "./config-schema.js";
|
||||||
|
|
||||||
|
// Test private key
|
||||||
|
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
const TEST_SK = new Uint8Array(
|
||||||
|
TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Unicode Attack Vectors
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("profile unicode attacks", () => {
|
||||||
|
describe("zero-width characters", () => {
|
||||||
|
it("handles zero-width space in name", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "test\u200Buser", // Zero-width space
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
// The character should be preserved (not stripped)
|
||||||
|
expect(result.profile?.name).toBe("test\u200Buser");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles zero-width joiner in name", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "test\u200Duser", // Zero-width joiner
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles zero-width non-joiner in about", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: "test\u200Cabout", // Zero-width non-joiner
|
||||||
|
};
|
||||||
|
const content = profileToContent(profile);
|
||||||
|
expect(content.about).toBe("test\u200Cabout");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RTL override attacks", () => {
|
||||||
|
it("handles RTL override in name", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "\u202Eevil\u202C", // Right-to-left override + pop direction
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
|
||||||
|
// UI should escape or handle this
|
||||||
|
const sanitized = sanitizeProfileForDisplay(result.profile!);
|
||||||
|
expect(sanitized.name).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles bidi embedding in about", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: "Normal \u202Breversed\u202C text", // LTR embedding
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("homoglyph attacks", () => {
|
||||||
|
it("handles Cyrillic homoglyphs", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
// Cyrillic 'а' (U+0430) looks like Latin 'a'
|
||||||
|
name: "\u0430dmin", // Fake "admin"
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
// Profile is accepted but apps should be aware
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Greek homoglyphs", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
// Greek 'ο' (U+03BF) looks like Latin 'o'
|
||||||
|
name: "b\u03BFt", // Looks like "bot"
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combining characters", () => {
|
||||||
|
it("handles combining diacritics", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "cafe\u0301", // 'e' + combining acute = 'é'
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.profile?.name).toBe("cafe\u0301");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles excessive combining characters (Zalgo text)", () => {
|
||||||
|
const zalgo =
|
||||||
|
"t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t";
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: zalgo.slice(0, 256), // Truncate to fit limit
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
// Should be valid but may look weird
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CJK and other scripts", () => {
|
||||||
|
it("handles Chinese characters", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "中文用户",
|
||||||
|
about: "我是一个机器人",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Japanese hiragana and katakana", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "ボット",
|
||||||
|
about: "これはテストです",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Korean characters", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "한국어사용자",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Arabic text", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "مستخدم",
|
||||||
|
about: "مرحبا بالعالم",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Hebrew text", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "משתמש",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Thai text", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "ผู้ใช้",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emoji edge cases", () => {
|
||||||
|
it("handles emoji sequences (ZWJ)", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "👨👩👧👦", // Family emoji using ZWJ
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles flag emojis", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "🇺🇸🇯🇵🇬🇧",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles skin tone modifiers", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "👋🏻👋🏽👋🏿",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// XSS Attack Vectors
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("profile XSS attacks", () => {
|
||||||
|
describe("script injection", () => {
|
||||||
|
it("escapes script tags", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: '<script>alert("xss")</script>',
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
expect(sanitized.name).not.toContain("<script>");
|
||||||
|
expect(sanitized.name).toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes nested script tags", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: '<<script>script>alert("xss")<</script>/script>',
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
expect(sanitized.about).not.toContain("<script>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event handler injection", () => {
|
||||||
|
it("escapes img onerror", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: '<img src="x" onerror="alert(1)">',
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
expect(sanitized.about).toContain("<img");
|
||||||
|
expect(sanitized.about).not.toContain('onerror="alert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes svg onload", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: '<svg onload="alert(1)">',
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
expect(sanitized.about).toContain("<svg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes body onload", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: '<body onload="alert(1)">',
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
expect(sanitized.about).toContain("<body");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("URL-based attacks", () => {
|
||||||
|
it("rejects javascript: URL in picture", () => {
|
||||||
|
const profile = {
|
||||||
|
picture: "javascript:alert('xss')",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects javascript: URL with encoding", () => {
|
||||||
|
const profile = {
|
||||||
|
picture: "javascript:alert('xss')",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects data: URL", () => {
|
||||||
|
const profile = {
|
||||||
|
picture: "data:text/html,<script>alert('xss')</script>",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects vbscript: URL", () => {
|
||||||
|
const profile = {
|
||||||
|
website: "vbscript:msgbox('xss')",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects file: URL", () => {
|
||||||
|
const profile = {
|
||||||
|
picture: "file:///etc/passwd",
|
||||||
|
};
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML attribute injection", () => {
|
||||||
|
it("escapes double quotes in fields", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: '" onclick="alert(1)" data-x="',
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
expect(sanitized.name).toContain(""");
|
||||||
|
expect(sanitized.name).not.toContain('onclick="alert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes single quotes in fields", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "' onclick='alert(1)' data-x='",
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
expect(sanitized.name).toContain("'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS injection", () => {
|
||||||
|
it("escapes style tags", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: '<style>body{background:url("javascript:alert(1)")}</style>',
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
expect(sanitized.about).toContain("<style>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Length Boundary Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("profile length boundaries", () => {
|
||||||
|
describe("name field (max 256)", () => {
|
||||||
|
it("accepts exactly 256 characters", () => {
|
||||||
|
const result = validateProfile({ name: "a".repeat(256) });
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 257 characters", () => {
|
||||||
|
const result = validateProfile({ name: "a".repeat(257) });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts empty string", () => {
|
||||||
|
const result = validateProfile({ name: "" });
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("displayName field (max 256)", () => {
|
||||||
|
it("accepts exactly 256 characters", () => {
|
||||||
|
const result = validateProfile({ displayName: "b".repeat(256) });
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 257 characters", () => {
|
||||||
|
const result = validateProfile({ displayName: "b".repeat(257) });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("about field (max 2000)", () => {
|
||||||
|
it("accepts exactly 2000 characters", () => {
|
||||||
|
const result = validateProfile({ about: "c".repeat(2000) });
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 2001 characters", () => {
|
||||||
|
const result = validateProfile({ about: "c".repeat(2001) });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("URL fields", () => {
|
||||||
|
it("accepts long valid HTTPS URLs", () => {
|
||||||
|
const longPath = "a".repeat(1000);
|
||||||
|
const result = validateProfile({
|
||||||
|
picture: `https://example.com/${longPath}.png`,
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid URL format", () => {
|
||||||
|
const result = validateProfile({
|
||||||
|
picture: "not-a-url",
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects URL without protocol", () => {
|
||||||
|
const result = validateProfile({
|
||||||
|
picture: "example.com/pic.png",
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type Confusion Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("profile type confusion", () => {
|
||||||
|
it("rejects number as name", () => {
|
||||||
|
const result = validateProfile({ name: 123 as unknown as string });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects array as about", () => {
|
||||||
|
const result = validateProfile({ about: ["hello"] as unknown as string });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects object as picture", () => {
|
||||||
|
const result = validateProfile({ picture: { url: "https://example.com" } as unknown as string });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects null as name", () => {
|
||||||
|
const result = validateProfile({ name: null as unknown as string });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects boolean as about", () => {
|
||||||
|
const result = validateProfile({ about: true as unknown as string });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects function as name", () => {
|
||||||
|
const result = validateProfile({ name: (() => "test") as unknown as string });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles prototype pollution attempt", () => {
|
||||||
|
const malicious = JSON.parse('{"__proto__": {"polluted": true}}') as unknown;
|
||||||
|
const result = validateProfile(malicious);
|
||||||
|
// Should not pollute Object.prototype
|
||||||
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Event Creation Edge Cases
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("event creation edge cases", () => {
|
||||||
|
it("handles profile with all fields at max length", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "a".repeat(256),
|
||||||
|
displayName: "b".repeat(256),
|
||||||
|
about: "c".repeat(2000),
|
||||||
|
nip05: "d".repeat(200) + "@example.com",
|
||||||
|
lud16: "e".repeat(200) + "@example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
expect(event.kind).toBe(0);
|
||||||
|
|
||||||
|
// Content should be parseable JSON
|
||||||
|
expect(() => JSON.parse(event.content)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles rapid sequential events with monotonic timestamps", () => {
|
||||||
|
const profile: NostrProfile = { name: "rapid" };
|
||||||
|
|
||||||
|
// Create events in quick succession
|
||||||
|
let lastTimestamp = 0;
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const event = createProfileEvent(TEST_SK, profile, lastTimestamp);
|
||||||
|
expect(event.created_at).toBeGreaterThan(lastTimestamp);
|
||||||
|
lastTimestamp = event.created_at;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles JSON special characters in content", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: 'test"user',
|
||||||
|
about: "line1\nline2\ttab\\backslash",
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
const parsed = JSON.parse(event.content) as { name: string; about: string };
|
||||||
|
|
||||||
|
expect(parsed.name).toBe('test"user');
|
||||||
|
expect(parsed.about).toContain("\n");
|
||||||
|
expect(parsed.about).toContain("\t");
|
||||||
|
expect(parsed.about).toContain("\\");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { verifyEvent, getPublicKey } from "nostr-tools";
|
||||||
|
import {
|
||||||
|
createProfileEvent,
|
||||||
|
profileToContent,
|
||||||
|
contentToProfile,
|
||||||
|
validateProfile,
|
||||||
|
sanitizeProfileForDisplay,
|
||||||
|
type ProfileContent,
|
||||||
|
} from "./nostr-profile.js";
|
||||||
|
import type { NostrProfile } from "./config-schema.js";
|
||||||
|
|
||||||
|
// Test private key (DO NOT use in production - this is a known test key)
|
||||||
|
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
const TEST_SK = new Uint8Array(
|
||||||
|
TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
|
||||||
|
);
|
||||||
|
const TEST_PUBKEY = getPublicKey(TEST_SK);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile Content Conversion Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("profileToContent", () => {
|
||||||
|
it("converts full profile to NIP-01 content format", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "testuser",
|
||||||
|
displayName: "Test User",
|
||||||
|
about: "A test user for unit testing",
|
||||||
|
picture: "https://example.com/avatar.png",
|
||||||
|
banner: "https://example.com/banner.png",
|
||||||
|
website: "https://example.com",
|
||||||
|
nip05: "testuser@example.com",
|
||||||
|
lud16: "testuser@walletofsatoshi.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = profileToContent(profile);
|
||||||
|
|
||||||
|
expect(content.name).toBe("testuser");
|
||||||
|
expect(content.display_name).toBe("Test User");
|
||||||
|
expect(content.about).toBe("A test user for unit testing");
|
||||||
|
expect(content.picture).toBe("https://example.com/avatar.png");
|
||||||
|
expect(content.banner).toBe("https://example.com/banner.png");
|
||||||
|
expect(content.website).toBe("https://example.com");
|
||||||
|
expect(content.nip05).toBe("testuser@example.com");
|
||||||
|
expect(content.lud16).toBe("testuser@walletofsatoshi.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits undefined fields from content", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "minimaluser",
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = profileToContent(profile);
|
||||||
|
|
||||||
|
expect(content.name).toBe("minimaluser");
|
||||||
|
expect("display_name" in content).toBe(false);
|
||||||
|
expect("about" in content).toBe(false);
|
||||||
|
expect("picture" in content).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty profile", () => {
|
||||||
|
const profile: NostrProfile = {};
|
||||||
|
const content = profileToContent(profile);
|
||||||
|
expect(Object.keys(content)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("contentToProfile", () => {
|
||||||
|
it("converts NIP-01 content to profile format", () => {
|
||||||
|
const content: ProfileContent = {
|
||||||
|
name: "testuser",
|
||||||
|
display_name: "Test User",
|
||||||
|
about: "A test user",
|
||||||
|
picture: "https://example.com/avatar.png",
|
||||||
|
nip05: "test@example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const profile = contentToProfile(content);
|
||||||
|
|
||||||
|
expect(profile.name).toBe("testuser");
|
||||||
|
expect(profile.displayName).toBe("Test User");
|
||||||
|
expect(profile.about).toBe("A test user");
|
||||||
|
expect(profile.picture).toBe("https://example.com/avatar.png");
|
||||||
|
expect(profile.nip05).toBe("test@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty content", () => {
|
||||||
|
const content: ProfileContent = {};
|
||||||
|
const profile = contentToProfile(content);
|
||||||
|
expect(Object.keys(profile).filter((k) => profile[k as keyof NostrProfile] !== undefined)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips profile data", () => {
|
||||||
|
const original: NostrProfile = {
|
||||||
|
name: "roundtrip",
|
||||||
|
displayName: "Round Trip Test",
|
||||||
|
about: "Testing round-trip conversion",
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = profileToContent(original);
|
||||||
|
const restored = contentToProfile(content);
|
||||||
|
|
||||||
|
expect(restored.name).toBe(original.name);
|
||||||
|
expect(restored.displayName).toBe(original.displayName);
|
||||||
|
expect(restored.about).toBe(original.about);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Event Creation Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("createProfileEvent", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a valid kind:0 event", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "testbot",
|
||||||
|
about: "A test bot",
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
|
||||||
|
expect(event.kind).toBe(0);
|
||||||
|
expect(event.pubkey).toBe(TEST_PUBKEY);
|
||||||
|
expect(event.tags).toEqual([]);
|
||||||
|
expect(event.id).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(event.sig).toMatch(/^[0-9a-f]{128}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes profile content as JSON in event content", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "jsontest",
|
||||||
|
displayName: "JSON Test User",
|
||||||
|
about: "Testing JSON serialization",
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
const parsedContent = JSON.parse(event.content) as ProfileContent;
|
||||||
|
|
||||||
|
expect(parsedContent.name).toBe("jsontest");
|
||||||
|
expect(parsedContent.display_name).toBe("JSON Test User");
|
||||||
|
expect(parsedContent.about).toBe("Testing JSON serialization");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a verifiable signature", () => {
|
||||||
|
const profile: NostrProfile = { name: "signaturetest" };
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
|
||||||
|
expect(verifyEvent(event)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses current timestamp when no lastPublishedAt provided", () => {
|
||||||
|
const profile: NostrProfile = { name: "timestamptest" };
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
|
||||||
|
const expectedTimestamp = Math.floor(Date.now() / 1000);
|
||||||
|
expect(event.created_at).toBe(expectedTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ensures monotonic timestamp when lastPublishedAt is in the future", () => {
|
||||||
|
// Current time is 2024-01-15T12:00:00Z = 1705320000
|
||||||
|
const futureTimestamp = 1705320000 + 3600; // 1 hour in the future
|
||||||
|
const profile: NostrProfile = { name: "monotonictest" };
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile, futureTimestamp);
|
||||||
|
|
||||||
|
expect(event.created_at).toBe(futureTimestamp + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses current time when lastPublishedAt is in the past", () => {
|
||||||
|
const pastTimestamp = 1705320000 - 3600; // 1 hour in the past
|
||||||
|
const profile: NostrProfile = { name: "pasttest" };
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile, pastTimestamp);
|
||||||
|
|
||||||
|
const expectedTimestamp = Math.floor(Date.now() / 1000);
|
||||||
|
expect(event.created_at).toBe(expectedTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile Validation Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("validateProfile", () => {
|
||||||
|
it("validates a correct profile", () => {
|
||||||
|
const profile = {
|
||||||
|
name: "validuser",
|
||||||
|
about: "A valid user",
|
||||||
|
picture: "https://example.com/pic.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.profile).toBeDefined();
|
||||||
|
expect(result.errors).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects profile with invalid URL", () => {
|
||||||
|
const profile = {
|
||||||
|
name: "invalidurl",
|
||||||
|
picture: "http://insecure.example.com/pic.png", // HTTP not HTTPS
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toBeDefined();
|
||||||
|
expect(result.errors!.some((e) => e.includes("https://"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects profile with javascript: URL", () => {
|
||||||
|
const profile = {
|
||||||
|
name: "xssattempt",
|
||||||
|
picture: "javascript:alert('xss')",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects profile with data: URL", () => {
|
||||||
|
const profile = {
|
||||||
|
name: "dataurl",
|
||||||
|
picture: "data:image/png;base64,abc123",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects name exceeding 256 characters", () => {
|
||||||
|
const profile = {
|
||||||
|
name: "a".repeat(257),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors!.some((e) => e.includes("256"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects about exceeding 2000 characters", () => {
|
||||||
|
const profile = {
|
||||||
|
about: "a".repeat(2001),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors!.some((e) => e.includes("2000"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts empty profile", () => {
|
||||||
|
const result = validateProfile({});
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects null input", () => {
|
||||||
|
const result = validateProfile(null);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-object input", () => {
|
||||||
|
const result = validateProfile("not an object");
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sanitization Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("sanitizeProfileForDisplay", () => {
|
||||||
|
it("escapes HTML in name field", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "<script>alert('xss')</script>",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
|
||||||
|
expect(sanitized.name).toBe("<script>alert('xss')</script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML in about field", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: 'Check out <img src="x" onerror="alert(1)">',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
|
||||||
|
expect(sanitized.about).toBe(
|
||||||
|
'Check out <img src="x" onerror="alert(1)">'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves URLs without modification", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
picture: "https://example.com/pic.png",
|
||||||
|
website: "https://example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
|
||||||
|
expect(sanitized.picture).toBe("https://example.com/pic.png");
|
||||||
|
expect(sanitized.website).toBe("https://example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles undefined fields", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
|
||||||
|
expect(sanitized.name).toBe("test");
|
||||||
|
expect(sanitized.about).toBeUndefined();
|
||||||
|
expect(sanitized.picture).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes ampersands", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "Tom & Jerry",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
|
||||||
|
expect(sanitized.name).toBe("Tom & Jerry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes quotes", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: 'Say "hello" to everyone',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = sanitizeProfileForDisplay(profile);
|
||||||
|
|
||||||
|
expect(sanitized.about).toBe("Say "hello" to everyone");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Edge Cases
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("handles emoji in profile fields", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "🤖 Bot",
|
||||||
|
about: "I am a 🤖 robot! 🎉",
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = profileToContent(profile);
|
||||||
|
expect(content.name).toBe("🤖 Bot");
|
||||||
|
expect(content.about).toBe("I am a 🤖 robot! 🎉");
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
const parsed = JSON.parse(event.content) as ProfileContent;
|
||||||
|
expect(parsed.name).toBe("🤖 Bot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unicode in profile fields", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "日本語ユーザー",
|
||||||
|
about: "Привет мир! 你好世界!",
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = profileToContent(profile);
|
||||||
|
expect(content.name).toBe("日本語ユーザー");
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
expect(verifyEvent(event)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles newlines in about field", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
about: "Line 1\nLine 2\nLine 3",
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = profileToContent(profile);
|
||||||
|
expect(content.about).toBe("Line 1\nLine 2\nLine 3");
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
const parsed = JSON.parse(event.content) as ProfileContent;
|
||||||
|
expect(parsed.about).toBe("Line 1\nLine 2\nLine 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles maximum length fields", () => {
|
||||||
|
const profile: NostrProfile = {
|
||||||
|
name: "a".repeat(256),
|
||||||
|
about: "b".repeat(2000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateProfile(profile);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
|
||||||
|
const event = createProfileEvent(TEST_SK, profile);
|
||||||
|
expect(verifyEvent(event)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Nostr Profile Management (NIP-01 kind:0)
|
||||||
|
*
|
||||||
|
* Profile events are "replaceable" - the latest created_at wins.
|
||||||
|
* This module handles profile event creation and publishing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { finalizeEvent, SimplePool, type Event } from "nostr-tools";
|
||||||
|
import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Result of a profile publish attempt */
|
||||||
|
export interface ProfilePublishResult {
|
||||||
|
/** Event ID of the published profile */
|
||||||
|
eventId: string;
|
||||||
|
/** Relays that successfully received the event */
|
||||||
|
successes: string[];
|
||||||
|
/** Relays that failed with their error messages */
|
||||||
|
failures: Array<{ relay: string; error: string }>;
|
||||||
|
/** Unix timestamp when the event was created */
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NIP-01 profile content (JSON inside kind:0 event) */
|
||||||
|
export interface ProfileContent {
|
||||||
|
name?: string;
|
||||||
|
display_name?: string;
|
||||||
|
about?: string;
|
||||||
|
picture?: string;
|
||||||
|
banner?: string;
|
||||||
|
website?: string;
|
||||||
|
nip05?: string;
|
||||||
|
lud16?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile Content Conversion
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert our config profile schema to NIP-01 content format.
|
||||||
|
* Strips undefined fields and validates URLs.
|
||||||
|
*/
|
||||||
|
export function profileToContent(profile: NostrProfile): ProfileContent {
|
||||||
|
const validated = NostrProfileSchema.parse(profile);
|
||||||
|
|
||||||
|
const content: ProfileContent = {};
|
||||||
|
|
||||||
|
if (validated.name !== undefined) content.name = validated.name;
|
||||||
|
if (validated.displayName !== undefined) content.display_name = validated.displayName;
|
||||||
|
if (validated.about !== undefined) content.about = validated.about;
|
||||||
|
if (validated.picture !== undefined) content.picture = validated.picture;
|
||||||
|
if (validated.banner !== undefined) content.banner = validated.banner;
|
||||||
|
if (validated.website !== undefined) content.website = validated.website;
|
||||||
|
if (validated.nip05 !== undefined) content.nip05 = validated.nip05;
|
||||||
|
if (validated.lud16 !== undefined) content.lud16 = validated.lud16;
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert NIP-01 content format back to our config profile schema.
|
||||||
|
* Useful for importing existing profiles from relays.
|
||||||
|
*/
|
||||||
|
export function contentToProfile(content: ProfileContent): NostrProfile {
|
||||||
|
const profile: NostrProfile = {};
|
||||||
|
|
||||||
|
if (content.name !== undefined) profile.name = content.name;
|
||||||
|
if (content.display_name !== undefined) profile.displayName = content.display_name;
|
||||||
|
if (content.about !== undefined) profile.about = content.about;
|
||||||
|
if (content.picture !== undefined) profile.picture = content.picture;
|
||||||
|
if (content.banner !== undefined) profile.banner = content.banner;
|
||||||
|
if (content.website !== undefined) profile.website = content.website;
|
||||||
|
if (content.nip05 !== undefined) profile.nip05 = content.nip05;
|
||||||
|
if (content.lud16 !== undefined) profile.lud16 = content.lud16;
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Event Creation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a signed kind:0 profile event.
|
||||||
|
*
|
||||||
|
* @param sk - Private key as Uint8Array (32 bytes)
|
||||||
|
* @param profile - Profile data to include
|
||||||
|
* @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee)
|
||||||
|
* @returns Signed Nostr event
|
||||||
|
*/
|
||||||
|
export function createProfileEvent(
|
||||||
|
sk: Uint8Array,
|
||||||
|
profile: NostrProfile,
|
||||||
|
lastPublishedAt?: number
|
||||||
|
): Event {
|
||||||
|
const content = profileToContent(profile);
|
||||||
|
const contentJson = JSON.stringify(content);
|
||||||
|
|
||||||
|
// Ensure monotonic timestamp (new event > previous)
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now;
|
||||||
|
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 0,
|
||||||
|
content: contentJson,
|
||||||
|
tags: [],
|
||||||
|
created_at: createdAt,
|
||||||
|
},
|
||||||
|
sk
|
||||||
|
);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile Publishing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Per-relay publish timeout (ms) */
|
||||||
|
const RELAY_PUBLISH_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a profile event to multiple relays.
|
||||||
|
*
|
||||||
|
* Best-effort: publishes to all relays in parallel, reports per-relay results.
|
||||||
|
* Does NOT retry automatically - caller should handle retries if needed.
|
||||||
|
*
|
||||||
|
* @param pool - SimplePool instance for relay connections
|
||||||
|
* @param relays - Array of relay WebSocket URLs
|
||||||
|
* @param event - Signed profile event (kind:0)
|
||||||
|
* @returns Publish results with successes and failures
|
||||||
|
*/
|
||||||
|
export async function publishProfileEvent(
|
||||||
|
pool: SimplePool,
|
||||||
|
relays: string[],
|
||||||
|
event: Event
|
||||||
|
): Promise<ProfilePublishResult> {
|
||||||
|
const successes: string[] = [];
|
||||||
|
const failures: Array<{ relay: string; error: string }> = [];
|
||||||
|
|
||||||
|
// Publish to each relay in parallel with timeout
|
||||||
|
const publishPromises = relays.map(async (relay) => {
|
||||||
|
try {
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.race([pool.publish([relay], event), timeoutPromise]);
|
||||||
|
|
||||||
|
successes.push(relay);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
failures.push({ relay, error: errorMessage });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(publishPromises);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId: event.id,
|
||||||
|
successes,
|
||||||
|
failures,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and publish a profile event in one call.
|
||||||
|
*
|
||||||
|
* @param pool - SimplePool instance
|
||||||
|
* @param sk - Private key as Uint8Array
|
||||||
|
* @param relays - Array of relay URLs
|
||||||
|
* @param profile - Profile data
|
||||||
|
* @param lastPublishedAt - Previous timestamp for monotonic ordering
|
||||||
|
* @returns Publish results
|
||||||
|
*/
|
||||||
|
export async function publishProfile(
|
||||||
|
pool: SimplePool,
|
||||||
|
sk: Uint8Array,
|
||||||
|
relays: string[],
|
||||||
|
profile: NostrProfile,
|
||||||
|
lastPublishedAt?: number
|
||||||
|
): Promise<ProfilePublishResult> {
|
||||||
|
const event = createProfileEvent(sk, profile, lastPublishedAt);
|
||||||
|
return publishProfileEvent(pool, relays, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile Validation Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a profile without throwing (returns result object).
|
||||||
|
*/
|
||||||
|
export function validateProfile(profile: unknown): {
|
||||||
|
valid: boolean;
|
||||||
|
profile?: NostrProfile;
|
||||||
|
errors?: string[];
|
||||||
|
} {
|
||||||
|
const result = NostrProfileSchema.safeParse(profile);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return { valid: true, profile: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize profile text fields to prevent XSS when displaying in UI.
|
||||||
|
* Escapes HTML special characters.
|
||||||
|
*/
|
||||||
|
export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile {
|
||||||
|
const escapeHtml = (str: string | undefined): string | undefined => {
|
||||||
|
if (str === undefined) return undefined;
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: escapeHtml(profile.name),
|
||||||
|
displayName: escapeHtml(profile.displayName),
|
||||||
|
about: escapeHtml(profile.about),
|
||||||
|
picture: profile.picture, // URLs already validated by schema
|
||||||
|
banner: profile.banner,
|
||||||
|
website: profile.website,
|
||||||
|
nip05: escapeHtml(profile.nip05),
|
||||||
|
lud16: escapeHtml(profile.lud16),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import {
|
||||||
|
readNostrBusState,
|
||||||
|
writeNostrBusState,
|
||||||
|
computeSinceTimestamp,
|
||||||
|
} from "./nostr-state-store.js";
|
||||||
|
import { setNostrRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
async function withTempStateDir<T>(fn: (dir: string) => Promise<T>) {
|
||||||
|
const previous = process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-nostr-"));
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = dir;
|
||||||
|
setNostrRuntime({
|
||||||
|
state: {
|
||||||
|
resolveStateDir: (env, homedir) => {
|
||||||
|
const override = env.CLAWDBOT_STATE_DIR?.trim();
|
||||||
|
if (override) return override;
|
||||||
|
return path.join(homedir(), ".clawdbot");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as PluginRuntime);
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
else process.env.CLAWDBOT_STATE_DIR = previous;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("nostr bus state store", () => {
|
||||||
|
it("persists and reloads state across restarts", async () => {
|
||||||
|
await withTempStateDir(async () => {
|
||||||
|
// Fresh start - no state
|
||||||
|
expect(await readNostrBusState({ accountId: "test-bot" })).toBeNull();
|
||||||
|
|
||||||
|
// Write state
|
||||||
|
await writeNostrBusState({
|
||||||
|
accountId: "test-bot",
|
||||||
|
lastProcessedAt: 1700000000,
|
||||||
|
gatewayStartedAt: 1700000100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read it back
|
||||||
|
const state = await readNostrBusState({ accountId: "test-bot" });
|
||||||
|
expect(state).toEqual({
|
||||||
|
version: 2,
|
||||||
|
lastProcessedAt: 1700000000,
|
||||||
|
gatewayStartedAt: 1700000100,
|
||||||
|
recentEventIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates state by accountId", async () => {
|
||||||
|
await withTempStateDir(async () => {
|
||||||
|
await writeNostrBusState({
|
||||||
|
accountId: "bot-a",
|
||||||
|
lastProcessedAt: 1000,
|
||||||
|
gatewayStartedAt: 1000,
|
||||||
|
});
|
||||||
|
await writeNostrBusState({
|
||||||
|
accountId: "bot-b",
|
||||||
|
lastProcessedAt: 2000,
|
||||||
|
gatewayStartedAt: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateA = await readNostrBusState({ accountId: "bot-a" });
|
||||||
|
const stateB = await readNostrBusState({ accountId: "bot-b" });
|
||||||
|
|
||||||
|
expect(stateA?.lastProcessedAt).toBe(1000);
|
||||||
|
expect(stateB?.lastProcessedAt).toBe(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeSinceTimestamp", () => {
|
||||||
|
it("returns now for null state (fresh start)", () => {
|
||||||
|
const now = 1700000000;
|
||||||
|
expect(computeSinceTimestamp(null, now)).toBe(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses lastProcessedAt when available", () => {
|
||||||
|
const state = {
|
||||||
|
version: 2,
|
||||||
|
lastProcessedAt: 1699999000,
|
||||||
|
gatewayStartedAt: null,
|
||||||
|
recentEventIds: [],
|
||||||
|
};
|
||||||
|
expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses gatewayStartedAt when lastProcessedAt is null", () => {
|
||||||
|
const state = {
|
||||||
|
version: 2,
|
||||||
|
lastProcessedAt: null,
|
||||||
|
gatewayStartedAt: 1699998000,
|
||||||
|
recentEventIds: [],
|
||||||
|
};
|
||||||
|
expect(computeSinceTimestamp(state, 1700000000)).toBe(1699998000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the max of both timestamps", () => {
|
||||||
|
const state = {
|
||||||
|
version: 2,
|
||||||
|
lastProcessedAt: 1699999000,
|
||||||
|
gatewayStartedAt: 1699998000,
|
||||||
|
recentEventIds: [],
|
||||||
|
};
|
||||||
|
expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to now if both are null", () => {
|
||||||
|
const state = {
|
||||||
|
version: 2,
|
||||||
|
lastProcessedAt: null,
|
||||||
|
gatewayStartedAt: null,
|
||||||
|
recentEventIds: [],
|
||||||
|
};
|
||||||
|
expect(computeSinceTimestamp(state, 1700000000)).toBe(1700000000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { getNostrRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const STORE_VERSION = 2;
|
||||||
|
const PROFILE_STATE_VERSION = 1;
|
||||||
|
|
||||||
|
type NostrBusStateV1 = {
|
||||||
|
version: 1;
|
||||||
|
/** Unix timestamp (seconds) of the last processed event */
|
||||||
|
lastProcessedAt: number | null;
|
||||||
|
/** Gateway startup timestamp (seconds) - events before this are old */
|
||||||
|
gatewayStartedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NostrBusState = {
|
||||||
|
version: 2;
|
||||||
|
/** Unix timestamp (seconds) of the last processed event */
|
||||||
|
lastProcessedAt: number | null;
|
||||||
|
/** Gateway startup timestamp (seconds) - events before this are old */
|
||||||
|
gatewayStartedAt: number | null;
|
||||||
|
/** Recent processed event IDs for overlap dedupe across restarts */
|
||||||
|
recentEventIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Profile publish state (separate from bus state) */
|
||||||
|
export type NostrProfileState = {
|
||||||
|
version: 1;
|
||||||
|
/** Unix timestamp (seconds) of last successful profile publish */
|
||||||
|
lastPublishedAt: number | null;
|
||||||
|
/** Event ID of the last published profile */
|
||||||
|
lastPublishedEventId: string | null;
|
||||||
|
/** Per-relay publish results from last attempt */
|
||||||
|
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeAccountId(accountId?: string): string {
|
||||||
|
const trimmed = accountId?.trim();
|
||||||
|
if (!trimmed) return "default";
|
||||||
|
return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNostrStatePath(
|
||||||
|
accountId?: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
): string {
|
||||||
|
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
|
||||||
|
const normalized = normalizeAccountId(accountId);
|
||||||
|
return path.join(stateDir, "nostr", `bus-state-${normalized}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNostrProfileStatePath(
|
||||||
|
accountId?: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
): string {
|
||||||
|
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
|
||||||
|
const normalized = normalizeAccountId(accountId);
|
||||||
|
return path.join(stateDir, "nostr", `profile-state-${normalized}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseState(raw: string): NostrBusState | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<NostrBusState> & Partial<NostrBusStateV1>;
|
||||||
|
|
||||||
|
if (parsed?.version === 2) {
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null,
|
||||||
|
gatewayStartedAt: typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null,
|
||||||
|
recentEventIds: Array.isArray(parsed.recentEventIds)
|
||||||
|
? parsed.recentEventIds.filter((x): x is string => typeof x === "string")
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back-compat: v1 state files
|
||||||
|
if (parsed?.version === 1) {
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null,
|
||||||
|
gatewayStartedAt: typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null,
|
||||||
|
recentEventIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readNostrBusState(params: {
|
||||||
|
accountId?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<NostrBusState | null> {
|
||||||
|
const filePath = resolveNostrStatePath(params.accountId, params.env);
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(filePath, "utf-8");
|
||||||
|
return safeParseState(raw);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
if (code === "ENOENT") return null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeNostrBusState(params: {
|
||||||
|
accountId?: string;
|
||||||
|
lastProcessedAt: number;
|
||||||
|
gatewayStartedAt: number;
|
||||||
|
recentEventIds?: string[];
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<void> {
|
||||||
|
const filePath = resolveNostrStatePath(params.accountId, params.env);
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
|
const tmp = path.join(
|
||||||
|
dir,
|
||||||
|
`${path.basename(filePath)}.${crypto.randomUUID()}.tmp`
|
||||||
|
);
|
||||||
|
const payload: NostrBusState = {
|
||||||
|
version: STORE_VERSION,
|
||||||
|
lastProcessedAt: params.lastProcessedAt,
|
||||||
|
gatewayStartedAt: params.gatewayStartedAt,
|
||||||
|
recentEventIds: (params.recentEventIds ?? []).filter((x): x is string => typeof x === "string"),
|
||||||
|
};
|
||||||
|
await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
await fs.chmod(tmp, 0o600);
|
||||||
|
await fs.rename(tmp, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the `since` timestamp for subscription.
|
||||||
|
* Returns the later of: lastProcessedAt or gatewayStartedAt (both from disk),
|
||||||
|
* falling back to `now` for fresh starts.
|
||||||
|
*/
|
||||||
|
export function computeSinceTimestamp(
|
||||||
|
state: NostrBusState | null,
|
||||||
|
nowSec: number = Math.floor(Date.now() / 1000)
|
||||||
|
): number {
|
||||||
|
if (!state) return nowSec;
|
||||||
|
|
||||||
|
// Use the most recent timestamp we have
|
||||||
|
const candidates = [
|
||||||
|
state.lastProcessedAt,
|
||||||
|
state.gatewayStartedAt,
|
||||||
|
].filter((t): t is number => t !== null && t > 0);
|
||||||
|
|
||||||
|
if (candidates.length === 0) return nowSec;
|
||||||
|
return Math.max(...candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile State Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function safeParseProfileState(raw: string): NostrProfileState | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<NostrProfileState>;
|
||||||
|
|
||||||
|
if (parsed?.version === 1) {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
lastPublishedAt:
|
||||||
|
typeof parsed.lastPublishedAt === "number" ? parsed.lastPublishedAt : null,
|
||||||
|
lastPublishedEventId:
|
||||||
|
typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null,
|
||||||
|
lastPublishResults:
|
||||||
|
parsed.lastPublishResults && typeof parsed.lastPublishResults === "object"
|
||||||
|
? (parsed.lastPublishResults as Record<string, "ok" | "failed" | "timeout">)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readNostrProfileState(params: {
|
||||||
|
accountId?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<NostrProfileState | null> {
|
||||||
|
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(filePath, "utf-8");
|
||||||
|
return safeParseProfileState(raw);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
if (code === "ENOENT") return null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeNostrProfileState(params: {
|
||||||
|
accountId?: string;
|
||||||
|
lastPublishedAt: number;
|
||||||
|
lastPublishedEventId: string;
|
||||||
|
lastPublishResults: Record<string, "ok" | "failed" | "timeout">;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<void> {
|
||||||
|
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
|
const tmp = path.join(
|
||||||
|
dir,
|
||||||
|
`${path.basename(filePath)}.${crypto.randomUUID()}.tmp`
|
||||||
|
);
|
||||||
|
const payload: NostrProfileState = {
|
||||||
|
version: PROFILE_STATE_VERSION,
|
||||||
|
lastPublishedAt: params.lastPublishedAt,
|
||||||
|
lastPublishedEventId: params.lastPublishedEventId,
|
||||||
|
lastPublishResults: params.lastPublishResults,
|
||||||
|
};
|
||||||
|
await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
await fs.chmod(tmp, 0o600);
|
||||||
|
await fs.rename(tmp, filePath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setNostrRuntime(next: PluginRuntime): void {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNostrRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("Nostr runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* LRU-based seen event tracker with TTL support.
|
||||||
|
* Prevents unbounded memory growth under high load or abuse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SeenTrackerOptions {
|
||||||
|
/** Maximum number of entries to track (default: 100,000) */
|
||||||
|
maxEntries?: number;
|
||||||
|
/** TTL in milliseconds (default: 1 hour) */
|
||||||
|
ttlMs?: number;
|
||||||
|
/** Prune interval in milliseconds (default: 10 minutes) */
|
||||||
|
pruneIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeenTracker {
|
||||||
|
/** Check if an ID has been seen (also marks it as seen if not) */
|
||||||
|
has: (id: string) => boolean;
|
||||||
|
/** Mark an ID as seen */
|
||||||
|
add: (id: string) => void;
|
||||||
|
/** Check if ID exists without marking */
|
||||||
|
peek: (id: string) => boolean;
|
||||||
|
/** Delete an ID */
|
||||||
|
delete: (id: string) => void;
|
||||||
|
/** Clear all entries */
|
||||||
|
clear: () => void;
|
||||||
|
/** Get current size */
|
||||||
|
size: () => number;
|
||||||
|
/** Stop the pruning timer */
|
||||||
|
stop: () => void;
|
||||||
|
/** Pre-seed with IDs (useful for restart recovery) */
|
||||||
|
seed: (ids: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
seenAt: number;
|
||||||
|
// For LRU: track order via doubly-linked list
|
||||||
|
prev: string | null;
|
||||||
|
next: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new seen tracker with LRU eviction and TTL expiration.
|
||||||
|
*/
|
||||||
|
export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
|
||||||
|
const maxEntries = options?.maxEntries ?? 100_000;
|
||||||
|
const ttlMs = options?.ttlMs ?? 60 * 60 * 1000; // 1 hour
|
||||||
|
const pruneIntervalMs = options?.pruneIntervalMs ?? 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
// Main storage
|
||||||
|
const entries = new Map<string, Entry>();
|
||||||
|
|
||||||
|
// LRU tracking: head = most recent, tail = least recent
|
||||||
|
let head: string | null = null;
|
||||||
|
let tail: string | null = null;
|
||||||
|
|
||||||
|
// Move an entry to the front (most recently used)
|
||||||
|
function moveToFront(id: string): void {
|
||||||
|
const entry = entries.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
// Already at front
|
||||||
|
if (head === id) return;
|
||||||
|
|
||||||
|
// Remove from current position
|
||||||
|
if (entry.prev) {
|
||||||
|
const prevEntry = entries.get(entry.prev);
|
||||||
|
if (prevEntry) prevEntry.next = entry.next;
|
||||||
|
}
|
||||||
|
if (entry.next) {
|
||||||
|
const nextEntry = entries.get(entry.next);
|
||||||
|
if (nextEntry) nextEntry.prev = entry.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tail if this was the tail
|
||||||
|
if (tail === id) {
|
||||||
|
tail = entry.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to front
|
||||||
|
entry.prev = null;
|
||||||
|
entry.next = head;
|
||||||
|
if (head) {
|
||||||
|
const headEntry = entries.get(head);
|
||||||
|
if (headEntry) headEntry.prev = id;
|
||||||
|
}
|
||||||
|
head = id;
|
||||||
|
|
||||||
|
// If no tail, this is also the tail
|
||||||
|
if (!tail) tail = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove an entry from the linked list
|
||||||
|
function removeFromList(id: string): void {
|
||||||
|
const entry = entries.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
if (entry.prev) {
|
||||||
|
const prevEntry = entries.get(entry.prev);
|
||||||
|
if (prevEntry) prevEntry.next = entry.next;
|
||||||
|
} else {
|
||||||
|
head = entry.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.next) {
|
||||||
|
const nextEntry = entries.get(entry.next);
|
||||||
|
if (nextEntry) nextEntry.prev = entry.prev;
|
||||||
|
} else {
|
||||||
|
tail = entry.prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict the least recently used entry
|
||||||
|
function evictLRU(): void {
|
||||||
|
if (!tail) return;
|
||||||
|
const idToEvict = tail;
|
||||||
|
removeFromList(idToEvict);
|
||||||
|
entries.delete(idToEvict);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune expired entries
|
||||||
|
function pruneExpired(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
|
||||||
|
for (const [id, entry] of entries) {
|
||||||
|
if (now - entry.seenAt > ttlMs) {
|
||||||
|
toDelete.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of toDelete) {
|
||||||
|
removeFromList(id);
|
||||||
|
entries.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start pruning timer
|
||||||
|
let pruneTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
if (pruneIntervalMs > 0) {
|
||||||
|
pruneTimer = setInterval(pruneExpired, pruneIntervalMs);
|
||||||
|
// Don't keep process alive just for pruning
|
||||||
|
if (pruneTimer.unref) pruneTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(id: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// If already exists, update and move to front
|
||||||
|
const existing = entries.get(id);
|
||||||
|
if (existing) {
|
||||||
|
existing.seenAt = now;
|
||||||
|
moveToFront(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict if at capacity
|
||||||
|
while (entries.size >= maxEntries) {
|
||||||
|
evictLRU();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new entry at front
|
||||||
|
const newEntry: Entry = {
|
||||||
|
seenAt: now,
|
||||||
|
prev: null,
|
||||||
|
next: head,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (head) {
|
||||||
|
const headEntry = entries.get(head);
|
||||||
|
if (headEntry) headEntry.prev = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.set(id, newEntry);
|
||||||
|
head = id;
|
||||||
|
if (!tail) tail = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function has(id: string): boolean {
|
||||||
|
const entry = entries.get(id);
|
||||||
|
if (!entry) {
|
||||||
|
add(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (Date.now() - entry.seenAt > ttlMs) {
|
||||||
|
removeFromList(id);
|
||||||
|
entries.delete(id);
|
||||||
|
add(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as recently used
|
||||||
|
entry.seenAt = Date.now();
|
||||||
|
moveToFront(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function peek(id: string): boolean {
|
||||||
|
const entry = entries.get(id);
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (Date.now() - entry.seenAt > ttlMs) {
|
||||||
|
removeFromList(id);
|
||||||
|
entries.delete(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEntry(id: string): void {
|
||||||
|
if (entries.has(id)) {
|
||||||
|
removeFromList(id);
|
||||||
|
entries.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(): void {
|
||||||
|
entries.clear();
|
||||||
|
head = null;
|
||||||
|
tail = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function size(): number {
|
||||||
|
return entries.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(): void {
|
||||||
|
if (pruneTimer) {
|
||||||
|
clearInterval(pruneTimer);
|
||||||
|
pruneTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(ids: string[]): void {
|
||||||
|
const now = Date.now();
|
||||||
|
// Seed in reverse order so first IDs end up at front
|
||||||
|
for (let i = ids.length - 1; i >= 0; i--) {
|
||||||
|
const id = ids[i];
|
||||||
|
if (!entries.has(id) && entries.size < maxEntries) {
|
||||||
|
const newEntry: Entry = {
|
||||||
|
seenAt: now,
|
||||||
|
prev: null,
|
||||||
|
next: head,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (head) {
|
||||||
|
const headEntry = entries.get(head);
|
||||||
|
if (headEntry) headEntry.prev = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.set(id, newEntry);
|
||||||
|
head = id;
|
||||||
|
if (!tail) tail = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
has,
|
||||||
|
add,
|
||||||
|
peek,
|
||||||
|
delete: deleteEntry,
|
||||||
|
clear,
|
||||||
|
size,
|
||||||
|
stop,
|
||||||
|
seed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
listNostrAccountIds,
|
||||||
|
resolveDefaultNostrAccountId,
|
||||||
|
resolveNostrAccount,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const TEST_PRIVATE_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
|
describe("listNostrAccountIds", () => {
|
||||||
|
it("returns empty array when not configured", () => {
|
||||||
|
const cfg = { channels: {} };
|
||||||
|
expect(listNostrAccountIds(cfg)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when nostr section exists but no privateKey", () => {
|
||||||
|
const cfg = { channels: { nostr: { enabled: true } } };
|
||||||
|
expect(listNostrAccountIds(cfg)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default when privateKey is configured", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
nostr: { privateKey: TEST_PRIVATE_KEY },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(listNostrAccountIds(cfg)).toEqual(["default"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveDefaultNostrAccountId", () => {
|
||||||
|
it("returns default when configured", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
nostr: { privateKey: TEST_PRIVATE_KEY },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default when not configured", () => {
|
||||||
|
const cfg = { channels: {} };
|
||||||
|
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveNostrAccount", () => {
|
||||||
|
it("resolves configured account", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
nostr: {
|
||||||
|
privateKey: TEST_PRIVATE_KEY,
|
||||||
|
name: "Test Bot",
|
||||||
|
relays: ["wss://test.relay"],
|
||||||
|
dmPolicy: "pairing" as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const account = resolveNostrAccount({ cfg });
|
||||||
|
|
||||||
|
expect(account.accountId).toBe("default");
|
||||||
|
expect(account.name).toBe("Test Bot");
|
||||||
|
expect(account.enabled).toBe(true);
|
||||||
|
expect(account.configured).toBe(true);
|
||||||
|
expect(account.privateKey).toBe(TEST_PRIVATE_KEY);
|
||||||
|
expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(account.relays).toEqual(["wss://test.relay"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves unconfigured account with defaults", () => {
|
||||||
|
const cfg = { channels: {} };
|
||||||
|
const account = resolveNostrAccount({ cfg });
|
||||||
|
|
||||||
|
expect(account.accountId).toBe("default");
|
||||||
|
expect(account.enabled).toBe(true);
|
||||||
|
expect(account.configured).toBe(false);
|
||||||
|
expect(account.privateKey).toBe("");
|
||||||
|
expect(account.publicKey).toBe("");
|
||||||
|
expect(account.relays).toContain("wss://relay.damus.io");
|
||||||
|
expect(account.relays).toContain("wss://nos.lol");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles disabled channel", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
nostr: {
|
||||||
|
enabled: false,
|
||||||
|
privateKey: TEST_PRIVATE_KEY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const account = resolveNostrAccount({ cfg });
|
||||||
|
|
||||||
|
expect(account.enabled).toBe(false);
|
||||||
|
expect(account.configured).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles custom accountId parameter", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
nostr: { privateKey: TEST_PRIVATE_KEY },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const account = resolveNostrAccount({ cfg, accountId: "custom" });
|
||||||
|
|
||||||
|
expect(account.accountId).toBe("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles allowFrom config", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
nostr: {
|
||||||
|
privateKey: TEST_PRIVATE_KEY,
|
||||||
|
allowFrom: ["npub1test", "0123456789abcdef"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const account = resolveNostrAccount({ cfg });
|
||||||
|
|
||||||
|
expect(account.config.allowFrom).toEqual(["npub1test", "0123456789abcdef"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles invalid private key gracefully", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
nostr: {
|
||||||
|
privateKey: "invalid-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const account = resolveNostrAccount({ cfg });
|
||||||
|
|
||||||
|
expect(account.configured).toBe(true); // key is present
|
||||||
|
expect(account.publicKey).toBe(""); // but can't derive pubkey
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all config options", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
nostr: {
|
||||||
|
privateKey: TEST_PRIVATE_KEY,
|
||||||
|
name: "Bot",
|
||||||
|
enabled: true,
|
||||||
|
relays: ["wss://relay1", "wss://relay2"],
|
||||||
|
dmPolicy: "allowlist" as const,
|
||||||
|
allowFrom: ["pubkey1", "pubkey2"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const account = resolveNostrAccount({ cfg });
|
||||||
|
|
||||||
|
expect(account.config).toEqual({
|
||||||
|
privateKey: TEST_PRIVATE_KEY,
|
||||||
|
name: "Bot",
|
||||||
|
enabled: true,
|
||||||
|
relays: ["wss://relay1", "wss://relay2"],
|
||||||
|
dmPolicy: "allowlist",
|
||||||
|
allowFrom: ["pubkey1", "pubkey2"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { getPublicKeyFromPrivate } from "./nostr-bus.js";
|
||||||
|
import { DEFAULT_RELAYS } from "./nostr-bus.js";
|
||||||
|
import type { NostrProfile } from "./config-schema.js";
|
||||||
|
|
||||||
|
export interface NostrAccountConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
name?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
relays?: string[];
|
||||||
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
profile?: NostrProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedNostrAccount {
|
||||||
|
accountId: string;
|
||||||
|
name?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
relays: string[];
|
||||||
|
profile?: NostrProfile;
|
||||||
|
config: NostrAccountConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ACCOUNT_ID = "default";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all configured Nostr account IDs
|
||||||
|
*/
|
||||||
|
export function listNostrAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
|
const nostrCfg = (cfg.channels as Record<string, unknown> | undefined)?.nostr as
|
||||||
|
| NostrAccountConfig
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// If privateKey is configured at top level, we have a default account
|
||||||
|
if (nostrCfg?.privateKey) {
|
||||||
|
return [DEFAULT_ACCOUNT_ID];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default account ID
|
||||||
|
*/
|
||||||
|
export function resolveDefaultNostrAccountId(cfg: ClawdbotConfig): string {
|
||||||
|
const ids = listNostrAccountIds(cfg);
|
||||||
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||||
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a Nostr account from config
|
||||||
|
*/
|
||||||
|
export function resolveNostrAccount(opts: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): ResolvedNostrAccount {
|
||||||
|
const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const nostrCfg = (opts.cfg.channels as Record<string, unknown> | undefined)?.nostr as
|
||||||
|
| NostrAccountConfig
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const baseEnabled = nostrCfg?.enabled !== false;
|
||||||
|
const privateKey = nostrCfg?.privateKey ?? "";
|
||||||
|
const configured = Boolean(privateKey.trim());
|
||||||
|
|
||||||
|
let publicKey = "";
|
||||||
|
if (configured) {
|
||||||
|
try {
|
||||||
|
publicKey = getPublicKeyFromPrivate(privateKey);
|
||||||
|
} catch {
|
||||||
|
// Invalid key - leave publicKey empty, configured will indicate issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
name: nostrCfg?.name?.trim() || undefined,
|
||||||
|
enabled: baseEnabled,
|
||||||
|
configured,
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
relays: nostrCfg?.relays ?? DEFAULT_RELAYS,
|
||||||
|
profile: nostrCfg?.profile,
|
||||||
|
config: {
|
||||||
|
enabled: nostrCfg?.enabled,
|
||||||
|
name: nostrCfg?.name,
|
||||||
|
privateKey: nostrCfg?.privateKey,
|
||||||
|
relays: nostrCfg?.relays,
|
||||||
|
dmPolicy: nostrCfg?.dmPolicy,
|
||||||
|
allowFrom: nostrCfg?.allowFrom,
|
||||||
|
profile: nostrCfg?.profile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Test setup file for nostr extension
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock console.error to suppress noise in tests
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
Generated
+99
@@ -264,6 +264,12 @@ importers:
|
|||||||
'@opentelemetry/api':
|
'@opentelemetry/api':
|
||||||
specifier: ^1.9.0
|
specifier: ^1.9.0
|
||||||
version: 1.9.0
|
version: 1.9.0
|
||||||
|
'@opentelemetry/api-logs':
|
||||||
|
specifier: ^0.210.0
|
||||||
|
version: 0.210.0
|
||||||
|
'@opentelemetry/exporter-logs-otlp-http':
|
||||||
|
specifier: ^0.210.0
|
||||||
|
version: 0.210.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/exporter-metrics-otlp-http':
|
'@opentelemetry/exporter-metrics-otlp-http':
|
||||||
specifier: ^0.210.0
|
specifier: ^0.210.0
|
||||||
version: 0.210.0(@opentelemetry/api@1.9.0)
|
version: 0.210.0(@opentelemetry/api@1.9.0)
|
||||||
@@ -273,6 +279,9 @@ importers:
|
|||||||
'@opentelemetry/resources':
|
'@opentelemetry/resources':
|
||||||
specifier: ^2.4.0
|
specifier: ^2.4.0
|
||||||
version: 2.4.0(@opentelemetry/api@1.9.0)
|
version: 2.4.0(@opentelemetry/api@1.9.0)
|
||||||
|
'@opentelemetry/sdk-logs':
|
||||||
|
specifier: ^0.210.0
|
||||||
|
version: 0.210.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/sdk-metrics':
|
'@opentelemetry/sdk-metrics':
|
||||||
specifier: ^2.4.0
|
specifier: ^2.4.0
|
||||||
version: 2.4.0(@opentelemetry/api@1.9.0)
|
version: 2.4.0(@opentelemetry/api@1.9.0)
|
||||||
@@ -353,6 +362,18 @@ importers:
|
|||||||
|
|
||||||
extensions/nextcloud-talk: {}
|
extensions/nextcloud-talk: {}
|
||||||
|
|
||||||
|
extensions/nostr:
|
||||||
|
dependencies:
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
|
nostr-tools:
|
||||||
|
specifier: ^2.10.4
|
||||||
|
version: 2.19.4(typescript@5.9.3)
|
||||||
|
zod:
|
||||||
|
specifier: ^4.3.5
|
||||||
|
version: 4.3.5
|
||||||
|
|
||||||
extensions/signal: {}
|
extensions/signal: {}
|
||||||
|
|
||||||
extensions/slack: {}
|
extensions/slack: {}
|
||||||
@@ -1333,9 +1354,26 @@ packages:
|
|||||||
'@napi-rs/wasm-runtime@1.1.1':
|
'@napi-rs/wasm-runtime@1.1.1':
|
||||||
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
||||||
|
|
||||||
|
'@noble/ciphers@0.5.3':
|
||||||
|
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
|
||||||
|
|
||||||
|
'@noble/curves@1.1.0':
|
||||||
|
resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==}
|
||||||
|
|
||||||
|
'@noble/curves@1.2.0':
|
||||||
|
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
|
||||||
|
|
||||||
'@noble/ed25519@3.0.0':
|
'@noble/ed25519@3.0.0':
|
||||||
resolution: {integrity: sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==}
|
resolution: {integrity: sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==}
|
||||||
|
|
||||||
|
'@noble/hashes@1.3.1':
|
||||||
|
resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
|
'@noble/hashes@1.3.2':
|
||||||
|
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
'@node-llama-cpp/linux-arm64@3.15.0':
|
'@node-llama-cpp/linux-arm64@3.15.0':
|
||||||
resolution: {integrity: sha512-IaHIllWlj6tGjhhCtyp1w6xA7AHaGJiVaXAZ+78hDs8X1SL9ySBN2Qceju8AQJALePtynbAfjgjTqjQ7Hyk+IQ==}
|
resolution: {integrity: sha512-IaHIllWlj6tGjhhCtyp1w6xA7AHaGJiVaXAZ+78hDs8X1SL9ySBN2Qceju8AQJALePtynbAfjgjTqjQ7Hyk+IQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -2114,6 +2152,15 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@scure/base@1.1.1':
|
||||||
|
resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==}
|
||||||
|
|
||||||
|
'@scure/bip32@1.3.1':
|
||||||
|
resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==}
|
||||||
|
|
||||||
|
'@scure/bip39@1.2.1':
|
||||||
|
resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==}
|
||||||
|
|
||||||
'@selderee/plugin-htmlparser2@0.11.0':
|
'@selderee/plugin-htmlparser2@0.11.0':
|
||||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||||
|
|
||||||
@@ -4075,6 +4122,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
nostr-tools@2.19.4:
|
||||||
|
resolution: {integrity: sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
nostr-wasm@0.1.0:
|
||||||
|
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
|
||||||
|
|
||||||
npmlog@6.0.2:
|
npmlog@6.0.2:
|
||||||
resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
|
resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
|
||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||||
@@ -6356,8 +6414,22 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@noble/ciphers@0.5.3': {}
|
||||||
|
|
||||||
|
'@noble/curves@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
|
||||||
|
'@noble/curves@1.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.3.2
|
||||||
|
|
||||||
'@noble/ed25519@3.0.0': {}
|
'@noble/ed25519@3.0.0': {}
|
||||||
|
|
||||||
|
'@noble/hashes@1.3.1': {}
|
||||||
|
|
||||||
|
'@noble/hashes@1.3.2': {}
|
||||||
|
|
||||||
'@node-llama-cpp/linux-arm64@3.15.0':
|
'@node-llama-cpp/linux-arm64@3.15.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -7070,6 +7142,19 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.55.2':
|
'@rollup/rollup-win32-x64-msvc@4.55.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@scure/base@1.1.1': {}
|
||||||
|
|
||||||
|
'@scure/bip32@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@noble/curves': 1.1.0
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
'@scure/base': 1.1.1
|
||||||
|
|
||||||
|
'@scure/bip39@1.2.1':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
'@scure/base': 1.1.1
|
||||||
|
|
||||||
'@selderee/plugin-htmlparser2@0.11.0':
|
'@selderee/plugin-htmlparser2@0.11.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
@@ -9429,6 +9514,20 @@ snapshots:
|
|||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
|
nostr-tools@2.19.4(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@noble/ciphers': 0.5.3
|
||||||
|
'@noble/curves': 1.2.0
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
'@scure/base': 1.1.1
|
||||||
|
'@scure/bip32': 1.3.1
|
||||||
|
'@scure/bip39': 1.2.1
|
||||||
|
nostr-wasm: 0.1.0
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
nostr-wasm@0.1.0: {}
|
||||||
|
|
||||||
npmlog@6.0.2:
|
npmlog@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
are-we-there-yet: 3.0.1
|
are-we-there-yet: 3.0.1
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ const writeBuildStamp = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!shouldBuild()) {
|
if (!shouldBuild()) {
|
||||||
logRunner("Skipping build; dist is fresh.");
|
|
||||||
runNode();
|
runNode();
|
||||||
} else {
|
} else {
|
||||||
logRunner("Building TypeScript (dist is stale).");
|
logRunner("Building TypeScript (dist is stale).");
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: sherpa-onnx-tts
|
||||||
|
description: Local text-to-speech via sherpa-onnx (offline, no cloud)
|
||||||
|
metadata: {"clawdbot":{"emoji":"🗣️","os":["darwin","linux","win32"],"requires":{"env":["SHERPA_ONNX_RUNTIME_DIR","SHERPA_ONNX_MODEL_DIR"]},"install":[{"id":"download-runtime-macos","kind":"download","os":["darwin"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (macOS)"},{"id":"download-runtime-linux-x64","kind":"download","os":["linux"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Linux x64)"},{"id":"download-runtime-win-x64","kind":"download","os":["win32"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Windows x64)"},{"id":"download-model-lessac","kind":"download","url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2","archive":"tar.bz2","extract":true,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/models","label":"Download Piper en_US lessac (high)"}]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# sherpa-onnx-tts
|
||||||
|
|
||||||
|
Local TTS using the sherpa-onnx offline CLI.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1) Download the runtime for your OS (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/runtime`)
|
||||||
|
2) Download a voice model (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/models`)
|
||||||
|
|
||||||
|
Update `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
skills: {
|
||||||
|
entries: {
|
||||||
|
"sherpa-onnx-tts": {
|
||||||
|
env: {
|
||||||
|
SHERPA_ONNX_RUNTIME_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/runtime",
|
||||||
|
SHERPA_ONNX_MODEL_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/models/vits-piper-en_US-lessac-high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper lives in this skill folder. Run it directly, or add the wrapper to PATH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="{baseDir}/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{baseDir}/bin/sherpa-onnx-tts -o ./tts.wav "Hello from local TTS."
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pick a different model from the sherpa-onnx `tts-models` release if you want another voice.
|
||||||
|
- If the model dir has multiple `.onnx` files, set `SHERPA_ONNX_MODEL_FILE` or pass `--model-file`.
|
||||||
|
- You can also pass `--tokens-file` or `--data-dir` to override the defaults.
|
||||||
|
- Windows: run `node {baseDir}\\bin\\sherpa-onnx-tts -o tts.wav "Hello from local TTS."`
|
||||||
Executable
+178
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
function usage(message) {
|
||||||
|
if (message) {
|
||||||
|
console.error(message);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
"\nUsage: sherpa-onnx-tts [--runtime-dir <dir>] [--model-dir <dir>] [--model-file <file>] [--tokens-file <file>] [--data-dir <dir>] [--output <file>] \"text\"",
|
||||||
|
);
|
||||||
|
console.error("\nRequired env (or flags):\n SHERPA_ONNX_RUNTIME_DIR\n SHERPA_ONNX_MODEL_DIR");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeDir(explicit) {
|
||||||
|
const value = explicit || process.env.SHERPA_ONNX_RUNTIME_DIR || "";
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelDir(explicit) {
|
||||||
|
const value = explicit || process.env.SHERPA_ONNX_MODEL_DIR || "";
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelFile(modelDir, explicitFlag) {
|
||||||
|
const explicit = (explicitFlag || process.env.SHERPA_ONNX_MODEL_FILE || "").trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
try {
|
||||||
|
const candidates = fs
|
||||||
|
.readdirSync(modelDir)
|
||||||
|
.filter((entry) => entry.endsWith(".onnx"))
|
||||||
|
.map((entry) => path.join(modelDir, entry));
|
||||||
|
if (candidates.length === 1) return candidates[0];
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTokensFile(modelDir, explicitFlag) {
|
||||||
|
const explicit = (explicitFlag || process.env.SHERPA_ONNX_TOKENS_FILE || "").trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const candidate = path.join(modelDir, "tokens.txt");
|
||||||
|
return fs.existsSync(candidate) ? candidate : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDataDir(modelDir, explicitFlag) {
|
||||||
|
const explicit = (explicitFlag || process.env.SHERPA_ONNX_DATA_DIR || "").trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const candidate = path.join(modelDir, "espeak-ng-data");
|
||||||
|
return fs.existsSync(candidate) ? candidate : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBinary(runtimeDir) {
|
||||||
|
const binName = process.platform === "win32" ? "sherpa-onnx-offline-tts.exe" : "sherpa-onnx-offline-tts";
|
||||||
|
return path.join(runtimeDir, "bin", binName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependEnvPath(current, next) {
|
||||||
|
if (!next) return current;
|
||||||
|
if (!current) return next;
|
||||||
|
return `${next}${path.delimiter}${current}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let runtimeDir = "";
|
||||||
|
let modelDir = "";
|
||||||
|
let modelFile = "";
|
||||||
|
let tokensFile = "";
|
||||||
|
let dataDir = "";
|
||||||
|
let output = "tts.wav";
|
||||||
|
const textParts = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === "--runtime-dir") {
|
||||||
|
runtimeDir = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--model-dir") {
|
||||||
|
modelDir = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--model-file") {
|
||||||
|
modelFile = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--tokens-file") {
|
||||||
|
tokensFile = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--data-dir") {
|
||||||
|
dataDir = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "-o" || arg === "--output") {
|
||||||
|
output = args[i + 1] || output;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--text") {
|
||||||
|
textParts.push(args[i + 1] || "");
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
textParts.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeDir = resolveRuntimeDir(runtimeDir);
|
||||||
|
modelDir = resolveModelDir(modelDir);
|
||||||
|
|
||||||
|
if (!runtimeDir || !modelDir) {
|
||||||
|
usage("Missing runtime/model directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
modelFile = resolveModelFile(modelDir, modelFile);
|
||||||
|
tokensFile = resolveTokensFile(modelDir, tokensFile);
|
||||||
|
dataDir = resolveDataDir(modelDir, dataDir);
|
||||||
|
|
||||||
|
if (!modelFile || !tokensFile || !dataDir) {
|
||||||
|
usage(
|
||||||
|
"Model directory is missing required files. Set SHERPA_ONNX_MODEL_FILE, SHERPA_ONNX_TOKENS_FILE, SHERPA_ONNX_DATA_DIR or pass --model-file/--tokens-file/--data-dir.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = textParts.join(" ").trim();
|
||||||
|
if (!text) {
|
||||||
|
usage("Missing text.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bin = resolveBinary(runtimeDir);
|
||||||
|
if (!fs.existsSync(bin)) {
|
||||||
|
usage(`TTS binary not found: ${bin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = { ...process.env };
|
||||||
|
const libDir = path.join(runtimeDir, "lib");
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
env.DYLD_LIBRARY_PATH = prependEnvPath(env.DYLD_LIBRARY_PATH || "", libDir);
|
||||||
|
} else if (process.platform === "win32") {
|
||||||
|
env.PATH = prependEnvPath(env.PATH || "", [path.join(runtimeDir, "bin"), libDir].join(path.delimiter));
|
||||||
|
} else {
|
||||||
|
env.LD_LIBRARY_PATH = prependEnvPath(env.LD_LIBRARY_PATH || "", libDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output);
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
|
||||||
|
const child = spawnSync(
|
||||||
|
bin,
|
||||||
|
[
|
||||||
|
`--vits-model=${modelFile}`,
|
||||||
|
`--vits-tokens=${tokensFile}`,
|
||||||
|
`--vits-data-dir=${dataDir}`,
|
||||||
|
`--output-filename=${outputPath}`,
|
||||||
|
text,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
stdio: "inherit",
|
||||||
|
env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof child.status === "number") {
|
||||||
|
process.exit(child.status);
|
||||||
|
}
|
||||||
|
if (child.error) {
|
||||||
|
console.error(child.error.message || String(child.error));
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
@@ -400,7 +400,7 @@ export function createExecTool(
|
|||||||
host = "gateway";
|
host = "gateway";
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredSecurity = defaults?.security ?? "deny";
|
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
|
||||||
const requestedSecurity = normalizeExecSecurity(params.security);
|
const requestedSecurity = normalizeExecSecurity(params.security);
|
||||||
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
||||||
if (elevatedRequested) {
|
if (elevatedRequested) {
|
||||||
@@ -447,7 +447,10 @@ export function createExecTool(
|
|||||||
applyPathPrepend(env, defaultPathPrepend);
|
applyPathPrepend(env, defaultPathPrepend);
|
||||||
|
|
||||||
if (host === "node") {
|
if (host === "node") {
|
||||||
const approvals = resolveExecApprovals(defaults?.agentId);
|
const approvals = resolveExecApprovals(
|
||||||
|
defaults?.agentId,
|
||||||
|
host === "node" ? { security: "allowlist" } : undefined,
|
||||||
|
);
|
||||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||||
const askFallback = approvals.agent.askFallback;
|
const askFallback = approvals.agent.askFallback;
|
||||||
@@ -616,7 +619,7 @@ export function createExecTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (host === "gateway") {
|
if (host === "gateway") {
|
||||||
const approvals = resolveExecApprovals(defaults?.agentId);
|
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
|
||||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||||
const askFallback = approvals.agent.askFallback;
|
const askFallback = approvals.agent.askFallback;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
diagnosticLogger as diag,
|
||||||
|
logMessageQueued,
|
||||||
|
logSessionStateChange,
|
||||||
|
} from "../../logging/diagnostic.js";
|
||||||
|
|
||||||
type EmbeddedPiQueueHandle = {
|
type EmbeddedPiQueueHandle = {
|
||||||
queueMessage: (text: string) => Promise<void>;
|
queueMessage: (text: string) => Promise<void>;
|
||||||
isStreaming: () => boolean;
|
isStreaming: () => boolean;
|
||||||
@@ -14,22 +20,40 @@ const EMBEDDED_RUN_WAITERS = new Map<string, Set<EmbeddedRunWaiter>>();
|
|||||||
|
|
||||||
export function queueEmbeddedPiMessage(sessionId: string, text: string): boolean {
|
export function queueEmbeddedPiMessage(sessionId: string, text: string): boolean {
|
||||||
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
|
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
|
||||||
if (!handle) return false;
|
if (!handle) {
|
||||||
if (!handle.isStreaming()) return false;
|
diag.debug(`queue message failed: sessionId=${sessionId} reason=no_active_run`);
|
||||||
if (handle.isCompacting()) return false;
|
return false;
|
||||||
|
}
|
||||||
|
if (!handle.isStreaming()) {
|
||||||
|
diag.debug(`queue message failed: sessionId=${sessionId} reason=not_streaming`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (handle.isCompacting()) {
|
||||||
|
diag.debug(`queue message failed: sessionId=${sessionId} reason=compacting`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
logMessageQueued({ sessionId, source: "pi-embedded-runner" });
|
||||||
void handle.queueMessage(text);
|
void handle.queueMessage(text);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function abortEmbeddedPiRun(sessionId: string): boolean {
|
export function abortEmbeddedPiRun(sessionId: string): boolean {
|
||||||
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
|
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
|
||||||
if (!handle) return false;
|
if (!handle) {
|
||||||
|
diag.debug(`abort failed: sessionId=${sessionId} reason=no_active_run`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
diag.info(`aborting run: sessionId=${sessionId}`);
|
||||||
handle.abort();
|
handle.abort();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEmbeddedPiRunActive(sessionId: string): boolean {
|
export function isEmbeddedPiRunActive(sessionId: string): boolean {
|
||||||
return ACTIVE_EMBEDDED_RUNS.has(sessionId);
|
const active = ACTIVE_EMBEDDED_RUNS.has(sessionId);
|
||||||
|
if (active) {
|
||||||
|
diag.debug(`run active check: sessionId=${sessionId} active=true`);
|
||||||
|
}
|
||||||
|
return active;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEmbeddedPiRunStreaming(sessionId: string): boolean {
|
export function isEmbeddedPiRunStreaming(sessionId: string): boolean {
|
||||||
@@ -40,6 +64,7 @@ export function isEmbeddedPiRunStreaming(sessionId: string): boolean {
|
|||||||
|
|
||||||
export function waitForEmbeddedPiRunEnd(sessionId: string, timeoutMs = 15_000): Promise<boolean> {
|
export function waitForEmbeddedPiRunEnd(sessionId: string, timeoutMs = 15_000): Promise<boolean> {
|
||||||
if (!sessionId || !ACTIVE_EMBEDDED_RUNS.has(sessionId)) return Promise.resolve(true);
|
if (!sessionId || !ACTIVE_EMBEDDED_RUNS.has(sessionId)) return Promise.resolve(true);
|
||||||
|
diag.debug(`waiting for run end: sessionId=${sessionId} timeoutMs=${timeoutMs}`);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const waiters = EMBEDDED_RUN_WAITERS.get(sessionId) ?? new Set();
|
const waiters = EMBEDDED_RUN_WAITERS.get(sessionId) ?? new Set();
|
||||||
const waiter: EmbeddedRunWaiter = {
|
const waiter: EmbeddedRunWaiter = {
|
||||||
@@ -48,6 +73,7 @@ export function waitForEmbeddedPiRunEnd(sessionId: string, timeoutMs = 15_000):
|
|||||||
() => {
|
() => {
|
||||||
waiters.delete(waiter);
|
waiters.delete(waiter);
|
||||||
if (waiters.size === 0) EMBEDDED_RUN_WAITERS.delete(sessionId);
|
if (waiters.size === 0) EMBEDDED_RUN_WAITERS.delete(sessionId);
|
||||||
|
diag.warn(`wait timeout: sessionId=${sessionId} timeoutMs=${timeoutMs}`);
|
||||||
resolve(false);
|
resolve(false);
|
||||||
},
|
},
|
||||||
Math.max(100, timeoutMs),
|
Math.max(100, timeoutMs),
|
||||||
@@ -68,6 +94,7 @@ function notifyEmbeddedRunEnded(sessionId: string) {
|
|||||||
const waiters = EMBEDDED_RUN_WAITERS.get(sessionId);
|
const waiters = EMBEDDED_RUN_WAITERS.get(sessionId);
|
||||||
if (!waiters || waiters.size === 0) return;
|
if (!waiters || waiters.size === 0) return;
|
||||||
EMBEDDED_RUN_WAITERS.delete(sessionId);
|
EMBEDDED_RUN_WAITERS.delete(sessionId);
|
||||||
|
diag.debug(`notifying waiters: sessionId=${sessionId} waiterCount=${waiters.size}`);
|
||||||
for (const waiter of waiters) {
|
for (const waiter of waiters) {
|
||||||
clearTimeout(waiter.timer);
|
clearTimeout(waiter.timer);
|
||||||
waiter.resolve(true);
|
waiter.resolve(true);
|
||||||
@@ -75,13 +102,24 @@ function notifyEmbeddedRunEnded(sessionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
|
export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
|
||||||
|
const wasActive = ACTIVE_EMBEDDED_RUNS.has(sessionId);
|
||||||
ACTIVE_EMBEDDED_RUNS.set(sessionId, handle);
|
ACTIVE_EMBEDDED_RUNS.set(sessionId, handle);
|
||||||
|
logSessionStateChange({
|
||||||
|
sessionId,
|
||||||
|
state: "processing",
|
||||||
|
reason: wasActive ? "run_replaced" : "run_started",
|
||||||
|
});
|
||||||
|
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
|
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
|
||||||
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
|
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
|
||||||
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
|
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
|
||||||
|
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
|
||||||
|
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
|
||||||
notifyEmbeddedRunEnded(sessionId);
|
notifyEmbeddedRunEnded(sessionId);
|
||||||
|
} else {
|
||||||
|
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveBrewExecutable } from "../infra/brew.js";
|
import { resolveBrewExecutable } from "../infra/brew.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
hasBinary,
|
hasBinary,
|
||||||
loadWorkspaceSkillEntries,
|
loadWorkspaceSkillEntries,
|
||||||
@@ -13,6 +16,7 @@ import {
|
|||||||
type SkillInstallSpec,
|
type SkillInstallSpec,
|
||||||
type SkillsInstallPreferences,
|
type SkillsInstallPreferences,
|
||||||
} from "./skills.js";
|
} from "./skills.js";
|
||||||
|
import { resolveSkillKey } from "./skills/frontmatter.js";
|
||||||
|
|
||||||
export type SkillInstallRequest = {
|
export type SkillInstallRequest = {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
@@ -30,6 +34,10 @@ export type SkillInstallResult = {
|
|||||||
code: number | null;
|
code: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
|
||||||
|
return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function");
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeInstallOutput(text: string): string | undefined {
|
function summarizeInstallOutput(text: string): string | undefined {
|
||||||
const raw = text.trim();
|
const raw = text.trim();
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
@@ -112,11 +120,162 @@ function buildInstallCommand(
|
|||||||
if (!spec.package) return { argv: null, error: "missing uv package" };
|
if (!spec.package) return { argv: null, error: "missing uv package" };
|
||||||
return { argv: ["uv", "tool", "install", spec.package] };
|
return { argv: ["uv", "tool", "install", spec.package] };
|
||||||
}
|
}
|
||||||
|
case "download": {
|
||||||
|
return { argv: null, error: "download install handled separately" };
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return { argv: null, error: "unsupported installer" };
|
return { argv: null, error: "unsupported installer" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDownloadTargetDir(entry: SkillEntry, spec: SkillInstallSpec): string {
|
||||||
|
if (spec.targetDir?.trim()) return resolveUserPath(spec.targetDir);
|
||||||
|
const key = resolveSkillKey(entry.skill, entry);
|
||||||
|
return path.join(CONFIG_DIR, "tools", key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveArchiveType(spec: SkillInstallSpec, filename: string): string | undefined {
|
||||||
|
const explicit = spec.archive?.trim().toLowerCase();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tar.gz";
|
||||||
|
if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) return "tar.bz2";
|
||||||
|
if (lower.endsWith(".zip")) return "zip";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(
|
||||||
|
url: string,
|
||||||
|
destPath: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<{ bytes: number }> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs));
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Download failed (${response.status} ${response.statusText})`);
|
||||||
|
}
|
||||||
|
await ensureDir(path.dirname(destPath));
|
||||||
|
const file = fs.createWriteStream(destPath);
|
||||||
|
const body = response.body as unknown;
|
||||||
|
const readable = isNodeReadableStream(body)
|
||||||
|
? body
|
||||||
|
: Readable.fromWeb(body as NodeReadableStream);
|
||||||
|
await pipeline(readable, file);
|
||||||
|
const stat = await fs.promises.stat(destPath);
|
||||||
|
return { bytes: stat.size };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractArchive(params: {
|
||||||
|
archivePath: string;
|
||||||
|
archiveType: string;
|
||||||
|
targetDir: string;
|
||||||
|
stripComponents?: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||||
|
const { archivePath, archiveType, targetDir, stripComponents, timeoutMs } = params;
|
||||||
|
if (archiveType === "zip") {
|
||||||
|
if (!hasBinary("unzip")) {
|
||||||
|
return { stdout: "", stderr: "unzip not found on PATH", code: null };
|
||||||
|
}
|
||||||
|
const argv = ["unzip", "-q", archivePath, "-d", targetDir];
|
||||||
|
return await runCommandWithTimeout(argv, { timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasBinary("tar")) {
|
||||||
|
return { stdout: "", stderr: "tar not found on PATH", code: null };
|
||||||
|
}
|
||||||
|
const argv = ["tar", "xf", archivePath, "-C", targetDir];
|
||||||
|
if (typeof stripComponents === "number" && Number.isFinite(stripComponents)) {
|
||||||
|
argv.push("--strip-components", String(Math.max(0, Math.floor(stripComponents))));
|
||||||
|
}
|
||||||
|
return await runCommandWithTimeout(argv, { timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installDownloadSpec(params: {
|
||||||
|
entry: SkillEntry;
|
||||||
|
spec: SkillInstallSpec;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<SkillInstallResult> {
|
||||||
|
const { entry, spec, timeoutMs } = params;
|
||||||
|
const url = spec.url?.trim();
|
||||||
|
if (!url) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "missing download url",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
filename = path.basename(parsed.pathname);
|
||||||
|
} catch {
|
||||||
|
filename = path.basename(url);
|
||||||
|
}
|
||||||
|
if (!filename) filename = "download";
|
||||||
|
|
||||||
|
const targetDir = resolveDownloadTargetDir(entry, spec);
|
||||||
|
await ensureDir(targetDir);
|
||||||
|
|
||||||
|
const archivePath = path.join(targetDir, filename);
|
||||||
|
let downloaded = 0;
|
||||||
|
try {
|
||||||
|
const result = await downloadFile(url, archivePath, timeoutMs);
|
||||||
|
downloaded = result.bytes;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { ok: false, message, stdout: "", stderr: message, code: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveType = resolveArchiveType(spec, filename);
|
||||||
|
const shouldExtract = spec.extract ?? Boolean(archiveType);
|
||||||
|
if (!shouldExtract) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Downloaded to ${archivePath}`,
|
||||||
|
stdout: `downloaded=${downloaded}`,
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!archiveType) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "extract requested but archive type could not be detected",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractResult = await extractArchive({
|
||||||
|
archivePath,
|
||||||
|
archiveType,
|
||||||
|
targetDir,
|
||||||
|
stripComponents: spec.stripComponents,
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
const success = extractResult.code === 0;
|
||||||
|
return {
|
||||||
|
ok: success,
|
||||||
|
message: success
|
||||||
|
? `Downloaded and extracted to ${targetDir}`
|
||||||
|
: formatInstallFailureMessage(extractResult),
|
||||||
|
stdout: extractResult.stdout.trim(),
|
||||||
|
stderr: extractResult.stderr.trim(),
|
||||||
|
code: extractResult.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> {
|
async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> {
|
||||||
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
|
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
|
||||||
if (!exe) return undefined;
|
if (!exe) return undefined;
|
||||||
@@ -167,6 +326,9 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
|
|||||||
code: null,
|
code: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (spec.kind === "download") {
|
||||||
|
return await installDownloadSpec({ entry, spec, timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
const prefs = resolveSkillsInstallPreferences(params.config);
|
const prefs = resolveSkillsInstallPreferences(params.config);
|
||||||
const command = buildInstallCommand(spec, prefs);
|
const command = buildInstallCommand(spec, prefs);
|
||||||
|
|||||||
+40
-27
@@ -100,36 +100,49 @@ function normalizeInstallOptions(
|
|||||||
): SkillInstallOption[] {
|
): SkillInstallOption[] {
|
||||||
const install = entry.clawdbot?.install ?? [];
|
const install = entry.clawdbot?.install ?? [];
|
||||||
if (install.length === 0) return [];
|
if (install.length === 0) return [];
|
||||||
const preferred = selectPreferredInstallSpec(install, prefs);
|
|
||||||
if (!preferred) return [];
|
const platform = process.platform;
|
||||||
const { spec, index } = preferred;
|
const filtered = install.filter((spec) => {
|
||||||
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
|
const osList = spec.os ?? [];
|
||||||
const bins = spec.bins ?? [];
|
return osList.length === 0 || osList.includes(platform);
|
||||||
let label = (spec.label ?? "").trim();
|
});
|
||||||
if (spec.kind === "node" && spec.package) {
|
if (filtered.length === 0) return [];
|
||||||
label = `Install ${spec.package} (${prefs.nodeManager})`;
|
|
||||||
}
|
const toOption = (spec: SkillInstallSpec, index: number): SkillInstallOption => {
|
||||||
if (!label) {
|
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
|
||||||
if (spec.kind === "brew" && spec.formula) {
|
const bins = spec.bins ?? [];
|
||||||
label = `Install ${spec.formula} (brew)`;
|
let label = (spec.label ?? "").trim();
|
||||||
} else if (spec.kind === "node" && spec.package) {
|
if (spec.kind === "node" && spec.package) {
|
||||||
label = `Install ${spec.package} (${prefs.nodeManager})`;
|
label = `Install ${spec.package} (${prefs.nodeManager})`;
|
||||||
} else if (spec.kind === "go" && spec.module) {
|
|
||||||
label = `Install ${spec.module} (go)`;
|
|
||||||
} else if (spec.kind === "uv" && spec.package) {
|
|
||||||
label = `Install ${spec.package} (uv)`;
|
|
||||||
} else {
|
|
||||||
label = "Run installer";
|
|
||||||
}
|
}
|
||||||
|
if (!label) {
|
||||||
|
if (spec.kind === "brew" && spec.formula) {
|
||||||
|
label = `Install ${spec.formula} (brew)`;
|
||||||
|
} else if (spec.kind === "node" && spec.package) {
|
||||||
|
label = `Install ${spec.package} (${prefs.nodeManager})`;
|
||||||
|
} else if (spec.kind === "go" && spec.module) {
|
||||||
|
label = `Install ${spec.module} (go)`;
|
||||||
|
} else if (spec.kind === "uv" && spec.package) {
|
||||||
|
label = `Install ${spec.package} (uv)`;
|
||||||
|
} else if (spec.kind === "download" && spec.url) {
|
||||||
|
const url = spec.url.trim();
|
||||||
|
const last = url.split("/").pop();
|
||||||
|
label = `Download ${last && last.length > 0 ? last : url}`;
|
||||||
|
} else {
|
||||||
|
label = "Run installer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id, kind: spec.kind, label, bins };
|
||||||
|
};
|
||||||
|
|
||||||
|
const allDownloads = filtered.every((spec) => spec.kind === "download");
|
||||||
|
if (allDownloads) {
|
||||||
|
return filtered.map((spec, index) => toOption(spec, index));
|
||||||
}
|
}
|
||||||
return [
|
|
||||||
{
|
const preferred = selectPreferredInstallSpec(filtered, prefs);
|
||||||
id,
|
if (!preferred) return [];
|
||||||
kind: spec.kind,
|
return [toOption(preferred.spec, preferred.index)];
|
||||||
label,
|
|
||||||
bins,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSkillStatus(
|
function buildSkillStatus(
|
||||||
|
|||||||
@@ -109,4 +109,33 @@ describe("buildWorkspaceSkillStatus", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters install options by OS", async () => {
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
|
||||||
|
const skillDir = path.join(workspaceDir, "skills", "install-skill");
|
||||||
|
|
||||||
|
await writeSkill({
|
||||||
|
dir: skillDir,
|
||||||
|
name: "install-skill",
|
||||||
|
description: "OS-specific installs",
|
||||||
|
metadata:
|
||||||
|
'{"clawdbot":{"requires":{"bins":["missing-bin"]},"install":[{"id":"mac","kind":"download","os":["darwin"],"url":"https://example.com/mac.tar.bz2"},{"id":"linux","kind":"download","os":["linux"],"url":"https://example.com/linux.tar.bz2"},{"id":"win","kind":"download","os":["win32"],"url":"https://example.com/win.tar.bz2"}]}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||||
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
|
});
|
||||||
|
const skill = report.skills.find((entry) => entry.name === "install-skill");
|
||||||
|
|
||||||
|
expect(skill).toBeDefined();
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
expect(skill?.install.map((opt) => opt.id)).toEqual(["mac"]);
|
||||||
|
} else if (process.platform === "linux") {
|
||||||
|
expect(skill?.install.map((opt) => opt.id)).toEqual(["linux"]);
|
||||||
|
} else if (process.platform === "win32") {
|
||||||
|
expect(skill?.install.map((opt) => opt.id)).toEqual(["win"]);
|
||||||
|
} else {
|
||||||
|
expect(skill?.install).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -35,7 +35,7 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
|||||||
const kindRaw =
|
const kindRaw =
|
||||||
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
|
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
|
||||||
const kind = kindRaw.trim().toLowerCase();
|
const kind = kindRaw.trim().toLowerCase();
|
||||||
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv") {
|
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +47,16 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
|||||||
if (typeof raw.label === "string") spec.label = raw.label;
|
if (typeof raw.label === "string") spec.label = raw.label;
|
||||||
const bins = normalizeStringList(raw.bins);
|
const bins = normalizeStringList(raw.bins);
|
||||||
if (bins.length > 0) spec.bins = bins;
|
if (bins.length > 0) spec.bins = bins;
|
||||||
|
const osList = normalizeStringList(raw.os);
|
||||||
|
if (osList.length > 0) spec.os = osList;
|
||||||
if (typeof raw.formula === "string") spec.formula = raw.formula;
|
if (typeof raw.formula === "string") spec.formula = raw.formula;
|
||||||
if (typeof raw.package === "string") spec.package = raw.package;
|
if (typeof raw.package === "string") spec.package = raw.package;
|
||||||
if (typeof raw.module === "string") spec.module = raw.module;
|
if (typeof raw.module === "string") spec.module = raw.module;
|
||||||
|
if (typeof raw.url === "string") spec.url = raw.url;
|
||||||
|
if (typeof raw.archive === "string") spec.archive = raw.archive;
|
||||||
|
if (typeof raw.extract === "boolean") spec.extract = raw.extract;
|
||||||
|
if (typeof raw.stripComponents === "number") spec.stripComponents = raw.stripComponents;
|
||||||
|
if (typeof raw.targetDir === "string") spec.targetDir = raw.targetDir;
|
||||||
|
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
|
|||||||
|
|
||||||
export type SkillInstallSpec = {
|
export type SkillInstallSpec = {
|
||||||
id?: string;
|
id?: string;
|
||||||
kind: "brew" | "node" | "go" | "uv";
|
kind: "brew" | "node" | "go" | "uv" | "download";
|
||||||
label?: string;
|
label?: string;
|
||||||
bins?: string[];
|
bins?: string[];
|
||||||
|
os?: string[];
|
||||||
formula?: string;
|
formula?: string;
|
||||||
package?: string;
|
package?: string;
|
||||||
module?: string;
|
module?: string;
|
||||||
|
url?: string;
|
||||||
|
archive?: string;
|
||||||
|
extract?: boolean;
|
||||||
|
stripComponents?: number;
|
||||||
|
targetDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClawdbotSkillMetadata = {
|
export type ClawdbotSkillMetadata = {
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ const browserConfigMocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
||||||
|
|
||||||
vi.mock("../../config/config.js", () => ({
|
const configMocks = vi.hoisted(() => ({
|
||||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../../config/config.js", () => configMocks);
|
||||||
|
|
||||||
const toolCommonMocks = vi.hoisted(() => ({
|
const toolCommonMocks = vi.hoisted(() => ({
|
||||||
imageResultFromFile: vi.fn(),
|
imageResultFromFile: vi.fn(),
|
||||||
@@ -70,11 +71,12 @@ import { createBrowserTool } from "./browser-tool.js";
|
|||||||
describe("browser tool snapshot maxChars", () => {
|
describe("browser tool snapshot maxChars", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies the default ai snapshot limit", async () => {
|
it("applies the default ai snapshot limit", async () => {
|
||||||
const tool = createBrowserTool();
|
const tool = createBrowserTool();
|
||||||
await tool.execute?.(null, { action: "snapshot", format: "ai" });
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
"http://127.0.0.1:18791",
|
||||||
@@ -90,7 +92,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
const override = 2_000;
|
const override = 2_000;
|
||||||
await tool.execute?.(null, {
|
await tool.execute?.(null, {
|
||||||
action: "snapshot",
|
action: "snapshot",
|
||||||
format: "ai",
|
snapshotFormat: "ai",
|
||||||
maxChars: override,
|
maxChars: override,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
const tool = createBrowserTool();
|
const tool = createBrowserTool();
|
||||||
await tool.execute?.(null, {
|
await tool.execute?.(null, {
|
||||||
action: "snapshot",
|
action: "snapshot",
|
||||||
format: "ai",
|
snapshotFormat: "ai",
|
||||||
maxChars: 0,
|
maxChars: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +126,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
|
|
||||||
it("passes refs mode through to browser snapshot", async () => {
|
it("passes refs mode through to browser snapshot", async () => {
|
||||||
const tool = createBrowserTool();
|
const tool = createBrowserTool();
|
||||||
await tool.execute?.(null, { action: "snapshot", format: "ai", refs: "aria" });
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
"http://127.0.0.1:18791",
|
||||||
@@ -135,9 +137,36 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses config snapshot defaults when mode is not provided", async () => {
|
||||||
|
configMocks.loadConfig.mockReturnValue({
|
||||||
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
|
});
|
||||||
|
const tool = createBrowserTool();
|
||||||
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||||
|
|
||||||
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
|
"http://127.0.0.1:18791",
|
||||||
|
expect.objectContaining({
|
||||||
|
mode: "efficient",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
||||||
|
configMocks.loadConfig.mockReturnValue({
|
||||||
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
|
});
|
||||||
|
const tool = createBrowserTool();
|
||||||
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "aria" });
|
||||||
|
|
||||||
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalled();
|
||||||
|
const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
|
||||||
|
expect(opts?.mode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
|
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
|
||||||
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
||||||
await tool.execute?.(null, { action: "snapshot", profile: "chrome", format: "ai" });
|
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
"http://127.0.0.1:18791",
|
||||||
@@ -151,6 +180,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
describe("browser tool snapshot labels", () => {
|
describe("browser tool snapshot labels", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns image + text when labels are requested", async () => {
|
it("returns image + text when labels are requested", async () => {
|
||||||
@@ -175,7 +205,7 @@ describe("browser tool snapshot labels", () => {
|
|||||||
|
|
||||||
const result = await tool.execute?.(null, {
|
const result = await tool.execute?.(null, {
|
||||||
action: "snapshot",
|
action: "snapshot",
|
||||||
format: "ai",
|
snapshotFormat: "ai",
|
||||||
labels: true,
|
labels: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -190,11 +190,17 @@ export function createBrowserTool(opts?: {
|
|||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "snapshot": {
|
case "snapshot": {
|
||||||
|
const snapshotDefaults = loadConfig().browser?.snapshotDefaults;
|
||||||
const format =
|
const format =
|
||||||
params.snapshotFormat === "ai" || params.snapshotFormat === "aria"
|
params.snapshotFormat === "ai" || params.snapshotFormat === "aria"
|
||||||
? (params.snapshotFormat as "ai" | "aria")
|
? (params.snapshotFormat as "ai" | "aria")
|
||||||
: "ai";
|
: "ai";
|
||||||
const mode = params.mode === "efficient" ? "efficient" : undefined;
|
const mode =
|
||||||
|
params.mode === "efficient"
|
||||||
|
? "efficient"
|
||||||
|
: format === "ai" && snapshotDefaults?.mode === "efficient"
|
||||||
|
? "efficient"
|
||||||
|
: undefined;
|
||||||
const labels = typeof params.labels === "boolean" ? params.labels : undefined;
|
const labels = typeof params.labels === "boolean" ? params.labels : undefined;
|
||||||
const refs = params.refs === "aria" || params.refs === "role" ? params.refs : undefined;
|
const refs = params.refs === "aria" || params.refs === "role" ? params.refs : undefined;
|
||||||
const hasMaxChars = Object.hasOwn(params, "maxChars");
|
const hasMaxChars = Object.hasOwn(params, "maxChars");
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe("cron tool", () => {
|
|||||||
data: {
|
data: {
|
||||||
name: "wake-up",
|
name: "wake-up",
|
||||||
schedule: { atMs: 123 },
|
schedule: { atMs: 123 },
|
||||||
payload: { text: "hello" },
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -105,7 +105,7 @@ describe("cron tool", () => {
|
|||||||
job: {
|
job: {
|
||||||
name: "reminder",
|
name: "reminder",
|
||||||
schedule: { atMs: 123 },
|
schedule: { atMs: 123 },
|
||||||
payload: { text: "Reminder: the thing." },
|
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import crypto from "node:crypto";
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { loadConfig } from "../../config/io.js";
|
||||||
|
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||||
import {
|
import {
|
||||||
formatDoctorNonInteractiveHint,
|
formatDoctorNonInteractiveHint,
|
||||||
@@ -77,11 +79,42 @@ export function createGatewayTool(opts?: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const note =
|
const note =
|
||||||
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
|
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
|
||||||
|
// Extract channel + threadId for routing after restart
|
||||||
|
let deliveryContext: { channel?: string; to?: string; accountId?: string } | undefined;
|
||||||
|
let threadId: string | undefined;
|
||||||
|
if (sessionKey) {
|
||||||
|
const threadMarker = ":thread:";
|
||||||
|
const threadIndex = sessionKey.lastIndexOf(threadMarker);
|
||||||
|
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
|
||||||
|
const threadIdRaw =
|
||||||
|
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
|
||||||
|
threadId = threadIdRaw?.trim() || undefined;
|
||||||
|
try {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
let entry = store[sessionKey];
|
||||||
|
if (!entry?.deliveryContext && threadIndex !== -1 && baseSessionKey) {
|
||||||
|
entry = store[baseSessionKey];
|
||||||
|
}
|
||||||
|
if (entry?.deliveryContext) {
|
||||||
|
deliveryContext = {
|
||||||
|
channel: entry.deliveryContext.channel,
|
||||||
|
to: entry.deliveryContext.to,
|
||||||
|
accountId: entry.deliveryContext.accountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore: best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
const payload: RestartSentinelPayload = {
|
const payload: RestartSentinelPayload = {
|
||||||
kind: "restart",
|
kind: "restart",
|
||||||
status: "ok",
|
status: "ok",
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
deliveryContext,
|
||||||
|
threadId,
|
||||||
message: note ?? reason ?? null,
|
message: note ?? reason ?? null,
|
||||||
doctorHint: formatDoctorNonInteractiveHint(),
|
doctorHint: formatDoctorNonInteractiveHint(),
|
||||||
stats: {
|
stats: {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const lookupMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("node:dns/promises", () => ({
|
||||||
|
lookup: lookupMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
|
||||||
|
return {
|
||||||
|
get: (key) => map[key.toLowerCase()] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectResponse(location: string): Response {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 302,
|
||||||
|
headers: makeHeaders({ location }),
|
||||||
|
body: { cancel: vi.fn() },
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResponse(body: string): Response {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: makeHeaders({ "content-type": "text/plain" }),
|
||||||
|
text: async () => body,
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("web_fetch SSRF protection", () => {
|
||||||
|
const priorFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// @ts-expect-error restore
|
||||||
|
global.fetch = priorFetch;
|
||||||
|
lookupMock.mockReset();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks localhost hostnames before fetch/firecrawl", async () => {
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
fetch: {
|
||||||
|
cacheTtlMinutes: 0,
|
||||||
|
firecrawl: { apiKey: "firecrawl-test" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tool?.execute?.("call", { url: "http://localhost/test" })).rejects.toThrow(
|
||||||
|
/Blocked hostname/i,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(lookupMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks private IP literals without DNS", async () => {
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tool?.execute?.("call", { url: "http://127.0.0.1/test" })).rejects.toThrow(
|
||||||
|
/private|internal|blocked/i,
|
||||||
|
);
|
||||||
|
await expect(tool?.execute?.("call", { url: "http://[::ffff:127.0.0.1]/" })).rejects.toThrow(
|
||||||
|
/private|internal|blocked/i,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(lookupMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when DNS resolves to private addresses", async () => {
|
||||||
|
lookupMock.mockImplementation(async (hostname: string) => {
|
||||||
|
if (hostname === "public.test") {
|
||||||
|
return [{ address: "93.184.216.34", family: 4 }];
|
||||||
|
}
|
||||||
|
return [{ address: "10.0.0.5", family: 4 }];
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tool?.execute?.("call", { url: "https://private.test/resource" })).rejects.toThrow(
|
||||||
|
/private|internal|blocked/i,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks redirects to private hosts", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||||
|
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1/secret"));
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tool?.execute?.("call", { url: "https://example.com" })).rejects.toThrow(
|
||||||
|
/private|internal|blocked/i,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows public hosts", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||||
|
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(textResponse("ok"));
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool?.execute?.("call", { url: "https://example.com" });
|
||||||
|
expect(result?.details).toMatchObject({
|
||||||
|
status: 200,
|
||||||
|
extractor: "raw",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { assertPublicHostname, SsrFBlockedError } from "../../infra/net/ssrf.js";
|
||||||
import { stringEnum } from "../schema/typebox.js";
|
import { stringEnum } from "../schema/typebox.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||||
@@ -29,6 +30,7 @@ export { extractReadableContent } from "./web-fetch-utils.js";
|
|||||||
const EXTRACT_MODES = ["markdown", "text"] as const;
|
const EXTRACT_MODES = ["markdown", "text"] as const;
|
||||||
|
|
||||||
const DEFAULT_FETCH_MAX_CHARS = 50_000;
|
const DEFAULT_FETCH_MAX_CHARS = 50_000;
|
||||||
|
const DEFAULT_FETCH_MAX_REDIRECTS = 3;
|
||||||
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
||||||
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
||||||
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
||||||
@@ -144,6 +146,11 @@ function resolveMaxChars(value: unknown, fallback: number): number {
|
|||||||
return Math.max(100, Math.floor(parsed));
|
return Math.max(100, Math.floor(parsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMaxRedirects(value: unknown, fallback: number): number {
|
||||||
|
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
return Math.max(0, Math.floor(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
function looksLikeHtml(value: string): boolean {
|
function looksLikeHtml(value: string): boolean {
|
||||||
const trimmed = value.trimStart();
|
const trimmed = value.trimStart();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
@@ -151,6 +158,68 @@ function looksLikeHtml(value: string): boolean {
|
|||||||
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRedirectStatus(status: number): boolean {
|
||||||
|
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRedirects(params: {
|
||||||
|
url: string;
|
||||||
|
maxRedirects: number;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
userAgent: string;
|
||||||
|
}): Promise<{ response: Response; finalUrl: string }> {
|
||||||
|
const signal = withTimeout(undefined, params.timeoutSeconds * 1000);
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let currentUrl = params.url;
|
||||||
|
let redirectCount = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(currentUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid URL: must be http or https");
|
||||||
|
}
|
||||||
|
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||||
|
throw new Error("Invalid URL: must be http or https");
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPublicHostname(parsedUrl.hostname);
|
||||||
|
|
||||||
|
const res = await fetch(parsedUrl.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "*/*",
|
||||||
|
"User-Agent": params.userAgent,
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRedirectStatus(res.status)) {
|
||||||
|
const location = res.headers.get("location");
|
||||||
|
if (!location) {
|
||||||
|
throw new Error(`Redirect missing location header (${res.status})`);
|
||||||
|
}
|
||||||
|
redirectCount += 1;
|
||||||
|
if (redirectCount > params.maxRedirects) {
|
||||||
|
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
|
||||||
|
}
|
||||||
|
const nextUrl = new URL(location, parsedUrl).toString();
|
||||||
|
if (visited.has(nextUrl)) {
|
||||||
|
throw new Error("Redirect loop detected");
|
||||||
|
}
|
||||||
|
visited.add(nextUrl);
|
||||||
|
void res.body?.cancel();
|
||||||
|
currentUrl = nextUrl;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { response: res, finalUrl: currentUrl };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatWebFetchErrorDetail(params: {
|
function formatWebFetchErrorDetail(params: {
|
||||||
detail: string;
|
detail: string;
|
||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
@@ -247,6 +316,7 @@ async function runWebFetch(params: {
|
|||||||
url: string;
|
url: string;
|
||||||
extractMode: ExtractMode;
|
extractMode: ExtractMode;
|
||||||
maxChars: number;
|
maxChars: number;
|
||||||
|
maxRedirects: number;
|
||||||
timeoutSeconds: number;
|
timeoutSeconds: number;
|
||||||
cacheTtlMs: number;
|
cacheTtlMs: number;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
@@ -278,20 +348,23 @@ async function runWebFetch(params: {
|
|||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
let finalUrl = params.url;
|
||||||
try {
|
try {
|
||||||
res = await fetch(parsedUrl.toString(), {
|
const result = await fetchWithRedirects({
|
||||||
method: "GET",
|
url: params.url,
|
||||||
headers: {
|
maxRedirects: params.maxRedirects,
|
||||||
Accept: "*/*",
|
timeoutSeconds: params.timeoutSeconds,
|
||||||
"User-Agent": params.userAgent,
|
userAgent: params.userAgent,
|
||||||
"Accept-Language": "en-US,en;q=0.9",
|
|
||||||
},
|
|
||||||
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
|
||||||
});
|
});
|
||||||
|
res = result.response;
|
||||||
|
finalUrl = result.finalUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof SsrFBlockedError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
||||||
const firecrawl = await fetchFirecrawlContent({
|
const firecrawl = await fetchFirecrawlContent({
|
||||||
url: params.url,
|
url: finalUrl,
|
||||||
extractMode: params.extractMode,
|
extractMode: params.extractMode,
|
||||||
apiKey: params.firecrawlApiKey,
|
apiKey: params.firecrawlApiKey,
|
||||||
baseUrl: params.firecrawlBaseUrl,
|
baseUrl: params.firecrawlBaseUrl,
|
||||||
@@ -304,7 +377,7 @@ async function runWebFetch(params: {
|
|||||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||||
const payload = {
|
const payload = {
|
||||||
url: params.url,
|
url: params.url,
|
||||||
finalUrl: firecrawl.finalUrl || params.url,
|
finalUrl: firecrawl.finalUrl || finalUrl,
|
||||||
status: firecrawl.status ?? 200,
|
status: firecrawl.status ?? 200,
|
||||||
contentType: "text/markdown",
|
contentType: "text/markdown",
|
||||||
title: firecrawl.title,
|
title: firecrawl.title,
|
||||||
@@ -339,7 +412,7 @@ async function runWebFetch(params: {
|
|||||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||||
const payload = {
|
const payload = {
|
||||||
url: params.url,
|
url: params.url,
|
||||||
finalUrl: firecrawl.finalUrl || params.url,
|
finalUrl: firecrawl.finalUrl || finalUrl,
|
||||||
status: firecrawl.status ?? res.status,
|
status: firecrawl.status ?? res.status,
|
||||||
contentType: "text/markdown",
|
contentType: "text/markdown",
|
||||||
title: firecrawl.title,
|
title: firecrawl.title,
|
||||||
@@ -374,7 +447,7 @@ async function runWebFetch(params: {
|
|||||||
if (params.readabilityEnabled) {
|
if (params.readabilityEnabled) {
|
||||||
const readable = await extractReadableContent({
|
const readable = await extractReadableContent({
|
||||||
html: body,
|
html: body,
|
||||||
url: res.url || params.url,
|
url: finalUrl,
|
||||||
extractMode: params.extractMode,
|
extractMode: params.extractMode,
|
||||||
});
|
});
|
||||||
if (readable?.text) {
|
if (readable?.text) {
|
||||||
@@ -382,7 +455,7 @@ async function runWebFetch(params: {
|
|||||||
title = readable.title;
|
title = readable.title;
|
||||||
extractor = "readability";
|
extractor = "readability";
|
||||||
} else {
|
} else {
|
||||||
const firecrawl = await tryFirecrawlFallback(params);
|
const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl });
|
||||||
if (firecrawl) {
|
if (firecrawl) {
|
||||||
text = firecrawl.text;
|
text = firecrawl.text;
|
||||||
title = firecrawl.title;
|
title = firecrawl.title;
|
||||||
@@ -411,7 +484,7 @@ async function runWebFetch(params: {
|
|||||||
const truncated = truncateText(text, params.maxChars);
|
const truncated = truncateText(text, params.maxChars);
|
||||||
const payload = {
|
const payload = {
|
||||||
url: params.url,
|
url: params.url,
|
||||||
finalUrl: res.url || params.url,
|
finalUrl,
|
||||||
status: res.status,
|
status: res.status,
|
||||||
contentType,
|
contentType,
|
||||||
title,
|
title,
|
||||||
@@ -508,6 +581,7 @@ export function createWebFetchTool(options?: {
|
|||||||
url,
|
url,
|
||||||
extractMode,
|
extractMode,
|
||||||
maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS),
|
maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS),
|
||||||
|
maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS),
|
||||||
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
||||||
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||||
userAgent,
|
userAgent,
|
||||||
|
|||||||
@@ -184,9 +184,7 @@ export async function buildStatusReply(params: {
|
|||||||
const requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
|
const requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
|
||||||
const runs = listSubagentRunsForRequester(requesterKey);
|
const runs = listSubagentRunsForRequester(requesterKey);
|
||||||
const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off";
|
const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off";
|
||||||
if (runs.length === 0) {
|
if (runs.length > 0) {
|
||||||
if (verboseEnabled) subagentsLine = "🤖 Subagents: none";
|
|
||||||
} else {
|
|
||||||
const active = runs.filter((entry) => !entry.endedAt);
|
const active = runs.filter((entry) => !entry.endedAt);
|
||||||
const done = runs.length - active.length;
|
const done = runs.length - active.length;
|
||||||
if (verboseEnabled) {
|
if (verboseEnabled) {
|
||||||
|
|||||||
@@ -215,6 +215,20 @@ describe("handleCommands subagents", () => {
|
|||||||
expect(result.reply?.text).toContain("Subagents: none");
|
expect(result.reply?.text).toContain("Subagents: none");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits subagent status line when none exist", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { mainKey: "main", scope: "per-sender" },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/status", cfg);
|
||||||
|
params.resolvedVerboseLevel = "on";
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).not.toContain("Subagents:");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns help for unknown subagents action", async () => {
|
it("returns help for unknown subagents action", async () => {
|
||||||
resetSubagentRegistryForTests();
|
resetSubagentRegistryForTests();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ const mocks = vi.hoisted(() => ({
|
|||||||
aborted: false,
|
aborted: false,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
const diagnosticMocks = vi.hoisted(() => ({
|
||||||
|
logMessageQueued: vi.fn(),
|
||||||
|
logMessageProcessed: vi.fn(),
|
||||||
|
logSessionStateChange: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./route-reply.js", () => ({
|
vi.mock("./route-reply.js", () => ({
|
||||||
isRoutableChannel: (channel: string | undefined) =>
|
isRoutableChannel: (channel: string | undefined) =>
|
||||||
@@ -34,6 +39,12 @@ vi.mock("./abort.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../logging/diagnostic.js", () => ({
|
||||||
|
logMessageQueued: diagnosticMocks.logMessageQueued,
|
||||||
|
logMessageProcessed: diagnosticMocks.logMessageProcessed,
|
||||||
|
logSessionStateChange: diagnosticMocks.logSessionStateChange,
|
||||||
|
}));
|
||||||
|
|
||||||
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
||||||
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
||||||
|
|
||||||
@@ -50,6 +61,9 @@ function createDispatcher(): ReplyDispatcher {
|
|||||||
describe("dispatchReplyFromConfig", () => {
|
describe("dispatchReplyFromConfig", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
|
diagnosticMocks.logMessageQueued.mockReset();
|
||||||
|
diagnosticMocks.logMessageProcessed.mockReset();
|
||||||
|
diagnosticMocks.logSessionStateChange.mockReset();
|
||||||
});
|
});
|
||||||
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
|
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
|
||||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
@@ -186,4 +200,74 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
|
|
||||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits diagnostics when enabled", async () => {
|
||||||
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
|
handled: false,
|
||||||
|
aborted: false,
|
||||||
|
});
|
||||||
|
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
|
||||||
|
const dispatcher = createDispatcher();
|
||||||
|
const ctx = buildTestCtx({
|
||||||
|
Provider: "slack",
|
||||||
|
Surface: "slack",
|
||||||
|
SessionKey: "agent:main:main",
|
||||||
|
MessageSid: "msg-1",
|
||||||
|
To: "slack:C123",
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
||||||
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||||
|
|
||||||
|
expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1);
|
||||||
|
expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
state: "processing",
|
||||||
|
reason: "message_start",
|
||||||
|
});
|
||||||
|
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "slack",
|
||||||
|
outcome: "completed",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks diagnostics skipped for duplicate inbound messages", async () => {
|
||||||
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
|
handled: false,
|
||||||
|
aborted: false,
|
||||||
|
});
|
||||||
|
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
|
||||||
|
const ctx = buildTestCtx({
|
||||||
|
Provider: "whatsapp",
|
||||||
|
OriginatingChannel: "whatsapp",
|
||||||
|
OriginatingTo: "whatsapp:+15555550123",
|
||||||
|
MessageSid: "msg-dup",
|
||||||
|
});
|
||||||
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
||||||
|
|
||||||
|
await dispatchReplyFromConfig({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcher: createDispatcher(),
|
||||||
|
replyResolver,
|
||||||
|
});
|
||||||
|
await dispatchReplyFromConfig({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcher: createDispatcher(),
|
||||||
|
replyResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||||
|
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "whatsapp",
|
||||||
|
outcome: "skipped",
|
||||||
|
reason: "duplicate",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||||
|
import {
|
||||||
|
logMessageProcessed,
|
||||||
|
logMessageQueued,
|
||||||
|
logSessionStateChange,
|
||||||
|
} from "../../logging/diagnostic.js";
|
||||||
import { getReplyFromConfig } from "../reply.js";
|
import { getReplyFromConfig } from "../reply.js";
|
||||||
import type { FinalizedMsgContext } from "../templating.js";
|
import type { FinalizedMsgContext } from "../templating.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
@@ -21,8 +27,55 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
replyResolver?: typeof getReplyFromConfig;
|
replyResolver?: typeof getReplyFromConfig;
|
||||||
}): Promise<DispatchFromConfigResult> {
|
}): Promise<DispatchFromConfigResult> {
|
||||||
const { ctx, cfg, dispatcher } = params;
|
const { ctx, cfg, dispatcher } = params;
|
||||||
|
const diagnosticsEnabled = isDiagnosticsEnabled(cfg);
|
||||||
|
const channel = String(ctx.Surface ?? ctx.Provider ?? "unknown").toLowerCase();
|
||||||
|
const chatId = ctx.To ?? ctx.From;
|
||||||
|
const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
|
||||||
|
const sessionKey = ctx.SessionKey;
|
||||||
|
const startTime = diagnosticsEnabled ? Date.now() : 0;
|
||||||
|
const canTrackSession = diagnosticsEnabled && Boolean(sessionKey);
|
||||||
|
|
||||||
|
const recordProcessed = (
|
||||||
|
outcome: "completed" | "skipped" | "error",
|
||||||
|
opts?: {
|
||||||
|
reason?: string;
|
||||||
|
error?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (!diagnosticsEnabled) return;
|
||||||
|
logMessageProcessed({
|
||||||
|
channel,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sessionKey,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
outcome,
|
||||||
|
reason: opts?.reason,
|
||||||
|
error: opts?.error,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const markProcessing = () => {
|
||||||
|
if (!canTrackSession || !sessionKey) return;
|
||||||
|
logMessageQueued({ sessionKey, channel, source: "dispatch" });
|
||||||
|
logSessionStateChange({
|
||||||
|
sessionKey,
|
||||||
|
state: "processing",
|
||||||
|
reason: "message_start",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const markIdle = (reason: string) => {
|
||||||
|
if (!canTrackSession || !sessionKey) return;
|
||||||
|
logSessionStateChange({
|
||||||
|
sessionKey,
|
||||||
|
state: "idle",
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (shouldSkipDuplicateInbound(ctx)) {
|
if (shouldSkipDuplicateInbound(ctx)) {
|
||||||
|
recordProcessed("skipped", { reason: "duplicate" });
|
||||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,95 +121,107 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
|
markProcessing();
|
||||||
if (fastAbort.handled) {
|
|
||||||
const payload = {
|
try {
|
||||||
text: formatAbortReplyText(fastAbort.stoppedSubagents),
|
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
|
||||||
} satisfies ReplyPayload;
|
if (fastAbort.handled) {
|
||||||
|
const payload = {
|
||||||
|
text: formatAbortReplyText(fastAbort.stoppedSubagents),
|
||||||
|
} satisfies ReplyPayload;
|
||||||
|
let queuedFinal = false;
|
||||||
|
let routedFinalCount = 0;
|
||||||
|
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||||
|
const result = await routeReply({
|
||||||
|
payload,
|
||||||
|
channel: originatingChannel,
|
||||||
|
to: originatingTo,
|
||||||
|
sessionKey: ctx.SessionKey,
|
||||||
|
accountId: ctx.AccountId,
|
||||||
|
threadId: ctx.MessageThreadId,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
queuedFinal = result.ok;
|
||||||
|
if (result.ok) routedFinalCount += 1;
|
||||||
|
if (!result.ok) {
|
||||||
|
logVerbose(
|
||||||
|
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queuedFinal = dispatcher.sendFinalReply(payload);
|
||||||
|
}
|
||||||
|
await dispatcher.waitForIdle();
|
||||||
|
const counts = dispatcher.getQueuedCounts();
|
||||||
|
counts.final += routedFinalCount;
|
||||||
|
recordProcessed("completed", { reason: "fast_abort" });
|
||||||
|
markIdle("message_completed");
|
||||||
|
return { queuedFinal, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
...params.replyOptions,
|
||||||
|
onToolResult: (payload: ReplyPayload) => {
|
||||||
|
if (shouldRouteToOriginating) {
|
||||||
|
// Fire-and-forget for streaming tool results when routing.
|
||||||
|
void sendPayloadAsync(payload);
|
||||||
|
} else {
|
||||||
|
// Synchronous dispatch to preserve callback timing.
|
||||||
|
dispatcher.sendToolResult(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBlockReply: (payload: ReplyPayload, context) => {
|
||||||
|
if (shouldRouteToOriginating) {
|
||||||
|
// Await routed sends so upstream can enforce ordering/timeouts.
|
||||||
|
return sendPayloadAsync(payload, context?.abortSignal);
|
||||||
|
} else {
|
||||||
|
// Synchronous dispatch to preserve callback timing.
|
||||||
|
dispatcher.sendBlockReply(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
|
||||||
|
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
|
||||||
|
|
||||||
let queuedFinal = false;
|
let queuedFinal = false;
|
||||||
let routedFinalCount = 0;
|
let routedFinalCount = 0;
|
||||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
for (const reply of replies) {
|
||||||
const result = await routeReply({
|
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||||
payload,
|
// Route final reply to originating channel.
|
||||||
channel: originatingChannel,
|
const result = await routeReply({
|
||||||
to: originatingTo,
|
payload: reply,
|
||||||
sessionKey: ctx.SessionKey,
|
channel: originatingChannel,
|
||||||
accountId: ctx.AccountId,
|
to: originatingTo,
|
||||||
threadId: ctx.MessageThreadId,
|
sessionKey: ctx.SessionKey,
|
||||||
cfg,
|
accountId: ctx.AccountId,
|
||||||
});
|
threadId: ctx.MessageThreadId,
|
||||||
queuedFinal = result.ok;
|
cfg,
|
||||||
if (result.ok) routedFinalCount += 1;
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
|
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
queuedFinal = result.ok || queuedFinal;
|
||||||
|
if (result.ok) routedFinalCount += 1;
|
||||||
|
} else {
|
||||||
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
queuedFinal = dispatcher.sendFinalReply(payload);
|
|
||||||
}
|
}
|
||||||
await dispatcher.waitForIdle();
|
await dispatcher.waitForIdle();
|
||||||
|
|
||||||
const counts = dispatcher.getQueuedCounts();
|
const counts = dispatcher.getQueuedCounts();
|
||||||
counts.final += routedFinalCount;
|
counts.final += routedFinalCount;
|
||||||
|
recordProcessed("completed");
|
||||||
|
markIdle("message_completed");
|
||||||
return { queuedFinal, counts };
|
return { queuedFinal, counts };
|
||||||
|
} catch (err) {
|
||||||
|
recordProcessed("error", { error: String(err) });
|
||||||
|
markIdle("message_error");
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
|
||||||
ctx,
|
|
||||||
{
|
|
||||||
...params.replyOptions,
|
|
||||||
onToolResult: (payload: ReplyPayload) => {
|
|
||||||
if (shouldRouteToOriginating) {
|
|
||||||
// Fire-and-forget for streaming tool results when routing.
|
|
||||||
void sendPayloadAsync(payload);
|
|
||||||
} else {
|
|
||||||
// Synchronous dispatch to preserve callback timing.
|
|
||||||
dispatcher.sendToolResult(payload);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onBlockReply: (payload: ReplyPayload, context) => {
|
|
||||||
if (shouldRouteToOriginating) {
|
|
||||||
// Await routed sends so upstream can enforce ordering/timeouts.
|
|
||||||
return sendPayloadAsync(payload, context?.abortSignal);
|
|
||||||
} else {
|
|
||||||
// Synchronous dispatch to preserve callback timing.
|
|
||||||
dispatcher.sendBlockReply(payload);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cfg,
|
|
||||||
);
|
|
||||||
|
|
||||||
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
|
|
||||||
|
|
||||||
let queuedFinal = false;
|
|
||||||
let routedFinalCount = 0;
|
|
||||||
for (const reply of replies) {
|
|
||||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
|
||||||
// Route final reply to originating channel.
|
|
||||||
const result = await routeReply({
|
|
||||||
payload: reply,
|
|
||||||
channel: originatingChannel,
|
|
||||||
to: originatingTo,
|
|
||||||
sessionKey: ctx.SessionKey,
|
|
||||||
accountId: ctx.AccountId,
|
|
||||||
threadId: ctx.MessageThreadId,
|
|
||||||
cfg,
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
logVerbose(
|
|
||||||
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
queuedFinal = result.ok || queuedFinal;
|
|
||||||
if (result.ok) routedFinalCount += 1;
|
|
||||||
} else {
|
|
||||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await dispatcher.waitForIdle();
|
|
||||||
|
|
||||||
const counts = dispatcher.getQueuedCounts();
|
|
||||||
counts.final += routedFinalCount;
|
|
||||||
return { queuedFinal, counts };
|
|
||||||
}
|
}
|
||||||
@@ -143,6 +143,22 @@ describe("buildStatusMessage", () => {
|
|||||||
expect(normalized).toContain("Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)");
|
expect(normalized).toContain("Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits media line when all decisions are none", () => {
|
||||||
|
const text = buildStatusMessage({
|
||||||
|
agent: { model: "anthropic/claude-opus-4-5" },
|
||||||
|
sessionEntry: { sessionId: "media-none", updatedAt: 0 },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
queue: { mode: "none" },
|
||||||
|
mediaDecisions: [
|
||||||
|
{ capability: "image", outcome: "no-attachment", attachments: [] },
|
||||||
|
{ capability: "audio", outcome: "no-attachment", attachments: [] },
|
||||||
|
{ capability: "video", outcome: "no-attachment", attachments: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeTestText(text)).not.toContain("Media:");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not show elevated label when session explicitly disables it", () => {
|
it("does not show elevated label when session explicitly disables it", () => {
|
||||||
const text = buildStatusMessage({
|
const text = buildStatusMessage({
|
||||||
agent: { model: "anthropic/claude-opus-4-5", elevatedDefault: "on" },
|
agent: { model: "anthropic/claude-opus-4-5", elevatedDefault: "on" },
|
||||||
|
|||||||
@@ -201,8 +201,9 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter((part): part is string => part != null);
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
|
if (parts.every((part) => part.endsWith(" none"))) return null;
|
||||||
return `📎 Media: ${parts.join(" · ")}`;
|
return `📎 Media: ${parts.join(" · ")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
c1cdb1b463e70d87976d88abf13e373e774c057e6796c947227c56b459af9d77
|
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5
|
||||||
+17440
-15247
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,78 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Command } from "commander";
|
||||||
|
|
||||||
|
const clientMocks = vi.hoisted(() => ({
|
||||||
|
browserSnapshot: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
format: "ai",
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://example.com",
|
||||||
|
snapshot: "ok",
|
||||||
|
})),
|
||||||
|
resolveBrowserControlUrl: vi.fn(() => "http://127.0.0.1:18791"),
|
||||||
|
}));
|
||||||
|
vi.mock("../browser/client.js", () => clientMocks);
|
||||||
|
|
||||||
|
const configMocks = vi.hoisted(() => ({
|
||||||
|
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||||
|
}));
|
||||||
|
vi.mock("../config/config.js", () => configMocks);
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock("../runtime.js", () => ({
|
||||||
|
defaultRuntime: runtime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("browser cli snapshot defaults", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses config snapshot defaults when mode is not provided", async () => {
|
||||||
|
configMocks.loadConfig.mockReturnValue({
|
||||||
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
|
});
|
||||||
|
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||||
|
const program = new Command();
|
||||||
|
const browser = program.command("browser").option("--json", false);
|
||||||
|
registerBrowserInspectCommands(browser, () => ({}));
|
||||||
|
|
||||||
|
await program.parseAsync(["browser", "snapshot"], { from: "user" });
|
||||||
|
|
||||||
|
expect(clientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
|
"http://127.0.0.1:18791",
|
||||||
|
expect.objectContaining({
|
||||||
|
format: "ai",
|
||||||
|
mode: "efficient",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
||||||
|
configMocks.loadConfig.mockReturnValue({
|
||||||
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
|
});
|
||||||
|
clientMocks.browserSnapshot.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
format: "aria",
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://example.com",
|
||||||
|
nodes: [],
|
||||||
|
});
|
||||||
|
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||||
|
const program = new Command();
|
||||||
|
const browser = program.command("browser").option("--json", false);
|
||||||
|
registerBrowserInspectCommands(browser, () => ({}));
|
||||||
|
|
||||||
|
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
|
||||||
|
|
||||||
|
expect(clientMocks.browserSnapshot).toHaveBeenCalled();
|
||||||
|
const [, opts] = clientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
|
||||||
|
expect(opts?.mode).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
|||||||
|
|
||||||
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
|
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
|
||||||
import { browserScreenshotAction } from "../browser/client-actions.js";
|
import { browserScreenshotAction } from "../browser/client-actions.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
@@ -62,7 +63,11 @@ export function registerBrowserInspectCommands(
|
|||||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
const format = opts.format === "aria" ? "aria" : "ai";
|
const format = opts.format === "aria" ? "aria" : "ai";
|
||||||
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : undefined;
|
const configMode =
|
||||||
|
format === "ai" && loadConfig().browser?.snapshotDefaults?.mode === "efficient"
|
||||||
|
? "efficient"
|
||||||
|
: undefined;
|
||||||
|
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
|
||||||
try {
|
try {
|
||||||
const result = await browserSnapshot(baseUrl, {
|
const result = await browserSnapshot(baseUrl, {
|
||||||
format,
|
format,
|
||||||
|
|||||||
+183
-3
@@ -173,7 +173,7 @@ describe("cron cli", () => {
|
|||||||
expect(clearPatch?.patch?.agentId).toBeNull();
|
expect(clearPatch?.patch?.agentId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not include model/thinking when no payload change is requested", async () => {
|
it("allows model/thinking updates without --message", async () => {
|
||||||
callGatewayFromCli.mockClear();
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
const { registerCronCli } = await import("./cron-cli.js");
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
@@ -186,8 +186,188 @@ describe("cron cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
const patch = updateCall?.[2] as { patch?: { payload?: unknown } };
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: { payload?: { kind?: string; model?: string; thinking?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
expect(patch?.patch?.payload).toBeUndefined();
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
|
expect(patch?.patch?.payload?.model).toBe("opus");
|
||||||
|
expect(patch?.patch?.payload?.thinking).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates delivery settings without requiring --message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["cron", "edit", "job-1", "--deliver", "--channel", "telegram", "--to", "19098680"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: {
|
||||||
|
payload?: {
|
||||||
|
kind?: string;
|
||||||
|
message?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
|
expect(patch?.patch?.payload?.deliver).toBe(true);
|
||||||
|
expect(patch?.patch?.payload?.channel).toBe("telegram");
|
||||||
|
expect(patch?.patch?.payload?.to).toBe("19098680");
|
||||||
|
expect(patch?.patch?.payload?.message).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports --no-deliver on cron edit", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["cron", "edit", "job-1", "--no-deliver"], { from: "user" });
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: { payload?: { kind?: string; deliver?: boolean } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
|
expect(patch?.patch?.payload?.deliver).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include undefined delivery fields when updating message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
// Update message without delivery flags - should NOT include undefined delivery fields
|
||||||
|
await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], {
|
||||||
|
from: "user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: {
|
||||||
|
payload?: {
|
||||||
|
message?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
bestEffortDeliver?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should include the new message
|
||||||
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
|
|
||||||
|
// Should NOT include delivery fields at all (to preserve existing values)
|
||||||
|
expect(patch?.patch?.payload).not.toHaveProperty("deliver");
|
||||||
|
expect(patch?.patch?.payload).not.toHaveProperty("channel");
|
||||||
|
expect(patch?.patch?.payload).not.toHaveProperty("to");
|
||||||
|
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes delivery fields when explicitly provided with message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
// Update message AND delivery - should include both
|
||||||
|
await program.parseAsync(
|
||||||
|
[
|
||||||
|
"cron",
|
||||||
|
"edit",
|
||||||
|
"job-1",
|
||||||
|
"--message",
|
||||||
|
"Updated message",
|
||||||
|
"--deliver",
|
||||||
|
"--channel",
|
||||||
|
"telegram",
|
||||||
|
"--to",
|
||||||
|
"19098680",
|
||||||
|
],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: {
|
||||||
|
payload?: {
|
||||||
|
message?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should include everything
|
||||||
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
|
expect(patch?.patch?.payload?.deliver).toBe(true);
|
||||||
|
expect(patch?.patch?.payload?.channel).toBe("telegram");
|
||||||
|
expect(patch?.patch?.payload?.to).toBe("19098680");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes best-effort delivery when provided with message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["cron", "edit", "job-1", "--message", "Updated message", "--best-effort-deliver"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
|
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes no-best-effort delivery when provided with message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["cron", "edit", "job-1", "--message", "Updated message", "--no-best-effort-deliver"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
|
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -10,6 +10,15 @@ import {
|
|||||||
warnIfCronSchedulerDisabled,
|
warnIfCronSchedulerDisabled,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
|
|
||||||
|
const assignIf = (
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
shouldAssign: boolean,
|
||||||
|
) => {
|
||||||
|
if (shouldAssign) target[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
export function registerCronEditCommand(cron: Command) {
|
export function registerCronEditCommand(cron: Command) {
|
||||||
addGatewayClientOptions(
|
addGatewayClientOptions(
|
||||||
cron
|
cron
|
||||||
@@ -38,14 +47,15 @@ export function registerCronEditCommand(cron: Command) {
|
|||||||
.option(
|
.option(
|
||||||
"--deliver",
|
"--deliver",
|
||||||
"Deliver agent output (required when using last-route delivery without --to)",
|
"Deliver agent output (required when using last-route delivery without --to)",
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
|
.option("--no-deliver", "Disable delivery")
|
||||||
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
|
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
|
||||||
.option(
|
.option(
|
||||||
"--to <dest>",
|
"--to <dest>",
|
||||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||||
)
|
)
|
||||||
.option("--best-effort-deliver", "Do not fail job if delivery fails", false)
|
.option("--best-effort-deliver", "Do not fail job if delivery fails")
|
||||||
|
.option("--no-best-effort-deliver", "Fail job when delivery fails")
|
||||||
.option("--post-prefix <prefix>", "Prefix for summary system event")
|
.option("--post-prefix <prefix>", "Prefix for summary system event")
|
||||||
.action(async (id, opts) => {
|
.action(async (id, opts) => {
|
||||||
try {
|
try {
|
||||||
@@ -105,35 +115,50 @@ export function registerCronEditCommand(cron: Command) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadChosen = [opts.systemEvent, opts.message].filter(Boolean).length;
|
const hasSystemEventPatch = typeof opts.systemEvent === "string";
|
||||||
if (payloadChosen > 1) throw new Error("Choose at most one payload change");
|
const model =
|
||||||
if (opts.systemEvent) {
|
typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined;
|
||||||
|
const thinking =
|
||||||
|
typeof opts.thinking === "string" && opts.thinking.trim()
|
||||||
|
? opts.thinking.trim()
|
||||||
|
: undefined;
|
||||||
|
const timeoutSeconds = opts.timeoutSeconds
|
||||||
|
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
||||||
|
: undefined;
|
||||||
|
const hasTimeoutSeconds = Boolean(timeoutSeconds && Number.isFinite(timeoutSeconds));
|
||||||
|
const hasAgentTurnPatch =
|
||||||
|
typeof opts.message === "string" ||
|
||||||
|
Boolean(model) ||
|
||||||
|
Boolean(thinking) ||
|
||||||
|
hasTimeoutSeconds ||
|
||||||
|
typeof opts.deliver === "boolean" ||
|
||||||
|
typeof opts.channel === "string" ||
|
||||||
|
typeof opts.to === "string" ||
|
||||||
|
typeof opts.bestEffortDeliver === "boolean";
|
||||||
|
if (hasSystemEventPatch && hasAgentTurnPatch) {
|
||||||
|
throw new Error("Choose at most one payload change");
|
||||||
|
}
|
||||||
|
if (hasSystemEventPatch) {
|
||||||
patch.payload = {
|
patch.payload = {
|
||||||
kind: "systemEvent",
|
kind: "systemEvent",
|
||||||
text: String(opts.systemEvent),
|
text: String(opts.systemEvent),
|
||||||
};
|
};
|
||||||
} else if (opts.message) {
|
} else if (hasAgentTurnPatch) {
|
||||||
const model =
|
const payload: Record<string, unknown> = { kind: "agentTurn" };
|
||||||
typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined;
|
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
|
||||||
const thinking =
|
assignIf(payload, "model", model, Boolean(model));
|
||||||
typeof opts.thinking === "string" && opts.thinking.trim()
|
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
||||||
? opts.thinking.trim()
|
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||||
: undefined;
|
assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean");
|
||||||
const timeoutSeconds = opts.timeoutSeconds
|
assignIf(payload, "channel", opts.channel, typeof opts.channel === "string");
|
||||||
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
assignIf(payload, "to", opts.to, typeof opts.to === "string");
|
||||||
: undefined;
|
assignIf(
|
||||||
patch.payload = {
|
payload,
|
||||||
kind: "agentTurn",
|
"bestEffortDeliver",
|
||||||
message: String(opts.message),
|
opts.bestEffortDeliver,
|
||||||
model,
|
typeof opts.bestEffortDeliver === "boolean",
|
||||||
thinking,
|
);
|
||||||
timeoutSeconds:
|
patch.payload = payload;
|
||||||
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
|
|
||||||
deliver: opts.deliver ? true : undefined,
|
|
||||||
channel: typeof opts.channel === "string" ? opts.channel : undefined,
|
|
||||||
to: typeof opts.to === "string" ? opts.to : undefined,
|
|
||||||
bestEffortDeliver: opts.bestEffortDeliver ? true : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof opts.postPrefix === "string") {
|
if (typeof opts.postPrefix === "string") {
|
||||||
|
|||||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user